full_search 0.2.0 → 0.3.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.
@@ -1,12 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ostruct"
3
+ class FilterColumnPlaceholder
4
+ attr_reader :name
5
+ def initialize(name:)
6
+ @name = name.to_s
7
+ end
8
+
9
+ def unindexed?
10
+ true
11
+ end
12
+ end
4
13
 
5
14
  module FullSearch
6
15
  class Index
7
16
  class << self
8
17
  def ensure_table!(model)
9
- return unless FullSearch::Index.sqlite?
18
+ sqlite!(model)
10
19
 
11
20
  conn = connection
12
21
  dsl = model.full_search_dsl
@@ -14,30 +23,31 @@ module FullSearch
14
23
 
15
24
  create_metadata_table!
16
25
 
17
- table_was_created = false
26
+ fts_was_created = false
18
27
  unless table_exists?(model)
19
28
  conn.execute(create_virtual_table_sql(model))
20
- table_was_created = true
29
+ fts_was_created = true
21
30
  end
22
31
 
32
+ trigram_was_created = false
23
33
  if dsl.typo_tolerance? && !trigram_table_exists?(model)
24
- FullSearch::Typo.warn_unsupported! unless FullSearch::Typo.supported?
34
+ FullSearch::Typo.warn_unsupported!(model) unless FullSearch::Typo.supported?(model)
25
35
  conn.execute(create_trigram_virtual_table_sql(model))
26
- table_was_created = true
36
+ trigram_was_created = true
27
37
  end
28
38
 
29
- if table_was_created
30
- conn.execute(backfill_sql(model))
31
- conn.execute(backfill_trigram_sql(model)) if dsl.typo_tolerance?
39
+ if fts_was_created || trigram_was_created
40
+ conn.execute(backfill_sql(model)) if fts_was_created
41
+ conn.execute(backfill_trigram_sql(model)) if trigram_was_created
32
42
  reindex_source_fields!(model) if dsl.fields.any?(&:source)
33
- store_config_hash!(model)
34
43
  end
35
44
 
45
+ store_config_hash!(model)
36
46
  ensure_triggers!(model) if model_table_exists?(model)
37
47
  end
38
48
 
39
49
  def rebuild!(model)
40
- return unless FullSearch::Index.sqlite?
50
+ sqlite!(model)
41
51
 
42
52
  dsl = model.full_search_dsl
43
53
  return unless dsl
@@ -47,11 +57,11 @@ module FullSearch
47
57
 
48
58
  with_rebuild_lock(model) do
49
59
  drop_triggers!(model)
50
- conn.execute("DROP TABLE IF EXISTS #{fts_table_name(model)};")
51
- conn.execute("DROP TABLE IF EXISTS #{trigram_table_name(model)};")
60
+ conn.execute("DROP TABLE IF EXISTS #{qt(fts_table_name(model))};")
61
+ conn.execute("DROP TABLE IF EXISTS #{qt(trigram_table_name(model))};")
52
62
  conn.execute(create_virtual_table_sql(model))
53
63
  if dsl.typo_tolerance?
54
- FullSearch::Typo.warn_unsupported! unless FullSearch::Typo.supported?
64
+ FullSearch::Typo.warn_unsupported!(model) unless FullSearch::Typo.supported?(model)
55
65
  conn.execute(create_trigram_virtual_table_sql(model))
56
66
  end
57
67
  conn.execute(backfill_sql(model))
@@ -64,7 +74,7 @@ module FullSearch
64
74
  end
65
75
 
66
76
  def rebuild_if_needed!(model)
67
- return false unless FullSearch::Index.sqlite?
77
+ sqlite!(model)
68
78
 
69
79
  dsl = model.full_search_dsl
70
80
  return false unless dsl
@@ -82,7 +92,8 @@ module FullSearch
82
92
  end
83
93
 
84
94
  def optimize!(model)
