full_search 0.1.0
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 +7 -0
- data/README.md +89 -0
- data/lib/full_search/callbacks.rb +86 -0
- data/lib/full_search/config.rb +25 -0
- data/lib/full_search/dsl.rb +94 -0
- data/lib/full_search/errors.rb +10 -0
- data/lib/full_search/exact_match.rb +22 -0
- data/lib/full_search/highlighter.rb +51 -0
- data/lib/full_search/index.rb +335 -0
- data/lib/full_search/model.rb +44 -0
- data/lib/full_search/query_parser.rb +126 -0
- data/lib/full_search/railtie.rb +28 -0
- data/lib/full_search/reindex_job.rb +15 -0
- data/lib/full_search/search.rb +113 -0
- data/lib/full_search/soft_delete.rb +12 -0
- data/lib/full_search/test_helpers.rb +17 -0
- data/lib/full_search/tokenizer.rb +15 -0
- data/lib/full_search/typo.rb +28 -0
- data/lib/full_search/version.rb +5 -0
- data/lib/full_search.rb +36 -0
- data/lib/generators/full_search/install/install_generator.rb +15 -0
- data/lib/generators/full_search/install/templates/full_search.rb +9 -0
- data/lib/tasks/full_search.rake +42 -0
- metadata +144 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ostruct"
|
|
4
|
+
|
|
5
|
+
module FullSearch
|
|
6
|
+
class Index
|
|
7
|
+
class << self
|
|
8
|
+
def ensure_table!(model)
|
|
9
|
+
return unless FullSearch::Index.sqlite?
|
|
10
|
+
|
|
11
|
+
conn = connection
|
|
12
|
+
dsl = model.full_search_dsl
|
|
13
|
+
return unless dsl
|
|
14
|
+
|
|
15
|
+
create_metadata_table!
|
|
16
|
+
|
|
17
|
+
unless table_exists?(model)
|
|
18
|
+
conn.execute(create_virtual_table_sql(model))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
if dsl.typo_tolerance? && !trigram_table_exists?(model)
|
|
22
|
+
FullSearch::Typo.warn_unsupported! unless FullSearch::Typo.supported?
|
|
23
|
+
conn.execute(create_trigram_virtual_table_sql(model))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
ensure_triggers!(model) if model_table_exists?(model)
|
|
27
|
+
store_config_hash!(model)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def rebuild!(model)
|
|
31
|
+
return unless FullSearch::Index.sqlite?
|
|
32
|
+
|
|
33
|
+
dsl = model.full_search_dsl
|
|
34
|
+
return unless dsl
|
|
35
|
+
conn = connection
|
|
36
|
+
|
|
37
|
+
create_metadata_table!
|
|
38
|
+
|
|
39
|
+
with_rebuild_lock(model) do
|
|
40
|
+
drop_triggers!(model)
|
|
41
|
+
conn.execute("DROP TABLE IF EXISTS #{fts_table_name(model)};")
|
|
42
|
+
conn.execute("DROP TABLE IF EXISTS #{trigram_table_name(model)};")
|
|
43
|
+
conn.execute(create_virtual_table_sql(model))
|
|
44
|
+
if dsl.typo_tolerance?
|
|
45
|
+
FullSearch::Typo.warn_unsupported! unless FullSearch::Typo.supported?
|
|
46
|
+
conn.execute(create_trigram_virtual_table_sql(model))
|
|
47
|
+
end
|
|
48
|
+
conn.execute(backfill_sql(model))
|
|
49
|
+
conn.execute(backfill_trigram_sql(model)) if dsl.typo_tolerance?
|
|
50
|
+
create_triggers!(model)
|
|
51
|
+
optimize!(model)
|
|
52
|
+
store_config_hash!(model, rebuilt_at: Time.current)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def optimize!(model)
|
|
57
|
+
connection.execute("INSERT INTO #{fts_table_name(model)}(#{fts_table_name(model)}) VALUES('optimize');")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def drop!(model)
|
|
61
|
+
drop_triggers!(model)
|
|
62
|
+
connection.execute("DROP TABLE IF EXISTS #{fts_table_name(model)};")
|
|
63
|
+
connection.execute("DROP TABLE IF EXISTS #{trigram_table_name(model)};")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def fts_table_name(model)
|
|
67
|
+
"#{model.table_name}_fts"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def trigram_table_name(model)
|
|
71
|
+
"#{fts_table_name(model)}_trigram"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def sqlite?
|
|
75
|
+
connection.adapter_name.downcase.include?("sqlite")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def stored_config_hash(model)
|
|
79
|
+
row = connection.execute(
|
|
80
|
+
"SELECT config_hash FROM full_search_index_versions WHERE table_name=#{q(model.table_name)}"
|
|
81
|
+
).first
|
|
82
|
+
row&.[]("config_hash")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def connection
|
|
88
|
+
ActiveRecord::Base.connection
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def q(value)
|
|
92
|
+
connection.quote(value)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def model_table_exists?(model)
|
|
96
|
+
connection.execute(
|
|
97
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=#{q(model.table_name)} LIMIT 1"
|
|
98
|
+
).any?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def table_exists?(model)
|
|
102
|
+
connection.execute(
|
|
103
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=#{q(fts_table_name(model))} LIMIT 1"
|
|
104
|
+
).any?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def create_metadata_table!
|
|
108
|
+
connection.execute(<<~SQL)
|
|
109
|
+
CREATE TABLE IF NOT EXISTS full_search_index_versions (
|
|
110
|
+
table_name TEXT PRIMARY KEY,
|
|
111
|
+
config_hash TEXT NOT NULL,
|
|
112
|
+
rebuilt_at DATETIME NOT NULL
|
|
113
|
+
);
|
|
114
|
+
SQL
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def store_config_hash!(model, rebuilt_at: Time.current)
|
|
118
|
+
sql = "INSERT INTO full_search_index_versions (table_name, config_hash, rebuilt_at) VALUES (#{q(model.table_name)}, #{q(model.full_search_dsl.config_hash)}, #{q(rebuilt_at)})
|
|
119
|
+
ON CONFLICT(table_name) DO UPDATE SET config_hash=excluded.config_hash, rebuilt_at=excluded.rebuilt_at;"
|
|
120
|
+
connection.execute(sql)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def create_virtual_table_sql(model)
|
|
124
|
+
dsl = model.full_search_dsl
|
|
125
|
+
columns = (dsl.fields + dsl.filters.map { |f| OpenStruct.new(name: f.name, unindexed?: true) })
|
|
126
|
+
column_list = columns.map { |c| c.respond_to?(:unindexed?) && c.unindexed? ? "#{c.name} UNINDEXED" : c.name }.join(", ")
|
|
127
|
+
|
|
128
|
+
<<~SQL
|
|
129
|
+
CREATE VIRTUAL TABLE #{fts_table_name(model)} USING fts5(
|
|
130
|
+
#{column_list},
|
|
131
|
+
tokenize='#{dsl.tokenize}'
|
|
132
|
+
);
|
|
133
|
+
SQL
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def backfill_sql(model)
|
|
137
|
+
dsl = model.full_search_dsl
|
|
138
|
+
cols = (dsl.fields + dsl.filters)
|
|
139
|
+
select = cols.map do |c|
|
|
140
|
+
if c.respond_to?(:source) && c.source
|
|
141
|
+
source_value_sql(c.source)
|
|
142
|
+
else
|
|
143
|
+
"#{model.table_name}.#{c.name}"
|
|
144
|
+
end
|
|
145
|
+
end.join(", ")
|
|
146
|
+
|
|
147
|
+
<<~SQL
|
|
148
|
+
INSERT INTO #{fts_table_name(model)}(rowid, #{cols.map(&:name).join(", ")})
|
|
149
|
+
SELECT #{model.table_name}.id, #{select} FROM #{model.table_name};
|
|
150
|
+
SQL
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def source_value_sql(_source_proc)
|
|
154
|
+
"''"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def ensure_triggers!(model)
|
|
158
|
+
existing = connection.execute(
|
|
159
|
+
"SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name=#{q(model.table_name)}"
|
|
160
|
+
).map { |r| r["name"] }
|
|
161
|
+
|
|
162
|
+
create_triggers!(model) unless (trigger_names(model) - existing).empty?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def create_triggers!(model)
|
|
166
|
+
connection.execute(insert_trigger_sql(model))
|
|
167
|
+
connection.execute(delete_trigger_sql(model))
|
|
168
|
+
connection.execute(update_trigger_sql(model))
|
|
169
|
+
if model.full_search_dsl.typo_tolerance?
|
|
170
|
+
connection.execute(insert_trigram_trigger_sql(model))
|
|
171
|
+
connection.execute(delete_trigram_trigger_sql(model))
|
|
172
|
+
connection.execute(update_trigram_trigger_sql(model))
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def drop_triggers!(model)
|
|
177
|
+
(trigger_names(model) + trigram_trigger_names(model)).each do |name|
|
|
178
|
+
connection.execute("DROP TRIGGER IF EXISTS #{name};")
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def trigger_names(model)
|
|
183
|
+
base = fts_table_name(model)
|
|
184
|
+
%W[#{base}_ai #{base}_ad #{base}_au]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def trigram_trigger_names(model)
|
|
188
|
+
base = trigram_table_name(model)
|
|
189
|
+
%W[#{base}_ai #{base}_ad #{base}_au]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def insert_trigger_sql(model)
|
|
193
|
+
dsl = model.full_search_dsl
|
|
194
|
+
cols = dsl.fields + dsl.filters
|
|
195
|
+
values = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
|
|
196
|
+
|
|
197
|
+
<<~SQL
|
|
198
|
+
CREATE TRIGGER #{trigger_names(model).first} AFTER INSERT ON #{model.table_name} BEGIN
|
|
199
|
+
INSERT INTO #{fts_table_name(model)}(rowid, #{col_names(cols)})
|
|
200
|
+
VALUES (new.id, #{values});
|
|
201
|
+
END;
|
|
202
|
+
SQL
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def delete_trigger_sql(model)
|
|
206
|
+
<<~SQL
|
|
207
|
+
CREATE TRIGGER #{trigger_names(model)[1]} AFTER DELETE ON #{model.table_name} BEGIN
|
|
208
|
+
DELETE FROM #{fts_table_name(model)} WHERE rowid = old.id;
|
|
209
|
+
END;
|
|
210
|
+
SQL
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def update_trigger_sql(model)
|
|
214
|
+
dsl = model.full_search_dsl
|
|
215
|
+
cols = dsl.fields + dsl.filters
|
|
216
|
+
values = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
|
|
217
|
+
cols_str = col_names(cols)
|
|
218
|
+
fts_table = fts_table_name(model)
|
|
219
|
+
|
|
220
|
+
when_clause = SoftDelete.delete_transition_sql(model)
|
|
221
|
+
|
|
222
|
+
<<~SQL
|
|
223
|
+
CREATE TRIGGER #{trigger_names(model)[2]} AFTER UPDATE ON #{model.table_name} #{when_clause}
|
|
224
|
+
BEGIN
|
|
225
|
+
DELETE FROM #{fts_table} WHERE rowid = old.id;
|
|
226
|
+
INSERT INTO #{fts_table}(rowid, #{cols_str})
|
|
227
|
+
VALUES (new.id, #{values});
|
|
228
|
+
END;
|
|
229
|
+
SQL
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def trigram_table_exists?(model)
|
|
233
|
+
connection.execute(
|
|
234
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=#{q(trigram_table_name(model))} LIMIT 1"
|
|
235
|
+
).any?
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def create_trigram_virtual_table_sql(model)
|
|
239
|
+
dsl = model.full_search_dsl
|
|
240
|
+
columns = (dsl.fields + dsl.filters.map { |f| OpenStruct.new(name: f.name, unindexed?: true) })
|
|
241
|
+
column_list = columns.map { |c| c.respond_to?(:unindexed?) && c.unindexed? ? "#{c.name} UNINDEXED" : c.name }.join(", ")
|
|
242
|
+
|
|
243
|
+
<<~SQL
|
|
244
|
+
CREATE VIRTUAL TABLE #{trigram_table_name(model)} USING fts5(
|
|
245
|
+
#{column_list},
|
|
246
|
+
tokenize='trigram'
|
|
247
|
+
);
|
|
248
|
+
SQL
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def backfill_trigram_sql(model)
|
|
252
|
+
dsl = model.full_search_dsl
|
|
253
|
+
cols = (dsl.fields + dsl.filters)
|
|
254
|
+
select = cols.map do |c|
|
|
255
|
+
if c.respond_to?(:source) && c.source
|
|
256
|
+
source_value_sql(c.source)
|
|
257
|
+
else
|
|
258
|
+
"#{model.table_name}.#{c.name}"
|
|
259
|
+
end
|
|
260
|
+
end.join(", ")
|
|
261
|
+
|
|
262
|
+
<<~SQL
|
|
263
|
+
INSERT INTO #{trigram_table_name(model)}(rowid, #{cols.map(&:name).join(", ")})
|
|
264
|
+
SELECT #{model.table_name}.id, #{select} FROM #{model.table_name};
|
|
265
|
+
SQL
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def insert_trigram_trigger_sql(model)
|
|
269
|
+
dsl = model.full_search_dsl
|
|
270
|
+
cols = dsl.fields + dsl.filters
|
|
271
|
+
values = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
|
|
272
|
+
|
|
273
|
+
<<~SQL
|
|
274
|
+
CREATE TRIGGER #{trigram_trigger_names(model).first} AFTER INSERT ON #{model.table_name} BEGIN
|
|
275
|
+
INSERT INTO #{trigram_table_name(model)}(rowid, #{col_names(cols)})
|
|
276
|
+
VALUES (new.id, #{values});
|
|
277
|
+
END;
|
|
278
|
+
SQL
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def delete_trigram_trigger_sql(model)
|
|
282
|
+
<<~SQL
|
|
283
|
+
CREATE TRIGGER #{trigram_trigger_names(model)[1]} AFTER DELETE ON #{model.table_name} BEGIN
|
|
284
|
+
DELETE FROM #{trigram_table_name(model)} WHERE rowid = old.id;
|
|
285
|
+
END;
|
|
286
|
+
SQL
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def update_trigram_trigger_sql(model)
|
|
290
|
+
dsl = model.full_search_dsl
|
|
291
|
+
cols = dsl.fields + dsl.filters
|
|
292
|
+
values = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
|
|
293
|
+
cols_str = col_names(cols)
|
|
294
|
+
trigram_table = trigram_table_name(model)
|
|
295
|
+
|
|
296
|
+
when_clause = SoftDelete.delete_transition_sql(model)
|
|
297
|
+
|
|
298
|
+
<<~SQL
|
|
299
|
+
CREATE TRIGGER #{trigram_trigger_names(model)[2]} AFTER UPDATE ON #{model.table_name} #{when_clause}
|
|
300
|
+
BEGIN
|
|
301
|
+
DELETE FROM #{trigram_table} WHERE rowid = old.id;
|
|
302
|
+
INSERT INTO #{trigram_table}(rowid, #{cols_str})
|
|
303
|
+
VALUES (new.id, #{values});
|
|
304
|
+
END;
|
|
305
|
+
SQL
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def col_names(cols)
|
|
309
|
+
cols.map(&:name).join(", ")
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def column_ref(col, prefix:)
|
|
313
|
+
if col.respond_to?(:source) && col.source
|
|
314
|
+
"''"
|
|
315
|
+
else
|
|
316
|
+
"#{prefix}.#{col.name}"
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def with_rebuild_lock(model)
|
|
321
|
+
if FullSearch.config.lock_rebuilds
|
|
322
|
+
connection.transaction do
|
|
323
|
+
connection.execute(
|
|
324
|
+
"INSERT INTO full_search_index_versions (table_name, config_hash, rebuilt_at) VALUES (#{q(model.table_name)}, #{q("")}, datetime('now'))
|
|
325
|
+
ON CONFLICT(table_name) DO UPDATE SET config_hash=excluded.config_hash;"
|
|
326
|
+
)
|
|
327
|
+
yield
|
|
328
|
+
end
|
|
329
|
+
else
|
|
330
|
+
yield
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FullSearch
|
|
4
|
+
module Model
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
class_methods do
|
|
8
|
+
def full_search(query_or_options = nil, filters: {}, include_soft_deleted: false, limit: nil, offset: nil, highlight: false, &block)
|
|
9
|
+
if block_given? || query_or_options.is_a?(Hash)
|
|
10
|
+
@full_search_dsl ||= FullSearch::Dsl.new(self)
|
|
11
|
+
@full_search_dsl.tokenize(query_or_options[:tokenize]) if query_or_options.is_a?(Hash) && query_or_options.key?(:tokenize)
|
|
12
|
+
@full_search_dsl.instance_eval(&block) if block_given?
|
|
13
|
+
FullSearch::Index.ensure_table!(self)
|
|
14
|
+
FullSearch::Callbacks.install!(self)
|
|
15
|
+
include InstanceMethods
|
|
16
|
+
FullSearch.models << self unless FullSearch.models.include?(self)
|
|
17
|
+
@full_search_dsl
|
|
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
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def full_search_dsl
|
|
24
|
+
@full_search_dsl
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def full_search_ids(query, filters: {}, include_soft_deleted: false, limit: 1000)
|
|
28
|
+
full_search(query, filters: filters, include_soft_deleted: include_soft_deleted, limit: limit).pluck(:id)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
module InstanceMethods
|
|
33
|
+
attr_accessor :full_search_snippet
|
|
34
|
+
|
|
35
|
+
def full_search_text_for(field_name)
|
|
36
|
+
dsl = self.class.full_search_dsl
|
|
37
|
+
field = dsl.fields.find { |f| f.name == field_name.to_s }
|
|
38
|
+
return nil unless field
|
|
39
|
+
|
|
40
|
+
field.source ? instance_exec(&field.source) : public_send(field.name)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module FullSearch
|
|
6
|
+
module QueryParser
|
|
7
|
+
Token = Data.define(:type, :value)
|
|
8
|
+
|
|
9
|
+
def self.parse(query)
|
|
10
|
+
tokens = tokenize(query.to_s.strip)
|
|
11
|
+
return [] if tokens.empty?
|
|
12
|
+
|
|
13
|
+
or_clauses = split_or(tokens)
|
|
14
|
+
|
|
15
|
+
if or_clauses.size == 1
|
|
16
|
+
parse_and_clause(or_clauses.first)
|
|
17
|
+
else
|
|
18
|
+
[:or, or_clauses.map { |clause| parse_and_clause(clause) }]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.to_match_expression(parsed)
|
|
23
|
+
return '""' if parsed.empty?
|
|
24
|
+
|
|
25
|
+
type, value = parsed
|
|
26
|
+
case type
|
|
27
|
+
when :and
|
|
28
|
+
build_and_expression(value)
|
|
29
|
+
when :or
|
|
30
|
+
value.map { |sub| to_match_expression(sub) }.join(" OR ")
|
|
31
|
+
else
|
|
32
|
+
node_to_match(parsed)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def self.build_and_expression(nodes)
|
|
39
|
+
non_excludes = nodes.reject { |n| n.first == :exclude }
|
|
40
|
+
excludes = nodes.select { |n| n.first == :exclude }
|
|
41
|
+
|
|
42
|
+
parts = non_excludes.map { |n| node_to_match(n) }
|
|
43
|
+
result = parts.size == 1 ? parts.first : "(#{parts.join(' AND ')})"
|
|
44
|
+
|
|
45
|
+
excludes.each do |node|
|
|
46
|
+
_, value = node
|
|
47
|
+
result = "#{result} NOT \"#{escape(value)}\""
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.node_to_match(node)
|
|
54
|
+
type, value = node
|
|
55
|
+
case type
|
|
56
|
+
when :term
|
|
57
|
+
'"' + escape(value) + '"*'
|
|
58
|
+
when :phrase
|
|
59
|
+
'"' + escape(value) + '"'
|
|
60
|
+
when :exclude
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.tokenize(query)
|
|
66
|
+
tokens = []
|
|
67
|
+
scanner = StringScanner.new(query)
|
|
68
|
+
|
|
69
|
+
until scanner.eos?
|
|
70
|
+
scanner.skip(/\s+/)
|
|
71
|
+
break if scanner.eos?
|
|
72
|
+
|
|
73
|
+
if scanner.scan(/"/)
|
|
74
|
+
phrase = scanner.scan_until(/"/)
|
|
75
|
+
if phrase
|
|
76
|
+
tokens << Token.new(:phrase, phrase.chomp('"'))
|
|
77
|
+
else
|
|
78
|
+
tokens << Token.new(:term, scanner.rest)
|
|
79
|
+
scanner.terminate
|
|
80
|
+
end
|
|
81
|
+
elsif scanner.scan(/-/)
|
|
82
|
+
scanner.skip(/\s+/)
|
|
83
|
+
tokens << Token.new(:exclude, scanner.scan(/\S+/))
|
|
84
|
+
elsif scanner.scan(/OR|or/i)
|
|
85
|
+
tokens << Token.new(:or, nil)
|
|
86
|
+
else
|
|
87
|
+
tokens << Token.new(:term, scanner.scan(/\S+/))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
tokens
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.split_or(tokens)
|
|
95
|
+
clauses = []
|
|
96
|
+
current = []
|
|
97
|
+
|
|
98
|
+
tokens.each do |token|
|
|
99
|
+
if token.type == :or
|
|
100
|
+
clauses << current unless current.empty?
|
|
101
|
+
current = []
|
|
102
|
+
else
|
|
103
|
+
current << token
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
clauses << current unless current.empty?
|
|
107
|
+
clauses
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.parse_and_clause(tokens)
|
|
111
|
+
nodes = tokens.map do |token|
|
|
112
|
+
case token.type
|
|
113
|
+
when :term then [:term, token.value]
|
|
114
|
+
when :phrase then [:phrase, token.value]
|
|
115
|
+
when :exclude then [:exclude, token.value]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
nodes.size == 1 ? nodes.first : [:and, nodes]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.escape(value)
|
|
123
|
+
value.to_s.gsub('"', '""')
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
|
|
5
|
+
module FullSearch
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
rake_tasks do
|
|
8
|
+
load File.expand_path("../tasks/full_search.rake", __dir__)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "full_search.ensure_tables" do
|
|
12
|
+
config.after_initialize do
|
|
13
|
+
next unless FullSearch.config.auto_manage_schema
|
|
14
|
+
ActiveSupport.on_load(:active_record) do
|
|
15
|
+
FullSearch.models.each do |model|
|
|
16
|
+
FullSearch::Index.ensure_table!(model)
|
|
17
|
+
next unless FullSearch.config.auto_manage_schema == true
|
|
18
|
+
|
|
19
|
+
stored = FullSearch::Index.stored_config_hash(model)
|
|
20
|
+
if stored && stored != model.full_search_dsl.config_hash
|
|
21
|
+
FullSearch::Index.rebuild!(model)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module FullSearch
|
|
6
|
+
class ReindexJob < ActiveJob::Base
|
|
7
|
+
def perform(model_name, record_id, field_name)
|
|
8
|
+
model = model_name.constantize
|
|
9
|
+
record = model.find_by(id: record_id)
|
|
10
|
+
return unless record
|
|
11
|
+
|
|
12
|
+
FullSearch::Callbacks.reindex_field!(record, field_name)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FullSearch
|
|
4
|
+
class Search
|
|
5
|
+
attr_reader :model, :query, :filters, :include_soft_deleted, :limit, :offset, :highlight
|
|
6
|
+
|
|
7
|
+
def initialize(model, query, filters:, include_soft_deleted:, limit:, offset:, highlight: false)
|
|
8
|
+
@model = model
|
|
9
|
+
@query = query.to_s.strip
|
|
10
|
+
@filters = filters
|
|
11
|
+
@include_soft_deleted = include_soft_deleted
|
|
12
|
+
@limit = limit
|
|
13
|
+
@offset = offset
|
|
14
|
+
@highlight = highlight
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def relation
|
|
18
|
+
validate_required_filters!
|
|
19
|
+
|
|
20
|
+
parsed = QueryParser.parse(query)
|
|
21
|
+
exact_ids = ExactMatch.ids_for(model, query, filters)
|
|
22
|
+
primary_ids = fts_match_ids(parsed)
|
|
23
|
+
fallback_ids = dsl.typo_tolerance? ? trigram_match_ids(parsed, primary_ids) : []
|
|
24
|
+
|
|
25
|
+
all_ids = (exact_ids + primary_ids + fallback_ids).uniq
|
|
26
|
+
return model.none if all_ids.empty?
|
|
27
|
+
|
|
28
|
+
rel = model.where(id: all_ids)
|
|
29
|
+
rel = rel.where(model.arel_table[dsl.soft_delete_column].eq(nil)) if dsl.soft_delete_column && !include_soft_deleted
|
|
30
|
+
rel = rel.limit(limit) if limit
|
|
31
|
+
rel = rel.offset(offset) if offset
|
|
32
|
+
|
|
33
|
+
order_sql = case_clause(all_ids)
|
|
34
|
+
rel = rel.order(Arel.sql(order_sql))
|
|
35
|
+
|
|
36
|
+
if highlight
|
|
37
|
+
Highlighter.apply!(rel.to_a, model, query)
|
|
38
|
+
else
|
|
39
|
+
rel
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def dsl
|
|
46
|
+
model.full_search_dsl
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def validate_required_filters!
|
|
50
|
+
dsl.filters.each do |filter|
|
|
51
|
+
next unless filter.required
|
|
52
|
+
raise MissingRequiredFilterError, "Missing required filter: #{filter.name}" unless filters.key?(filter.name.to_sym) || filters.key?(filter.name)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def fts_match_ids(parsed)
|
|
57
|
+
return [] if query.empty?
|
|
58
|
+
|
|
59
|
+
match_expr = QueryParser.to_match_expression(parsed)
|
|
60
|
+
fts_table = FullSearch::Index.fts_table_name(model)
|
|
61
|
+
|
|
62
|
+
sql = <<~SQL
|
|
63
|
+
SELECT #{model.table_name}.id
|
|
64
|
+
FROM #{fts_table}
|
|
65
|
+
JOIN #{model.table_name} ON #{model.table_name}.id = #{fts_table}.rowid
|
|
66
|
+
WHERE #{fts_table} MATCH #{connection.quote(match_expr)}
|
|
67
|
+
SQL
|
|
68
|
+
|
|
69
|
+
filter_conditions = filters.map do |name, value|
|
|
70
|
+
"AND #{fts_table}.#{name} = #{connection.quote(value)}"
|
|
71
|
+
end.join(" ")
|
|
72
|
+
|
|
73
|
+
connection.execute("#{sql} #{filter_conditions}").map { |r| r["id"] }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def trigram_match_ids(parsed, primary_ids)
|
|
77
|
+
return [] if primary_ids.any?
|
|
78
|
+
|
|
79
|
+
match_expr = QueryParser.to_match_expression(parsed)
|
|
80
|
+
return [] if match_expr.empty?
|
|
81
|
+
|
|
82
|
+
term = parsed.last rescue nil
|
|
83
|
+
return [] if term.nil? || term.length < dsl.typo_tolerance_min_term_length.to_i
|
|
84
|
+
|
|
85
|
+
trigram_table = FullSearch::Index.trigram_table_name(model)
|
|
86
|
+
|
|
87
|
+
sql = <<~SQL
|
|
88
|
+
SELECT #{model.table_name}.id
|
|
89
|
+
FROM #{trigram_table}
|
|
90
|
+
JOIN #{model.table_name} ON #{model.table_name}.id = #{trigram_table}.rowid
|
|
91
|
+
WHERE #{trigram_table} MATCH #{connection.quote(match_expr)}
|
|
92
|
+
SQL
|
|
93
|
+
|
|
94
|
+
filter_conditions = filters.map do |name, value|
|
|
95
|
+
"AND #{trigram_table}.#{name} = #{connection.quote(value)}"
|
|
96
|
+
end.join(" ")
|
|
97
|
+
|
|
98
|
+
connection.execute("#{sql} #{filter_conditions}").map { |r| r["id"] }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def bare_term?(parsed)
|
|
102
|
+
parsed.is_a?(Array) && parsed.size == 2 && parsed.first == :term
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def case_clause(ids)
|
|
106
|
+
"CASE id #{ids.map.with_index { |id, i| "WHEN #{id} THEN #{i}" }.join(" ")} END"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def connection
|
|
110
|
+
ActiveRecord::Base.connection
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FullSearch
|
|
4
|
+
module TestHelpers
|
|
5
|
+
def rebuild_full_search_index(model_name)
|
|
6
|
+
model = model_name.is_a?(Class) ? model_name : model_name.to_s.camelize.constantize
|
|
7
|
+
FullSearch::Index.drop!(model)
|
|
8
|
+
FullSearch::Index.rebuild!(model)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def ensure_full_search_tables
|
|
12
|
+
FullSearch.models.each do |model|
|
|
13
|
+
FullSearch::Index.ensure_table!(model)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|