full_search 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a18ce04c25c9a2670d1ad9394594a277b78201e74e11585c63e1848d6d6cf343
4
- data.tar.gz: 4d170670fd73af2e350ee427fce766658769612ea592772b29089ac395bc7b0c
3
+ metadata.gz: 5343bec22958a4f873a86fa226a66e925606f1eb01743e70993b6f2a4d598786
4
+ data.tar.gz: 3cb1d8b6d8cc8577901c4f6eb79e3340d5899da4ae1bb378ae752e4c89ed7d28
5
5
  SHA512:
6
- metadata.gz: d0276bef97a441548574747ddd7aeb67609b390bad8a8864618231cfac30d41f24e2d01cb55205b070d5c0f2249afbf4b7f0b133d51acb396e2a61a528923b18
7
- data.tar.gz: 6e7808b9b6f3e8ccb8e3964c3494f42336c18975f6c79c335a397927aaf6ef3d920f84dd5548000182ddac652b376f71c1751c090f0c65191ccb62e4627dd5a7
6
+ metadata.gz: 792eee7f1854aeac5b038f8e9496bf353637ffa85e72117e69ef6ae085c14f6849e43b7fd9c3c9edec5457ac94eaadc1bf6ea6b121294474d43ed135617d70b1
7
+ data.tar.gz: 3221491574ddbeca5e5299fc38b2959fb31b679c89244bdaaba68ffba8da2a06f3419d61cb857f754bb66a0a55dc80770a816e35ed396e46c3b7a53834f85b5a
@@ -2,17 +2,19 @@
2
2
 
3
3
  module FullSearch
4
4
  class Dsl
5
- attr_reader :fields, :exact_matches, :filters, :model_class, :tokenize, :highlight_config
5
+ attr_reader :fields, :exact_matches, :filters, :model_class, :tokenize, :highlight_config, :rank_bys
6
6
 
7
7
  Field = Data.define(:name, :weight, :source, :reindex_on, :async)
8
8
  ExactMatch = Data.define(:name, :source)
9
9
  Filter = Data.define(:name, :required)
10
+ RankBy = Data.define(:column, :direction)
10
11
 
11
12
  def initialize(model_class)
12
13
  @model_class = model_class
13
14
  @fields = []
14
15
  @exact_matches = []
15
16
  @filters = []
17
+ @rank_bys = []
16
18
  @tokenize = FullSearch.config.default_tokenizer
17
19
  @soft_delete_column = nil
18
20
  end
@@ -31,6 +33,13 @@ module FullSearch
31
33
  @exact_matches << ExactMatch.new(name: name.to_s, source: source)
32
34
  end
33
35
 
36
+ def rank_by(column, direction = :desc)
37
+ unless valid_name?(column)
38
+ raise InvalidFieldError, "Invalid rank_by column: #{column.inspect}"
39
+ end
40
+ @rank_bys << RankBy.new(column: column.to_s, direction: direction.to_sym)
41
+ end
42
+
34
43
  def filter(name, required: false)
35
44
  unless valid_name?(name)
36
45
  raise InvalidFieldError, "Invalid filter name: #{name.inspect}"
@@ -81,7 +90,8 @@ module FullSearch
81
90
  typo_tolerance_min_term_length,
82
91
  fields.map { |f| [f.name, f.weight, f.source.nil? ? "column" : "source", f.reindex_on, f.async] },
83
92
  exact_matches.map { |e| [e.name] },
84
- filters.map { |f| [f.name, f.required] }
93
+ filters.map { |f| [f.name, f.required] },
94
+ rank_bys.map { |r| [r.column, r.direction] }
85
95
  ].inspect)
86
96
  end
87
97
 
@@ -3,19 +3,55 @@
3
3
  module FullSearch
4
4
  class Highlighter
5
5
  def self.apply!(records, model, query)
6
+ snippets = build_snippets(model, query)
7
+ records.each { |record| record.full_search_snippet = snippets[record.id] }
8
+ records
9
+ end
10
+
11
+ def self.apply_fields!(records, model, query)
12
+ fields = build_field_snippets(model, query)
13
+ records.each { |record| record.full_search_highlight_fields = fields[record.id] || {} }
14
+ records
15
+ end
16
+
17
+ private
18
+
19
+ def self.build_snippets(model, query)
20
+ rows = highlight_rows(model, query)
21
+ cols = model.full_search_dsl.fields.map(&:name)
22
+
23
+ rows.to_h do |row|
24
+ text = cols.map { |col| row["#{col}_snippet"] }.compact.join(" ").strip
25
+ [row["rowid"], text.presence]
26
+ end
27
+ end
28
+
29
+ def self.build_field_snippets(model, query)
30
+ rows = highlight_rows(model, query)
6
31
  dsl = model.full_search_dsl
7
- config = dsl.highlight_config
8
- return records unless config
32
+ cols = dsl.fields.map(&:name)
33
+ open_tag = (dsl.highlight_config || { open_tag: "<mark>" })[:open_tag]
9
34
 
