full_search 0.1.0 → 0.1.2
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 +4 -4
- data/lib/full_search/dsl.rb +12 -2
- data/lib/full_search/highlighter.rb +44 -20
- data/lib/full_search/model.rb +3 -3
- data/lib/full_search/multi_search.rb +64 -0
- data/lib/full_search/search.rb +60 -9
- data/lib/full_search/version.rb +1 -1
- data/lib/full_search.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 025c498951cfbd1e531bb07c3994af9b195bf0411bfe65b65ff96aacedbc157a
|
|
4
|
+
data.tar.gz: f1c0d8eb49892d94f567ab162e8c94576b8f574848077d95d5942236d51b215a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3ea481bc2b665046432f415a6e892b4735b1aedee46716ac271b51cb873c490b002280000b02a92ba3369b8e3c6a27b98d8da18b876b4269a6a516cc51262b4
|
|
7
|
+
data.tar.gz: 93e2ed7136f1b20d7bb7025891d29c015b61db0d427c527eb4345e6bfd94ec0968562aba8528590737d89cbad2988c4d029ad114f3f18e6467d5a19f3ff8c4b2
|
data/lib/full_search/dsl.rb
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/full_search/model.rb
CHANGED
|
@@ -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
|
data/lib/full_search/search.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -80,7 +82,11 @@ module FullSearch
|
|
|
80
82
|
return [] if match_expr.empty?
|
|
81
83
|
|
|
82
84
|
term = parsed.last rescue nil
|
|
83
|
-
return [] if term.nil?
|
|
85
|
+
return [] if term.nil?
|
|
86
|
+
|
|
87
|
+
if term.length < dsl.typo_tolerance_min_term_length.to_i
|
|
88
|
+
return like_prefix_ids(term)
|
|
89
|
+
end
|
|
84
90
|
|
|
85
91
|
trigram_table = FullSearch::Index.trigram_table_name(model)
|
|
86
92
|
|
|
@@ -98,12 +104,57 @@ module FullSearch
|
|
|
98
104
|
connection.execute("#{sql} #{filter_conditions}").map { |r| r["id"] }
|
|
99
105
|
end
|
|
100
106
|
|
|
101
|
-
def
|
|
102
|
-
|
|
107
|
+
def like_prefix_ids(term)
|
|
108
|
+
column_fields = dsl.fields.select { |f| f.source.nil? }
|
|
109
|
+
return [] if column_fields.empty?
|
|
110
|
+
|
|
111
|
+
like_conditions = column_fields.map do |field|
|
|
112
|
+
"#{connection.quote_table_name(model.table_name)}.#{connection.quote_column_name(field.name)} LIKE #{connection.quote("#{term}%")}"
|
|
113
|
+
end.join(" OR ")
|
|
114
|
+
|
|
115
|
+
sql = <<~SQL
|
|
116
|
+
SELECT #{model.table_name}.id
|
|
117
|
+
FROM #{model.table_name}
|
|
118
|
+
WHERE (#{like_conditions})
|
|
119
|
+
SQL
|
|
120
|
+
|
|
121
|
+
filter_conditions = filters.map do |name, value|
|
|
122
|
+
"AND #{model.table_name}.#{name} = #{connection.quote(value)}"
|
|
123
|
+
end.join(" ")
|
|
124
|
+
|
|
125
|
+
connection.execute("#{sql} #{filter_conditions}").map { |r| r["id"] }
|
|
103
126
|
end
|
|
104
127
|
|
|
105
|
-
def
|
|
106
|
-
|
|
128
|
+
def apply_ranking(rel, all_ids, exact_ids)
|
|
129
|
+
return rel if all_ids.empty?
|
|
130
|
+
|
|
131
|
+
order_parts = []
|
|
132
|
+
|
|
133
|
+
if exact_ids.any?
|
|
134
|
+
order_parts << "CASE #{model.table_name}.id #{exact_ids.map { |id| "WHEN #{id} THEN 0" }.join(" ")} ELSE 1 END"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
fts_table = FullSearch::Index.fts_table_name(model)
|
|
138
|
+
match_expr = QueryParser.to_match_expression(QueryParser.parse(query))
|
|
139
|
+
|
|
140
|
+
rank_subquery = <<~SQL
|
|
141
|
+
SELECT rowid, rank
|
|
142
|
+
FROM #{fts_table}
|
|
143
|
+
WHERE #{fts_table} MATCH #{connection.quote(match_expr)}
|
|
144
|
+
SQL
|
|
145
|
+
|
|
146
|
+
rel = rel
|
|
147
|
+
.select("#{model.table_name}.*, fts_rank.rank AS full_search_rank")
|
|
148
|
+
.joins("LEFT JOIN (#{rank_subquery}) AS fts_rank ON fts_rank.rowid = #{model.table_name}.id")
|
|
149
|
+
|
|
150
|
+
order_parts << "COALESCE(fts_rank.rank, 1)"
|
|
151
|
+
|
|
152
|
+
dsl.rank_bys.each do |rank_by|
|
|
153
|
+
col = "#{connection.quote_table_name(model.table_name)}.#{connection.quote_column_name(rank_by.column)}"
|
|
154
|
+
order_parts << "#{col} #{rank_by.direction.to_s.upcase} NULLS LAST"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
rel.order(Arel.sql(order_parts.join(", ")))
|
|
107
158
|
end
|
|
108
159
|
|
|
109
160
|
def connection
|
data/lib/full_search/version.rb
CHANGED
data/lib/full_search.rb
CHANGED
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.
|
|
4
|
+
version: 0.1.2
|
|
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
|