85
- connection.execute("INSERT INTO #{fts_table_name(model)}(#{fts_table_name(model)}) VALUES('optimize');")
95
+ sqlite!(model)
96
+ connection.execute("INSERT INTO #{qt(fts_table_name(model))}(#{qt(fts_table_name(model))}) VALUES('optimize');")
86
97
  end
87
98
 
88
99
  def reindex_source_fields!(model)
@@ -92,9 +103,10 @@ module FullSearch
92
103
  end
93
104
 
94
105
  def drop!(model)
106
+ sqlite!(model)
95
107
  drop_triggers!(model)
96
- connection.execute("DROP TABLE IF EXISTS #{fts_table_name(model)};")
97
- connection.execute("DROP TABLE IF EXISTS #{trigram_table_name(model)};")
108
+ connection.execute("DROP TABLE IF EXISTS #{qt(fts_table_name(model))};")
109
+ connection.execute("DROP TABLE IF EXISTS #{qt(trigram_table_name(model))};")
98
110
  end
99
111
 
100
112
  def fts_table_name(model)
@@ -105,10 +117,21 @@ module FullSearch
105
117
  "#{fts_table_name(model)}_trigram"
106
118
  end
107
119
 
108
- def sqlite?
120
+ def sqlite?(model = nil)
121
+ if model
122
+ model.connection.adapter_name.downcase.include?("sqlite")
123
+ else
124
+ connection.adapter_name.downcase.include?("sqlite")
125
+ end
126
+ rescue
109
127
  connection.adapter_name.downcase.include?("sqlite")
110
128
  end
111
129
 
130
+ def sqlite!(model)
131
+ adapter = model ? model.connection.adapter_name : connection.adapter_name
132
+ raise UnsupportedDatabaseError, "full_search requires SQLite, but #{adapter} is configured" unless sqlite?(model)
133
+ end
134
+
112
135
  def stored_config_hash(model)
113
136
  row = connection.execute(
114
137
  "SELECT config_hash FROM full_search_index_versions WHERE table_name=#{q(model.table_name)}"
@@ -126,6 +149,14 @@ module FullSearch
126
149
  connection.quote(value)
127
150
  end
128
151
 
152
+ def qt(name)
153
+ connection.quote_table_name(name)
154
+ end
155
+
156
+ def qc(name)
157
+ connection.quote_column_name(name)
158
+ end
159
+
129
160
  def model_table_exists?(model)
130
161
  connection.execute(
131
162
  "SELECT name FROM sqlite_master WHERE type='table' AND name=#{q(model.table_name)} LIMIT 1"
@@ -156,11 +187,11 @@ module FullSearch
156
187
 
157
188
  def create_virtual_table_sql(model)
158
189
  dsl = model.full_search_dsl
159
- columns = (dsl.fields + dsl.filters.map { |f| OpenStruct.new(name: f.name, unindexed?: true) })
160
- column_list = columns.map { |c| c.respond_to?(:unindexed?) && c.unindexed? ? "#{c.name} UNINDEXED" : c.name }.join(", ")
190
+ columns = (dsl.fields + dsl.filters.map { |f| FilterColumnPlaceholder.new(name: f.name) })
191
+ column_list = columns.map { |c| (c.respond_to?(:unindexed?) && c.unindexed?) ? "#{qc(c.name)} UNINDEXED" : qc(c.name) }.join(", ")
161
192
 
162
193
  <<~SQL
163
- CREATE VIRTUAL TABLE #{fts_table_name(model)} USING fts5(
194
+ CREATE VIRTUAL TABLE #{qt(fts_table_name(model))} USING fts5(
164
195
  #{column_list},
165
196
  tokenize='#{dsl.tokenize}'
166
197
  );
@@ -174,13 +205,13 @@ module FullSearch
174
205
  if c.respond_to?(:source) && c.source
175
206
  source_value_sql(c.source)
176
207
  else
177
- "#{model.table_name}.#{c.name}"
208
+ "#{qt(model.table_name)}.#{qc(c.name)}"
178
209
  end
179
210
  end.join(", ")
180
211
 
181
212
  <<~SQL
182
- INSERT INTO #{fts_table_name(model)}(rowid, #{cols.map(&:name).join(", ")})
183
- SELECT #{model.table_name}.id, #{select} FROM #{model.table_name};
213
+ INSERT INTO #{qt(fts_table_name(model))}(rowid, #{cols.map { |c| qc(c.name) }.join(", ")})
214
+ SELECT #{qt(model.table_name)}.id, #{select} FROM #{qt(model.table_name)};
184
215
  SQL
185
216
  end
186
217
 
@@ -193,13 +224,20 @@ module FullSearch
193
224
  "SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name=#{q(model.table_name)}"
194
225
  ).map { |r| r["name"] }
