full_search 0.4.4 → 0.4.6

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: 99b631f72162065698bc187ad0b88cb7602cbead185b5c82209db713774e763d
4
- data.tar.gz: 5e1dd5a0782e69cba81b0b2c8f32170046f4c0e3ad55debb063837117285a559
3
+ metadata.gz: ffdca4041a68a79686da2b8d636b3feadb88d39fd6ca2ff8f3c6ec955d52355c
4
+ data.tar.gz: 8438b904a538c0d8c8e0250bf751209b44f8a095aacfc1089cb180a5cbb21d8f
5
5
  SHA512:
6
- metadata.gz: 146763f433fba78ed55b6a638b1d8a80bed0ea886daf294ff8e9389d09efef104b7ae6729d8897ee3ea267a4d38cf5d9956e691f4524da4a0861df6827c909ba
7
- data.tar.gz: 65c5e59005e1730e12fadda9126b397a5fffdef462bcda088e7f14259b3635d1df7356d91dd23ebb312e6403489bf461b6088fc29a68b86b38fdbb80c4786ac2
6
+ metadata.gz: 447a5e7b51909e3098a38439935facf534ad7a97c84eb399af7fbd5e844c966787ac74651a58993000d12b0c18d3a6851fe3ea1e32b854629ff97b5c9e1906a0
7
+ data.tar.gz: d43d646f9e95ad6bc8d4425c8601b9c5f2695f106965cba1b53881cf8e155581a3421055f1b6babc0468aaf0a02c589e1e615f95dfecb42cd776adebae320bb5
@@ -19,6 +19,19 @@ module FullSearch
19
19
  models_in_bulk_import.delete(model)
20
20
  FullSearch::Index.create_triggers!(model)
21
21
  FullSearch::BackfillJob.perform_later(model.name)
22
+ rescue => e
23
+ # Triggers are still dropped at this point; the FTS index will rot on
24
+ # every subsequent write. Retry once, then raise loudly so the caller
25
+ # knows the index is compromised instead of failing silent.
26
+ retry_count = (Thread.current[:_fs_bulk_import_retry] ||= 0)
27
+ if retry_count < 1
28
+ Thread.current[:_fs_bulk_import_retry] = retry_count + 1
29
+ retry
30
+ end
31
+ raise FullSearch::TriggerRestoreError,
32
+ "Failed to restore FTS triggers for #{model.table_name} after bulk import: #{e.message}"
33
+ ensure
34
+ Thread.current[:_fs_bulk_import_retry] = nil
22
35
  end
23
36
 
24
37
  def bulk_importing?(model)
@@ -10,4 +10,5 @@ module FullSearch
10
10
  class UnsupportedDatabaseError < Error; end
11
11
  class MissingTableError < Error; end
12
12
  class InvalidQueryError < Error; end
13
+ class TriggerRestoreError < Error; end
13
14
  end
@@ -184,6 +184,7 @@ module FullSearch
184
184
  drop_fts_table_safely!(fts_table_name(model))
185
185
  drop_fts_table_safely!(trigram_table_name(model))
186
186
  end
187
+
187
188
  # Drops an FTS5 virtual table, recovering from corruption where a bare
188
189
  # DROP TABLE IF EXISTS raises "invalid fts5 file format" because the
189
190
  # shadow tables were tampered with out-of-band. When that happens we
@@ -266,6 +267,22 @@ module FullSearch
266
267
  ).any?
267
268
  end
268
269
 
270
+ def missing_triggers?(model)
271
+ return false if FullSearch.bulk_importing?(model)
272
+ return false unless model_table_exists?(model)
273
+ return false unless table_exists?(model)
274
+
275
+ IndexCache.fetch("missing_triggers:#{model.table_name}") do
276
+ existing = connection.execute(
277
+ "SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name=#{q(model.table_name)}"
278
+ ).map { |r| r["name"] }
279
+
280
+ expected = trigger_names(model)
281
+ expected += trigram_trigger_names(model) if trigram_table_needed?(model)
282
+ (expected - existing).any?
283
+ end
284
+ end
285
+
269
286
  private
270
287
 
271
288
  def connection
@@ -289,9 +306,11 @@ module FullSearch
289
306
  end
290
307
 
291
308
  def model_table_exists?(model)
292
- connection.execute(
293
- "SELECT name FROM sqlite_master WHERE type='table' AND name=#{q(model.table_name)} LIMIT 1"
294
- ).any?
309
+ IndexCache.fetch("model_table_exists:#{model.table_name}") do
310
+ connection.execute(
311
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=#{q(model.table_name)} LIMIT 1"
312
+ ).any?
313
+ end
295
314
  end