35
+ rows.to_h do |row|
36
+ snippets = cols.each_with_object({}) do |col, hash|
37
+ snippet = row["#{col}_snippet"].to_s.strip
38
+ hash[col.to_s] = snippet if snippet.include?(open_tag)
39
+ end
40
+ [row["rowid"], snippets]
41
+ end
42
+ end
43
+
44
+ def self.highlight_rows(model, query)
45
+ dsl = model.full_search_dsl
46
+ config = dsl.highlight_config || { open_tag: "<mark>", close_tag: "</mark>" }
10
47
  match_expr = QueryParser.to_match_expression(QueryParser.parse(query))
11
- return records if match_expr.empty?
48
+ return [] if match_expr.empty?
12
49
 
13
50
  table = FullSearch::Index.fts_table_name(model)
51
+ cols = dsl.fields.map(&:name)
52
+ return [] if cols.empty?
14
53
 
15
- content_cols = dsl.fields.map(&:name)
16
- return records if content_cols.empty?
17
-
18
- highlight_parts = content_cols.each_with_index.map do |col, idx|
54
+ highlight_parts = cols.each_with_index.map do |col, idx|
19
55
  "highlight(#{table}, #{idx}, #{quote(config[:open_tag])}, #{quote(config[:close_tag])}) AS #{col}_snippet"
20
56
  end.join(", ")
21
57
 
@@ -25,21 +61,9 @@ module FullSearch
25
61
  WHERE #{table} MATCH #{quote(match_expr)}
26
62
  SQL
27
63
 
28
- rows = connection.execute(sql)
29
-
30
- snippets = rows.map do |r|
31
- [r["rowid"], content_cols.map { |col| r["#{col}_snippet"] }.compact.join(" ").strip]
32
- end.to_h
33
-
34
- records.each do |record|
35
- record.full_search_snippet = snippets[record.id]
36
- end
37
-
38
- records
64
+ connection.execute(sql)
39
65
  end
40
66
 
41
- private
42
-
43
67
  def self.connection
44
68
  ActiveRecord::Base.connection
45
69
  end
@@ -5,7 +5,7 @@ module FullSearch
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  class_methods do
8
- def full_search(query_or_options = nil, filters: {}, include_soft_deleted: false, limit: nil, offset: nil, highlight: false, &block)
8
+ def full_search(query_or_options = nil, filters: {}, include_soft_deleted: false, limit: nil, offset: nil, highlight: false, highlight_fields: false, &block)
9
9
  if block_given? || query_or_options.is_a?(Hash)
10
10
  @full_search_dsl ||= FullSearch::Dsl.new(self)
11
11
  @full_search_dsl.tokenize(query_or_options[:tokenize]) if query_or_options.is_a?(Hash) && query_or_options.key?(:tokenize)
@@ -16,7 +16,7 @@ module FullSearch
16
16
  FullSearch.models << self unless FullSearch.models.include?(self)
17
17
  @full_search_dsl
18
18
  else
19
- FullSearch::Search.new(self, query_or_options, filters: filters, include_soft_deleted: include_soft_deleted, limit: limit, offset: offset, highlight: highlight).relation
19
+ FullSearch::Search.new(self, query_or_options, filters: filters, include_soft_deleted: include_soft_deleted, limit: limit, offset: offset, highlight: highlight, highlight_fields: highlight_fields).relation
20
20
  end
21
21
  end
22
22
 
@@ -30,7 +30,7 @@ module FullSearch
30
30
  end
31
31
 
32
32
  module InstanceMethods
33
- attr_accessor :full_search_snippet
33
+ attr_accessor :full_search_snippet, :full_search_highlight_fields
34
34
 
35
35
  def full_search_text_for(field_name)
36
36
  dsl = self.class.full_search_dsl
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullSearch
4
+ class MultiSearch
5
+ def self.call(query:, groups:)
6
+ new(query: query, groups: groups).call
7
+ end
8
+
9
+ def initialize(query:, groups:)
10
+ @query = query.to_s.strip
11
+ @groups = groups
12
+ end
13
+
14
+ def call
15
+ searched = groups.map do |group|
16
+ model = fetch(group, :model)
17
+ filters = group[:filters] || {}
18
+ limit = positive_integer(group[:limit], 8)
19
+ offset = positive_integer(group[:offset], 0)
20
+ raw_limit = limit + 1
21
+
22
+ relation = model.full_search(
23
+ query,
24
+ filters: filters,
25
+ limit: raw_limit,
26
+ offset: offset,
27
+ highlight_fields: group[:highlight_fields]
28
+ )
29
+
30
+ relation = group[:scope].call(relation) if group[:scope]
31
+
32
+ records = relation.to_a
33
+ has_more = records.size > limit
34
+ records = records.first(limit) if has_more
35
+
36
+ group.slice(:key, :label, :icon, :model).merge(
37
+ results: records,
38
+ has_more: has_more,
39
+ total_count: records.size
40
+ )
41
+ end
42
+
43
+ { groups: searched, total_count: searched.sum { |g| g[:total_count] } }
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :query, :groups
49
+
50
+ def fetch(group, key)
51
+ group.fetch(key) { fail ArgumentError, "Missing group key: #{key}" }
52
+ end
53
+
54
+ def positive_integer(value, default)
55
+ value.to_i.positive? ? value.to_i : default
56
+ end
57
+ end
58
+
59
+ class << self
60
+ def multi_search(...)
61
+ MultiSearch.call(...)
62
+ end
63
+ end
64
+ end
@@ -2,9 +2,9 @@
2
2
 
