Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
49cea0b04a
commit
77b8390171
|
@ -2539,12 +2539,10 @@ Rails/IncludeUrlHelper:
|
|||
- 'ee/app/presenters/merge_request_approver_presenter.rb'
|
||||
- 'ee/spec/helpers/ee/projects/security/configuration_helper_spec.rb'
|
||||
- 'ee/spec/lib/banzai/filter/cross_project_issuable_information_filter_spec.rb'
|
||||
- 'ee/spec/lib/banzai/filter/issuable_state_filter_spec.rb'
|
||||
- 'lib/gitlab/ci/badge/metadata.rb'
|
||||
- 'spec/helpers/merge_requests_helper_spec.rb'
|
||||
- 'spec/helpers/nav/top_nav_helper_spec.rb'
|
||||
- 'spec/helpers/notify_helper_spec.rb'
|
||||
- 'spec/lib/banzai/filter/issuable_state_filter_spec.rb'
|
||||
- 'spec/lib/banzai/filter/reference_redactor_filter_spec.rb'
|
||||
- 'spec/lib/banzai/reference_redactor_spec.rb'
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
56b0b20253ee17e1f2e423360b6596ebcafb8307
|
||||
518670d57d1a6527aaf46b5b9bf5cb00f2e8f11b
|
||||
|
|
|
@ -86,7 +86,7 @@ export default {
|
|||
:file-name="blob.name"
|
||||
:type="activeViewer.fileType"
|
||||
:hide-line-numbers="hideLineNumbers"
|
||||
data-qa-selector="file_content"
|
||||
data-qa-selector="blob_viewer_file_content"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
@ -98,6 +98,7 @@ export default {
|
|||
:is-locked="isLocked"
|
||||
:can-lock="canLock"
|
||||
data-testid="lock"
|
||||
data-qa-selector="lock_button"
|
||||
/>
|
||||
<gl-button v-gl-modal="replaceModalId" data-testid="replace">
|
||||
{{ $options.i18n.replace }}
|
||||
|
|
|
@ -188,6 +188,7 @@ export default {
|
|||
v-model="form.fields['commit_message'].value"
|
||||
v-validation:[form.showValidation]
|
||||
name="commit_message"
|
||||
data-qa-selector="commit_message_field"
|
||||
:state="form.fields['commit_message'].state"
|
||||
:disabled="loading"
|
||||
required
|
||||
|
|
|
@ -9,10 +9,11 @@ import {
|
|||
GlIntersectionObserver,
|
||||
} from '@gitlab/ui';
|
||||
import { once } from 'lodash';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
import api from '~/api';
|
||||
import { sprintf, s__, __ } from '~/locale';
|
||||
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
|
||||
import { EXTENSION_ICON_CLASS } from '../../constants';
|
||||
import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants';
|
||||
import StatusIcon from './status_icon.vue';
|
||||
import Actions from './actions.vue';
|
||||
|
||||
|
@ -20,6 +21,7 @@ export const LOADING_STATES = {
|
|||
collapsedLoading: 'collapsedLoading',
|
||||
collapsedError: 'collapsedError',
|
||||
expandedLoading: 'expandedLoading',
|
||||
expandedError: 'expandedError',
|
||||
};
|
||||
|
||||
export default {
|
||||
|
@ -40,8 +42,8 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
loadingState: LOADING_STATES.collapsedLoading,
|
||||
collapsedData: null,
|
||||
fullData: null,
|
||||
collapsedData: {},
|
||||
fullData: [],
|
||||
isCollapsed: true,
|
||||
showFade: false,
|
||||
};
|
||||
|
@ -53,6 +55,9 @@ export default {
|
|||
widgetLoadingText() {
|
||||
return this.$options.i18n?.loading || __('Loading...');
|
||||
},
|
||||
widgetErrorText() {
|
||||
return this.$options.i18n?.error || __('Failed to load');
|
||||
},
|
||||
isLoadingSummary() {
|
||||
return this.loadingState === LOADING_STATES.collapsedLoading;
|
||||
},
|
||||
|
@ -60,11 +65,16 @@ export default {
|
|||
return this.loadingState === LOADING_STATES.expandedLoading;
|
||||
},
|
||||
isCollapsible() {
|
||||
if (this.isLoadingSummary) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return !this.isLoadingSummary && this.loadingState !== LOADING_STATES.collapsedError;
|
||||
},
|
||||
hasFullData() {
|
||||
return this.fullData.length > 0;
|
||||
},
|
||||
hasFetchError() {
|
||||
return (
|
||||
this.loadingState === LOADING_STATES.collapsedError ||
|
||||
this.loadingState === LOADING_STATES.expandedError
|
||||
);
|
||||
},
|
||||
collapseButtonLabel() {
|
||||
return sprintf(
|
||||
|
@ -75,6 +85,7 @@ export default {
|
|||
);
|
||||
},
|
||||
statusIconName() {
|
||||
if (this.hasFetchError) return EXTENSION_ICONS.error;
|
||||
if (this.isLoadingSummary) return null;
|
||||
|
||||
return this.statusIcon(this.collapsedData);
|
||||
|
@ -100,7 +111,8 @@ export default {
|
|||
})
|
||||
.catch((e) => {
|
||||
this.loadingState = LOADING_STATES.collapsedError;
|
||||
throw e;
|
||||
|
||||
Sentry.captureException(e);
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
|
@ -115,7 +127,7 @@ export default {
|
|||
this.triggerRedisTracking();
|
||||
},
|
||||
loadAllData() {
|
||||
if (this.fullData) return;
|
||||
if (this.hasFullData) return;
|
||||
|
||||
this.loadingState = LOADING_STATES.expandedLoading;
|
||||
|
||||
|
@ -125,8 +137,9 @@ export default {
|
|||
this.fullData = data;
|
||||
})
|
||||
.catch((e) => {
|
||||
this.loadingState = null;
|
||||
throw e;
|
||||
this.loadingState = LOADING_STATES.expandedError;
|
||||
|
||||
Sentry.captureException(e);
|
||||
});
|
||||
},
|
||||
appear(index) {
|
||||
|
@ -158,6 +171,7 @@ export default {
|
|||
>
|
||||
<div class="gl-flex-grow-1">
|
||||
<template v-if="isLoadingSummary">{{ widgetLoadingText }}</template>
|
||||
<template v-else-if="hasFetchError">{{ widgetErrorText }}</template>
|
||||
<div v-else v-safe-html="summary(collapsedData)"></div>
|
||||
</div>
|
||||
<actions
|
||||
|
@ -189,7 +203,7 @@ export default {
|
|||
<gl-loading-icon size="sm" inline /> {{ __('Loading...') }}
|
||||
</div>
|
||||
<smart-virtual-list
|
||||
v-else-if="fullData"
|
||||
v-else-if="hasFullData"
|
||||
:length="fullData.length"
|
||||
:remain="20"
|
||||
:size="32"
|
||||
|
|
|
@ -21,7 +21,7 @@ module PreviewMarkdown
|
|||
|
||||
def projects_filter_params
|
||||
{
|
||||
issuable_state_filter_enabled: true,
|
||||
issuable_reference_expansion_enabled: true,
|
||||
suggestions_filter_enabled: params[:preview_suggestions].present?
|
||||
}
|
||||
end
|
||||
|
|
|
@ -110,7 +110,7 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli
|
|||
def render_draft_note(note)
|
||||
params = { target_id: merge_request.id, target_type: 'MergeRequest', text: note.note }
|
||||
result = PreviewMarkdownService.new(@project, current_user, params).execute
|
||||
markdown_params = { markdown_engine: result[:markdown_engine], issuable_state_filter_enabled: true }
|
||||
markdown_params = { markdown_engine: result[:markdown_engine], issuable_reference_expansion_enabled: true }
|
||||
|
||||
note.rendered_note = view_context.markdown(result[:text], markdown_params)
|
||||
note.users_referenced = result[:users]
|
||||
|
|
|
@ -181,7 +181,7 @@ module MarkupHelper
|
|||
wiki: wiki,
|
||||
repository: wiki.repository,
|
||||
page_slug: wiki_page.slug,
|
||||
issuable_state_filter_enabled: true
|
||||
issuable_reference_expansion_enabled: true
|
||||
).merge(render_wiki_content_context_container(wiki))
|
||||
end
|
||||
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ChatName < ApplicationRecord
|
||||
include LooseForeignKey
|
||||
|
||||
LAST_USED_AT_INTERVAL = 1.hour
|
||||
|
||||
belongs_to :integration, foreign_key: :service_id
|
||||
|
@ -16,8 +14,6 @@ class ChatName < ApplicationRecord
|
|||
validates :user_id, uniqueness: { scope: [:service_id] }
|
||||
validates :chat_id, uniqueness: { scope: [:service_id, :team_id] }
|
||||
|
||||
loose_foreign_key :ci_pipeline_chat_data, :chat_name_id, on_delete: :async_delete
|
||||
|
||||
# Updates the "last_used_timestamp" but only if it wasn't already updated
|
||||
# recently.
|
||||
#
|
||||
|
|
|
@ -12,7 +12,6 @@ module Ci
|
|||
include Gitlab::Utils::StrongMemoize
|
||||
include TaggableQueries
|
||||
include Presentable
|
||||
include LooseForeignKey
|
||||
|
||||
add_authentication_token_field :token, encrypted: :optional
|
||||
|
||||
|
@ -180,8 +179,6 @@ module Ci
|
|||
|
||||
validates :config, json_schema: { filename: 'ci_runner_config' }
|
||||
|
||||
loose_foreign_key :clusters_applications_runners, :runner_id, on_delete: :async_nullify
|
||||
|
||||
# Searches for runners matching the given query.
|
||||
#
|
||||
# This method uses ILIKE on PostgreSQL for the description field and performs a full match on tokens.
|
||||
|
|
|
@ -43,7 +43,7 @@ module Issuable
|
|||
|
||||
included do
|
||||
cache_markdown_field :title, pipeline: :single_line
|
||||
cache_markdown_field :description, issuable_state_filter_enabled: true
|
||||
cache_markdown_field :description, issuable_reference_expansion_enabled: true
|
||||
|
||||
redact_field :description
|
||||
|
||||
|
|
|
@ -1,79 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module LooseForeignKey
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# This concern adds loose foreign key support to ActiveRecord models.
|
||||
# Loose foreign keys allow delayed processing of associated database records
|
||||
# with similar guarantees than a database foreign key.
|
||||
#
|
||||
# Prerequisites:
|
||||
#
|
||||
# To start using the concern, you'll need to install a database trigger to the parent
|
||||
# table in a standard DB migration (not post-migration).
|
||||
#
|
||||
# > track_record_deletions(:projects)
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# > class Ci::Build < ApplicationRecord
|
||||
# >
|
||||
# > loose_foreign_key :security_scans, :build_id, on_delete: :async_delete
|
||||
# >
|
||||
# > # associations can be still defined, the dependent options is no longer necessary:
|
||||
# > has_many :security_scans, class_name: 'Security::Scan'
|
||||
# >
|
||||
# > end
|
||||
#
|
||||
# Options for on_delete:
|
||||
#
|
||||
# - :async_delete - deletes the children rows via an asynchronous process.
|
||||
# - :async_nullify - sets the foreign key column to null via an asynchronous process.
|
||||
#
|
||||
# How it works:
|
||||
#
|
||||
# When adding loose foreign key support to the table, a DELETE trigger is installed
|
||||
# which tracks the record deletions (stores primary key value of the deleted row) in
|
||||
# a database table.
|
||||
#
|
||||
# These deletion records are processed asynchronously and records are cleaned up
|
||||
# according to the loose foreign key definitions described in the model.
|
||||
#
|
||||
# The cleanup happens in batches, which reduces the likelyhood of statement timeouts.
|
||||
#
|
||||
# When all associations related to the deleted record are cleaned up, the record itself
|
||||
# is deleted.
|
||||
included do
|
||||
class_attribute :loose_foreign_key_definitions, default: []
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def loose_foreign_key(to_table, column, options)
|
||||
symbolized_options = options.symbolize_keys
|
||||
|
||||
unless base_class?
|
||||
raise <<~MSG
|
||||
loose_foreign_key can be only used on base classes, inherited classes are not supported.
|
||||
Please define the loose_foreign_key on the #{base_class.name} class.
|
||||
MSG
|
||||
end
|
||||
|
||||
on_delete_options = %i[async_delete async_nullify]
|
||||
|
||||
unless on_delete_options.include?(symbolized_options[:on_delete]&.to_sym)
|
||||
raise "Invalid on_delete option given: #{symbolized_options[:on_delete]}. Valid options: #{on_delete_options.join(', ')}"
|
||||
end
|
||||
|
||||
definition = ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
|
||||
table_name.to_s,
|
||||
to_table.to_s,
|
||||
{
|
||||
column: column.to_s,
|
||||
on_delete: symbolized_options[:on_delete].to_sym
|
||||
}
|
||||
)
|
||||
|
||||
self.loose_foreign_key_definitions += [definition]
|
||||
end
|
||||
end
|
||||
end
|
|
@ -506,7 +506,7 @@ class MergeRequest < ApplicationRecord
|
|||
def self.reference_pattern
|
||||
@reference_pattern ||= %r{
|
||||
(#{Project.reference_pattern})?
|
||||
#{Regexp.escape(reference_prefix)}(?<merge_request>\d+)
|
||||
#{Regexp.escape(reference_prefix)}(?<merge_request>\d+)(?<format>\+)?
|
||||
}x
|
||||
end
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ class Note < ApplicationRecord
|
|||
include FromUnion
|
||||
include Sortable
|
||||
|
||||
cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true
|
||||
cache_markdown_field :note, pipeline: :note, issuable_reference_expansion_enabled: true
|
||||
|
||||
redact_field :note
|
||||
|
||||
|
|
|
@ -476,7 +476,8 @@ class Project < ApplicationRecord
|
|||
validates :project_feature, presence: true
|
||||
|
||||
validates :namespace, presence: true
|
||||
validates :project_namespace, presence: true, if: -> { self.namespace && self.root_namespace.project_namespace_creation_enabled? }
|
||||
validates :project_namespace, presence: true, on: :create, if: -> { self.namespace && self.root_namespace.project_namespace_creation_enabled? }
|
||||
validates :project_namespace, presence: true, on: :update, if: -> { self.project_namespace_id_changed?(to: nil) }
|
||||
validates :name, uniqueness: { scope: :namespace_id }
|
||||
validates :import_url, public_url: { schemes: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS },
|
||||
ports: ->(project) { project.persisted? ? VALID_MIRROR_PORTS : VALID_IMPORT_PORTS },
|
||||
|
|
|
@ -136,7 +136,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
|
|||
pipeline: :gfm,
|
||||
author: author,
|
||||
project: project,
|
||||
issuable_state_filter_enabled: true
|
||||
issuable_reference_expansion_enabled: true
|
||||
)
|
||||
end
|
||||
|
||||
|
@ -146,7 +146,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
|
|||
pipeline: :gfm,
|
||||
author: author,
|
||||
project: project,
|
||||
issuable_state_filter_enabled: true
|
||||
issuable_reference_expansion_enabled: true
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
module LooseForeignKeys
|
||||
class BatchCleanerService
|
||||
def initialize(parent_klass:, deleted_parent_records:, modification_tracker: LooseForeignKeys::ModificationTracker.new, models_by_table_name:)
|
||||
@parent_klass = parent_klass
|
||||
def initialize(parent_table:, loose_foreign_key_definitions:, deleted_parent_records:, modification_tracker: LooseForeignKeys::ModificationTracker.new)
|
||||
@parent_table = parent_table
|
||||
@loose_foreign_key_definitions = loose_foreign_key_definitions
|
||||
@deleted_parent_records = deleted_parent_records
|
||||
@modification_tracker = modification_tracker
|
||||
@models_by_table_name = models_by_table_name
|
||||
@deleted_records_counter = Gitlab::Metrics.counter(
|
||||
:loose_foreign_key_processed_deleted_records,
|
||||
'The number of processed loose foreign key deleted records'
|
||||
|
@ -14,11 +14,11 @@ module LooseForeignKeys
|
|||
end
|
||||
|
||||
def execute
|
||||
parent_klass.loose_foreign_key_definitions.each do |foreign_key_definition|
|
||||
run_cleaner_service(foreign_key_definition, with_skip_locked: true)
|
||||
loose_foreign_key_definitions.each do |loose_foreign_key_definition|
|
||||
run_cleaner_service(loose_foreign_key_definition, with_skip_locked: true)
|
||||
break if modification_tracker.over_limit?
|
||||
|
||||
run_cleaner_service(foreign_key_definition, with_skip_locked: false)
|
||||
run_cleaner_service(loose_foreign_key_definition, with_skip_locked: false)
|
||||
break if modification_tracker.over_limit?
|
||||
end
|
||||
|
||||
|
@ -27,12 +27,12 @@ module LooseForeignKeys
|
|||
# At this point, all associations are cleaned up, we can update the status of the parent records
|
||||
update_count = LooseForeignKeys::DeletedRecord.mark_records_processed(deleted_parent_records)
|
||||
|
||||
deleted_records_counter.increment({ table: parent_klass.table_name, db_config_name: LooseForeignKeys::DeletedRecord.connection.pool.db_config.name }, update_count)
|
||||
deleted_records_counter.increment({ table: parent_table, db_config_name: LooseForeignKeys::DeletedRecord.connection.pool.db_config.name }, update_count)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :parent_klass, :deleted_parent_records, :modification_tracker, :models_by_table_name, :deleted_records_counter
|
||||
attr_reader :parent_table, :loose_foreign_key_definitions, :deleted_parent_records, :modification_tracker, :deleted_records_counter
|
||||
|
||||
def record_result(cleaner, result)
|
||||
if cleaner.async_delete?
|
||||
|
@ -42,19 +42,22 @@ module LooseForeignKeys
|
|||
end
|
||||
end
|
||||
|
||||
def run_cleaner_service(foreign_key_definition, with_skip_locked:)
|
||||
cleaner = CleanerService.new(
|
||||
model: models_by_table_name.fetch(foreign_key_definition.to_table),
|
||||
foreign_key_definition: foreign_key_definition,
|
||||
deleted_parent_records: deleted_parent_records,
|
||||
with_skip_locked: with_skip_locked
|
||||
)
|
||||
def run_cleaner_service(loose_foreign_key_definition, with_skip_locked:)
|
||||
base_models_for_gitlab_schema = Gitlab::Database.schemas_to_base_models.fetch(loose_foreign_key_definition.options[:gitlab_schema])
|
||||
base_models_for_gitlab_schema.each do |base_model|
|
||||
cleaner = CleanerService.new(
|
||||
loose_foreign_key_definition: loose_foreign_key_definition,
|
||||
connection: base_model.connection,
|
||||
deleted_parent_records: deleted_parent_records,
|
||||
with_skip_locked: with_skip_locked
|
||||
)
|
||||
|
||||
loop do
|
||||
result = cleaner.execute
|
||||
record_result(cleaner, result)
|
||||
loop do
|
||||
result = cleaner.execute
|
||||
record_result(cleaner, result)
|
||||
|
||||
break if modification_tracker.over_limit? || result[:affected_rows] == 0
|
||||
break if modification_tracker.over_limit? || result[:affected_rows] == 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,11 +6,9 @@ module LooseForeignKeys
|
|||
DELETE_LIMIT = 1000
|
||||
UPDATE_LIMIT = 500
|
||||
|
||||
delegate :connection, to: :model
|
||||
|
||||
def initialize(model:, foreign_key_definition:, deleted_parent_records:, with_skip_locked: false)
|
||||
@model = model
|
||||
@foreign_key_definition = foreign_key_definition
|
||||
def initialize(loose_foreign_key_definition:, connection:, deleted_parent_records:, with_skip_locked: false)
|
||||
@loose_foreign_key_definition = loose_foreign_key_definition
|
||||
@connection = connection
|
||||
@deleted_parent_records = deleted_parent_records
|
||||
@with_skip_locked = with_skip_locked
|
||||
end
|
||||
|
@ -18,20 +16,20 @@ module LooseForeignKeys
|
|||
def execute
|
||||
result = connection.execute(build_query)
|
||||
|
||||
{ affected_rows: result.cmd_tuples, table: foreign_key_definition.to_table }
|
||||
{ affected_rows: result.cmd_tuples, table: loose_foreign_key_definition.to_table }
|
||||
end
|
||||
|
||||
def async_delete?
|
||||
foreign_key_definition.on_delete == :async_delete
|
||||
loose_foreign_key_definition.on_delete == :async_delete
|
||||
end
|
||||
|
||||
def async_nullify?
|
||||
foreign_key_definition.on_delete == :async_nullify
|
||||
loose_foreign_key_definition.on_delete == :async_nullify
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :model, :foreign_key_definition, :deleted_parent_records, :with_skip_locked
|
||||
attr_reader :loose_foreign_key_definition, :connection, :deleted_parent_records, :with_skip_locked
|
||||
|
||||
def build_query
|
||||
query = if async_delete?
|
||||
|
@ -39,10 +37,10 @@ module LooseForeignKeys
|
|||
elsif async_nullify?
|
||||
update_query
|
||||
else
|
||||
raise "Invalid on_delete argument: #{foreign_key_definition.on_delete}"
|
||||
raise "Invalid on_delete argument: #{loose_foreign_key_definition.on_delete}"
|
||||
end
|
||||
|
||||
unless query.include?(%{"#{foreign_key_definition.column}" IN (})
|
||||
unless query.include?(%{"#{loose_foreign_key_definition.column}" IN (})
|
||||
raise("FATAL: foreign key condition is missing from the generated query: #{query}")
|
||||
end
|
||||
|
||||
|
@ -50,15 +48,15 @@ module LooseForeignKeys
|
|||
end
|
||||
|
||||
def arel_table
|
||||
@arel_table ||= model.arel_table
|
||||
@arel_table ||= Arel::Table.new(loose_foreign_key_definition.to_table)
|
||||
end
|
||||
|
||||
def primary_keys
|
||||
@primary_keys ||= connection.primary_keys(model.table_name).map { |key| arel_table[key] }
|
||||
@primary_keys ||= connection.primary_keys(loose_foreign_key_definition.to_table).map { |key| arel_table[key] }
|
||||
end
|
||||
|
||||
def quoted_table_name
|
||||
@quoted_table_name ||= Arel.sql(connection.quote_table_name(model.table_name))
|
||||
@quoted_table_name ||= Arel.sql(connection.quote_table_name(loose_foreign_key_definition.to_table))
|
||||
end
|
||||
|
||||
def delete_query
|
||||
|
@ -71,7 +69,7 @@ module LooseForeignKeys
|
|||
def update_query
|
||||
query = Arel::UpdateManager.new
|
||||
query.table(quoted_table_name)
|
||||
query.set([[arel_table[foreign_key_definition.column], nil]])
|
||||
query.set([[arel_table[loose_foreign_key_definition.column], nil]])
|
||||
|
||||
add_in_query_with_limit(query, UPDATE_LIMIT)
|
||||
end
|
||||
|
@ -88,7 +86,7 @@ module LooseForeignKeys
|
|||
def in_query_with_limit(limit)
|
||||
in_query = Arel::SelectManager.new
|
||||
in_query.from(quoted_table_name)
|
||||
in_query.where(arel_table[foreign_key_definition.column].in(deleted_parent_records.map(&:primary_key_value)))
|
||||
in_query.where(arel_table[loose_foreign_key_definition.column].in(deleted_parent_records.map(&:primary_key_value)))
|
||||
in_query.projections = primary_keys
|
||||
in_query.take(limit)
|
||||
in_query.lock(Arel.sql('FOR UPDATE SKIP LOCKED')) if with_skip_locked
|
||||
|
|
|
@ -21,13 +21,16 @@ module LooseForeignKeys
|
|||
|
||||
break if modification_tracker.over_limit?
|
||||
|
||||
model = find_parent_model!(table)
|
||||
loose_foreign_key_definitions = Gitlab::Database::LooseForeignKeys.definitions_by_table[table]
|
||||
|
||||
next if loose_foreign_key_definitions.empty?
|
||||
|
||||
LooseForeignKeys::BatchCleanerService
|
||||
.new(parent_klass: model,
|
||||
deleted_parent_records: records,
|
||||
modification_tracker: modification_tracker,
|
||||
models_by_table_name: models_by_table_name)
|
||||
.new(
|
||||
parent_table: table,
|
||||
loose_foreign_key_definitions: loose_foreign_key_definitions,
|
||||
deleted_parent_records: records,
|
||||
modification_tracker: modification_tracker)
|
||||
.execute
|
||||
|
||||
break if modification_tracker.over_limit?
|
||||
|
@ -45,30 +48,12 @@ module LooseForeignKeys
|
|||
LooseForeignKeys::DeletedRecord.load_batch_for_table(fully_qualified_table_name, BATCH_SIZE)
|
||||
end
|
||||
|
||||
def find_parent_model!(table)
|
||||
models_by_table_name.fetch(table)
|
||||
end
|
||||
|
||||
def current_schema
|
||||
@current_schema = connection.current_schema
|
||||
end
|
||||
|
||||
def tracked_tables
|
||||
@tracked_tables ||= models_by_table_name
|
||||
.select { |table_name, model| model.respond_to?(:loose_foreign_key_definitions) }
|
||||
.keys
|
||||
end
|
||||
|
||||
def models_by_table_name
|
||||
@models_by_table_name ||= begin
|
||||
all_models
|
||||
.select(&:base_class?)
|
||||
.index_by(&:table_name)
|
||||
end
|
||||
end
|
||||
|
||||
def all_models
|
||||
ApplicationRecord.descendants
|
||||
@tracked_tables ||= Gitlab::Database::LooseForeignKeys.definitions_by_table.keys
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -42,7 +42,7 @@ Gitlab::Experiment.configure do |config|
|
|||
# This behavior doesn't make perfect sense for self managed installs either,
|
||||
# so we don't think we should redirect in those cases.
|
||||
#
|
||||
valid_domains = %w[about.gitlab.com docs.gitlab.com gitlab.com]
|
||||
valid_domains = %w[about.gitlab.com docs.gitlab.com gitlab.com gdk.test localhost]
|
||||
config.redirect_url_validator = lambda do |url|
|
||||
Gitlab.dev_env_or_com? && (url = URI.parse(url)) && valid_domains.include?(url.host)
|
||||
rescue URI::InvalidURIError
|
||||
|
|
|
@ -52,25 +52,40 @@ For this procedure to work, we must register which tables to clean up asynchrono
|
|||
|
||||
## Example migration and configuration
|
||||
|
||||
### Configure the model
|
||||
### Configure the loose foreign key
|
||||
|
||||
First, tell the application that the `projects` table has a new loose foreign key.
|
||||
You can do this in the `Project` model:
|
||||
Loose foreign keys are defined in a YAML file. The configuration requires the
|
||||
following information:
|
||||
|
||||
```ruby
|
||||
class Project < ApplicationRecord
|
||||
# ...
|
||||
- Parent table name (`projects`)
|
||||
- Child table name (`ci_pipelines`)
|
||||
- The data cleanup method (`async_delete` or `async_nullify`)
|
||||
|
||||
include LooseForeignKey
|
||||
The YAML file is located at `lib/gitlab/database/gitlab_loose_foreign_keys.yml`. The file groups
|
||||
foreign key definitions by the name of the parent table. The parent table can have multiple loose
|
||||
foreign key definitions, therefore we store them as an array.
|
||||
|
||||
loose_foreign_key :ci_pipelines, :project_id, on_delete: :async_delete # or async_nullify
|
||||
Example definition:
|
||||
|
||||
# ...
|
||||
end
|
||||
```yaml
|
||||
projects:
|
||||
- to_table: ci_pipelines
|
||||
column: project_id
|
||||
on_delete: async_delete
|
||||
```
|
||||
|
||||
This instruction ensures the asynchronous cleanup process knows about the association, and the
|
||||
how to do the cleanup. In this case, the associated `ci_pipelines` records are deleted.
|
||||
If the `projects` key is already present in the YAML file, then a new entry can be added
|
||||
to the array:
|
||||
|
||||
```yaml
|
||||
projects:
|
||||
- to_table: ci_pipelines
|
||||
column: project_id
|
||||
on_delete: async_delete
|
||||
- to_table: another_table
|
||||
column: project_id
|
||||
on_delete: :async_nullify
|
||||
```
|
||||
|
||||
### Track record changes
|
||||
|
||||
|
@ -127,6 +142,19 @@ end
|
|||
At this point, the setup phase is concluded. The deleted `projects` records should be automatically
|
||||
picked up by the scheduled cleanup worker job.
|
||||
|
||||
## Testing
|
||||
|
||||
The "`it has loose foreign keys`" shared example can be used to test the presence of the `ON DELETE` trigger and the
|
||||
loose foreign key definitions.
|
||||
|
||||
Simply add to the model test file:
|
||||
|
||||
```ruby
|
||||
it_behaves_like 'it has loose foreign keys' do
|
||||
let(:factory_name) { :project }
|
||||
end
|
||||
```
|
||||
|
||||
## Caveats of loose foreign keys
|
||||
|
||||
### Record creation
|
||||
|
|
|
@ -1079,6 +1079,48 @@ export default {
|
|||
- **EE extra HTML**
|
||||
- For the templates that have extra HTML in EE we should move it into a new component and use the `ee_else_ce` dynamic import
|
||||
|
||||
#### Testing modules using EE/CE aliases
|
||||
|
||||
When writing Frontend tests, if the module under test imports other modules with `ee_else_ce/...` and these modules are also needed by the relevant test, then the relevant test **must** import these modules with `ee_else_ce/...`. This avoids unexpected EE or FOSS failures, and helps ensure the EE behaves like CE when it is unlicensed.
|
||||
|
||||
For example:
|
||||
|
||||
```vue
|
||||
<script>
|
||||
// ~/foo/component_under_test.vue
|
||||
|
||||
import FriendComponent from 'ee_else_ce/components/friend.vue;'
|
||||
|
||||
export default {
|
||||
name: 'ComponentUnderTest',
|
||||
components: { FriendComponent }.
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<friend-component />
|
||||
</template>
|
||||
```
|
||||
|
||||
```javascript
|
||||
// spec/frontend/foo/component_under_test_spec.js
|
||||
|
||||
// ...
|
||||
// because we referenced the component using ee_else_ce we have to do the same in the spec.
|
||||
import Friend from 'ee_else_ce/components/friend.vue;'
|
||||
|
||||
describe('ComponentUnderTest', () => {
|
||||
const findFriend = () => wrapper.find(Friend);
|
||||
|
||||
it('renders friend', () => {
|
||||
// This would fail in CE if we did `ee/component...`
|
||||
// and would fail in EE if we did `~/component...`
|
||||
expect(findFriend().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
### Non Vue Files
|
||||
|
||||
For regular JS files, the approach is similar.
|
||||
|
|
|
@ -54,3 +54,26 @@ import issueExtension from '~/vue_merge_request_widget/extensions/issues';
|
|||
// Register the imported extension
|
||||
registerExtension(issueExtension);
|
||||
```
|
||||
|
||||
## Fetching errors
|
||||
|
||||
If `fetchCollapsedData()` or `fetchFullData()` methods throw an error:
|
||||
|
||||
- The loading state of the extension is updated to `LOADING_STATES.collapsedError` and `LOADING_STATES.expandedError`
|
||||
respectively.
|
||||
- The extensions header displays an error icon and updates the text to be either:
|
||||
- The text defined in `$options.i18n.error`.
|
||||
- "Failed to load" if `$options.i18n.error` is not defined.
|
||||
- The error is sent to Sentry to log that it occurred.
|
||||
|
||||
To customise the error text, you need to add it to the `i18n` object in your extension:
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
//...
|
||||
i18n: {
|
||||
//...
|
||||
error: __('Your error text'),
|
||||
},
|
||||
};
|
||||
```
|
||||
|
|
|
@ -357,7 +357,7 @@ scan_execution_policy:
|
|||
- type: schedule
|
||||
branches:
|
||||
- main
|
||||
cadence: */10 * * * *
|
||||
cadence: "*/10 * * * *"
|
||||
actions:
|
||||
- scan: dast
|
||||
scanner_profile: Scanner Profile C
|
||||
|
@ -378,7 +378,7 @@ scan_execution_policy:
|
|||
enabled: true
|
||||
rules:
|
||||
- type: schedule
|
||||
cadence: '15 3 * * *'
|
||||
cadence: "15 3 * * *
|
||||
clusters:
|
||||
production-cluster:
|
||||
containers:
|
||||
|
|
|
@ -516,30 +516,30 @@ version to reference other projects from the same namespace.
|
|||
|
||||
GitLab Flavored Markdown recognizes the following:
|
||||
|
||||
| references | input | cross-project reference | shortcut inside same namespace |
|
||||
| :------------------------------ | :------------------------- | :-------------------------------------- | :----------------------------- |
|
||||
| specific user | `@user_name` | | |
|
||||
| specific group | `@group_name` | | |
|
||||
| entire team | `@all` | | |
|
||||
| project | `namespace/project>` | | |
|
||||
| issue | ``#123`` | `namespace/project#123` | `project#123` |
|
||||
| merge request | `!123` | `namespace/project!123` | `project!123` |
|
||||
| snippet | `$123` | `namespace/project$123` | `project$123` |
|
||||
| epic **(ULTIMATE)** | `&123` | `group1/subgroup&123` | |
|
||||
| vulnerability **(ULTIMATE)** (1)| `[vulnerability:123]` | `[vulnerability:namespace/project/123]` | `[vulnerability:project/123]` |
|
||||
| feature flag | `[feature_flag:123]` | `[feature_flag:namespace/project/123]` | `[feature_flag:project/123]` |
|
||||
| label by ID | `~123` | `namespace/project~123` | `project~123` |
|
||||
| one-word label by name | `~bug` | `namespace/project~bug` | `project~bug` |
|
||||
| multi-word label by name | `~"feature request"` | `namespace/project~"feature request"` | `project~"feature request"` |
|
||||
| scoped label by name | `~"priority::high"` | `namespace/project~"priority::high"` | `project~"priority::high"` |
|
||||
| project milestone by ID | `%123` | `namespace/project%123` | `project%123` |
|
||||
| one-word milestone by name | `%v1.23` | `namespace/project%v1.23` | `project%v1.23` |
|
||||
| multi-word milestone by name | `%"release candidate"` | `namespace/project%"release candidate"` | `project%"release candidate"` |
|
||||
| specific commit | `9ba12248` | `namespace/project@9ba12248` | `project@9ba12248` |
|
||||
| commit range comparison | `9ba12248...b19a04f5` | `namespace/project@9ba12248...b19a04f5` | `project@9ba12248...b19a04f5` |
|
||||
| repository file references | `[README](doc/README.md)` | | |
|
||||
| repository file line references | `[README](doc/README.md#L13)` | | |
|
||||
| [alert](../operations/incident_management/alerts.md) | `^alert#123` | `namespace/project^alert#123` | `project^alert#123` |
|
||||
| references | input | cross-project reference | shortcut inside same namespace |
|
||||
| :--------------------------------------------------- | :---------------------------- | :----------------------------------------- | :------------------------------- |
|
||||
| specific user | `@user_name` | | |
|
||||
| specific group | `@group_name` | | |
|
||||
| entire team | `@all` | | |
|
||||
| project | `namespace/project>` | | |
|
||||
| issue | ``#123`` | `namespace/project#123` | `project#123` |
|
||||
| merge request | `!123` | `namespace/project!123` | `project!123` |
|
||||
| snippet | `$123` | `namespace/project$123` | `project$123` |
|
||||
| [epic](group/epics/index.md) | `&123` | `group1/subgroup&123` | |
|
||||
| vulnerability **(ULTIMATE)** <sup>1</sup> | `[vulnerability:123]` | `[vulnerability:namespace/project/123]` | `[vulnerability:project/123]` |
|
||||
| feature flag | `[feature_flag:123]` | `[feature_flag:namespace/project/123]` | `[feature_flag:project/123]` |
|
||||
| label by ID | `~123` | `namespace/project~123` | `project~123` |
|
||||
| one-word label by name | `~bug` | `namespace/project~bug` | `project~bug` |
|
||||
| multi-word label by name | `~"feature request"` | `namespace/project~"feature request"` | `project~"feature request"` |
|
||||
| scoped label by name | `~"priority::high"` | `namespace/project~"priority::high"` | `project~"priority::high"` |
|
||||
| project milestone by ID | `%123` | `namespace/project%123` | `project%123` |
|
||||
| one-word milestone by name | `%v1.23` | `namespace/project%v1.23` | `project%v1.23` |
|
||||
| multi-word milestone by name | `%"release candidate"` | `namespace/project%"release candidate"` | `project%"release candidate"` |
|
||||
| specific commit | `9ba12248` | `namespace/project@9ba12248` | `project@9ba12248` |
|
||||
| commit range comparison | `9ba12248...b19a04f5` | `namespace/project@9ba12248...b19a04f5` | `project@9ba12248...b19a04f5` |
|
||||
| repository file references | `[README](doc/README.md)` | | |
|
||||
| repository file line references | `[README](doc/README.md#L13)` | | |
|
||||
| [alert](../operations/incident_management/alerts.md) | `^alert#123` | `namespace/project^alert#123` | `project^alert#123` |
|
||||
|
||||
1. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/222483) in GitLab 13.7.
|
||||
|
||||
|
@ -554,6 +554,16 @@ In addition to this, links to some objects are also recognized and formatted. So
|
|||
- The issues designs tab: `"https://gitlab.com/gitlab-org/gitlab/-/issues/1234/designs"`, which are rendered as `#1234 (designs)`.
|
||||
- Links to individual designs: `"https://gitlab.com/gitlab-org/gitlab/-/issues/1234/designs/layout.png"`, which are rendered as `#1234[layout.png]`.
|
||||
|
||||
### Show the issue, merge request, or epic title in the reference
|
||||
|
||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15694) in GitLab 14.6.
|
||||
|
||||
To include the title in the rendered link of an issue, merge request, or epic, add a plus (`+`)
|
||||
at the end of the reference. For example, a reference like `#123+` is rendered as
|
||||
`The issue title (#123)`.
|
||||
|
||||
Expanding titles does not apply to URL references, like `https://gitlab.com/gitlab-org/gitlab/-/issues/1234`.
|
||||
|
||||
### Embedding metrics in GitLab Flavored Markdown
|
||||
|
||||
Metric charts can be embedded in GitLab Flavored Markdown. Read
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Banzai
|
||||
module Filter
|
||||
# HTML filter that appends extra information to issuable links.
|
||||
# Runs as a post-process filter as issuable might change while
|
||||
# Markdown is in the cache.
|
||||
#
|
||||
# This filter supports cross-project references.
|
||||
class IssuableReferenceExpansionFilter < HTML::Pipeline::Filter
|
||||
include Gitlab::Utils::StrongMemoize
|
||||
|
||||
VISIBLE_STATES = %w(closed merged).freeze
|
||||
|
||||
def call
|
||||
return doc unless context[:issuable_reference_expansion_enabled]
|
||||
|
||||
context = RenderContext.new(project, current_user)
|
||||
extractor = Banzai::IssuableExtractor.new(context)
|
||||
issuables = extractor.extract([doc])
|
||||
|
||||
issuables.each do |node, issuable|
|
||||
next if !can_read_cross_project? && cross_referenced?(issuable)
|
||||
next unless should_expand?(node, issuable)
|
||||
|
||||
case node.attr('data-reference-format')
|
||||
when '+'
|
||||
expand_reference_with_title_and_state(node, issuable)
|
||||
else
|
||||
expand_reference_with_state(node, issuable)
|
||||
end
|
||||
end
|
||||
|
||||
doc
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Example: Issue Title (#123 - closed)
|
||||
def expand_reference_with_title_and_state(node, issuable)
|
||||
node.content = "#{issuable.title.truncate(50)} (#{node.content}"
|
||||
node.content += " - #{issuable_state_text(issuable)}" if VISIBLE_STATES.include?(issuable.state)
|
||||
node.content += ')'
|
||||
end
|
||||
|
||||
# Example: #123 (closed)
|
||||
def expand_reference_with_state(node, issuable)
|
||||
node.content += " (#{issuable_state_text(issuable)})"
|
||||
end
|
||||
|
||||
def issuable_state_text(issuable)
|
||||
moved_issue?(issuable) ? s_("IssuableStatus|moved") : issuable.state
|
||||
end
|
||||
|
||||
def moved_issue?(issuable)
|
||||
issuable.instance_of?(Issue) && issuable.moved?
|
||||
end
|
||||
|
||||
def should_expand?(node, issuable)
|
||||
# We add this extra check to avoid unescaping HTML and generating reference link text for every reference
|
||||
return unless node.attr('data-reference-format').present? || VISIBLE_STATES.include?(issuable.state)
|
||||
|
||||
CGI.unescapeHTML(node.inner_html) == issuable.reference_link_text(project || group)
|
||||
end
|
||||
|
||||
def cross_referenced?(issuable)
|
||||
return true if issuable.project != project
|
||||
return true if issuable.respond_to?(:group) && issuable.group != group
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def can_read_cross_project?
|
||||
strong_memoize(:can_read_cross_project) do
|
||||
Ability.allowed?(current_user, :read_cross_project)
|
||||
end
|
||||
end
|
||||
|
||||
def current_user
|
||||
context[:current_user]
|
||||
end
|
||||
|
||||
def project
|
||||
context[:project]
|
||||
end
|
||||
|
||||
def group
|
||||
context[:group]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,66 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Banzai
|
||||
module Filter
|
||||
# HTML filter that appends state information to issuable links.
|
||||
# Runs as a post-process filter as issuable state might change while
|
||||
# Markdown is in the cache.
|
||||
#
|
||||
# This filter supports cross-project references.
|
||||
class IssuableStateFilter < HTML::Pipeline::Filter
|
||||
VISIBLE_STATES = %w(closed merged).freeze
|
||||
|
||||
def call
|
||||
return doc unless context[:issuable_state_filter_enabled]
|
||||
|
||||
context = RenderContext.new(project, current_user)
|
||||
extractor = Banzai::IssuableExtractor.new(context)
|
||||
issuables = extractor.extract([doc])
|
||||
|
||||
issuables.each do |node, issuable|
|
||||
next if !can_read_cross_project? && cross_referenced?(issuable)
|
||||
|
||||
if VISIBLE_STATES.include?(issuable.state) && issuable_reference?(node.inner_html, issuable)
|
||||
state = moved_issue?(issuable) ? s_("IssuableStatus|moved") : issuable.state
|
||||
node.content += " (#{state})"
|
||||
end
|
||||
end
|
||||
|
||||
doc
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def moved_issue?(issuable)
|
||||
issuable.instance_of?(Issue) && issuable.moved?
|
||||
end
|
||||
|
||||
def issuable_reference?(text, issuable)
|
||||
CGI.unescapeHTML(text) == issuable.reference_link_text(project || group)
|
||||
end
|
||||
|
||||
def cross_referenced?(issuable)
|
||||
return true if issuable.project != project
|
||||
return true if issuable.respond_to?(:group) && issuable.group != group
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def can_read_cross_project?
|
||||
Ability.allowed?(current_user, :read_cross_project)
|
||||
end
|
||||
|
||||
def current_user
|
||||
context[:current_user]
|
||||
end
|
||||
|
||||
def project
|
||||
context[:project]
|
||||
end
|
||||
|
||||
def group
|
||||
context[:group]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -205,6 +205,8 @@ module Banzai
|
|||
data_attributes = data_attributes_for(link_content || match, parent, object,
|
||||
link_content: !!link_content,
|
||||
link_reference: link_reference)
|
||||
data_attributes[:reference_format] = matches[:format] if matches.names.include?("format")
|
||||
|
||||
data = data_attribute(data_attributes)
|
||||
|
||||
url =
|
||||
|
|
|
@ -19,7 +19,7 @@ module Banzai
|
|||
# prevent unnecessary Gitaly calls from being made.
|
||||
Filter::UploadLinkFilter,
|
||||
Filter::RepositoryLinkFilter,
|
||||
Filter::IssuableStateFilter,
|
||||
Filter::IssuableReferenceExpansionFilter,
|
||||
Filter::SuggestionFilter
|
||||
]
|
||||
end
|
||||
|
|
|
@ -36,6 +36,7 @@ module Gitlab
|
|||
if Rails.env.development?
|
||||
allow_webpack_dev_server(directives)
|
||||
allow_letter_opener(directives)
|
||||
allow_snowplow_micro(directives) if Gitlab::Tracking.snowplow_micro_enabled?
|
||||
allow_customersdot(directives) if ENV['CUSTOMER_PORTAL_URL'].present?
|
||||
end
|
||||
|
||||
|
@ -138,6 +139,11 @@ module Gitlab
|
|||
append_to_directive(directives, 'frame_src', Gitlab::Utils.append_path(Gitlab.config.gitlab.url, '/rails/letter_opener/'))
|
||||
end
|
||||
|
||||
def self.allow_snowplow_micro(directives)
|
||||
url = URI.join(Gitlab::Tracking::Destinations::SnowplowMicro.new.uri, '/').to_s
|
||||
append_to_directive(directives, 'connect_src', url)
|
||||
end
|
||||
|
||||
# Using 'self' in the CSP introduces several CSP bypass opportunities
|
||||
# for this reason we list the URLs where GitLab frames itself instead
|
||||
def self.allow_framed_gitlab_paths(directives)
|
||||
|
|
|
@ -63,6 +63,15 @@ module Gitlab
|
|||
}.compact.with_indifferent_access.freeze
|
||||
end
|
||||
|
||||
# This returns a list of base models with connection associated for a given gitlab_schema
|
||||
def self.schemas_to_base_models
|
||||
@schemas_to_base_models ||= {
|
||||
gitlab_main: [self.database_base_models.fetch(:main)],
|
||||
gitlab_ci: [self.database_base_models[:ci] || self.database_base_models.fetch(:main)], # use CI or fallback to main
|
||||
gitlab_shared: self.database_base_models.values # all models
|
||||
}.with_indifferent_access.freeze
|
||||
end
|
||||
|
||||
# We configure the database connection pool size automatically based on the
|
||||
# configured concurrency. We also add some headroom, to make sure we don't
|
||||
# run out of connections when more threads besides the 'user-facing' ones
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
chat_names:
|
||||
- to_table: ci_pipeline_chat_data
|
||||
column: chat_name_id
|
||||
on_delete: async_delete
|
||||
ci_runners:
|
||||
- to_table: clusters_applications_runners
|
||||
column: runner_id
|
||||
on_delete: async_nullify
|
|
@ -0,0 +1,38 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Database
|
||||
module LooseForeignKeys
|
||||
def self.definitions_by_table
|
||||
@definitions_by_table ||= definitions.group_by(&:from_table).with_indifferent_access.freeze
|
||||
end
|
||||
|
||||
def self.definitions
|
||||
@definitions ||= loose_foreign_keys_yaml.flat_map do |parent_table_name, configs|
|
||||
configs.map { |config| build_definition(parent_table_name, config) }
|
||||
end.freeze
|
||||
end
|
||||
|
||||
def self.build_definition(parent_table_name, config)
|
||||
to_table = config.fetch('to_table')
|
||||
|
||||
ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
|
||||
parent_table_name,
|
||||
to_table,
|
||||
{
|
||||
column: config.fetch('column'),
|
||||
on_delete: config.fetch('on_delete').to_sym,
|
||||
gitlab_schema: GitlabSchema.table_schema(to_table)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def self.loose_foreign_keys_yaml
|
||||
@loose_foreign_keys_yaml ||= YAML.load_file(Rails.root.join('lib/gitlab/database/gitlab_loose_foreign_keys.yml'))
|
||||
end
|
||||
|
||||
private_class_method :build_definition
|
||||
private_class_method :loose_foreign_keys_yaml
|
||||
end
|
||||
end
|
||||
end
|
|
@ -413,7 +413,7 @@ module Gitlab
|
|||
end
|
||||
|
||||
def issue
|
||||
@issue ||= /(?<issue>\d+\b)/
|
||||
@issue ||= /(?<issue>\d+)(?<format>\+)?(?=\W|\z)/
|
||||
end
|
||||
|
||||
def base64_regex
|
||||
|
|
|
@ -25,6 +25,10 @@ module Gitlab
|
|||
snowplow.hostname
|
||||
end
|
||||
|
||||
def snowplow_micro_enabled?
|
||||
Rails.env.development? && Gitlab::Utils.to_boolean(ENV['SNOWPLOW_MICRO_ENABLE'])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def snowplow
|
||||
|
@ -34,10 +38,6 @@ module Gitlab
|
|||
Gitlab::Tracking::Destinations::Snowplow.new
|
||||
end
|
||||
end
|
||||
|
||||
def snowplow_micro_enabled?
|
||||
Rails.env.development? && Gitlab::Utils.to_boolean(ENV['SNOWPLOW_MICRO_ENABLE'])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -23,8 +23,6 @@ module Gitlab
|
|||
"#{uri.host}:#{uri.port}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def uri
|
||||
strong_memoize(:snowplow_uri) do
|
||||
uri = URI(ENV['SNOWPLOW_MICRO_URI'] || DEFAULT_URI)
|
||||
|
@ -33,6 +31,8 @@ module Gitlab
|
|||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
override :cookie_domain
|
||||
def cookie_domain
|
||||
'.gitlab.com'
|
||||
|
|
|
@ -14351,6 +14351,9 @@ msgstr ""
|
|||
msgid "Failed to install."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to load"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to load assignees."
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -443,6 +443,29 @@ module QA
|
|||
# Some checkboxes and radio buttons are hidden by their labels and cannot be clicked directly
|
||||
click_by_js ? page.execute_script("arguments[0].click();", box) : box.click
|
||||
end
|
||||
|
||||
def feature_flag_controlled_element(feature_flag, element_when_flag_enabled, element_when_flag_disabled)
|
||||
# Feature flags can change the UI elements shown, but we need admin access to get feature flag values, which
|
||||
# prevents us running the tests on production. Instead we detect the UI element that should be shown when the
|
||||
# feature flag is enabled and otherwise use the element that should be displayed when the feature flag is
|
||||
# disabled.
|
||||
|
||||
# Check both options once quickly so that the test doesn't wait unnecessarily if the UI has loaded
|
||||
# We wait for requests first and wait one second for the element because it can take a moment for a Vue app to
|
||||
# load and render the UI
|
||||
wait_for_requests
|
||||
|
||||
return element_when_flag_enabled if has_element?(element_when_flag_enabled, wait: 1)
|
||||
return element_when_flag_disabled if has_element?(element_when_flag_disabled, wait: 1)
|
||||
|
||||
# Check both options again, this time waiting for the default duration
|
||||
return element_when_flag_enabled if has_element?(element_when_flag_enabled)
|
||||
return element_when_flag_disabled if has_element?(element_when_flag_disabled)
|
||||
|
||||
raise ElementNotFound,
|
||||
"Could not find the expected element as #{element_when_flag_enabled} or #{element_when_flag_disabled}." \
|
||||
"The relevant feature flag is #{feature_flag}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module QA
|
||||
module Page
|
||||
module Component
|
||||
module BlobContent
|
||||
extend QA::Page::PageConcern
|
||||
|
||||
def self.included(base)
|
||||
super
|
||||
|
||||
base.view 'app/assets/javascripts/blob/components/blob_header_filepath.vue' do
|
||||
element :file_title_content
|
||||
end
|
||||
|
||||
base.view 'app/assets/javascripts/blob/components/blob_content.vue' do
|
||||
element :blob_viewer_file_content
|
||||
end
|
||||
|
||||
base.view 'app/assets/javascripts/blob/components/blob_header_default_actions.vue' do
|
||||
element :default_actions_container
|
||||
element :copy_contents_button
|
||||
end
|
||||
|
||||
base.view 'app/views/projects/blob/_header_content.html.haml' do
|
||||
element :file_name_content
|
||||
end
|
||||
|
||||
base.view 'app/views/shared/_file_highlight.html.haml' do
|
||||
element :file_content
|
||||
end
|
||||
end
|
||||
|
||||
def has_file?(name)
|
||||
has_file_name?(name)
|
||||
end
|
||||
|
||||
def has_no_file?(name)
|
||||
has_no_file_name?(name)
|
||||
end
|
||||
|
||||
def has_file_name?(file_name, file_number = nil)
|
||||
within_file_by_number(file_name_element, file_number) { has_text?(file_name) }
|
||||
end
|
||||
|
||||
def has_no_file_name?(file_name)
|
||||
within_element(file_name_element) do
|
||||
has_no_text?(file_name)
|
||||
end
|
||||
end
|
||||
|
||||
def has_file_content?(file_content, file_number = nil)
|
||||
within_file_by_number(file_content_element, file_number) { has_text?(file_content) }
|
||||
end
|
||||
|
||||
def has_no_file_content?(file_content)
|
||||
within_element(file_content_element) do
|
||||
has_no_text?(file_content)
|
||||
end
|
||||
end
|
||||
|
||||
def click_copy_file_contents(file_number = nil)
|
||||
within_file_by_number(:default_actions_container, file_number) { click_element(:copy_contents_button) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def file_content_element
|
||||
feature_flag_controlled_element(:refactor_blob_viewer, :blob_viewer_file_content, :file_content)
|
||||
end
|
||||
|
||||
def file_name_element
|
||||
feature_flag_controlled_element(:refactor_blob_viewer, :file_title_content, :file_name_content)
|
||||
end
|
||||
|
||||
def within_file_by_number(element, file_number)
|
||||
method = file_number ? 'within_element_by_index' : 'within_element'
|
||||
send(method, element, file_number) { yield }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -26,7 +26,7 @@ module QA
|
|||
end
|
||||
|
||||
base.view 'app/assets/javascripts/blob/components/blob_content.vue' do
|
||||
element :file_content
|
||||
element :blob_viewer_file_content
|
||||
end
|
||||
|
||||
base.view 'app/assets/javascripts/snippets/components/snippet_header.vue' do
|
||||
|
@ -130,11 +130,11 @@ module QA
|
|||
|
||||
def has_file_content?(file_content, file_number = nil)
|
||||
if file_number
|
||||
within_element_by_index(:file_content, file_number - 1) do
|
||||
within_element_by_index(:blob_viewer_file_content, file_number - 1) do
|
||||
has_text?(file_content)
|
||||
end
|
||||
else
|
||||
within_element(:file_content) do
|
||||
within_element(:blob_viewer_file_content) do
|
||||
has_text?(file_content)
|
||||
end
|
||||
end
|
||||
|
@ -142,11 +142,11 @@ module QA
|
|||
|
||||
def has_no_file_content?(file_content, file_number = nil)
|
||||
if file_number
|
||||
within_element_by_index(:file_content, file_number - 1) do
|
||||
within_element_by_index(:blob_viewer_file_content, file_number - 1) do
|
||||
has_no_text?(file_content)
|
||||
end
|
||||
else
|
||||
within_element(:file_content) do
|
||||
within_element(:blob_viewer_file_content) do
|
||||
has_no_text?(file_content)
|
||||
end
|
||||
end
|
||||
|
@ -207,7 +207,7 @@ module QA
|
|||
end
|
||||
|
||||
def has_syntax_highlighting?(language)
|
||||
within_element(:file_content) do
|
||||
within_element(:blob_viewer_file_content) do
|
||||
find('.line')['lang'].to_s == language
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,6 +6,7 @@ module QA
|
|||
module Snippet
|
||||
class Show < Page::Base
|
||||
include Page::Component::Snippet
|
||||
include Page::Component::BlobContent
|
||||
|
||||
view 'app/assets/javascripts/snippets/components/snippet_title.vue' do
|
||||
element :snippet_title_content, required: true
|
||||
|
|
|
@ -10,6 +10,10 @@ module QA
|
|||
def self.included(base)
|
||||
super
|
||||
|
||||
base.view 'app/assets/javascripts/repository/components/delete_blob_modal.vue' do
|
||||
element :commit_message_field
|
||||
end
|
||||
|
||||
base.view 'app/views/shared/_commit_message_container.html.haml' do
|
||||
element :commit_message_field
|
||||
end
|
||||
|
|
|
@ -8,24 +8,21 @@ module QA
|
|||
include Project::SubMenus::Settings
|
||||
include Project::SubMenus::Common
|
||||
include Layout::Flash
|
||||
include Page::Component::BlobContent
|
||||
|
||||
view 'app/assets/javascripts/repository/components/blob_button_group.vue' do
|
||||
element :lock_button
|
||||
end
|
||||
|
||||
view 'app/helpers/blob_helper.rb' do
|
||||
element :edit_button, "_('Edit')" # rubocop:disable QA/ElementWithPattern
|
||||
element :delete_button, '_("Delete")' # rubocop:disable QA/ElementWithPattern
|
||||
end
|
||||
|
||||
view 'app/views/projects/blob/_header_content.html.haml' do
|
||||
element :file_name_content
|
||||
end
|
||||
|
||||
view 'app/views/projects/blob/_remove.html.haml' do
|
||||
element :delete_file_button, "button_tag 'Delete file'" # rubocop:disable QA/ElementWithPattern
|
||||
end
|
||||
|
||||
view 'app/views/shared/_file_highlight.html.haml' do
|
||||
element :file_content
|
||||
end
|
||||
|
||||
def click_edit
|
||||
click_on 'Edit'
|
||||
end
|
||||
|
@ -37,26 +34,6 @@ module QA
|
|||
def click_delete_file
|
||||
click_on 'Delete file'
|
||||
end
|
||||
|
||||
def has_file?(name)
|
||||
has_element?(:file_name_content, text: name)
|
||||
end
|
||||
|
||||
def has_no_file?(name)
|
||||
has_no_element?(:file_name_content, text: name)
|
||||
end
|
||||
|
||||
def has_file_content?(file_content, file_number = nil)
|
||||
if file_number
|
||||
within_element_by_index(:file_content, file_number - 1) do
|
||||
has_text?(file_content)
|
||||
end
|
||||
else
|
||||
within_element(:file_content) do
|
||||
has_text?(file_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,6 +6,7 @@ module QA
|
|||
module Snippet
|
||||
class Index < Page::Base
|
||||
include Page::Component::Snippet
|
||||
include Page::Component::BlobContent
|
||||
|
||||
view 'app/views/shared/snippets/_snippet.html.haml' do
|
||||
element :snippet_link
|
||||
|
|
|
@ -6,6 +6,7 @@ module QA
|
|||
module Snippet
|
||||
class Show < Page::Base
|
||||
include Page::Component::Snippet
|
||||
include Page::Component::BlobContent
|
||||
|
||||
view 'app/views/projects/notes/_actions.html.haml' do
|
||||
element :edit_comment_button
|
||||
|
|
|
@ -263,6 +263,8 @@ RSpec.describe ApplicationExperiment, :experiment do
|
|||
"https://badplace.com\nhttps://gitlab.com" | nil
|
||||
'https://gitlabbcom' | nil
|
||||
'https://gitlabbcom/' | nil
|
||||
'http://gdk.test/foo/bar' | 'http://gdk.test/foo/bar'
|
||||
'http://localhost:3000/foo/bar' | 'http://localhost:3000/foo/bar'
|
||||
end
|
||||
|
||||
with_them do
|
||||
|
|
|
@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils';
|
|||
import MockAdapter from 'axios-mock-adapter';
|
||||
import Vue, { nextTick } from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data';
|
||||
|
@ -19,10 +20,15 @@ import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/consta
|
|||
import eventHub from '~/vue_merge_request_widget/event_hub';
|
||||
import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
|
||||
import { stateKey } from '~/vue_merge_request_widget/stores/state_maps';
|
||||
import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue';
|
||||
import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql';
|
||||
import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data';
|
||||
import mockData from './mock_data';
|
||||
import testExtension from './test_extension';
|
||||
import {
|
||||
workingExtension,
|
||||
collapsedDataErrorExtension,
|
||||
fullDataErrorExtension,
|
||||
} from './test_extensions';
|
||||
|
||||
jest.mock('~/api.js');
|
||||
|
||||
|
@ -892,7 +898,7 @@ describe('MrWidgetOptions', () => {
|
|||
|
||||
describe('mock extension', () => {
|
||||
beforeEach(() => {
|
||||
registerExtension(testExtension);
|
||||
registerExtension(workingExtension);
|
||||
|
||||
createComponent();
|
||||
});
|
||||
|
@ -914,7 +920,7 @@ describe('MrWidgetOptions', () => {
|
|||
.find('[data-testid="widget-extension"] [data-testid="toggle-button"]')
|
||||
.trigger('click');
|
||||
|
||||
await Vue.nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith('test_expand_event');
|
||||
});
|
||||
|
@ -926,7 +932,7 @@ describe('MrWidgetOptions', () => {
|
|||
.find('[data-testid="widget-extension"] [data-testid="toggle-button"]')
|
||||
.trigger('click');
|
||||
|
||||
await Vue.nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-testid="widget-extension-top-level"]').find(GlDropdown).exists(),
|
||||
|
@ -952,4 +958,50 @@ describe('MrWidgetOptions', () => {
|
|||
expect(collapsedSection.find(GlButton).text()).toBe('Full report');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mock extension errors', () => {
|
||||
let captureException;
|
||||
|
||||
const itHandlesTheException = () => {
|
||||
expect(captureException).toHaveBeenCalledTimes(1);
|
||||
expect(captureException).toHaveBeenCalledWith(new Error('Fetch error'));
|
||||
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
captureException = jest.spyOn(Sentry, 'captureException');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
registeredExtensions.extensions = [];
|
||||
captureException = null;
|
||||
});
|
||||
|
||||
it('handles collapsed data fetch errors', async () => {
|
||||
registerExtension(collapsedDataErrorExtension);
|
||||
createComponent();
|
||||
await waitForPromises();
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]').exists(),
|
||||
).toBe(false);
|
||||
itHandlesTheException();
|
||||
});
|
||||
|
||||
it('handles full data fetch errors', async () => {
|
||||
registerExtension(fullDataErrorExtension);
|
||||
createComponent();
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.findComponent(StatusIcon).props('iconName')).not.toBe('error');
|
||||
wrapper
|
||||
.find('[data-testid="widget-extension"] [data-testid="toggle-button"]')
|
||||
.trigger('click');
|
||||
|
||||
await nextTick();
|
||||
await waitForPromises();
|
||||
|
||||
itHandlesTheException();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
|
||||
|
||||
export default {
|
||||
name: 'WidgetTestExtension',
|
||||
props: ['targetProjectFullPath'],
|
||||
expandEvent: 'test_expand_event',
|
||||
computed: {
|
||||
summary({ count, targetProjectFullPath }) {
|
||||
return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
|
||||
},
|
||||
statusIcon({ count }) {
|
||||
return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchCollapsedData({ targetProjectFullPath }) {
|
||||
return Promise.resolve({ targetProjectFullPath, count: 1 });
|
||||
},
|
||||
fetchFullData() {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 1,
|
||||
text: 'Hello world',
|
||||
icon: {
|
||||
name: EXTENSION_ICONS.failed,
|
||||
},
|
||||
badge: {
|
||||
text: 'Closed',
|
||||
},
|
||||
link: {
|
||||
href: 'https://gitlab.com',
|
||||
text: 'GitLab.com',
|
||||
},
|
||||
actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }],
|
||||
},
|
||||
]);
|
||||
},
|
||||
},
|
||||
};
|
|
@ -0,0 +1,99 @@
|
|||
import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
|
||||
|
||||
export const workingExtension = {
|
||||
name: 'WidgetTestExtension',
|
||||
props: ['targetProjectFullPath'],
|
||||
expandEvent: 'test_expand_event',
|
||||
computed: {
|
||||
summary({ count, targetProjectFullPath }) {
|
||||
return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
|
||||
},
|
||||
statusIcon({ count }) {
|
||||
return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchCollapsedData({ targetProjectFullPath }) {
|
||||
return Promise.resolve({ targetProjectFullPath, count: 1 });
|
||||
},
|
||||
fetchFullData() {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 1,
|
||||
text: 'Hello world',
|
||||
icon: {
|
||||
name: EXTENSION_ICONS.failed,
|
||||
},
|
||||
badge: {
|
||||
text: 'Closed',
|
||||
},
|
||||
link: {
|
||||
href: 'https://gitlab.com',
|
||||
text: 'GitLab.com',
|
||||
},
|
||||
actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }],
|
||||
},
|
||||
]);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const collapsedDataErrorExtension = {
|
||||
name: 'WidgetTestCollapsedErrorExtension',
|
||||
props: ['targetProjectFullPath'],
|
||||
expandEvent: 'test_expand_event',
|
||||
computed: {
|
||||
summary({ count, targetProjectFullPath }) {
|
||||
return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
|
||||
},
|
||||
statusIcon({ count }) {
|
||||
return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchCollapsedData() {
|
||||
return Promise.reject(new Error('Fetch error'));
|
||||
},
|
||||
fetchFullData() {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 1,
|
||||
text: 'Hello world',
|
||||
icon: {
|
||||
name: EXTENSION_ICONS.failed,
|
||||
},
|
||||
badge: {
|
||||
text: 'Closed',
|
||||
},
|
||||
link: {
|
||||
href: 'https://gitlab.com',
|
||||
text: 'GitLab.com',
|
||||
},
|
||||
actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }],
|
||||
},
|
||||
]);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const fullDataErrorExtension = {
|
||||
name: 'WidgetTestCollapsedErrorExtension',
|
||||
props: ['targetProjectFullPath'],
|
||||
expandEvent: 'test_expand_event',
|
||||
computed: {
|
||||
summary({ count, targetProjectFullPath }) {
|
||||
return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
|
||||
},
|
||||
statusIcon({ count }) {
|
||||
return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchCollapsedData({ targetProjectFullPath }) {
|
||||
return Promise.resolve({ targetProjectFullPath, count: 1 });
|
||||
},
|
||||
fetchFullData() {
|
||||
return Promise.reject(new Error('Fetch error'));
|
||||
},
|
||||
},
|
||||
};
|
|
@ -321,7 +321,7 @@ RSpec.describe MarkupHelper do
|
|||
let(:context) do
|
||||
{
|
||||
pipeline: :wiki, project: project, wiki: wiki,
|
||||
page_slug: 'nested/page', issuable_state_filter_enabled: true,
|
||||
page_slug: 'nested/page', issuable_reference_expansion_enabled: true,
|
||||
repository: wiki_repository
|
||||
}
|
||||
end
|
||||
|
|
|
@ -2,28 +2,27 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Banzai::Filter::IssuableStateFilter do
|
||||
include ActionView::Helpers::UrlHelper
|
||||
RSpec.describe Banzai::Filter::IssuableReferenceExpansionFilter do
|
||||
include FilterSpecHelper
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:context) { { current_user: user, issuable_state_filter_enabled: true } }
|
||||
let(:closed_issue) { create_issue(:closed) }
|
||||
let(:project) { create(:project, :public) }
|
||||
let(:group) { create(:group) }
|
||||
let(:other_project) { create(:project, :public) }
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:project) { create(:project, :public) }
|
||||
let_it_be(:group) { create(:group) }
|
||||
let_it_be(:other_project) { create(:project, :public) }
|
||||
let_it_be(:closed_issue) { create_issue(:closed) }
|
||||
|
||||
let(:context) { { current_user: user, issuable_reference_expansion_enabled: true } }
|
||||
|
||||
def create_link(text, data)
|
||||
link_to(text, '', class: 'gfm has-tooltip', data: data)
|
||||
ActionController::Base.helpers.link_to(text, '', class: 'gfm has-tooltip', data: data)
|
||||
end
|
||||
|
||||
def create_issue(state)
|
||||
create(:issue, state, project: project)
|
||||
def create_issue(state, attributes = {})
|
||||
create(:issue, state, attributes.merge(project: project))
|
||||
end
|
||||
|
||||
def create_merge_request(state)
|
||||
create(:merge_request, state,
|
||||
source_project: project, target_project: project)
|
||||
def create_merge_request(state, attributes = {})
|
||||
create(:merge_request, state, attributes.merge(source_project: project, target_project: project))
|
||||
end
|
||||
|
||||
it 'ignores non-GFM links' do
|
||||
|
@ -139,6 +138,30 @@ RSpec.describe Banzai::Filter::IssuableStateFilter do
|
|||
|
||||
expect(doc.css('a').last.text).to eq("#{moved_issue.to_reference} (moved)")
|
||||
end
|
||||
|
||||
it 'shows title for references with +' do
|
||||
issue = create_issue(:opened, title: 'Some issue')
|
||||
link = create_link(issue.to_reference, issue: issue.id, reference_type: 'issue', reference_format: '+')
|
||||
doc = filter(link, context)
|
||||
|
||||
expect(doc.css('a').last.text).to eq("#{issue.title} (#{issue.to_reference})")
|
||||
end
|
||||
|
||||
it 'truncates long title for references with +' do
|
||||
issue = create_issue(:opened, title: 'Some issue ' * 10)
|
||||
link = create_link(issue.to_reference, issue: issue.id, reference_type: 'issue', reference_format: '+')
|
||||
doc = filter(link, context)
|
||||
|
||||
expect(doc.css('a').last.text).to eq("#{issue.title.truncate(50)} (#{issue.to_reference})")
|
||||
end
|
||||
|
||||
it 'shows both title and state for closed references with +' do
|
||||
issue = create_issue(:closed, title: 'Some issue')
|
||||
link = create_link(issue.to_reference, issue: issue.id, reference_type: 'issue', reference_format: '+')
|
||||
doc = filter(link, context)
|
||||
|
||||
expect(doc.css('a').last.text).to eq("#{issue.title} (#{issue.to_reference} - closed)")
|
||||
end
|
||||
end
|
||||
|
||||
context 'for merge request references' do
|
||||
|
@ -197,5 +220,20 @@ RSpec.describe Banzai::Filter::IssuableStateFilter do
|
|||
|
||||
expect(doc.css('a').last.text).to eq("#{merge_request.to_reference} (merged)")
|
||||
end
|
||||
|
||||
it 'shows title for references with +' do
|
||||
merge_request = create_merge_request(:opened, title: 'Some merge request')
|
||||
|
||||
link = create_link(
|
||||
merge_request.to_reference,
|
||||
merge_request: merge_request.id,
|
||||
reference_type: 'merge_request',
|
||||
reference_format: '+'
|
||||
)
|
||||
|
||||
doc = filter(link, context)
|
||||
|
||||
expect(doc.css('a').last.text).to eq("#{merge_request.title} (#{merge_request.to_reference})")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -116,6 +116,14 @@ RSpec.describe Banzai::Filter::References::IssueReferenceFilter do
|
|||
expect(doc.children.first.attr('data-original')).to eq inner_html
|
||||
end
|
||||
|
||||
it 'includes a data-reference-format attribute' do
|
||||
doc = reference_filter("Issue #{reference}+")
|
||||
link = doc.css('a').first
|
||||
|
||||
expect(link).to have_attribute('data-reference-format')
|
||||
expect(link.attr('data-reference-format')).to eq('+')
|
||||
end
|
||||
|
||||
it 'supports an :only_path context' do
|
||||
doc = reference_filter("Issue #{reference}", only_path: true)
|
||||
link = doc.css('a').first.attr('href')
|
||||
|
|
|
@ -109,6 +109,14 @@ RSpec.describe Banzai::Filter::References::MergeRequestReferenceFilter do
|
|||
expect(link.attr('data-merge-request')).to eq merge.id.to_s
|
||||
end
|
||||
|
||||
it 'includes a data-reference-format attribute' do
|
||||
doc = reference_filter("Merge #{reference}+")
|
||||
link = doc.css('a').first
|
||||
|
||||
expect(link).to have_attribute('data-reference-format')
|
||||
expect(link.attr('data-reference-format')).to eq('+')
|
||||
end
|
||||
|
||||
it 'supports an :only_path context' do
|
||||
doc = reference_filter("Merge #{reference}", only_path: true)
|
||||
link = doc.css('a').first.attr('href')
|
||||
|
|
|
@ -128,7 +128,7 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do
|
|||
end
|
||||
end
|
||||
|
||||
context 'letter_opener applicaiton URL' do
|
||||
context 'letter_opener application URL' do
|
||||
let(:gitlab_url) { 'http://gitlab.example.com' }
|
||||
let(:letter_opener_url) { "#{gitlab_url}/rails/letter_opener/" }
|
||||
|
||||
|
@ -156,6 +156,46 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'Snowplow Micro event collector' do
|
||||
let(:snowplow_micro_hostname) { 'localhost:9090' }
|
||||
let(:snowplow_micro_url) { "http://#{snowplow_micro_hostname}/" }
|
||||
|
||||
before do
|
||||
stub_env('SNOWPLOW_MICRO_ENABLE', 1)
|
||||
allow(Gitlab::Tracking).to receive(:collector_hostname).and_return(snowplow_micro_hostname)
|
||||
end
|
||||
|
||||
context 'when in production' do
|
||||
before do
|
||||
stub_rails_env('production')
|
||||
end
|
||||
|
||||
it 'does not add Snowplow Micro URL to connect-src' do
|
||||
expect(directives['connect_src']).not_to include(snowplow_micro_url)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in development' do
|
||||
before do
|
||||
stub_rails_env('development')
|
||||
end
|
||||
|
||||
it 'adds Snowplow Micro URL with trailing slash to connect-src' do
|
||||
expect(directives['connect_src']).to match(Regexp.new(snowplow_micro_url))
|
||||
end
|
||||
|
||||
context 'when not enabled using ENV[SNOWPLOW_MICRO_ENABLE]' do
|
||||
before do
|
||||
stub_env('SNOWPLOW_MICRO_ENABLE', nil)
|
||||
end
|
||||
|
||||
it 'does not add Snowplow Micro URL to connect-src' do
|
||||
expect(directives['connect_src']).not_to include(snowplow_micro_url)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#load' do
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Database::LooseForeignKeys do
|
||||
describe 'verify all definitions' do
|
||||
subject(:definitions) { described_class.definitions }
|
||||
|
||||
it 'all definitions have assigned a known gitlab_schema and on_delete' do
|
||||
is_expected.to all(have_attributes(
|
||||
options: a_hash_including(
|
||||
column: be_a(String),
|
||||
gitlab_schema: be_in(Gitlab::Database.schemas_to_base_models.symbolize_keys.keys),
|
||||
on_delete: be_in([:async_delete, :async_nullify])
|
||||
),
|
||||
from_table: be_a(String),
|
||||
to_table: be_a(String)
|
||||
))
|
||||
end
|
||||
|
||||
describe 'ensuring database integrity' do
|
||||
def base_models_for(table)
|
||||
parent_table_schema = Gitlab::Database::GitlabSchema.table_schema(table)
|
||||
Gitlab::Database.schemas_to_base_models.fetch(parent_table_schema)
|
||||
end
|
||||
|
||||
it 'all `from_table` tables are present' do
|
||||
definitions.each do |definition|
|
||||
base_models_for(definition.from_table).each do |model|
|
||||
expect(model.connection).to be_table_exist(definition.from_table)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'all `to_table` tables are present' do
|
||||
definitions.each do |definition|
|
||||
base_models_for(definition.to_table).each do |model|
|
||||
expect(model.connection).to be_table_exist(definition.to_table)
|
||||
expect(model.connection).to be_column_exist(definition.to_table, definition.column)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -46,9 +46,5 @@ RSpec.describe ChatName do
|
|||
|
||||
it_behaves_like 'it has loose foreign keys' do
|
||||
let(:factory_name) { :chat_name }
|
||||
|
||||
before do
|
||||
Ci::PipelineChatData # ensure that the referenced model is loaded
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -7,10 +7,6 @@ RSpec.describe Ci::Runner do
|
|||
|
||||
it_behaves_like 'it has loose foreign keys' do
|
||||
let(:factory_name) { :ci_runner }
|
||||
|
||||
before do
|
||||
Clusters::Applications::Runner # ensure that the referenced model is loaded
|
||||
end
|
||||
end
|
||||
|
||||
describe 'groups association' do
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe LooseForeignKey do
|
||||
let(:project_klass) do
|
||||
Class.new(ApplicationRecord) do
|
||||
include LooseForeignKey
|
||||
|
||||
self.table_name = 'projects'
|
||||
|
||||
loose_foreign_key :issues, :project_id, on_delete: :async_delete
|
||||
loose_foreign_key 'merge_requests', 'project_id', 'on_delete' => 'async_nullify'
|
||||
end
|
||||
end
|
||||
|
||||
it 'exposes the loose foreign key definitions' do
|
||||
definitions = project_klass.loose_foreign_key_definitions
|
||||
|
||||
tables = definitions.map(&:to_table)
|
||||
expect(tables).to eq(%w[issues merge_requests])
|
||||
end
|
||||
|
||||
it 'casts strings to symbol' do
|
||||
definition = project_klass.loose_foreign_key_definitions.last
|
||||
|
||||
expect(definition.from_table).to eq('projects')
|
||||
expect(definition.to_table).to eq('merge_requests')
|
||||
expect(definition.column).to eq('project_id')
|
||||
expect(definition.on_delete).to eq(:async_nullify)
|
||||
end
|
||||
|
||||
context 'validation' do
|
||||
context 'on_delete validation' do
|
||||
let(:invalid_class) do
|
||||
Class.new(ApplicationRecord) do
|
||||
include LooseForeignKey
|
||||
|
||||
self.table_name = 'projects'
|
||||
|
||||
loose_foreign_key :issues, :project_id, on_delete: :async_delete
|
||||
loose_foreign_key :merge_requests, :project_id, on_delete: :async_nullify
|
||||
loose_foreign_key :merge_requests, :project_id, on_delete: :destroy
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises error when invalid `on_delete` option was given' do
|
||||
expect { invalid_class }.to raise_error /Invalid on_delete option given: destroy/
|
||||
end
|
||||
end
|
||||
|
||||
context 'inheritance validation' do
|
||||
let(:inherited_project_class) do
|
||||
Class.new(Project) do
|
||||
include LooseForeignKey
|
||||
|
||||
loose_foreign_key :issues, :project_id, on_delete: :async_delete
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises error when loose_foreign_key is defined in a child ActiveRecord model' do
|
||||
expect { inherited_project_class }.to raise_error /Please define the loose_foreign_key on the Project class/
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -261,7 +261,49 @@ RSpec.describe Project, factory_default: :keep do
|
|||
end
|
||||
|
||||
context 'updating a project' do
|
||||
context 'with project namespaces' do
|
||||
shared_examples 'project update' do
|
||||
let_it_be(:project_namespace) { create(:project_namespace) }
|
||||
let_it_be(:project) { project_namespace.project }
|
||||
|
||||
context 'when project namespace is not set' do
|
||||
before do
|
||||
project.update_column(:project_namespace_id, nil)
|
||||
project.reload
|
||||
end
|
||||
|
||||
it 'updates the project successfully' do
|
||||
# pre-check that project does not have a project namespace
|
||||
expect(project.project_namespace).to be_nil
|
||||
|
||||
project.update!(path: 'hopefully-valid-path2')
|
||||
|
||||
expect(project).to be_persisted
|
||||
expect(project).to be_valid
|
||||
expect(project.path).to eq('hopefully-valid-path2')
|
||||
expect(project.project_namespace).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when project has an associated project namespace' do
|
||||
# when FF is disabled creating a project does not create a project_namespace, so we create one
|
||||
it 'project is INVALID when trying to remove project namespace' do
|
||||
project.reload
|
||||
# check that project actually has an associated project namespace
|
||||
expect(project.project_namespace_id).to eq(project_namespace.id)
|
||||
|
||||
expect do
|
||||
project.update!(project_namespace_id: nil, path: 'hopefully-valid-path1')
|
||||
end.to raise_error(ActiveRecord::RecordInvalid)
|
||||
expect(project).to be_invalid
|
||||
expect(project.errors.full_messages).to include("Project namespace can't be blank")
|
||||
expect(project.reload.project_namespace).to be_in_sync_with_project(project)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with create_project_namespace_on_project_create FF enabled' do
|
||||
it_behaves_like 'project update'
|
||||
|
||||
it 'keeps project namespace in sync with project' do
|
||||
project = create(:project)
|
||||
project.update!(path: 'hopefully-valid-path1')
|
||||
|
@ -270,19 +312,21 @@ RSpec.describe Project, factory_default: :keep do
|
|||
expect(project.project_namespace).to be_persisted
|
||||
expect(project.project_namespace).to be_in_sync_with_project(project)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with FF disabled' do
|
||||
before do
|
||||
stub_feature_flags(create_project_namespace_on_project_create: false)
|
||||
end
|
||||
context 'with create_project_namespace_on_project_create FF disabled' do
|
||||
before do
|
||||
stub_feature_flags(create_project_namespace_on_project_create: false)
|
||||
end
|
||||
|
||||
it 'does not create a project namespace when project is updated' do
|
||||
project = create(:project)
|
||||
project.update!(path: 'hopefully-valid-path1')
|
||||
it_behaves_like 'project update'
|
||||
|
||||
expect(project).to be_persisted
|
||||
expect(project.project_namespace).to be_nil
|
||||
end
|
||||
it 'does not create a project namespace when project is updated' do
|
||||
project = create(:project)
|
||||
project.update!(path: 'hopefully-valid-path1')
|
||||
|
||||
expect(project).to be_persisted
|
||||
expect(project.project_namespace).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -46,6 +46,47 @@ RSpec.describe Ci::CreatePipelineService do
|
|||
end
|
||||
# rubocop:enable Metrics/ParameterLists
|
||||
|
||||
context 'performance' do
|
||||
it_behaves_like 'pipelines are created without N+1 SQL queries' do
|
||||
let(:config1) do
|
||||
<<~YAML
|
||||
job1:
|
||||
stage: build
|
||||
script: exit 0
|
||||
|
||||
job2:
|
||||
stage: test
|
||||
script: exit 0
|
||||
YAML
|
||||
end
|
||||
|
||||
let(:config2) do
|
||||
<<~YAML
|
||||
job1:
|
||||
stage: build
|
||||
script: exit 0
|
||||
|
||||
job2:
|
||||
stage: test
|
||||
script: exit 0
|
||||
|
||||
job3:
|
||||
stage: deploy
|
||||
script: exit 0
|
||||
YAML
|
||||
end
|
||||
|
||||
let(:accepted_n_plus_ones) do
|
||||
1 + # SELECT "ci_instance_variables"
|
||||
1 + # INSERT INTO "ci_stages"
|
||||
1 + # SELECT "ci_builds".* FROM "ci_builds"
|
||||
1 + # INSERT INTO "ci_builds"
|
||||
1 + # INSERT INTO "ci_builds_metadata"
|
||||
1 # SELECT "taggings".* FROM "taggings"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'valid params' do
|
||||
let(:pipeline) { execute_service.payload }
|
||||
|
||||
|
|
|
@ -21,33 +21,34 @@ RSpec.describe LooseForeignKeys::BatchCleanerService do
|
|||
migration.track_record_deletions(:_test_loose_fk_parent_table)
|
||||
end
|
||||
|
||||
let(:parent_model) do
|
||||
Class.new(ApplicationRecord) do
|
||||
self.table_name = '_test_loose_fk_parent_table'
|
||||
|
||||
include LooseForeignKey
|
||||
|
||||
loose_foreign_key :_test_loose_fk_child_table_1, :parent_id, on_delete: :async_delete
|
||||
loose_foreign_key :_test_loose_fk_child_table_2, :parent_id_with_different_column, on_delete: :async_nullify
|
||||
end
|
||||
end
|
||||
|
||||
let(:child_model_1) do
|
||||
Class.new(ApplicationRecord) do
|
||||
self.table_name = '_test_loose_fk_child_table_1'
|
||||
end
|
||||
end
|
||||
|
||||
let(:child_model_2) do
|
||||
Class.new(ApplicationRecord) do
|
||||
self.table_name = '_test_loose_fk_child_table_2'
|
||||
end
|
||||
let(:loose_foreign_key_definitions) do
|
||||
[
|
||||
ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
|
||||
'_test_loose_fk_parent_table',
|
||||
'_test_loose_fk_child_table_1',
|
||||
{
|
||||
column: 'parent_id',
|
||||
on_delete: :async_delete,
|
||||
gitlab_schema: :gitlab_main
|
||||
}
|
||||
),
|
||||
ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
|
||||
'_test_loose_fk_parent_table',
|
||||
'_test_loose_fk_child_table_2',
|
||||
{
|
||||
column: 'parent_id_with_different_column',
|
||||
on_delete: :async_nullify,
|
||||
gitlab_schema: :gitlab_main
|
||||
}
|
||||
)
|
||||
]
|
||||
end
|
||||
|
||||
let(:loose_fk_parent_table) { table(:_test_loose_fk_parent_table) }
|
||||
let(:loose_fk_child_table_1) { table(:_test_loose_fk_child_table_1) }
|
||||
let(:loose_fk_child_table_2) { table(:_test_loose_fk_child_table_2) }
|
||||
let(:parent_record_1) { parent_model.create! }
|
||||
let(:other_parent_record) { parent_model.create! }
|
||||
let(:parent_record_1) { loose_fk_parent_table.create! }
|
||||
let(:other_parent_record) { loose_fk_parent_table.create! }
|
||||
|
||||
before(:all) do
|
||||
create_table_structure
|
||||
|
@ -87,12 +88,10 @@ RSpec.describe LooseForeignKeys::BatchCleanerService do
|
|||
expect(loose_fk_child_table_1.count).to eq(4)
|
||||
expect(loose_fk_child_table_2.count).to eq(4)
|
||||
|
||||
described_class.new(parent_klass: parent_model,
|
||||
deleted_parent_records: LooseForeignKeys::DeletedRecord.status_pending.all,
|
||||
models_by_table_name: {
|
||||
'_test_loose_fk_child_table_1' => child_model_1,
|
||||
'_test_loose_fk_child_table_2' => child_model_2
|
||||
}).execute
|
||||
described_class.new(parent_table: '_test_loose_fk_parent_table',
|
||||
loose_foreign_key_definitions: loose_foreign_key_definitions,
|
||||
deleted_parent_records: LooseForeignKeys::DeletedRecord.status_pending.all
|
||||
).execute
|
||||
end
|
||||
|
||||
it 'cleans up the child records' do
|
||||
|
@ -108,7 +107,7 @@ RSpec.describe LooseForeignKeys::BatchCleanerService do
|
|||
it 'records the DeletedRecord status updates', :prometheus do
|
||||
counter = Gitlab::Metrics.registry.get(:loose_foreign_key_processed_deleted_records)
|
||||
|
||||
expect(counter.get(table: parent_model.table_name, db_config_name: 'main')).to eq(1)
|
||||
expect(counter.get(table: loose_fk_parent_table.table_name, db_config_name: 'main')).to eq(1)
|
||||
end
|
||||
|
||||
it 'does not delete unrelated records' do
|
||||
|
|
|
@ -17,17 +17,17 @@ RSpec.describe LooseForeignKeys::CleanerService do
|
|||
'issues',
|
||||
{
|
||||
column: 'project_id',
|
||||
on_delete: :async_nullify
|
||||
on_delete: :async_nullify,
|
||||
gitlab_schema: :gitlab_main
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
subject(:cleaner_service) do
|
||||
described_class.new(
|
||||
model: Issue,
|
||||
foreign_key_definition: loose_fk_definition,
|
||||
deleted_parent_records: deleted_records
|
||||
)
|
||||
loose_foreign_key_definition: loose_fk_definition,
|
||||
connection: ApplicationRecord.connection,
|
||||
deleted_parent_records: deleted_records)
|
||||
end
|
||||
|
||||
context 'when invalid foreign key definition is passed' do
|
||||
|
@ -84,7 +84,8 @@ RSpec.describe LooseForeignKeys::CleanerService do
|
|||
'project_authorizations',
|
||||
{
|
||||
column: 'user_id',
|
||||
on_delete: :async_delete
|
||||
on_delete: :async_delete,
|
||||
gitlab_schema: :gitlab_main
|
||||
}
|
||||
)
|
||||
end
|
||||
|
@ -97,8 +98,8 @@ RSpec.describe LooseForeignKeys::CleanerService do
|
|||
|
||||
subject(:cleaner_service) do
|
||||
described_class.new(
|
||||
model: ProjectAuthorization,
|
||||
foreign_key_definition: loose_fk_definition,
|
||||
loose_foreign_key_definition: loose_fk_definition,
|
||||
connection: ApplicationRecord.connection,
|
||||
deleted_parent_records: deleted_records
|
||||
)
|
||||
end
|
||||
|
@ -130,8 +131,8 @@ RSpec.describe LooseForeignKeys::CleanerService do
|
|||
context 'when with_skip_locked parameter is true' do
|
||||
subject(:cleaner_service) do
|
||||
described_class.new(
|
||||
model: Issue,
|
||||
foreign_key_definition: loose_fk_definition,
|
||||
loose_foreign_key_definition: loose_fk_definition,
|
||||
connection: ApplicationRecord.connection,
|
||||
deleted_parent_records: deleted_records,
|
||||
with_skip_locked: true
|
||||
)
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.shared_examples 'pipelines are created without N+1 SQL queries' do
|
||||
before do
|
||||
# warm up
|
||||
stub_ci_pipeline_yaml_file(config1)
|
||||
execute_service
|
||||
end
|
||||
|
||||
it 'avoids N+1 queries', :aggregate_failures, :request_store, :use_sql_query_cache do
|
||||
control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
|
||||
stub_ci_pipeline_yaml_file(config1)
|
||||
|
||||
pipeline = execute_service.payload
|
||||
|
||||
expect(pipeline).to be_created_successfully
|
||||
end
|
||||
|
||||
expect do
|
||||
stub_ci_pipeline_yaml_file(config2)
|
||||
|
||||
pipeline = execute_service.payload
|
||||
|
||||
expect(pipeline).to be_created_successfully
|
||||
end.not_to exceed_all_query_limit(control).with_threshold(accepted_n_plus_ones)
|
||||
end
|
||||
end
|
|
@ -5,16 +5,9 @@ RSpec.shared_examples 'it has loose foreign keys' do
|
|||
let(:table_name) { described_class.table_name }
|
||||
let(:connection) { described_class.connection }
|
||||
|
||||
it 'includes the LooseForeignKey module' do
|
||||
expect(described_class.ancestors).to include(LooseForeignKey)
|
||||
end
|
||||
|
||||
it 'responds to #loose_foreign_key_definitions' do
|
||||
expect(described_class).to respond_to(:loose_foreign_key_definitions)
|
||||
end
|
||||
|
||||
it 'has at least one loose foreign key definition' do
|
||||
expect(described_class.loose_foreign_key_definitions.size).to be > 0
|
||||
definitions = Gitlab::Database::LooseForeignKeys.definitions_by_table[table_name]
|
||||
expect(definitions.size).to be > 0
|
||||
end
|
||||
|
||||
it 'has the deletion trigger present' do
|
||||
|
|
|
@ -27,43 +27,40 @@ RSpec.describe LooseForeignKeys::CleanupWorker do
|
|||
migration.track_record_deletions(:_test_loose_fk_parent_table_2)
|
||||
end
|
||||
|
||||
let!(:parent_model_1) do
|
||||
Class.new(ApplicationRecord) do
|
||||
self.table_name = '_test_loose_fk_parent_table_1'
|
||||
|
||||
include LooseForeignKey
|
||||
|
||||
loose_foreign_key :_test_loose_fk_child_table_1_1, :parent_id, on_delete: :async_delete
|
||||
loose_foreign_key :_test_loose_fk_child_table_1_2, :parent_id_with_different_column, on_delete: :async_nullify
|
||||
end
|
||||
end
|
||||
|
||||
let!(:parent_model_2) do
|
||||
Class.new(ApplicationRecord) do
|
||||
self.table_name = '_test_loose_fk_parent_table_2'
|
||||
|
||||
include LooseForeignKey
|
||||
|
||||
loose_foreign_key :_test_loose_fk_child_table_2_1, :parent_id, on_delete: :async_delete
|
||||
end
|
||||
end
|
||||
|
||||
let!(:child_model_1) do
|
||||
Class.new(ApplicationRecord) do
|
||||
self.table_name = '_test_loose_fk_child_table_1_1'
|
||||
end
|
||||
end
|
||||
|
||||
let!(:child_model_2) do
|
||||
Class.new(ApplicationRecord) do
|
||||
self.table_name = '_test_loose_fk_child_table_1_2'
|
||||
end
|
||||
end
|
||||
|
||||
let!(:child_model_3) do
|
||||
Class.new(ApplicationRecord) do
|
||||
self.table_name = '_test_loose_fk_child_table_2_1'
|
||||
end
|
||||
let(:all_loose_foreign_key_definitions) do
|
||||
{
|
||||
'_test_loose_fk_parent_table_1' => [
|
||||
ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
|
||||
'_test_loose_fk_parent_table_1',
|
||||
'_test_loose_fk_child_table_1_1',
|
||||
{
|
||||
column: 'parent_id',
|
||||
on_delete: :async_delete,
|
||||
gitlab_schema: :gitlab_main
|
||||
}
|
||||
),
|
||||
ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
|
||||
'_test_loose_fk_parent_table_1',
|
||||
'_test_loose_fk_child_table_1_2',
|
||||
{
|
||||
column: 'parent_id_with_different_column',
|
||||
on_delete: :async_nullify,
|
||||
gitlab_schema: :gitlab_main
|
||||
}
|
||||
)
|
||||
],
|
||||
'_test_loose_fk_parent_table_2' => [
|
||||
ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
|
||||
'_test_loose_fk_parent_table_2',
|
||||
'_test_loose_fk_child_table_2_1',
|
||||
{
|
||||
column: 'parent_id',
|
||||
on_delete: :async_delete,
|
||||
gitlab_schema: :gitlab_main
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
let(:loose_fk_parent_table_1) { table(:_test_loose_fk_parent_table_1) }
|
||||
|
@ -87,6 +84,8 @@ RSpec.describe LooseForeignKeys::CleanupWorker do
|
|||
end
|
||||
|
||||
before do
|
||||
allow(Gitlab::Database::LooseForeignKeys).to receive(:definitions_by_table).and_return(all_loose_foreign_key_definitions)
|
||||
|
||||
parent_record_1 = loose_fk_parent_table_1.create!
|
||||
loose_fk_child_table_1_1.create!(parent_id: parent_record_1.id)
|
||||
loose_fk_child_table_1_2.create!(parent_id_with_different_column: parent_record_1.id)
|
||||
|
@ -98,8 +97,8 @@ RSpec.describe LooseForeignKeys::CleanupWorker do
|
|||
parent_record_3 = loose_fk_parent_table_2.create!
|
||||
5.times { loose_fk_child_table_2_1.create!(parent_id: parent_record_3.id) }
|
||||
|
||||
parent_model_1.delete_all
|
||||
parent_model_2.delete_all
|
||||
loose_fk_parent_table_1.delete_all
|
||||
loose_fk_parent_table_2.delete_all
|
||||
end
|
||||
|
||||
it 'cleans up all rows' do
|
||||
|
|
Loading…
Reference in New Issue