Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-12-18 18:10:03 +00:00
parent dd8b80356b
commit dbe46935eb
28 changed files with 453 additions and 325 deletions

View file

@ -1,12 +1,13 @@
<script>
import updateMixin from '../../mixins/update';
import markdownField from '../../../vue_shared/components/markdown/field.vue';
import markdownField from '~/vue_shared/components/markdown/field.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
markdownField,
},
mixins: [updateMixin],
mixins: [glFeatureFlagsMixin(), updateMixin],
props: {
formState: {
type: Object,
@ -55,7 +56,7 @@ export default {
class="note-textarea js-gfm-input js-autosize markdown-area
qa-description-textarea"
dir="auto"
data-supports-quick-actions="true"
:data-supports-quick-actions="!glFeatures.tributeAutocomplete"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.meta.enter="updateIssuable"

View file

@ -20,6 +20,7 @@ import eventHub from '../event_hub';
import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue';
import markdownField from '~/vue_shared/components/markdown/field.vue';
import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import noteSignedOutWidget from './note_signed_out_widget.vue';
import discussionLockedWidget from './discussion_locked_widget.vue';
import issuableStateMixin from '../mixins/issuable_state';
@ -36,7 +37,7 @@ export default {
TimelineEntryItem,
GlIcon,
},
mixins: [issuableStateMixin],
mixins: [glFeatureFlagsMixin(), issuableStateMixin],
props: {
noteableType: {
type: String,
@ -339,7 +340,7 @@ export default {
class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
data-qa-selector="comment_field"
data-testid="comment-field"
data-supports-quick-actions="true"
:data-supports-quick-actions="!glFeatures.tributeAutocomplete"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.up="editCurrentUserLastNote()"

View file

@ -3,8 +3,9 @@
import { mapGetters, mapActions, mapState } from 'vuex';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
import NoteableWarning from '../../vue_shared/components/notes/noteable_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue';
import markdownField from '~/vue_shared/components/markdown/field.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
import { __, sprintf } from '~/locale';
@ -16,7 +17,7 @@ export default {
NoteableWarning,
markdownField,
},
mixins: [issuableStateMixin, resolvable],
mixins: [glFeatureFlagsMixin(), issuableStateMixin, resolvable],
props: {
noteBody: {
type: String,
@ -342,7 +343,7 @@ export default {
ref="textarea"
slot="textarea"
v-model="updatedNoteBody"
:data-supports-quick-actions="!isEditing"
:data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete"
name="note[note]"
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
data-qa-selector="reply_field"

View file

@ -13,6 +13,7 @@ export const GfmAutocompleteType = {
Members: 'members',
MergeRequests: 'mergeRequests',
Milestones: 'milestones',
QuickActions: 'commands',
Snippets: 'snippets',
};
@ -142,6 +143,34 @@ export const tributeConfig = {
},
},
[GfmAutocompleteType.QuickActions]: {
config: {
trigger: '/',
fillAttr: 'name',
lookup: value => `${value.name}${value.aliases.join()}`,
menuItemTemplate: ({ original }) => {
const aliases = original.aliases.length
? `<small>(or /${original.aliases.join(', /')})</small>`
: '';
const params = original.params.length ? `<small>${original.params.join(' ')}</small>` : '';
let description = '';
if (original.warning) {
const confidentialIcon =
original.icon === 'confidential' ? spriteIcon('eye-slash', 's16 gl-mr-2') : '';
description = `<small>${confidentialIcon}<em>${original.warning}</em></small>`;
} else if (original.description) {
description = `<small><em>${original.description}</em></small>`;
}
return `<div>/${original.name} ${aliases} ${params}</div>
<div>${description}</div>`;
},
},
},
[GfmAutocompleteType.Snippets]: {
config: {
trigger: '$',

View file

@ -1,14 +1,16 @@
# frozen_string_literal: true
# rubocop:disable Graphql/AuthorizeTypes
module Types
module Admin
module Analytics
module InstanceStatistics
class MeasurementType < BaseObject
include Gitlab::Graphql::Authorize::AuthorizeResource
graphql_name 'InstanceStatisticsMeasurement'
description 'Represents a recorded measurement (object count) for the Admins'
authorize :read_instance_statistics_measurements
field :recorded_at, Types::TimeType, null: true,
description: 'The time the measurement was recorded'

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
module Analytics
module InstanceStatistics
class MeasurementPolicy < BasePolicy
delegate { :global }
end
end
end

View file

@ -100,6 +100,7 @@ class GlobalPolicy < BasePolicy
enable :update_custom_attribute
enable :approve_user
enable :reject_user
enable :read_instance_statistics_measurements
end
# We can't use `read_statistics` because the user may have different permissions for different projects

View file

@ -32,7 +32,7 @@ module Gitlab
return
end
partitioned_table.postgres_partitions.each do |partition|
partitioned_table.postgres_partitions.order(:name).each do |partition|
partition_index_name = generated_index_name(partition.identifier, options[:name])
partition_options = options.merge(name: partition_index_name)

View file

@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe 'factories' do
include DatabaseHelpers
include Database::DatabaseHelpers
shared_examples 'factory' do |factory|
describe "#{factory.name} factory" do

View file

@ -723,20 +723,15 @@ RSpec.describe 'GFM autocomplete', :js do
expect(page).not_to have_selector('.tribute-container')
end
it 'triggers autocomplete after selecting a quick action' do
it 'autocompletes for quick actions' do
note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('/as')
wait_for_requests
note.native.send_keys(:tab)
end
find('.atwho-view li', text: '/assign')
note.native.send_keys(:tab)
note.native.send_keys(:right)
wait_for_requests
user_item = find('.tribute-container ul', text: user.username, visible: true)
expect(user_item).to have_content(user.username)
expect(note.value).to have_text('/assign')
end
end
@ -755,15 +750,14 @@ RSpec.describe 'GFM autocomplete', :js do
note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('/as')
note.native.send_keys('/assign ')
# The `/assign` ajax response might replace the one by `@` below causing a failed test
# so we need to wait for the `/assign` ajax request to finish first
wait_for_requests
note.native.send_keys('@')
wait_for_requests
end
find('.atwho-view li', text: '/assign')
note.native.send_keys(:tab)
note.native.send_keys(:right)
wait_for_requests
expect(find('.tribute-container ul', visible: true)).not_to have_content(user.username)
expect(find('.tribute-container ul', visible: true)).to have_content(unassigned_user.username)
end
@ -775,12 +769,14 @@ RSpec.describe 'GFM autocomplete', :js do
page.within '.timeline-content-form' do
note.native.send_keys('/assign @user2')
note.native.send_keys(:enter)
note.native.send_keys('/assign @')
note.native.send_keys(:right)
note.native.send_keys('/assign ')
# The `/assign` ajax response might replace the one by `@` below causing a failed test
# so we need to wait for the `/assign` ajax request to finish first
wait_for_requests
note.native.send_keys('@')
wait_for_requests
end
wait_for_requests
expect(find('.tribute-container ul', visible: true)).not_to have_content(user.username)
expect(find('.tribute-container ul', visible: true)).to have_content(unassigned_user.username)
end

View file

@ -10,6 +10,7 @@ RSpec.describe "User comments on issue", :js do
let(:user) { create(:user) }
before do
stub_feature_flags(tribute_autocomplete: false)
project.add_guest(user)
sign_in(user)

View file

@ -52,4 +52,9 @@ exports[`gfm_autocomplete/utils merge requests config shows the reference and ti
exports[`gfm_autocomplete/utils milestones config shows the title in the menu item 1`] = `"13.2 &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
exports[`gfm_autocomplete/utils quick actions config shows the name, aliases, params and description in the menu item 1`] = `
"<div>/unlabel <small>(or /remove_label)</small> <small>~label1 ~\\"label 2\\"</small></div>
<div><small><em>Remove all or specific label(s)</em></small></div>"
`;
exports[`gfm_autocomplete/utils snippets config shows the id and title in the menu item 1`] = `"<small>123456</small> Snippet title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;

View file

@ -339,6 +339,36 @@ describe('gfm_autocomplete/utils', () => {
});
});
describe('quick actions config', () => {
const quickActionsConfig = tributeConfig[GfmAutocompleteType.QuickActions].config;
const quickAction = {
name: 'unlabel',
aliases: ['remove_label'],
description: 'Remove all or specific label(s)',
warning: '',
icon: '',
params: ['~label1 ~"label 2"'],
};
it('uses / as the trigger', () => {
expect(quickActionsConfig.trigger).toBe('/');
});
it('inserts the name on autocomplete selection', () => {
expect(quickActionsConfig.fillAttr).toBe('name');
});
it('searches using both the name and aliases', () => {
expect(quickActionsConfig.lookup(quickAction)).toBe(
`${quickAction.name}${quickAction.aliases.join(', /')}`,
);
});
it('shows the name, aliases, params and description in the menu item', () => {
expect(quickActionsConfig.menuItemTemplate({ original: quickAction })).toMatchSnapshot();
});
});
describe('snippets config', () => {
const snippetsConfig = tributeConfig[GfmAutocompleteType.Snippets].config;
const snippet = {

View file

@ -8,4 +8,48 @@ RSpec.describe GitlabSchema.types['InstanceStatisticsMeasurement'] do
it { is_expected.to have_graphql_field(:recorded_at) }
it { is_expected.to have_graphql_field(:identifier) }
it { is_expected.to have_graphql_field(:count) }
describe 'authorization' do
let_it_be(:measurement) { create(:instance_statistics_measurement, :project_count) }
let(:user) { create(:user) }
let(:query) do
<<~GRAPHQL
query instanceStatisticsMeasurements($identifier: MeasurementIdentifier!) {
instanceStatisticsMeasurements(identifier: $identifier) {
nodes {
count
identifier
}
}
}
GRAPHQL
end
subject do
GitlabSchema.execute(
query,
variables: { identifier: 'PROJECTS' },
context: { current_user: user }
).to_h
end
context 'when the user is not admin' do
it 'returns no data' do
expect(subject.dig('data', 'instanceStatisticsMeasurements')).to be_nil
end
end
context 'when user is an admin' do
let(:user) { create(:user, :admin) }
before do
stub_feature_flags(user_mode_in_session: false)
end
it 'returns data' do
expect(subject.dig('data', 'instanceStatisticsMeasurements', 'nodes')).not_to be_empty
end
end
end
end

View file

@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::Partitioning::PartitionCreator do
include PartitioningHelpers
include Database::PartitioningHelpers
include ExclusiveLeaseHelpers
describe '.register' do

View file

@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::Partitioning::ReplaceTable, '#perform' do
include TableSchemaHelpers
include Database::TableSchemaHelpers
subject(:replace_table) { described_class.new(original_table, replacement_table, archived_table, 'id').perform }

View file

@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::ForeignKeyHelpers do
include TriggerHelpers
include Database::TriggerHelpers
let(:model) do
ActiveRecord::Migration.new.extend(described_class)

View file

@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::IndexHelpers do
include TableSchemaHelpers
include Database::TableSchemaHelpers
let(:migration) do
ActiveRecord::Migration.new.extend(described_class)

View file

@ -3,9 +3,9 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHelpers do
include PartitioningHelpers
include TriggerHelpers
include TableSchemaHelpers
include Database::PartitioningHelpers
include Database::TriggerHelpers
include Database::TableSchemaHelpers
let(:migration) do
ActiveRecord::Migration.new.extend(described_class)

View file

@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::Reindexing::IndexSelection do
include DatabaseHelpers
include Database::DatabaseHelpers
subject { described_class.new(Gitlab::Database::PostgresIndex.all).to_a }

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
module Database
module DatabaseHelpers
# In order to directly work with views using factories,
# we can swapout the view for a table of identical structure.
def swapout_view_for_table(view)
ActiveRecord::Base.connection.execute(<<~SQL)
CREATE TABLE #{view}_copy (LIKE #{view});
DROP VIEW #{view};
ALTER TABLE #{view}_copy RENAME TO #{view};
SQL
end
end
end

View file

@ -0,0 +1,96 @@
# frozen_string_literal: true
module Database
module PartitioningHelpers
def expect_table_partitioned_by(table, columns, part_type: :range)
columns_with_part_type = columns.map { |c| [part_type.to_s, c] }
actual_columns = find_partitioned_columns(table)
expect(columns_with_part_type).to match_array(actual_columns)
end
def expect_range_partition_of(partition_name, table_name, min_value, max_value)
definition = find_partition_definition(partition_name, schema: Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA)
expect(definition).not_to be_nil
expect(definition['base_table']).to eq(table_name.to_s)
expect(definition['condition']).to eq("FOR VALUES FROM (#{min_value}) TO (#{max_value})")
end
def expect_total_partitions(table_name, count, schema: Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA)
partitions = find_partitions(table_name, schema: schema)
expect(partitions.size).to eq(count)
end
def expect_range_partitions_for(table_name, partitions)
partitions.each do |suffix, (min_value, max_value)|
partition_name = "#{table_name}_#{suffix}"
expect_range_partition_of(partition_name, table_name, min_value, max_value)
end
expect_total_partitions(table_name, partitions.size, schema: Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA)
end
def expect_hash_partition_of(partition_name, table_name, modulus, remainder)
definition = find_partition_definition(partition_name, schema: Gitlab::Database::STATIC_PARTITIONS_SCHEMA)
expect(definition).not_to be_nil
expect(definition['base_table']).to eq(table_name.to_s)
expect(definition['condition']).to eq("FOR VALUES WITH (modulus #{modulus}, remainder #{remainder})")
end
private
def find_partitioned_columns(table)
connection.select_rows(<<~SQL)
select
case partstrat
when 'l' then 'list'
when 'r' then 'range'
when 'h' then 'hash'
end as partstrat,
cols.column_name
from (
select partrelid, partstrat, unnest(partattrs) as col_pos
from pg_partitioned_table
) pg_part
inner join pg_class
on pg_part.partrelid = pg_class.oid
inner join information_schema.columns cols
on cols.table_name = pg_class.relname
and cols.ordinal_position = pg_part.col_pos
where pg_class.relname = '#{table}';
SQL
end
def find_partition_definition(partition, schema: )
connection.select_one(<<~SQL)
select
parent_class.relname as base_table,
pg_get_expr(pg_class.relpartbound, inhrelid) as condition
from pg_class
inner join pg_inherits i on pg_class.oid = inhrelid
inner join pg_class parent_class on parent_class.oid = inhparent
inner join pg_namespace ON pg_namespace.oid = pg_class.relnamespace
where pg_namespace.nspname = '#{schema}'
and pg_class.relname = '#{partition}'
and pg_class.relispartition
SQL
end
def find_partitions(partition, schema: Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA)
connection.select_rows(<<~SQL)
select
pg_class.relname
from pg_class
inner join pg_inherits i on pg_class.oid = inhrelid
inner join pg_class parent_class on parent_class.oid = inhparent
inner join pg_namespace ON pg_namespace.oid = pg_class.relnamespace
where pg_namespace.nspname = '#{schema}'
and parent_class.relname = '#{partition}'
and pg_class.relispartition
SQL
end
end
end

View file

@ -0,0 +1,114 @@
# frozen_string_literal: true
module Database
module TableSchemaHelpers
def connection
ActiveRecord::Base.connection
end
def expect_table_to_be_replaced(original_table:, replacement_table:, archived_table:)
original_oid = table_oid(original_table)
replacement_oid = table_oid(replacement_table)
yield
expect(table_oid(original_table)).to eq(replacement_oid)
expect(table_oid(archived_table)).to eq(original_oid)
expect(table_oid(replacement_table)).to be_nil
end
def expect_index_to_exist(name, schema: nil)
expect(index_exists_by_name(name, schema: schema)).to eq(true)
end
def expect_index_not_to_exist(name, schema: nil)
expect(index_exists_by_name(name, schema: schema)).to be_nil
end
def expect_primary_keys_after_tables(tables, schema: nil)
tables.each do |table|
primary_key = primary_key_constraint_name(table, schema: schema)
expect(primary_key).to eq("#{table}_pkey")
end
end
def table_oid(name)
connection.select_value(<<~SQL)
SELECT oid
FROM pg_catalog.pg_class
WHERE relname = '#{name}'
SQL
end
def table_type(name)
connection.select_value(<<~SQL)
SELECT
CASE class.relkind
WHEN 'r' THEN 'normal'
WHEN 'p' THEN 'partitioned'
ELSE 'other'
END as table_type
FROM pg_catalog.pg_class class
WHERE class.relname = '#{name}'
SQL
end
def sequence_owned_by(table_name, column_name)
connection.select_value(<<~SQL)
SELECT
sequence.relname as name
FROM pg_catalog.pg_class as sequence
INNER JOIN pg_catalog.pg_depend depend
ON depend.objid = sequence.oid
INNER JOIN pg_catalog.pg_class class
ON class.oid = depend.refobjid
INNER JOIN pg_catalog.pg_attribute attribute
ON attribute.attnum = depend.refobjsubid
AND attribute.attrelid = depend.refobjid
WHERE class.relname = '#{table_name}'
AND attribute.attname = '#{column_name}'
SQL
end
def default_expression_for(table_name, column_name)
connection.select_value(<<~SQL)
SELECT
pg_get_expr(attrdef.adbin, attrdef.adrelid) AS default_value
FROM pg_catalog.pg_attribute attribute
INNER JOIN pg_catalog.pg_attrdef attrdef
ON attribute.attrelid = attrdef.adrelid
AND attribute.attnum = attrdef.adnum
WHERE attribute.attrelid = '#{table_name}'::regclass
AND attribute.attname = '#{column_name}'
SQL
end
def primary_key_constraint_name(table_name, schema: nil)
table_name = schema ? "#{schema}.#{table_name}" : table_name
connection.select_value(<<~SQL)
SELECT
conname AS constraint_name
FROM pg_catalog.pg_constraint
WHERE pg_constraint.conrelid = '#{table_name}'::regclass
AND pg_constraint.contype = 'p'
SQL
end
def index_exists_by_name(index, schema: nil)
schema = schema ? "'#{schema}'" : 'current_schema'
connection.select_value(<<~SQL)
SELECT true
FROM pg_catalog.pg_index i
INNER JOIN pg_catalog.pg_class c
ON c.oid = i.indexrelid
INNER JOIN pg_catalog.pg_namespace n
ON c.relnamespace = n.oid
WHERE c.relname = '#{index}'
AND n.nspname = #{schema}
SQL
end
end
end

View file

@ -0,0 +1,68 @@
# frozen_string_literal: true
module Database
module TriggerHelpers
def expect_function_to_exist(name)
expect(find_function_def(name)).not_to be_nil
end
def expect_function_not_to_exist(name)
expect(find_function_def(name)).to be_nil
end
def expect_function_to_contain(name, *statements)
return_stmt, *body_stmts = parsed_function_statements(name).reverse
expect(return_stmt).to eq('return old')
expect(body_stmts).to contain_exactly(*statements)
end
def expect_trigger_not_to_exist(table_name, name)
expect(find_trigger_def(table_name, name)).to be_nil
end
def expect_valid_function_trigger(table_name, name, fn_name, fires_on)
events, timing, definition = cleaned_trigger_def(table_name, name)
events = events&.split(',')
expected_timing, expected_events = fires_on.first
expect(timing).to eq(expected_timing.to_s)
expect(events).to match_array(Array.wrap(expected_events))
expect(definition).to match(%r{execute (?:procedure|function) #{fn_name}()})
end
private
def parsed_function_statements(name)
cleaned_definition = find_function_def(name)['body'].downcase.gsub(/\s+/, ' ')
statements = cleaned_definition.sub(/\A\s*begin\s*(.*)\s*end\s*\Z/, "\\1")
statements.split(';').map! { |stmt| stmt.strip.presence }.compact!
end
def find_function_def(name)
connection.select_one(<<~SQL)
SELECT prosrc AS body
FROM pg_proc
WHERE proname = '#{name}'
SQL
end
def cleaned_trigger_def(table_name, name)
find_trigger_def(table_name, name).values_at('event', 'action_timing', 'action_statement').map!(&:downcase)
end
def find_trigger_def(table_name, name)
connection.select_one(<<~SQL)
SELECT
string_agg(event_manipulation, ',') AS event,
action_timing,
action_statement
FROM information_schema.triggers
WHERE event_object_table = '#{table_name}'
AND trigger_name = '#{name}'
GROUP BY 2, 3
SQL
end
end
end

View file

@ -1,13 +0,0 @@
# frozen_string_literal: true
module DatabaseHelpers
# In order to directly work with views using factories,
# we can swapout the view for a table of identical structure.
def swapout_view_for_table(view)
ActiveRecord::Base.connection.execute(<<~SQL)
CREATE TABLE #{view}_copy (LIKE #{view});
DROP VIEW #{view};
ALTER TABLE #{view}_copy RENAME TO #{view};
SQL
end
end

View file

@ -1,94 +0,0 @@
# frozen_string_literal: true
module PartitioningHelpers
def expect_table_partitioned_by(table, columns, part_type: :range)
columns_with_part_type = columns.map { |c| [part_type.to_s, c] }
actual_columns = find_partitioned_columns(table)
expect(columns_with_part_type).to match_array(actual_columns)
end
def expect_range_partition_of(partition_name, table_name, min_value, max_value)
definition = find_partition_definition(partition_name, schema: Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA)
expect(definition).not_to be_nil
expect(definition['base_table']).to eq(table_name.to_s)
expect(definition['condition']).to eq("FOR VALUES FROM (#{min_value}) TO (#{max_value})")
end
def expect_total_partitions(table_name, count, schema: Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA)
partitions = find_partitions(table_name, schema: schema)
expect(partitions.size).to eq(count)
end
def expect_range_partitions_for(table_name, partitions)
partitions.each do |suffix, (min_value, max_value)|
partition_name = "#{table_name}_#{suffix}"
expect_range_partition_of(partition_name, table_name, min_value, max_value)
end
expect_total_partitions(table_name, partitions.size, schema: Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA)
end
def expect_hash_partition_of(partition_name, table_name, modulus, remainder)
definition = find_partition_definition(partition_name, schema: Gitlab::Database::STATIC_PARTITIONS_SCHEMA)
expect(definition).not_to be_nil
expect(definition['base_table']).to eq(table_name.to_s)
expect(definition['condition']).to eq("FOR VALUES WITH (modulus #{modulus}, remainder #{remainder})")
end
private
def find_partitioned_columns(table)
connection.select_rows(<<~SQL)
select
case partstrat
when 'l' then 'list'
when 'r' then 'range'
when 'h' then 'hash'
end as partstrat,
cols.column_name
from (
select partrelid, partstrat, unnest(partattrs) as col_pos
from pg_partitioned_table
) pg_part
inner join pg_class
on pg_part.partrelid = pg_class.oid
inner join information_schema.columns cols
on cols.table_name = pg_class.relname
and cols.ordinal_position = pg_part.col_pos
where pg_class.relname = '#{table}';
SQL
end
def find_partition_definition(partition, schema: )
connection.select_one(<<~SQL)
select
parent_class.relname as base_table,
pg_get_expr(pg_class.relpartbound, inhrelid) as condition
from pg_class
inner join pg_inherits i on pg_class.oid = inhrelid
inner join pg_class parent_class on parent_class.oid = inhparent
inner join pg_namespace ON pg_namespace.oid = pg_class.relnamespace
where pg_namespace.nspname = '#{schema}'
and pg_class.relname = '#{partition}'
and pg_class.relispartition
SQL
end
def find_partitions(partition, schema: Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA)
connection.select_rows(<<~SQL)
select
pg_class.relname
from pg_class
inner join pg_inherits i on pg_class.oid = inhrelid
inner join pg_class parent_class on parent_class.oid = inhparent
inner join pg_namespace ON pg_namespace.oid = pg_class.relnamespace
where pg_namespace.nspname = '#{schema}'
and parent_class.relname = '#{partition}'
and pg_class.relispartition
SQL
end
end

View file

@ -1,112 +0,0 @@
# frozen_string_literal: true
module TableSchemaHelpers
def connection
ActiveRecord::Base.connection
end
def expect_table_to_be_replaced(original_table:, replacement_table:, archived_table:)
original_oid = table_oid(original_table)
replacement_oid = table_oid(replacement_table)
yield
expect(table_oid(original_table)).to eq(replacement_oid)
expect(table_oid(archived_table)).to eq(original_oid)
expect(table_oid(replacement_table)).to be_nil
end
def expect_index_to_exist(name, schema: nil)
expect(index_exists_by_name(name, schema: schema)).to eq(true)
end
def expect_index_not_to_exist(name, schema: nil)
expect(index_exists_by_name(name, schema: schema)).to be_nil
end
def expect_primary_keys_after_tables(tables, schema: nil)
tables.each do |table|
primary_key = primary_key_constraint_name(table, schema: schema)
expect(primary_key).to eq("#{table}_pkey")
end
end
def table_oid(name)
connection.select_value(<<~SQL)
SELECT oid
FROM pg_catalog.pg_class
WHERE relname = '#{name}'
SQL
end
def table_type(name)
connection.select_value(<<~SQL)
SELECT
CASE class.relkind
WHEN 'r' THEN 'normal'
WHEN 'p' THEN 'partitioned'
ELSE 'other'
END as table_type
FROM pg_catalog.pg_class class
WHERE class.relname = '#{name}'
SQL
end
def sequence_owned_by(table_name, column_name)
connection.select_value(<<~SQL)
SELECT
sequence.relname as name
FROM pg_catalog.pg_class as sequence
INNER JOIN pg_catalog.pg_depend depend
ON depend.objid = sequence.oid
INNER JOIN pg_catalog.pg_class class
ON class.oid = depend.refobjid
INNER JOIN pg_catalog.pg_attribute attribute
ON attribute.attnum = depend.refobjsubid
AND attribute.attrelid = depend.refobjid
WHERE class.relname = '#{table_name}'
AND attribute.attname = '#{column_name}'
SQL
end
def default_expression_for(table_name, column_name)
connection.select_value(<<~SQL)
SELECT
pg_get_expr(attrdef.adbin, attrdef.adrelid) AS default_value
FROM pg_catalog.pg_attribute attribute
INNER JOIN pg_catalog.pg_attrdef attrdef
ON attribute.attrelid = attrdef.adrelid
AND attribute.attnum = attrdef.adnum
WHERE attribute.attrelid = '#{table_name}'::regclass
AND attribute.attname = '#{column_name}'
SQL
end
def primary_key_constraint_name(table_name, schema: nil)
table_name = schema ? "#{schema}.#{table_name}" : table_name
connection.select_value(<<~SQL)
SELECT
conname AS constraint_name
FROM pg_catalog.pg_constraint
WHERE pg_constraint.conrelid = '#{table_name}'::regclass
AND pg_constraint.contype = 'p'
SQL
end
def index_exists_by_name(index, schema: nil)
schema = schema ? "'#{schema}'" : 'current_schema'
connection.select_value(<<~SQL)
SELECT true
FROM pg_catalog.pg_index i
INNER JOIN pg_catalog.pg_class c
ON c.oid = i.indexrelid
INNER JOIN pg_catalog.pg_namespace n
ON c.relnamespace = n.oid
WHERE c.relname = '#{index}'
AND n.nspname = #{schema}
SQL
end
end

View file

@ -1,66 +0,0 @@
# frozen_string_literal: true
module TriggerHelpers
def expect_function_to_exist(name)
expect(find_function_def(name)).not_to be_nil
end
def expect_function_not_to_exist(name)
expect(find_function_def(name)).to be_nil
end
def expect_function_to_contain(name, *statements)
return_stmt, *body_stmts = parsed_function_statements(name).reverse
expect(return_stmt).to eq('return old')
expect(body_stmts).to contain_exactly(*statements)
end
def expect_trigger_not_to_exist(table_name, name)
expect(find_trigger_def(table_name, name)).to be_nil
end
def expect_valid_function_trigger(table_name, name, fn_name, fires_on)
events, timing, definition = cleaned_trigger_def(table_name, name)
events = events&.split(',')
expected_timing, expected_events = fires_on.first
expect(timing).to eq(expected_timing.to_s)
expect(events).to match_array(Array.wrap(expected_events))
expect(definition).to match(%r{execute (?:procedure|function) #{fn_name}()})
end
private
def parsed_function_statements(name)
cleaned_definition = find_function_def(name)['body'].downcase.gsub(/\s+/, ' ')
statements = cleaned_definition.sub(/\A\s*begin\s*(.*)\s*end\s*\Z/, "\\1")
statements.split(';').map! { |stmt| stmt.strip.presence }.compact!
end
def find_function_def(name)
connection.select_one(<<~SQL)
SELECT prosrc AS body
FROM pg_proc
WHERE proname = '#{name}'
SQL
end
def cleaned_trigger_def(table_name, name)
find_trigger_def(table_name, name).values_at('event', 'action_timing', 'action_statement').map!(&:downcase)
end
def find_trigger_def(table_name, name)
connection.select_one(<<~SQL)
SELECT
string_agg(event_manipulation, ',') AS event,
action_timing,
action_statement
FROM information_schema.triggers
WHERE event_object_table = '#{table_name}'
AND trigger_name = '#{name}'
GROUP BY 2, 3
SQL
end
end