195
226
 
196
- create_triggers!(model) unless (trigger_names(model) - existing).empty?
227
+ expected = trigger_names(model)
228
+ expected += trigram_trigger_names(model) if model.full_search_dsl.typo_tolerance?
229
+ return if (expected - existing).empty? && (existing - expected).empty?
230
+
231
+ rebuild!(model)
197
232
  end
198
233
 
199
234
  def create_triggers!(model)
200
235
  connection.execute(insert_trigger_sql(model))
201
236
  connection.execute(delete_trigger_sql(model))
202
237
  connection.execute(update_trigger_sql(model))
238
+ if model.full_search_dsl.soft_delete_column
239
+ connection.execute(soft_delete_removal_trigger_sql(model))
240
+ end
203
241
  if model.full_search_dsl.typo_tolerance?
204
242
  connection.execute(insert_trigram_trigger_sql(model))
205
243
  connection.execute(delete_trigram_trigger_sql(model))
@@ -209,13 +247,15 @@ module FullSearch
209
247
 
210
248
  def drop_triggers!(model)
211
249
  (trigger_names(model) + trigram_trigger_names(model)).each do |name|
212
- connection.execute("DROP TRIGGER IF EXISTS #{name};")
250
+ connection.execute("DROP TRIGGER IF EXISTS #{qt(name)};")
213
251
  end
214
252
  end
215
253
 
216
254
  def trigger_names(model)
217
255
  base = fts_table_name(model)
