diff --git a/ee/lib/gitlab/elastic/bool_expr.rb b/ee/lib/gitlab/elastic/bool_expr.rb index d97fb823808c18ff0f2d9bdd9294cdd8d84d1097..10c131885589dbd06c8380fc2e2dd4d37b8d83de 100644 --- a/ee/lib/gitlab/elastic/bool_expr.rb +++ b/ee/lib/gitlab/elastic/bool_expr.rb @@ -1,9 +1,8 @@ -# rubocop:disable Naming/FileName # frozen_string_literal: true module Gitlab module Elastic - BoolExpr = Struct.new(:must, :must_not, :should, :filter) do # rubocop:disable Lint/StructNewOverride + BoolExpr = Struct.new(:must, :must_not, :should, :filter, :minimum_should_match) do # rubocop:disable Lint/StructNewOverride -- existing implementation def initialize super reset! @@ -14,6 +13,7 @@ def reset! self.must_not = [] self.should = [] self.filter = [] + self.minimum_should_match = nil end def to_h @@ -26,5 +26,3 @@ def eql?(other) end end end - -# rubocop:enable Naming/FileName diff --git a/ee/lib/search/elastic/issue_query_builder.rb b/ee/lib/search/elastic/issue_query_builder.rb index c4a91ab887eaa2e723a0fab72d3dcd05d7151eca..3ffbf17bf8ef8c12811fa5cd0e8dc9dd0b0f1bf1 100644 --- a/ee/lib/search/elastic/issue_query_builder.rb +++ b/ee/lib/search/elastic/issue_query_builder.rb @@ -15,7 +15,13 @@ def build # iid field can be added here as lenient option will # pardon format errors, like integer out of range. fields = %w[iid^3 title^2 description] - ::Search::Elastic::Queries.by_simple_query_string(fields: fields, query: query, options: options) + + if Feature.enabled?(:search_uses_match_queries, options[:current_user]) && + !::Search::Elastic::Queries::ADVANCED_QUERY_SYNTAX_REGEX.match?(query) + ::Search::Elastic::Queries.by_multi_match_query(fields: fields, query: query, options: options) + else + ::Search::Elastic::Queries.by_simple_query_string(fields: fields, query: query, options: options) + end end query_hash = ::Search::Elastic::Filters.by_authorization(query_hash: query_hash, options: options) diff --git a/ee/lib/search/elastic/queries.rb b/ee/lib/search/elastic/queries.rb index 0b57837dc41e51cf2be7a65c9df8a522adb0f996..42ff20e1c280b824a8f6e1316a5712d2b6238355 100644 --- a/ee/lib/search/elastic/queries.rb +++ b/ee/lib/search/elastic/queries.rb @@ -3,11 +3,11 @@ module Search module Elastic module Queries + ADVANCED_QUERY_SYNTAX_REGEX = /[+*"\-|()~\\]/ + class << self include ::Elastic::Latest::QueryContext::Aware - AGGREGATION_LIMIT = 500 - def by_iid(iid:, doc_type:) bool_expr = Gitlab::Elastic::BoolExpr.new bool_expr.filter = [ @@ -22,27 +22,81 @@ def by_iid(iid:, doc_type:) } end - def by_simple_query_string(fields:, query:, options:) + def by_multi_match_query(fields:, query:, options:) fields = ::Elastic::Latest::CustomLanguageAnalyzers.add_custom_analyzers_fields(fields) - fields = remove_fields_boost(fields) if options[:count_only] - query_hash = - if query.present? - simple_query_string = simple_query_string(fields, query, options) + bool_expr = Gitlab::Elastic::BoolExpr.new + + if query.present? + bool_expr = Gitlab::Elastic::BoolExpr.new + unless options[:no_join_project] + bool_expr.filter << { + term: { + type: { + _name: context.name(:doc, :is_a, options[:doc_type]), + value: options[:doc_type] + } + } + } + end + + multi_match_bool = Gitlab::Elastic::BoolExpr.new + multi_match_bool.should << multi_match_query(fields, query, options.merge(operator: :or)) + multi_match_bool.should << multi_match_query(fields, query, options.merge(operator: :and)) + multi_match_bool.should << multi_match_phrase_query(fields, query, options) + multi_match_bool.minimum_should_match = 1 - build_bool_query(simple_query_string, options) + if options[:count_only] + bool_expr.filter << { bool: multi_match_bool } else - bool_expr = Gitlab::Elastic::BoolExpr.new - bool_expr.must = { match_all: {} } - { - query: { - bool: bool_expr - }, - track_scores: true + bool_expr.must << { bool: multi_match_bool } + end + else + bool_expr.must = { match_all: {} } + end + + query_hash = { query: { bool: bool_expr } } + query_hash[:track_scores] = true unless query.present? + + if options[:count_only] + query_hash[:size] = 0 + else + query_hash[:highlight] = apply_highlight(fields) + end + + query_hash + end + + def by_simple_query_string(fields:, query:, options:) + fields = ::Elastic::Latest::CustomLanguageAnalyzers.add_custom_analyzers_fields(fields) + fields = remove_fields_boost(fields) if options[:count_only] + + bool_expr = Gitlab::Elastic::BoolExpr.new + if query.present? + unless options[:no_join_project] + bool_expr.filter << { + term: { + type: { + _name: context.name(:doc, :is_a, options[:doc_type]), + value: options[:doc_type] + } + } } end + if options[:count_only] + bool_expr.filter << simple_query_string(fields, query, options) + else + bool_expr.must << simple_query_string(fields, query, options) + end + else + bool_expr.must = { match_all: {} } + end + + query_hash = { query: { bool: bool_expr } } + query_hash[:track_scores] = true unless query.present? + if options[:count_only] query_hash[:size] = 0 else @@ -70,29 +124,26 @@ def simple_query_string(fields, query, options) } end - def build_bool_query(simple_query_string, options) - bool_expr = Gitlab::Elastic::BoolExpr.new - - unless options[:no_join_project] - bool_expr.filter << { - term: { - type: { - _name: context.name(:doc, :is_a, options[:doc_type]), - value: options[:doc_type] - } - } + def multi_match_phrase_query(fields, query, options) + { + multi_match: { + _name: context.name(options[:doc_type], :multi_match_phrase, :search_terms), + type: :phrase, + fields: fields, + query: query, + lenient: true } - end - - if options[:count_only] - bool_expr.filter << simple_query_string - else - bool_expr.must << simple_query_string - end + } + end + def multi_match_query(fields, query, options) { - query: { - bool: bool_expr + multi_match: { + _name: context.name(options[:doc_type], :multi_match, options[:operator], :search_terms), + fields: fields, + query: query, + operator: options[:operator], + lenient: true } } end diff --git a/ee/spec/elastic/migrate/20230720000000_reindex_wikis_to_fix_routing_spec.rb b/ee/spec/elastic/migrate/20230720000000_reindex_wikis_to_fix_routing_spec.rb index f48f9c6606b1710e173af18cf218960380a30074..d93637a2cd77914d59d5515fbfd97ad45fcc7918 100644 --- a/ee/spec/elastic/migrate/20230720000000_reindex_wikis_to_fix_routing_spec.rb +++ b/ee/spec/elastic/migrate/20230720000000_reindex_wikis_to_fix_routing_spec.rb @@ -86,6 +86,7 @@ def set_old_schema_version_in_three_documents! client.update_by_query(index: Elastic::Latest::WikiConfig.index_name, max_docs: 3, refresh: true, + wait_for_completion: true, body: { script: { lang: 'painless', source: 'ctx._source.schema_version = 2305' } } ) end diff --git a/ee/spec/lib/elastic/latest/issue_class_proxy_spec.rb b/ee/spec/lib/elastic/latest/issue_class_proxy_spec.rb index d8222f413268e504bff9ce009f69b4841679c67a..d3d79ef97ab7e73316d16203927576d7de9d3e54 100644 --- a/ee/spec/lib/elastic/latest/issue_class_proxy_spec.rb +++ b/ee/spec/lib/elastic/latest/issue_class_proxy_spec.rb @@ -5,16 +5,16 @@ RSpec.describe Elastic::Latest::IssueClassProxy, :elastic, :sidekiq_inline, feature_category: :global_search do before do stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) - stub_feature_flags(search_uses_match_queries: false) end - subject { described_class.new(Issue, use_separate_indices: true) } + subject(:proxy) { described_class.new(Issue, use_separate_indices: true) } let!(:group) { create(:group) } let!(:project) { create(:project, :public, group: group) } let!(:user) { create(:user, developer_of: project) } let!(:label) { create(:label, project: project) } let!(:issue) { create(:labeled_issue, title: 'test', project: project, labels: [label]) } + let(:query) { 'test' } let(:options) do { @@ -33,7 +33,7 @@ shared_examples 'returns aggregations' do it 'filters by labels' do - result = subject.issue_aggregations('test', options) + result = proxy.issue_aggregations('test', options) expect(result.first.name).to eq('labels') expect(result.first.buckets.first.symbolize_keys).to match( @@ -59,7 +59,7 @@ end describe '#elastic_search' do - let(:result) { subject.elastic_search('test', options: options) } + let(:result) { proxy.elastic_search(query, options: options) } describe 'search on basis of hidden attribute' do context 'when author of the issue is banned' do @@ -79,7 +79,7 @@ it 'current_user is empty then user can not see the issue' do options[:current_user] = nil - result = subject.elastic_search('test', options: options) + result = proxy.elastic_search('test', options: options) expect(elasticsearch_hit_ids(result)).not_to include issue.id end end @@ -100,31 +100,54 @@ it 'current_user is empty then user can see the issue' do options[:current_user] = nil - result = subject.elastic_search('test', options: options) + result = proxy.elastic_search('test', options: options) expect(elasticsearch_hit_ids(result)).to include issue.id end end end describe 'named queries' do - let(:project_ids) { [project.id] } - let(:group_ids) { [] } # TODO - group.id - let(:options) do - { - current_user: user, - project_ids: project_ids, - group_ids: group_ids, - public_and_internal_projects: false, - order_by: nil, - sort: nil, - labels: [label.id] - } + using RSpec::Parameterized::TableSyntax + + where(:projects, :groups) do + [] | [] + [ref(:project)] | [] + [] | [ref(:group)] + [ref(:project)] | [ref(:group)] end - describe 'hidden filter' do - context 'when user can admin all resources' do - before do - allow(user).to receive(:can_admin_all_resources?).and_return(true) + with_them do + let(:project_ids) { projects.map(&:id) } + let(:group_ids) { groups.map(&:id) } + let(:options) { base_options } + + let(:base_options) do + { + current_user: user, + project_ids: project_ids, + group_ids: group_ids, + public_and_internal_projects: false, + order_by: nil, + sort: nil + } + end + + describe 'base query' do + shared_examples 'a query that uses simple_query_string' do + it 'includes the correct base query name' do + result.response + + assert_named_queries('issue:match:search_terms') + end + end + + shared_examples 'a query that uses multi_match' do + it 'includes the correct base query name' do + result.response + + assert_named_queries('issue:multi_match:or:search_terms', 'issue:multi_match:and:search_terms', + 'issue:multi_match_phrase:search_terms') + end end context 'when search_query_builder feature flag is false' do @@ -132,106 +155,222 @@ stub_feature_flags(search_query_builder: false) end - it 'does not filter hidden issues' do + context 'when search_uses_match_queries feature flag is false' do + before do + stub_feature_flags(search_uses_match_queries: false) + end + + it_behaves_like 'a query that uses simple_query_string' + end + + it_behaves_like 'a query that uses multi_match' + end + + context 'when querying by iid' do + let(:query) { '#1' } + + it 'includes the correct base query name' do result.response - assert_named_queries(without: ['issue:hidden:non_hidden']) + assert_named_queries('issue:related:iid', 'doc:is_a:issue') end end - it 'does not filter hidden issues' do - result.response + context 'when search_uses_match_queries feature flag is false' do + before do + stub_feature_flags(search_uses_match_queries: false) + end - assert_named_queries(without: ['filters:non_hidden']) + it_behaves_like 'a query that uses simple_query_string' end - end - context 'when user cannot admin all resources' do - before do - allow(user).to receive(:can_admin_all_resources?).and_return(false) + context 'when using advanced search syntax' do + let(:query) { 'test -banner' } + + it_behaves_like 'a query that uses simple_query_string' end + it_behaves_like 'a query that uses multi_match' + end + + describe 'state filter' do context 'when search_query_builder feature flag is false' do before do stub_feature_flags(search_query_builder: false) end - it 'filters hidden issues' do + it 'does not filter by state in the query' do result.response - assert_named_queries('issue:hidden:non_hidden') + assert_named_queries(without: ['issue:match:state']) end end - it 'filters hidden issues' do + it 'does not filter by state in the query' do result.response - assert_named_queries('filters:not_hidden') + assert_named_queries(without: ['filters:state']) end - end - end - context 'when label filters are passed' do - context 'when search_query_builder feature flag is false' do - before do - stub_feature_flags(search_query_builder: false) - end + context 'when state option is provided' do + let(:options) { base_options.merge(state: 'opened') } - it 'filters the labels in the query' do - result.response + context 'when search_query_builder feature flag is false' do + before do + stub_feature_flags(search_query_builder: false) + end + + it 'filters by state in the query' do + result.response + + assert_named_queries('issue:match:state') + end + end + + it 'filters by state in the query' do + result.response - assert_named_queries('issue:match:search_terms', 'issue:filter:label_ids', 'issue:archived:non_archived') + assert_named_queries('filters:state') + end end end - it 'filters the labels in the query' do - result.response + describe 'hidden filter' do + context 'when user can admin all resources' do + before do + allow(user).to receive(:can_admin_all_resources?).and_return(true) + end - assert_named_queries('issue:match:search_terms', 'filters:label_ids', 'filters:non_archived') - end - end + context 'when search_query_builder feature flag is false' do + before do + stub_feature_flags(search_query_builder: false) + end - context 'when include_archived is set' do - let(:options) { { include_archived: true } } + it 'does not filter hidden issues' do + result.response - context 'when search_query_builder feature flag is false' do - before do - stub_feature_flags(search_query_builder: false) - end + assert_named_queries(without: ['issue:hidden:non_hidden']) + end + end - it 'does not have a filter for archived' do - result.response + it 'does not filter hidden issues' do + result.response - assert_named_queries(without: ['issue:archived:non_archived']) + assert_named_queries(without: ['filters:non_hidden']) + end end - end - it 'does not have a filter for archived' do - result.response + context 'when user cannot admin all resources' do + before do + allow(user).to receive(:can_admin_all_resources?).and_return(false) + end + + context 'when search_query_builder feature flag is false' do + before do + stub_feature_flags(search_query_builder: false) + end + + it 'filters hidden issues' do + result.response + + assert_named_queries('issue:hidden:non_hidden') + end + end - assert_named_queries(without: ['filters:non_archived']) + it 'filters hidden issues' do + result.response + + assert_named_queries('filters:not_hidden') + end + end end - end - context 'when include_archived is not set' do - let(:options) { {} } + describe 'label filter' do + context 'when search_query_builder feature flag is false' do + before do + stub_feature_flags(search_query_builder: false) + end + + it 'filters the labels in the query' do + result.response - context 'when search_query_builder feature flag is false' do - before do - stub_feature_flags(search_query_builder: false) + assert_named_queries(without: ['issue:filter:label_ids']) + end end - it 'does have a filter for archived' do + it 'filters the labels in the query' do result.response - assert_named_queries('issue:match:search_terms', 'issue:archived:non_archived') + assert_named_queries(without: ['filters:label_ids']) + end + + context 'when labels option is provided' do + let(:options) { base_options.merge(labels: [label.id]) } + + context 'when search_query_builder feature flag is false' do + before do + stub_feature_flags(search_query_builder: false) + end + + it 'filters the labels in the query' do + result.response + + assert_named_queries('issue:filter:label_ids') + end + end + + it 'filters the labels in the query' do + result.response + + assert_named_queries('filters:label_ids') + end end end - it 'does have a filter for archived' do - result.response + describe 'archived filter' do + context 'when include_archived is set' do + let(:options) { { include_archived: true } } + + context 'when search_query_builder feature flag is false' do + before do + stub_feature_flags(search_query_builder: false) + end + + it 'does not have a filter for archived' do + result.response + + assert_named_queries(without: ['issue:archived:non_archived']) + end + end + + it 'does not have a filter for archived' do + result.response + + assert_named_queries(without: ['filters:non_archived']) + end + end + + context 'when include_archived is not set' do + let(:options) { {} } + + context 'when search_query_builder feature flag is false' do + before do + stub_feature_flags(search_query_builder: false) + end + + it 'does have a filter for archived' do + result.response - assert_named_queries('issue:match:search_terms', 'filters:non_archived') + assert_named_queries('issue:archived:non_archived') + end + end + + it 'does have a filter for archived' do + result.response + + assert_named_queries('filters:non_archived') + end + end end end end diff --git a/ee/spec/lib/gitlab/elastic/group_search_results_spec.rb b/ee/spec/lib/gitlab/elastic/group_search_results_spec.rb index 5352404f0bc5a0a4551c7b00351b323532940a31..a727eb6579117b826dbd9e05d40a0e383d89bc20 100644 --- a/ee/spec/lib/gitlab/elastic/group_search_results_spec.rb +++ b/ee/spec/lib/gitlab/elastic/group_search_results_spec.rb @@ -19,19 +19,44 @@ end context 'for issues search', :sidekiq_inline do - let_it_be_with_reload(:project) { create(:project, :public, group: group, developers: user) } - let_it_be_with_reload(:closed_result) { create(:issue, :closed, project: project, title: 'foo closed') } - let_it_be_with_reload(:opened_result) { create(:issue, :opened, project: project, title: 'foo opened') } - let_it_be_with_reload(:confidential_result) { create(:issue, :confidential, project: project, title: 'foo confidential') } + let_it_be(:project) { create(:project, :public, group: group, developers: user) } + let_it_be(:closed_result) { create(:issue, :closed, project: project, title: 'foo closed') } + let_it_be(:opened_result) { create(:issue, :opened, project: project, title: 'foo opened') } + let_it_be(:confidential_result) { create(:issue, :confidential, project: project, title: 'foo confidential') } let(:query) { 'foo' } let(:scope) { 'issues' } before do + stub_feature_flags(search_uses_match_queries: true) ::Elastic::ProcessInitialBookkeepingService.backfill_projects!(project) ensure_elasticsearch_index! end + context 'when advanced search query syntax is used' do + let(:query) { 'foo -banner' } + + include_examples 'search results filtered by state' + include_examples 'search results filtered by confidential' + include_examples 'search results filtered by labels' + it_behaves_like 'namespace ancestry_filter for aggregations' do + let(:query_name) { 'filters:namespace:ancestry_filter:descendants' } + end + end + + context 'when search_uses_match_queries flag is false' do + before do + stub_feature_flags(search_uses_match_queries: false) + end + + include_examples 'search results filtered by state' + include_examples 'search results filtered by confidential' + include_examples 'search results filtered by labels' + it_behaves_like 'namespace ancestry_filter for aggregations' do + let(:query_name) { 'filters:namespace:ancestry_filter:descendants' } + end + end + context 'when search_query_builder feature flag is false' do before do stub_feature_flags(search_query_builder: false) diff --git a/ee/spec/lib/gitlab/elastic/project_search_results_spec.rb b/ee/spec/lib/gitlab/elastic/project_search_results_spec.rb index e825a4c387d04692b35c6cac07e38610518bb326..63bc0f6e4367f48162f1c9e8df3b48b4e2c97006 100644 --- a/ee/spec/lib/gitlab/elastic/project_search_results_spec.rb +++ b/ee/spec/lib/gitlab/elastic/project_search_results_spec.rb @@ -93,6 +93,34 @@ ensure_elasticsearch_index! end + context 'when advanced search query syntax is used' do + let(:query) { 'foo -banner' } + + include_examples 'search results filtered by state' + include_examples 'search results filtered by confidential' + include_examples 'search results filtered by labels' + end + + context 'when search_uses_match_queries flag is false' do + before do + stub_feature_flags(search_uses_match_queries: false) + end + + include_examples 'search results filtered by state' + include_examples 'search results filtered by confidential' + include_examples 'search results filtered by labels' + end + + context 'when search_query_builder feature flag is false' do + before do + stub_feature_flags(search_query_builder: false) + end + + include_examples 'search results filtered by state' + include_examples 'search results filtered by confidential' + include_examples 'search results filtered by labels' + end + include_examples 'search results filtered by state' include_examples 'search results filtered by confidential' include_examples 'search results filtered by labels' diff --git a/ee/spec/lib/gitlab/elastic/search_results_spec.rb b/ee/spec/lib/gitlab/elastic/search_results_spec.rb index e9b09fc9a9fa01dfe095f47fb97bf2769941155a..d0df2baf8504acae3eb91c91ceeead6b3e2daf55 100644 --- a/ee/spec/lib/gitlab/elastic/search_results_spec.rb +++ b/ee/spec/lib/gitlab/elastic/search_results_spec.rb @@ -1259,61 +1259,81 @@ def search_for(term) private_project2.project_members.create!(user: user, access_level: ProjectMember::DEVELOPER) end - context 'issues' do - it 'finds right set of issues' do - issue_1 = create :issue, project: internal_project, title: "Internal project" - create :issue, project: private_project1, title: "Private project" - issue_3 = create :issue, project: private_project2, title: "Private project where I'm a member" - issue_4 = create :issue, project: public_project, title: "Public project" + context 'for issues' do + shared_examples 'issues respect visibility' do + it 'finds right set of issues' do + issue_1 = create :issue, project: internal_project, title: "Internal project" + create :issue, project: private_project1, title: "Private project" + issue_3 = create :issue, project: private_project2, title: "Private project where I'm a member" + issue_4 = create :issue, project: public_project, title: "Public project" - ensure_elasticsearch_index! - - # Authenticated search - results = described_class.new(user, 'project', limit_project_ids) - issues = results.objects('issues') + ensure_elasticsearch_index! - expect(issues).to include issue_1 - expect(issues).to include issue_3 - expect(issues).to include issue_4 - expect(results.issues_count).to eq 3 + # Authenticated search + results = described_class.new(user, 'project', limit_project_ids) + issues = results.objects('issues') - # Unauthenticated search - results = described_class.new(nil, 'project', []) - issues = results.objects('issues') + expect(issues).to include issue_1 + expect(issues).to include issue_3 + expect(issues).to include issue_4 + expect(results.issues_count).to eq 3 - expect(issues).to include issue_4 - expect(results.issues_count).to eq 1 - end + # Unauthenticated search + results = described_class.new(nil, 'project', []) + issues = results.objects('issues') - context 'when different issue descriptions', :aggregate_failures do - let(:examples) do - code_examples.merge( - 'screen' => 'Screenshots or screen recordings', - 'problem' => 'Problem to solve' - ) + expect(issues).to include issue_4 + expect(results.issues_count).to eq 1 end - include_context 'with code examples' do - before do - examples.values.uniq.each do |description| - sha = Digest::SHA256.hexdigest(description) - create :issue, project: private_project2, title: sha, description: description - end - - ensure_elasticsearch_index! + context 'when different issue descriptions', :aggregate_failures do + let(:examples) do + code_examples.merge( + 'screen' => 'Screenshots or screen recordings', + 'problem' => 'Problem to solve' + ) end - it 'finds all examples' do - examples.each do |search_term, description| - sha = Digest::SHA256.hexdigest(description) + include_context 'with code examples' do + before do + examples.values.uniq.each do |description| + sha = Digest::SHA256.hexdigest(description) + create :issue, project: private_project2, title: sha, description: description + end - results = described_class.new(user, search_term, limit_project_ids) - issues = results.objects('issues') - expect(issues.map(&:title)).to include(sha), "failed to find #{search_term}" + ensure_elasticsearch_index! + end + + it 'finds all examples' do + examples.each do |search_term, description| + sha = Digest::SHA256.hexdigest(description) + + results = described_class.new(user, search_term, limit_project_ids) + issues = results.objects('issues') + expect(issues.map(&:title)).to include(sha), "failed to find #{search_term}" + end end end end end + + it_behaves_like 'issues respect visibility' + + context 'when search_uses_match_queries flag is false' do + before do + stub_feature_flags(search_uses_match_queries: false) + end + + it_behaves_like 'issues respect visibility' + end + + context 'when search_query_builder feature flag is false' do + before do + stub_feature_flags(search_query_builder: false) + end + + it_behaves_like 'issues respect visibility' + end end context 'milestones' do diff --git a/ee/spec/lib/search/elastic/queries_spec.rb b/ee/spec/lib/search/elastic/queries_spec.rb index 4f30de5c4dafe14349224e0665f2d44bce74638e..eb023a028137c7c5d85939e97b651cc0063f638c 100644 --- a/ee/spec/lib/search/elastic/queries_spec.rb +++ b/ee/spec/lib/search/elastic/queries_spec.rb @@ -42,7 +42,7 @@ query: 'foo bar', lenient: true, default_operator: :and } } ] - expect(by_simple_query_string[:query][:bool][:must]).to eq(expected_must) + expect(by_simple_query_string[:query][:bool][:must]).to eql(expected_must) end end @@ -109,4 +109,127 @@ end end end + + describe '#by_multi_match_query' do + let(:query) { 'foo bar' } + let(:options) { base_options } + let(:base_options) { { doc_type: 'my_type' } } + let(:fields) { %w[iid^3 title^2 description] } + + subject(:by_multi_match_query) do + described_class.by_multi_match_query(fields: fields, query: query, options: options) + end + + context 'when custom elasticsearch analyzers are enabled' do + before do + stub_ee_application_setting(elasticsearch_analyzers_smartcn_enabled: true, + elasticsearch_analyzers_smartcn_search: true) + end + + it 'applies custom analyzer fields to multi_match_query' do + expected_must = [{ bool: { + should: [ + { multi_match: { _name: 'my_type:multi_match:or:search_terms', + fields: %w[iid^3 title^2 description title.smartcn description.smartcn], + query: 'foo bar', operator: :or, lenient: true } }, + { multi_match: { _name: 'my_type:multi_match:and:search_terms', + fields: %w[iid^3 title^2 description title.smartcn description.smartcn], + query: 'foo bar', operator: :and, lenient: true } }, + { multi_match: { _name: 'my_type:multi_match_phrase:search_terms', + type: :phrase, fields: %w[iid^3 title^2 description title.smartcn description.smartcn], + query: 'foo bar', lenient: true } } + ], + minimum_should_match: 1 + } }] + + expect(by_multi_match_query[:query][:bool][:must]).to eql(expected_must) + end + end + + it 'applies highlight in query' do + expected = { fields: { iid: {}, title: {}, description: {} }, + number_of_fragments: 0, pre_tags: ['gitlabelasticsearch→'], post_tags: ['â†gitlabelasticsearch'] } + + expect(by_multi_match_query[:highlight]).to eq(expected) + end + + context 'when query is provided' do + it 'returns a by_multi_match_query query as a should and adds doc type as a filter' do + expected_must = [{ bool: { + should: [ + { multi_match: { _name: 'my_type:multi_match:or:search_terms', + fields: %w[iid^3 title^2 description], + query: 'foo bar', operator: :or, lenient: true } }, + { multi_match: { _name: 'my_type:multi_match:and:search_terms', + fields: %w[iid^3 title^2 description], + query: 'foo bar', operator: :and, lenient: true } }, + { multi_match: { _name: 'my_type:multi_match_phrase:search_terms', + type: :phrase, fields: %w[iid^3 title^2 description], + query: 'foo bar', lenient: true } } + ], + minimum_should_match: 1 + } }] + + expected_filter = [ + { term: { type: { _name: 'doc:is_a:my_type', value: 'my_type' } } } + ] + + expect(by_multi_match_query[:query][:bool][:must]).to eql(expected_must) + expect(by_multi_match_query[:query][:bool][:must_not]).to eq([]) + expect(by_multi_match_query[:query][:bool][:should]).to eq([]) + expect(by_multi_match_query[:query][:bool][:filter]).to eq(expected_filter) + end + end + + context 'when query is not provided' do + let(:query) { nil } + + it 'returns a match_all query' do + expected_must = { match_all: {} } + + expect(by_multi_match_query[:query][:bool][:must]).to eq(expected_must) + expect(by_multi_match_query[:query][:bool][:must_not]).to eq([]) + expect(by_multi_match_query[:query][:bool][:should]).to eq([]) + expect(by_multi_match_query[:query][:bool][:filter]).to eq([]) + expect(by_multi_match_query[:track_scores]).to eq(true) + end + end + + context 'when options[:count_only] is true' do + let(:options) { base_options.merge(count_only: true) } + + it 'adds size set to 0 in query' do + expect(by_multi_match_query[:size]).to eq(0) + end + + it 'does not apply highlight in query' do + expect(by_multi_match_query[:highlight]).to be_nil + end + + it 'removes field boosts and returns a by_multi_match_query as a filter' do + expected_filter = [ + { term: { type: { _name: 'doc:is_a:my_type', value: 'my_type' } } }, + { bool: { + should: [ + { multi_match: { _name: 'my_type:multi_match:or:search_terms', + fields: %w[iid title description], + query: 'foo bar', operator: :or, lenient: true } }, + { multi_match: { _name: 'my_type:multi_match:and:search_terms', + fields: %w[iid title description], + query: 'foo bar', operator: :and, lenient: true } }, + { multi_match: { _name: 'my_type:multi_match_phrase:search_terms', + type: :phrase, fields: %w[iid title description], + query: 'foo bar', lenient: true } } + ], + minimum_should_match: 1 + } } + ] + + expect(by_multi_match_query[:query][:bool][:must]).to eq([]) + expect(by_multi_match_query[:query][:bool][:must_not]).to eq([]) + expect(by_multi_match_query[:query][:bool][:should]).to eq([]) + expect(by_multi_match_query[:query][:bool][:filter]).to eql(expected_filter) + end + end + end end diff --git a/ee/spec/models/concerns/elastic/issue_spec.rb b/ee/spec/models/concerns/elastic/issue_spec.rb index f4da3758a9a8bd53e4b90d18e972658d04ad5705..8147e795f2cd6328e4904b417e30d46809246a16 100644 --- a/ee/spec/models/concerns/elastic/issue_spec.rb +++ b/ee/spec/models/concerns/elastic/issue_spec.rb @@ -33,24 +33,6 @@ expect(described_class.elastic_search('bla-bla', options: { project_ids: :any, public_and_internal_projects: true }).total_count).to eq(3) end - context 'when search_query_builder feature flag is false' do - before do - stub_feature_flags(search_query_builder: false) - end - - it 'names elasticsearch queries' do - described_class.elastic_search('*').total_count - - assert_named_queries('issue:match:search_terms', 'issue:authorized:project') - end - end - - it 'names elasticsearch queries' do - described_class.elastic_search('*').total_count - - assert_named_queries('issue:match:search_terms', 'filters:project') - end - it 'searches by iid and scopes to type: issue only', :sidekiq_inline do issue = create :issue, title: 'bla-bla issue', project: project create :issue, description: 'term2 in description', project: project