summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-02-24 06:13:09 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-02-24 06:13:09 +0000
commit89bfc148f90c410512f9c470ca1e50485b7000b2 (patch)
tree9ff12f0c281483cd24585ae9fc5acc4be6a90ac9 /app
parentdf6d6623faac958bea9787a1cda4259cbcdc1287 (diff)
downloadgitlab-ce-89bfc148f90c410512f9c470ca1e50485b7000b2.tar.gz
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue2
-rw-r--r--app/controllers/concerns/issuable_collections.rb4
-rw-r--r--app/finders/issuable_finder.rb8
-rw-r--r--app/graphql/resolvers/work_item_resolver.rb2
-rw-r--r--app/graphql/types/work_item_type.rb2
-rw-r--r--app/models/concerns/pg_full_text_searchable.rb111
-rw-r--r--app/models/issue.rb14
-rw-r--r--app/models/issues/search_data.rb11
-rw-r--r--app/models/note.rb1
-rw-r--r--app/policies/project_policy.rb2
-rw-r--r--app/policies/work_item_policy.rb11
11 files changed, 155 insertions, 13 deletions
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index 102afaf308f..d5a7fc36ace 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -116,7 +116,7 @@ export default {
<gl-dropdown
v-if="displayFilters"
id="discussion-filter-dropdown"
- class="gl-mr-3 full-width-mobile discussion-filter-container js-discussion-filter-container"
+ class="full-width-mobile discussion-filter-container js-discussion-filter-container"
data-qa-selector="discussion_filter_dropdown"
:text="currentFilter.title"
:disabled="isLoading"
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index 4841225de08..63ff1390d92 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -117,6 +117,10 @@ module IssuableCollections
options[:attempt_group_search_optimizations] = true
end
+ if collection_type == 'Issue' && Feature.enabled?(:issues_full_text_search, @project || @group, default_enabled: :yaml)
+ options[:attempt_full_text_search] = true
+ end
+
params.permit(finder_type.valid_params).merge(options)
end
end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 3e436f30971..416db1c9c7d 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -37,6 +37,7 @@
# attempt_project_search_optimizations: boolean
# crm_contact_id: integer
# crm_organization_id: integer
+# attempt_full_text_search: boolean
#
class IssuableFinder
prepend FinderWithCrossProjectAccess
@@ -46,6 +47,7 @@ class IssuableFinder
requires_cross_project_access unless: -> { params.project? }
+ FULL_TEXT_SEARCH_TERM_REGEX = /\A[\p{ASCII}|\p{Latin}]+\z/.freeze
NEGATABLE_PARAMS_HELPER_KEYS = %i[project_id scope status include_subgroups].freeze
attr_accessor :current_user, :params
@@ -331,6 +333,8 @@ class IssuableFinder
return items if items.is_a?(ActiveRecord::NullRelation)
return items if Feature.enabled?(:disable_anonymous_search, type: :ops) && current_user.nil?
+ return items.pg_full_text_search(search) if use_full_text_search?
+
if use_cte_for_search?
cte = Gitlab::SQL::CTE.new(klass.table_name, items)
@@ -341,6 +345,10 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
+ def use_full_text_search?
+ params[:attempt_full_text_search] && params[:search] =~ FULL_TEXT_SEARCH_TERM_REGEX
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def by_iids(items)
params[:iids].present? ? items.where(iid: params[:iids]) : items
diff --git a/app/graphql/resolvers/work_item_resolver.rb b/app/graphql/resolvers/work_item_resolver.rb
index b1733d657e4..6eb23916d83 100644
--- a/app/graphql/resolvers/work_item_resolver.rb
+++ b/app/graphql/resolvers/work_item_resolver.rb
@@ -4,7 +4,7 @@ module Resolvers
class WorkItemResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
- authorize :read_issue
+ authorize :read_work_item
type Types::WorkItemType, null: true
diff --git a/app/graphql/types/work_item_type.rb b/app/graphql/types/work_item_type.rb
index 78cef150b11..512b9ef64d2 100644
--- a/app/graphql/types/work_item_type.rb
+++ b/app/graphql/types/work_item_type.rb
@@ -4,7 +4,7 @@ module Types
class WorkItemType < BaseObject
graphql_name 'WorkItem'
- authorize :read_issue
+ authorize :read_work_item
field :description, GraphQL::Types::String, null: true,
description: 'Description of the work item.'
diff --git a/app/models/concerns/pg_full_text_searchable.rb b/app/models/concerns/pg_full_text_searchable.rb
new file mode 100644
index 00000000000..612a9c5908b
--- /dev/null
+++ b/app/models/concerns/pg_full_text_searchable.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+# This module adds PG full-text search capabilities to a model.
+# A `search_data` association with a `search_vector` column is required.
+#
+# Declare the fields that will be part of the search vector with their
+# corresponding weights. Possible values for weight are A, B, C, or D.
+# For example:
+#
+# include PgFullTextSearchable
+# pg_full_text_searchable columns: [{ name: 'title', weight: 'A' }, { name: 'description', weight: 'B' }]
+#
+# This module sets up an after_commit hook that updates the search data
+# when the searchable columns are changed. You will need to implement the
+# `#persist_pg_full_text_search_vector` method that does the actual insert or update.
+#
+# This also adds a `pg_full_text_search` scope so you can do:
+#
+# Model.pg_full_text_search("some search term")
+
+module PgFullTextSearchable
+ extend ActiveSupport::Concern
+
+ LONG_WORDS_REGEX = %r([A-Za-z0-9+/]{50,}).freeze
+ TSVECTOR_MAX_LENGTH = 1.megabyte.freeze
+ TEXT_SEARCH_DICTIONARY = 'english'
+
+ def update_search_data!
+ tsvector_sql_nodes = self.class.pg_full_text_searchable_columns.map do |column, weight|
+ tsvector_arel_node(column, weight)&.to_sql
+ end
+
+ persist_pg_full_text_search_vector(Arel.sql(tsvector_sql_nodes.compact.join(' || ')))
+ rescue ActiveRecord::StatementInvalid => e
+ raise unless e.cause.is_a?(PG::ProgramLimitExceeded) && e.message.include?('string is too long for tsvector')
+
+ Gitlab::AppJsonLogger.error(
+ message: 'Error updating search data: string is too long for tsvector',
+ class: self.class.name,
+ model_id: self.id
+ )
+ end
+
+ private
+
+ def persist_pg_full_text_search_vector(search_vector)
+ raise NotImplementedError
+ end
+
+ def tsvector_arel_node(column, weight)
+ return if self[column].blank?
+
+ column_text = self[column].gsub(LONG_WORDS_REGEX, ' ')
+ column_text = column_text[0..(TSVECTOR_MAX_LENGTH - 1)]
+ column_text = ActiveSupport::Inflector.transliterate(column_text)
+
+ Arel::Nodes::NamedFunction.new(
+ 'setweight',
+ [
+ Arel::Nodes::NamedFunction.new(
+ 'to_tsvector',
+ [Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), Arel::Nodes.build_quoted(column_text)]
+ ),
+ Arel::Nodes.build_quoted(weight)
+ ]
+ )
+ end
+
+ included do
+ cattr_reader :pg_full_text_searchable_columns do
+ {}
+ end
+ end
+
+ class_methods do
+ def pg_full_text_searchable(columns:)
+ raise 'Full text search columns already defined!' if pg_full_text_searchable_columns.present?
+
+ columns.each do |column|
+ pg_full_text_searchable_columns[column[:name]] = column[:weight]
+ end
+
+ # We update this outside the transaction because this could raise an error if the resulting tsvector
+ # is too long. When that happens, we still persist the create / update but the model will not have a
+ # search data record. This is fine in most cases because this is a very rare occurrence and only happens
+ # with strings that are most likely unsearchable anyway.
+ #
+ # We also do not want to use a subtransaction here due to: https://gitlab.com/groups/gitlab-org/-/epics/6540
+ after_save_commit do
+ next unless pg_full_text_searchable_columns.keys.any? { |f| saved_changes.has_key?(f) }
+
+ update_search_data!
+ end
+ end
+
+ def pg_full_text_search(search_term)
+ search_data_table = reflect_on_association(:search_data).klass.arel_table
+
+ joins(:search_data).where(
+ Arel::Nodes::InfixOperation.new(
+ '@@',
+ search_data_table[:search_vector],
+ Arel::Nodes::NamedFunction.new(
+ 'websearch_to_tsquery',
+ [Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), Arel::Nodes.build_quoted(search_term)]
+ )
+ )
+ )
+ end
+ end
+end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 68ea6cb3abc..75727fff2cd 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -24,6 +24,7 @@ class Issue < ApplicationRecord
include Todoable
include FromUnion
include EachBatch
+ include PgFullTextSearchable
extend ::Gitlab::Utils::Override
@@ -77,6 +78,7 @@ class Issue < ApplicationRecord
end
end
+ has_one :search_data, class_name: 'Issues::SearchData'
has_one :issuable_severity
has_one :sentry_issue
has_one :alert_management_alert, class_name: 'AlertManagement::Alert'
@@ -102,6 +104,8 @@ class Issue < ApplicationRecord
alias_attribute :external_author, :service_desk_reply_to
+ pg_full_text_searchable columns: [{ name: 'title', weight: 'A' }, { name: 'description', weight: 'B' }]
+
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
scope :not_in_projects, ->(project_ids) { where.not(project_id: project_ids) }
@@ -233,6 +237,11 @@ class Issue < ApplicationRecord
def order_upvotes_asc
reorder(upvotes_count: :asc)
end
+
+ override :pg_full_text_search
+ def pg_full_text_search(search_term)
+ super.where('issue_search_data.project_id = issues.project_id')
+ end
end
def next_object_by_relative_position(ignoring: nil, order: :asc)
@@ -611,6 +620,11 @@ class Issue < ApplicationRecord
private
+ override :persist_pg_full_text_search_vector
+ def persist_pg_full_text_search_vector(search_vector)
+ Issues::SearchData.upsert({ project_id: project_id, issue_id: id, search_vector: search_vector }, unique_by: %i(project_id issue_id))
+ end
+
def spammable_attribute_changed?
title_changed? ||
description_changed? ||
diff --git a/app/models/issues/search_data.rb b/app/models/issues/search_data.rb
new file mode 100644
index 00000000000..0eda292796d
--- /dev/null
+++ b/app/models/issues/search_data.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Issues
+ class SearchData < ApplicationRecord
+ extend SuppressCompositePrimaryKeyWarning
+
+ self.table_name = 'issue_search_data'
+
+ belongs_to :issue
+ end
+end
diff --git a/app/models/note.rb b/app/models/note.rb
index 3f3fa968393..1d661367599 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -609,7 +609,6 @@ class Note < ApplicationRecord
def show_outdated_changes?
return false unless for_merge_request?
- return false unless Feature.enabled?(:display_outdated_line_diff, noteable.source_project, default_enabled: :yaml)
return false unless system?
return false unless change_position&.line_range
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 4cc5ed06d61..147ca9c9881 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -264,8 +264,6 @@ class ProjectPolicy < BasePolicy
enable :create_work_item
end
- rule { can?(:update_issue) }.enable :update_work_item
-
# These abilities are not allowed to admins that are not members of the project,
# that's why they are defined separately.
rule { guest & can?(:download_code) }.enable :build_download_code
diff --git a/app/policies/work_item_policy.rb b/app/policies/work_item_policy.rb
index 7ba5102a406..b4723bc7ed8 100644
--- a/app/policies/work_item_policy.rb
+++ b/app/policies/work_item_policy.rb
@@ -1,12 +1,9 @@
# frozen_string_literal: true
-class WorkItemPolicy < BasePolicy
- delegate { @subject.project }
+class WorkItemPolicy < IssuePolicy
+ rule { can?(:owner_access) | is_author }.enable :delete_work_item
- desc 'User is author of the work item'
- condition(:author) do
- @user && @user == @subject.author
- end
+ rule { can?(:update_issue) }.enable :update_work_item
- rule { can?(:owner_access) | author }.enable :delete_work_item
+ rule { can?(:read_issue) }.enable :read_work_item
end