218
- %W[#{base}_ai #{base}_ad #{base}_au]
256
+ names = %W[#{base}_ai #{base}_ad #{base}_au]
257
+ names << "#{base}_au_soft_delete" if model.full_search_dsl.soft_delete_column
258
+ names
219
259
  end
220
260
 
221
261
  def trigram_trigger_names(model)
@@ -229,8 +269,8 @@ module FullSearch
229
269
  values = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
230
270
 
231
271
  <<~SQL
232
- CREATE TRIGGER #{trigger_names(model).first} AFTER INSERT ON #{model.table_name} BEGIN
233
- INSERT INTO #{fts_table_name(model)}(rowid, #{col_names(cols)})
272
+ CREATE TRIGGER #{qt(trigger_names(model).first)} AFTER INSERT ON #{qt(model.table_name)} BEGIN
273
+ INSERT INTO #{qt(fts_table_name(model))}(rowid, #{col_names(cols)})
234
274
  VALUES (new.id, #{values});
235
275
  END;
236
276
  SQL
@@ -238,8 +278,8 @@ module FullSearch
238
278
 
239
279
  def delete_trigger_sql(model)
240
280
  <<~SQL
241
- CREATE TRIGGER #{trigger_names(model)[1]} AFTER DELETE ON #{model.table_name} BEGIN
242
- DELETE FROM #{fts_table_name(model)} WHERE rowid = old.id;
281
+ CREATE TRIGGER #{qt(trigger_names(model)[1])} AFTER DELETE ON #{qt(model.table_name)} BEGIN
282
+ DELETE FROM #{qt(fts_table_name(model))} WHERE rowid = old.id;
243
283
  END;
244
284
  SQL
245
285
  end
@@ -249,12 +289,12 @@ module FullSearch
249
289
  cols = dsl.fields + dsl.filters
250
290
  values = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
251
291
  cols_str = col_names(cols)
252
- fts_table = fts_table_name(model)
292
+ fts_table = qt(fts_table_name(model))
253
293
 
254
- when_clause = SoftDelete.delete_transition_sql(model)
294
+ when_clause = SoftDelete.active_update_clause(model)
255
295
 
256
296
  <<~SQL
257
- CREATE TRIGGER #{trigger_names(model)[2]} AFTER UPDATE ON #{model.table_name} #{when_clause}
297
+ CREATE TRIGGER #{qt(trigger_names(model)[2])} AFTER UPDATE ON #{qt(model.table_name)} #{when_clause}
258
298
  BEGIN
259
299
  DELETE FROM #{fts_table} WHERE rowid = old.id;
260
300
  INSERT INTO #{fts_table}(rowid, #{cols_str})
@@ -263,6 +303,19 @@ module FullSearch
263
303
  SQL
264
304
  end
265
305
 
306
+ def soft_delete_removal_trigger_sql(model)
307
+ model.full_search_dsl
308
+ fts_table = qt(fts_table_name(model))
309
+ when_clause = SoftDelete.soft_delete_remove_clause(model)
310
+
311
+ <<~SQL
312
+ CREATE TRIGGER #{qt("#{trigger_names(model)[2]}_soft_delete")} AFTER UPDATE ON #{qt(model.table_name)} #{when_clause}
313
+ BEGIN
314
+ DELETE FROM #{fts_table} WHERE rowid = old.id;
315
+ END;
316
+ SQL
317
+ end
318
+
266
319
  def trigram_table_exists?(model)
267
320
  connection.execute(
268
321
  "SELECT name FROM sqlite_master WHERE type='table' AND name=#{q(trigram_table_name(model))} LIMIT 1"
@@ -271,11 +324,11 @@ module FullSearch
271
324
 
272
325
  def create_trigram_virtual_table_sql(model)
273
326
  dsl = model.full_search_dsl
274
- columns = (dsl.fields + dsl.filters.map { |f| OpenStruct.new(name: f.name, unindexed?: true) })
275
- column_list = columns.map { |c| c.respond_to?(:unindexed?) && c.unindexed? ? "#{c.name} UNINDEXED" : c.name }.join(", ")
327
+ columns = (dsl.fields + dsl.filters.map { |f| FilterColumnPlaceholder.new(name: f.name) })
328
+ column_list = columns.map { |c| (c.respond_to?(:unindexed?) && c.unindexed?) ? "#{qc(c.name)} UNINDEXED" : qc(c.name) }.join(", ")
276
329
 
277
330
  <<~SQL
278
- CREATE VIRTUAL TABLE #{trigram_table_name(model)} USING fts5(
331
+ CREATE VIRTUAL TABLE #{qt(trigram_table_name(model))} USING fts5(
279
332
  #{column_list},
280
333
  tokenize='trigram'
281
334
  );
@@ -289,13 +342,13 @@ module FullSearch
289
342
  if c.respond_to?(:source) && c.source
290
343
  source_value_sql(c.source)
291
344
  else
292
- "#{model.table_name}.#{c.name}"
345
+ "#{qt(model.table_name)}.#{qc(c.name)}"
293
346
  end
294
347
  end.join(", ")
295
348
 
296
349
  <<~SQL
297
- INSERT INTO #{trigram_table_name(model)}(rowid, #{cols.map(&:name).join(", ")})
298
- SELECT #{model.table_name}.id, #{select} FROM #{model.table_name};
350
+ INSERT INTO #{qt(trigram_table_name(model))}(rowid, #{cols.map { |c| qc(c.name) }.join(", ")})
351
+ SELECT #{qt(model.table_name)}.id, #{select} FROM #{qt(model.table_name)};
299
352
  SQL
300
353
  end
301
354
 
@@ -305,8 +358,8 @@ module FullSearch
305
358
  values = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
306
359
 
307
360
  <<~SQL
308
- CREATE TRIGGER #{trigram_trigger_names(model).first} AFTER INSERT ON #{model.table_name} BEGIN
309
- INSERT INTO #{trigram_table_name(model)}(rowid, #{col_names(cols)})
361
+ CREATE TRIGGER #{qt(trigram_trigger_names(model).first)} AFTER INSERT ON #{qt(model.table_name)} BEGIN
362
+ INSERT INTO #{qt(trigram_table_name(model))}(rowid, #{col_names(cols)})
310
363
  VALUES (new.id, #{values});
311
364
  END;
312
365
  SQL
@@ -314,8 +367,8 @@ module FullSearch
314
367
 
315
368
  def delete_trigram_trigger_sql(model)
316
369
  <<~SQL
317
- CREATE TRIGGER #{trigram_trigger_names(model)[1]} AFTER DELETE ON #{model.table_name} BEGIN
318
- DELETE FROM #{trigram_table_name(model)} WHERE rowid = old.id;
370
+ CREATE TRIGGER #{qt(trigram_trigger_names(model)[1])} AFTER DELETE ON #{qt(model.table_name)} BEGIN
371
+ DELETE FROM #{qt(trigram_table_name(model))} WHERE rowid = old.id;
319
372
  END;
320
373
  SQL
321
374
  end
@@ -325,12 +378,12 @@ module FullSearch
325
378
  cols = dsl.fields + dsl.filters
326
379
  values = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
327
380
  cols_str = col_names(cols)
328
- trigram_table = trigram_table_name(model)
381
+ trigram_table = qt(trigram_table_name(model))
329
382
 
330
- when_clause = SoftDelete.delete_transition_sql(model)
383
+ when_clause = SoftDelete.active_update_clause(model)
331
384
 
332
385
  <<~SQL
333
- CREATE TRIGGER #{trigram_trigger_names(model)[2]} AFTER UPDATE ON #{model.table_name} #{when_clause}
386
+ CREATE TRIGGER #{qt(trigram_trigger_names(model)[2])} AFTER UPDATE ON #{qt(model.table_name)} #{when_clause}
334
387
  BEGIN
335
388
  DELETE FROM #{trigram_table} WHERE rowid = old.id;
336
389
  INSERT INTO #{trigram_table}(rowid, #{cols_str})
@@ -340,22 +393,25 @@ module FullSearch
340
393
  end
341
394
 
342
395
  def col_names(cols)
343
- cols.map(&:name).join(", ")
396
+ cols.map { |c| qc(c.name) }.join(", ")
344
397
  end
345
398
 
346
399
  def column_ref(col, prefix:)
347
400
  if col.respond_to?(:source) && col.source
348
401
  "''"
349
402
  else
350
- "#{prefix}.#{col.name}"
403
+ "#{prefix}.#{qc(col.name)}"
351
404
  end
352
405
  end
353
406
 
407
+ # NOTE: This lock prevents concurrent rebuilds within the same process/connection only.
408
+ # For multi-process or multi-host deployments, run full_search:rebuild from a single
409
+ # deployment step. See README.
354
410
  def with_rebuild_lock(model)
355
411
  if FullSearch.config.lock_rebuilds
356
412
  connection.transaction do
357
413
  connection.execute(
358
- "INSERT INTO full_search_index_versions (table_name, config_hash, rebuilt_at) VALUES (#{q(model.table_name)}, #{q("")}, datetime('now'))
414
+ "INSERT INTO full_search_index_versions (table_name, config_hash, rebuilt_at) VALUES (#{q(model.table_name)}, #{q("__rebuilding__")}, datetime('now'))
359
415
  ON CONFLICT(table_name) DO UPDATE SET config_hash=excluded.config_hash;"
360
416
  )
361
417
  yield
@@ -13,7 +13,8 @@ module FullSearch
13
13
  FullSearch::Index.ensure_table!(self)
14
14
  FullSearch::Callbacks.install!(self)
15
15
  include InstanceMethods
16
- FullSearch.models << self unless FullSearch.models.include?(self)
16
+
17
+ FullSearch.register_model(self)
17
18
  @full_search_dsl
18
19
  else
19
20
  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, matching_strategy: matching_strategy).relation
@@ -14,6 +14,8 @@ module FullSearch
14
14
  def call
15
15
  searched = groups.map do |group|
16
16
  model = fetch(group, :model)
17
+ raise FullSearch::NotConfiguredError, "#{model} is not full_search configured" unless model.full_search_dsl
18
+
17
19
  filters = group[:filters] || {}
18
20
  limit = positive_integer(group[:limit], 8)
19
21
  offset = positive_integer(group[:offset], 0)
@@ -24,6 +26,7 @@ module FullSearch
24
26
  filters: filters,
25
27
  limit: raw_limit,
26
28
  offset: offset,
29
+ highlight: group[:highlight],
27
30
  highlight_fields: group[:highlight_fields]
28
31
  )
29
32
 
@@ -40,7 +43,7 @@ module FullSearch
40
43
  )
41
44
  end
42
45
 
43
- { groups: searched, total_count: searched.sum { |g| g[:total_count] } }
46
+ {groups: searched, total_count: searched.sum { |g| g[:total_count] }}
44
47
  end
45
48
 
46
49
  private
@@ -6,121 +6,134 @@ module FullSearch
6
6
  module QueryParser
7
7
  Token = Data.define(:type, :value)
8
8
 
9
- def self.parse(query)
10
- tokens = tokenize(query.to_s.strip)
11
- return [] if tokens.empty?
9
+ MAX_QUERY_LENGTH = 255
12
10
 
13
- or_clauses = split_or(tokens)
11
+ class << self
12
+ def parse(query)
13
+ query = query.to_s.strip
14
+ validate!(query)
15
+ tokens = tokenize(query)
16
+ return [] if tokens.empty?
14
17
 
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) }]
18
+ or_clauses = split_or(tokens)
19
+
20
+ if or_clauses.size == 1
21
+ parse_and_clause(or_clauses.first)
22
+ else
23
+ [:or, or_clauses.map { |clause| parse_and_clause(clause) }]
24
+ end
19
25
  end