296
315
 
297
316
  def table_exists?(model)
@@ -353,16 +372,7 @@ module FullSearch
353
372
 
354
373
  def ensure_triggers!(model)
355
374
  return if FullSearch.bulk_importing?(model)
356
-
357
- existing = connection.execute(
358
- "SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name=#{q(model.table_name)}"
359
- ).map { |r| r["name"] }
360
-
361
- expected = trigger_names(model)
362
- expected += trigram_trigger_names(model) if trigram_table_needed?(model)
363
- return if (expected - existing).empty? && (existing - expected).empty?
364
-
365
- rebuild!(model)
375
+ rebuild!(model) if missing_triggers?(model)
366
376
  end
367
377
 
368
378
  def trigger_names(model)
@@ -377,6 +387,8 @@ module FullSearch
377
387
  %W[#{base}_ai #{base}_ad #{base}_au]
378
388
  end
379
389
 
390
+ private
391
+
380
392
  def insert_trigger_sql(model)
381
393
  dsl = model.full_search_dsl
382
394
  cols = dsl.fields + dsl.filters + extra_columns(model)
@@ -12,40 +12,42 @@ module FullSearch
12
12
  end
13
13
 
14
14
  def call
15
- searched = groups.map do |group|
16
- model = fetch(group, :model)
17
- raise FullSearch::NotConfiguredError, "#{model} is not full_search configured" unless model.full_search_dsl
15
+ IndexCache.with_cache do
16
+ searched = groups.map do |group|
17
+ model = fetch(group, :model)
18
+ raise FullSearch::NotConfiguredError, "#{model} is not full_search configured" unless model.full_search_dsl
18
19
 
19
- filters = group[:filters] || {}
20
- limit = positive_integer(group[:limit], 8)
21
- offset = positive_integer(group[:offset], 0)
22
- raw_limit = limit + 1
20
+ filters = group[:filters] || {}
21
+ limit = positive_integer(group[:limit], 8)
22
+ offset = positive_integer(group[:offset], 0)
23
+ raw_limit = limit + 1
23
24
 
24
- relation = model.full_search(
25
- query,
26
- filters: filters,
27
- limit: raw_limit,
28
- offset: offset,
29
- highlight: group[:highlight],
30
- highlight_fields: group[:highlight_fields],
31
- matching_strategy: group[:matching_strategy],
32
- per_strategy_limit: group[:per_strategy_limit],
33
- scope: group[:scope],
34
- includes: group[:includes]
35
- )
25
+ relation = model.full_search(
26
+ query,
27
+ filters: filters,
28
+ limit: raw_limit,
29
+ offset: offset,
30
+ highlight: group[:highlight],
31
+ highlight_fields: group[:highlight_fields],
32
+ matching_strategy: group[:matching_strategy],
33
+ per_strategy_limit: group[:per_strategy_limit],
34
+ scope: group[:scope],
35
+ includes: group[:includes]
36
+ )
36
37
 
37
- records = relation.to_a
38
- has_more = records.size > limit
39
- records = records.first(limit) if has_more
38
+ records = relation.to_a
39
+ has_more = records.size > limit
40
+ records = records.first(limit) if has_more
40
41
 
41
- group.slice(:key, :label, :icon, :model).merge(
42
- results: records,
43
- has_more: has_more,
44
- total_count: records.size
45
- )
46
- end
42
+ group.slice(:key, :label, :icon, :model).merge(
43
+ results: records,
44
+ has_more: has_more,
45
+ total_count: records.size
46
+ )
47
+ end
47
48
 
48
- {groups: searched, total_count: searched.sum { |g| g[:total_count] }}
49
+ {groups: searched, total_count: searched.sum { |g| g[:total_count] }}
50
+ end
49
51
  end
50
52
 
51
53
  private
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullSearch
4
+ module OnceRebuilt
5
+ class << self
6
+ def per_test_case(*models)
7
+ models = FullSearch.models.to_a if models.empty?
8
+ unless already_rebuilt?(models)
9
+ rebuild_models!(models)
10
+ mark_rebuilt!(models)
11
+ end
12
+
13
+ yield if block_given?
14
+ end
15
+
16
+ def clear!
17
+ @rebuilt = Set.new
18
+ end
19
+
20
+ private
21
+
22
+ def already_rebuilt?(models)
23
+ keys = models.map { |model| cache_key(model) }
24
+ @rebuilt ||= Set.new
25
+ keys.all? { |key| @rebuilt.include?(key) }
26
+ end
27
+
28
+ def mark_rebuilt!(models)
29
+ @rebuilt ||= Set.new
30
+ models.each { |model| @rebuilt << cache_key(model) }
31
+ end
32
+
33
+ def cache_key(model)
34
+ [
35
+ model.connection_db_config.database,
36
+ model.table_name,
37
+ model.full_search_dsl&.config_hash,
38
+ model.connection_db_config.schema_cache_path
39
+ ].compact.join(":")
40
+ end
41
+
42
+ include FullSearch::TestHelpers
43
+
44
+ def rebuild_models!(models)
45
+ models.each { |model| rebuild_full_search_index(model) }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -92,6 +92,21 @@ module FullSearch
92
92
  raise MissingTableError, "FTS table `#{FullSearch::Index.fts_table_name(model)}` does not exist. Run `bin/rails full_search:prepare` to create it."
93
93
  end
94
94
 
95
+ if FullSearch::Index.missing_triggers?(model)
96
+ if FullSearch.config.auto_rebuild_on_stale_query
97
+ FullSearch::Index.rebuild!(model)
98
+ return
99
+ end
100
+
101
+ case FullSearch.config.stale_query_behavior
102
+ when :raise
103
+ raise ConfigChangedError, "FTS triggers for #{model.table_name} are missing; run full_search:rebuild"
104
+ when :log_and_fallback
105
+ Rails.logger.warn("[full_search] FTS triggers for #{model.table_name} are missing; results may be incomplete")
106
+ end
107
+ return
108
+ end
109
+
95
110
  stored = begin
96
111
  FullSearch::Index.stored_config_hash(model)
97
112
  rescue ActiveRecord::StatementInvalid
@@ -54,6 +54,10 @@ module FullSearch
54
54
  end
55
55
  end
56
56
 
57
+ def once_full_search_rebuilt(*models, &block)
58
+ FullSearch::OnceRebuilt.per_test_case(*models, &block)
59
+ end
60
+
57
61
  def with_full_search_rebuild(model)
58
62
  model = resolve_full_search_model(model)
59
63
  rebuild_full_search_index(model)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FullSearch
4
- VERSION = "0.4.4"
4
+ VERSION = "0.4.6"
5
5
  end
data/lib/full_search.rb CHANGED
@@ -30,6 +30,7 @@ require "full_search/backfill_job"
30
30
  require "full_search/test_helpers"
31
31
  require "full_search/multi_search"
32
32
  require "full_search/schema_dumper_patch"
33
+ require "full_search/once_rebuilt"
33
34
 
34
35
  ActiveSupport.on_load(:active_record) do
35
36
  include FullSearch::Model
@@ -105,7 +105,9 @@ namespace :full_search do
105
105
  ""
106
106
  end
107
107
 
108
- puts "#{model.table_name}: #{status}#{drift_info}"
108
+ trigger_info = FullSearch::Index.missing_triggers?(model) ? " | MISSING TRIGGERS" : ""
109
+
110
+ puts "#{model.table_name}: #{status}#{drift_info}#{trigger_info}"
109
111
  end
110
112
  end
111
113
 
@@ -120,6 +122,10 @@ namespace :full_search do
120
122
  next
121
123
  end
122
124
 
125
+ if FullSearch::Index.missing_triggers?(model)
126
+ unhealthy << "#{model.table_name}: missing FTS triggers"
127
+ end
128
+
123
129
  stored = FullSearch::Index.stored_config_hash(model)
124
130
  current = model.full_search_dsl.config_hash
125
131
  if stored != current
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.4.4
4
+ version: 0.4.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben D'Angelo
@@ -155,6 +155,7 @@ files:
155
155
  - lib/full_search/instrumentation.rb
156
156
  - lib/full_search/model.rb
157
157
  - lib/full_search/multi_search.rb
158
+ - lib/full_search/once_rebuilt.rb
158
159
  - lib/full_search/optimize_job.rb
159
160
  - lib/full_search/query_parser.rb
160
161
  - lib/full_search/quoting.rb
@@ -188,7 +189,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
188
189
  - !ruby/object:Gem::Version
189
190
  version: '2.0'
190
191
  requirements: []
191
- rubygems_version: 4.0.10
192
+ rubygems_version: 4.0.15
192
193
  specification_version: 4
193
194
  summary: SQLite FTS5 full-text search for Rails/ActiveRecord
194
195
  test_files: []