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.
@@ -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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullSearch
4
+ module SoftDelete
5
+ def self.delete_transition_sql(model)
6
+ dsl = model.full_search_dsl
7
+ return nil unless dsl&.soft_delete_column
8
+
9
+ "WHEN new.#{dsl.soft_delete_column} IS NULL"
10
+ end
11
+ end
12
+ 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