20
- end
21
26
 
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)
27
+ def to_match_expression(parsed)
28
+ return '""' if parsed.empty?
29
+
30
+ type, value = parsed
31
+ case type
32
+ when :and
33
+ build_and_expression(value)
34
+ when :or
35
+ value.map { |sub| to_match_expression(sub) }.join(" OR ")
36
+ else
37
+ node_to_match(parsed)
38
+ end
33
39
  end
34
- end
35
40
 
36
- private
41
+ private
37
42
 
38
- def self.build_and_expression(nodes)
39
- non_excludes = nodes.reject { |n| n.first == :exclude }
40
- excludes = nodes.select { |n| n.first == :exclude }
43
+ def build_and_expression(nodes)
44
+ non_excludes = nodes.reject { |n| n.first == :exclude }
45
+ excludes = nodes.select { |n| n.first == :exclude }
41
46
 
42
- parts = non_excludes.map { |n| node_to_match(n) }
43
- result = parts.size == 1 ? parts.first : "(#{parts.join(' AND ')})"
47
+ parts = non_excludes.map { |n| node_to_match(n) }
48
+ result = (parts.size == 1) ? parts.first : "(#{parts.join(" AND ")})"
44
49
 
45
- excludes.each do |node|
46
- _, value = node
47
- result = "#{result} NOT \"#{escape(value)}\""
48
- end
50
+ excludes.each do |node|
51
+ _, value = node
52
+ result = "#{result} NOT \"#{escape(value)}\""
53
+ end
49
54
 
