diff --git a/app/models/filter_subscription.rb b/app/models/filter_subscription.rb index cb088e142..4360ad256 100644 --- a/app/models/filter_subscription.rb +++ b/app/models/filter_subscription.rb @@ -70,7 +70,7 @@ def scope_searchable(searchable) end scope = scope.where.not("tag_ids && ARRAY[?]", query[:filter_out_tag_ids]) if query[:filter_out_tag_ids].present? - scope = scope.fulltext_search(query[:fulltext]) if query[:fulltext].present? + scope = scope.fulltext_search(query[:fulltext], prefix_search: query[:prefix_search]) if query[:fulltext].present? scope end diff --git a/app/models/searchable/message_thread.rb b/app/models/searchable/message_thread.rb index 8fad30af7..0867c8906 100644 --- a/app/models/searchable/message_thread.rb +++ b/app/models/searchable/message_thread.rb @@ -22,30 +22,39 @@ class Searchable::MessageThread < ApplicationRecord scope :with_tag_id, ->(tag_id) { where("tag_ids && ARRAY[?]", [tag_id]) } include PgSearch::Model - pg_search_scope :pg_search_all, - against: [:title, :content, :note, :tag_names], - using: { - tsearch: { - highlight: { - StartSel: '', # if you change classed aad them to view in comment - StopSel: '', - MaxWords: 15, - MinWords: 0, - ShortWord: 1, - HighlightAll: true, - MaxFragments: 2, - FragmentDelimiter: '…' - } - } - } + + + pg_search_scope :pg_search_all, lambda { |query, is_prefix = false| + { + query: query, + against: [:title, :content, :note, :tag_names], + using: { + tsearch: { + prefix: is_prefix, + highlight: { + StartSel: '', # if you change classed aad them to view in comment + StopSel: '', + MaxWords: 15, + MinWords: 0, + ShortWord: 1, + HighlightAll: true, + MaxFragments: 2, + FragmentDelimiter: '…' + } + } + } + } + } def self.matching(scopeable) scopeable.scope_searchable(self) # double dispatch end - def self.fulltext_search(query) + + def self.fulltext_search(query, prefix_search: false) pg_search_all( - Searchable::IndexHelpers.searchable_string(query) + Searchable::IndexHelpers.searchable_string(query), + prefix_search ) end @@ -71,7 +80,7 @@ def self.search_ids(query_filter, search_permissions:, cursor:, per_page:, direc end end scope = scope.where.not("tag_ids && ARRAY[?]", query_filter[:filter_out_tag_ids]) if query_filter[:filter_out_tag_ids].present? - scope = scope.fulltext_search(query_filter[:fulltext]).with_pg_search_highlight if query_filter[:fulltext].present? + scope = scope.fulltext_search(query_filter[:fulltext], prefix_search: query_filter[:prefix_search]).with_pg_search_highlight if query_filter[:fulltext].present? scope = scope.select(:message_thread_id, :last_message_delivered_at) # remove default order rule given by pg_search diff --git a/app/models/searchable/message_thread_query.rb b/app/models/searchable/message_thread_query.rb index d27b991b2..1c097911d 100644 --- a/app/models/searchable/message_thread_query.rb +++ b/app/models/searchable/message_thread_query.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Searchable::MessageThreadQuery + PREFIX_SEARCH_REGEXP = /^\S{4,}\*$/ + def self.parse(query) filter_labels = [] filter_out_labels = [] @@ -21,16 +23,19 @@ def self.parse(query) with_text = with_text.gsub("#{match[0]}:#{match[1]}", "") end + with_text = with_text.gsub(/\s+/, ' ').strip + { - fulltext: with_text.gsub(/\s+/, ' ').strip, + fulltext: with_text, + prefix_search: with_text.match?(PREFIX_SEARCH_REGEXP), filter_labels: filter_labels, filter_out_labels: filter_out_labels, } end def self.labels_to_ids(parsed_query, tenant:) - fulltext, filter_labels, filter_out_labels = - parsed_query.fetch_values(:fulltext, :filter_labels, :filter_out_labels) + fulltext, prefix_search, filter_labels, filter_out_labels = + parsed_query.fetch_values(:fulltext, :prefix_search, :filter_labels, :filter_out_labels) # TODO maybe with one query found_all, filter_tag_ids = label_names_to_tag_ids(tenant, filter_labels) @@ -48,6 +53,7 @@ def self.labels_to_ids(parsed_query, tenant:) result[:filter_out_tag_ids] = filter_out_tag_ids if filter_out_tag_ids.present? result[:fulltext] = fulltext if fulltext.present? + result[:prefix_search] = prefix_search result end diff --git a/test/models/searchable/message_thread_query_test.rb b/test/models/searchable/message_thread_query_test.rb index 30eacb0e5..ae4315ca8 100644 --- a/test/models/searchable/message_thread_query_test.rb +++ b/test/models/searchable/message_thread_query_test.rb @@ -4,12 +4,14 @@ class Searchable::MessageThreadQueryTest < ActiveSupport::TestCase test "parser empty" do assert_equal Searchable::MessageThreadQuery.parse(''), { fulltext: '', + prefix_search: false, filter_labels: [], filter_out_labels: [] } assert_equal Searchable::MessageThreadQuery.parse(nil), { fulltext: '', + prefix_search: false, filter_labels: [], filter_out_labels: [] } @@ -18,6 +20,7 @@ class Searchable::MessageThreadQueryTest < ActiveSupport::TestCase test "parser only include tag" do assert_equal Searchable::MessageThreadQuery.parse('label:(tag one/with something)'), { fulltext: '', + prefix_search: false, filter_labels: ['tag one/with something'], filter_out_labels: [] } @@ -26,6 +29,7 @@ class Searchable::MessageThreadQueryTest < ActiveSupport::TestCase test "parser only exclude tag" do assert_equal Searchable::MessageThreadQuery.parse('-label:(without)'), { fulltext: '', + prefix_search: false, filter_labels: [], filter_out_labels: ['without'] } @@ -35,6 +39,7 @@ class Searchable::MessageThreadQueryTest < ActiveSupport::TestCase query = 'label:(tag one/with something) hello label:(tag two) world -label:(without this tag) ending' assert_equal Searchable::MessageThreadQuery.parse(query), { fulltext: 'hello world ending', + prefix_search: false, filter_labels: ['tag one/with something', 'tag two'], filter_out_labels: ['without this tag'] } @@ -44,6 +49,7 @@ class Searchable::MessageThreadQueryTest < ActiveSupport::TestCase query = 'something -label:* else' assert_equal Searchable::MessageThreadQuery.parse(query), { fulltext: 'something else', + prefix_search: false, filter_labels: [], filter_out_labels: ["*"] } @@ -53,8 +59,29 @@ class Searchable::MessageThreadQueryTest < ActiveSupport::TestCase query = 'something -label:* else -label:two' assert_equal Searchable::MessageThreadQuery.parse(query), { fulltext: 'something else', + prefix_search: false, filter_labels: [], filter_out_labels: ["*", "two"] } end + + test "parser with prefix search" do + query = 'someth* -label:*' + assert_equal Searchable::MessageThreadQuery.parse(query), { + fulltext: 'someth*', + prefix_search: true, + filter_labels: [], + filter_out_labels: ["*"] + } + end + + test "parser without prefix search for multiple words" do + query = 'tell me someth*' + assert_equal Searchable::MessageThreadQuery.parse(query), { + fulltext: 'tell me someth*', + prefix_search: false, + filter_labels: [], + filter_out_labels: [] + } + end end