Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
dd8b80356b
commit
dbe46935eb
28 changed files with 453 additions and 325 deletions
|
@ -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"
|
||||
|
|
|
@ -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()"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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: '$',
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Analytics
|
||||
module InstanceStatistics
|
||||
class MeasurementPolicy < BasePolicy
|
||||
delegate { :global }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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 <script>alert('hi')</script>"`;
|
||||
|
||||
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 <script>alert('hi')</script>"`;
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Database::Partitioning::PartitionCreator do
|
||||
include PartitioningHelpers
|
||||
include Database::PartitioningHelpers
|
||||
include ExclusiveLeaseHelpers
|
||||
|
||||
describe '.register' do
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
15
spec/support/helpers/database/database_helpers.rb
Normal file
15
spec/support/helpers/database/database_helpers.rb
Normal 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
|
96
spec/support/helpers/database/partitioning_helpers.rb
Normal file
96
spec/support/helpers/database/partitioning_helpers.rb
Normal 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
|
114
spec/support/helpers/database/table_schema_helpers.rb
Normal file
114
spec/support/helpers/database/table_schema_helpers.rb
Normal 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
|
68
spec/support/helpers/database/trigger_helpers.rb
Normal file
68
spec/support/helpers/database/trigger_helpers.rb
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in a new issue