50
- result
51
- end
55
+ result
56
+ end
52
57
 
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
58
+ def node_to_match(node)
59
+ type, value = node
60
+ case type
61
+ when :term
62
+ '"' + escape(value) + '"*'
63
+ when :phrase
64
+ '"' + escape(value) + '"'
65
+ when :exclude
66
+ nil
67
+ end
62
68
  end
63
- end
64
69
 
65
- def self.tokenize(query)
66
- tokens = []
67
- scanner = StringScanner.new(query)
70
+ def validate!(query)
71
+ if query.bytesize > MAX_QUERY_LENGTH
72
+ raise InvalidQueryError, "Query too long (max #{MAX_QUERY_LENGTH} chars)"
73
+ end
74
+ raise InvalidQueryError, "Query contains invalid characters" if query.include?("\x00")
75
+ end
68
76
 
69
- until scanner.eos?
70
- scanner.skip(/\s+/)
71
- break if scanner.eos?
77
+ def tokenize(query)
78
+ tokens = []
79
+ scanner = StringScanner.new(query)
72
80
 
73
- if scanner.scan(/"/)
74
- phrase = scanner.scan_until(/"/)
75
- if phrase
76
- tokens << Token.new(:phrase, phrase.chomp('"'))
81
+ until scanner.eos?
82
+ scanner.skip(/\s+/)
83
+ break if scanner.eos?
84
+
85
+ if scanner.scan('"')
86
+ phrase = scanner.scan_until(/"/)
87
+ if phrase
88
+ tokens << Token.new(:phrase, phrase.chomp('"'))
89
+ else
90
+ tokens << Token.new(:term, scanner.rest)
91
+ scanner.terminate
92
+ end
93
+ elsif scanner.scan("-")
94
+ scanner.skip(/\s+/)
95
+ tokens << Token.new(:exclude, scanner.scan(/\S+/))
96
+ elsif scanner.scan(/OR|or/i)
97
+ tokens << Token.new(:or, nil)
77
98
  else
78
- tokens << Token.new(:term, scanner.rest)
79
- scanner.terminate
99
+ tokens << Token.new(:term, scanner.scan(/\S+/))
80
100
  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
101
  end
89
- end
90
102
 
91
- tokens
92
- end
103
+ tokens
104
+ end
93
105
 
94
- def self.split_or(tokens)
95
- clauses = []
96
- current = []
106
+ def split_or(tokens)
107
+ clauses = []
108
+ current = []
97
109
 
98
- tokens.each do |token|
99
- if token.type == :or
100
- clauses << current unless current.empty?
101
- current = []
102
- else
103
- current << token
110
+ tokens.each do |token|
111
+ if token.type == :or
112
+ clauses << current unless current.empty?
113
+ current = []
114
+ else
115
+ current << token
116
+ end
104
117
  end
118
+ clauses << current unless current.empty?
119
+ clauses
105
120
  end
106
- clauses << current unless current.empty?
107
- clauses
108
- end
109
121
 
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]
122
+ def parse_and_clause(tokens)
123
+ nodes = tokens.map do |token|
124
+ case token.type
125
+ when :term then [:term, token.value]
126
+ when :phrase then [:phrase, token.value]
127
+ when :exclude then [:exclude, token.value]
128
+ end
116
129
  end
117
- end
118
130
 
119
- nodes.size == 1 ? nodes.first : [:and, nodes]
120
- end
131
+ (nodes.size == 1) ? nodes.first : [:and, nodes]
132
+ end
121
133
 
122
- def self.escape(value)
123
- value.to_s.gsub('"', '""')
134
+ def escape(value)
135
+ value.to_s.gsub('"', '""')
136
+ end
124
137
  end
125
138
  end
126
139
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullSearch
4
+ module Quoting
5
+ private
6
+
7
+ def q(value) = connection.quote(value)
8
+ def qt(name) = connection.quote_table_name(name)
9
+ def qc(name) = connection.quote_column_name(name)
10
+ end
11
+ end