3
3
  module FullSearch
4
4
  class Search
5
- attr_reader :model, :query, :filters, :include_soft_deleted, :limit, :offset, :highlight
5
+ attr_reader :model, :query, :filters, :include_soft_deleted, :limit, :offset, :highlight, :highlight_fields
6
6
 
7
- def initialize(model, query, filters:, include_soft_deleted:, limit:, offset:, highlight: false)
7
+ def initialize(model, query, filters:, include_soft_deleted:, limit:, offset:, highlight: false, highlight_fields: false)
8
8
  @model = model
9
9
  @query = query.to_s.strip
10
10
  @filters = filters
@@ -12,6 +12,7 @@ module FullSearch
12
12
  @limit = limit
13
13
  @offset = offset
14
14
  @highlight = highlight
15
+ @highlight_fields = highlight_fields
15
16
  end
16
17
 
17
18
  def relation
@@ -30,11 +31,12 @@ module FullSearch
30
31
  rel = rel.limit(limit) if limit
31
32
  rel = rel.offset(offset) if offset
32
33
 
33
- order_sql = case_clause(all_ids)
34
- rel = rel.order(Arel.sql(order_sql))
34
+ rel = apply_ranking(rel, all_ids, exact_ids)
35
35
 
36
36
  if highlight
37
37
  Highlighter.apply!(rel.to_a, model, query)
38
+ elsif highlight_fields
39
+ Highlighter.apply_fields!(rel.to_a, model, query)
38
40
  else
39
41
  rel
40
42
  end
@@ -98,12 +100,36 @@ module FullSearch
98
100
  connection.execute("#{sql} #{filter_conditions}").map { |r| r["id"] }
99
101
  end
100
102
 
101
- def bare_term?(parsed)
102
- parsed.is_a?(Array) && parsed.size == 2 && parsed.first == :term
103
- end
103
+ def apply_ranking(rel, all_ids, exact_ids)
104
+ return rel if all_ids.empty?
105
+
106
+ order_parts = []
107
+
108
+ if exact_ids.any?
109
+ order_parts << "CASE #{model.table_name}.id #{exact_ids.map { |id| "WHEN #{id} THEN 0" }.join(" ")} ELSE 1 END"
110
+ end
111
+
112
+ fts_table = FullSearch::Index.fts_table_name(model)
113
+ match_expr = QueryParser.to_match_expression(QueryParser.parse(query))
114
+
115
+ rank_subquery = <<~SQL
116
+ SELECT rowid, rank
117
+ FROM #{fts_table}
118
+ WHERE #{fts_table} MATCH #{connection.quote(match_expr)}
119
+ SQL
120
+
121
+ rel = rel
122
+ .select("#{model.table_name}.*, fts_rank.rank AS full_search_rank")
123
+ .joins("LEFT JOIN (#{rank_subquery}) AS fts_rank ON fts_rank.rowid = #{model.table_name}.id")
124
+
125
+ order_parts << "COALESCE(fts_rank.rank, 1)"
126
+
127
+ dsl.rank_bys.each do |rank_by|
128
+ col = "#{connection.quote_table_name(model.table_name)}.#{connection.quote_column_name(rank_by.column)}"
129
+ order_parts << "#{col} #{rank_by.direction.to_s.upcase} NULLS LAST"
130
+ end
104
131
 
105
- def case_clause(ids)
106
- "CASE id #{ids.map.with_index { |id, i| "WHEN #{id} THEN #{i}" }.join(" ")} END"
132
+ rel.order(Arel.sql(order_parts.join(", ")))
107
133
  end
108
134
 
109
135
  def connection
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FullSearch
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
data/lib/full_search.rb CHANGED
@@ -20,6 +20,7 @@ require "full_search/soft_delete"
20
20
  require "full_search/callbacks"
21
21
  require "full_search/reindex_job"
22
22
  require "full_search/test_helpers"
23
+ require "full_search/multi_search"
23
24
 
24
25
  ActiveSupport.on_load(:active_record) do
25
26
  include FullSearch::Model
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: full_search
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben D'Angelo
@@ -108,6 +108,7 @@ files:
108
108
  - lib/full_search/highlighter.rb
109
109
  - lib/full_search/index.rb
110
110
  - lib/full_search/model.rb
111
+ - lib/full_search/multi_search.rb
111
112
  - lib/full_search/query_parser.rb
112
113
  - lib/full_search/railtie.rb
113
114
  - lib/full_search/reindex_job.rb