full_search 0.3.8 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a86070d05b32fc24ce6ac007a33c673c3831760ce76554bbb9d636e6e0c6be0f
4
- data.tar.gz: 293b0be9931c7ffa5de9971aa52b2bf9014c2b0816d1364f839ccba57af8f814
3
+ metadata.gz: 8175568a013e8a29d107a5b3bceebc7e9be6b9c85f56d8ef178d3c2c72a73678
4
+ data.tar.gz: d77fe210358656fe1b471baeb374c27d2f493544739d4ec326c06aee3dddadf1
5
5
  SHA512:
6
- metadata.gz: cd9854cc22d14bd739723e5011a831fd34d0e523ef6a6df09bf45fa1f3a7880c466f4ba121eb07d9d92daf65449edf74b5e0ddc42dc15b521ea34e78b496be3a
7
- data.tar.gz: f704e822cee03ad98f2ed96dbc802e3e5b777df659ecdc3d8187ace6088494ab318b6f1598c74b840697909b465e539a76ee9f758d6a709041d9e0b198d45129
6
+ metadata.gz: 6e786b098bc3cd046151835f11bbf98b4a5213feb46e70a0ae6872a4f24bba6eb22ec5e8a17ebe045328eaab1d2016682fda67e47c5582fb73ae72b262c7bf08
7
+ data.tar.gz: 4d5284fe9b059ee7010ff099cd88e494beffe1804084f29be86921bd895059cc98dd567fdf9c09ef20a2ec9af99badef3416f6085bb3f85e019f44657028244a
data/README.md CHANGED
@@ -223,6 +223,18 @@ The rebuild task checks each model's stored config hash against the current DSL
223
223
  bin/rails full_search:reset
224
224
  ```
225
225
 
226
+ ### Schema dump
227
+
228
+ By default, `full_search` includes its FTS virtual tables in `db/schema.rb` so that `db:schema:load` produces a complete, loadable database. To exclude them:
229
+
230
+ ```ruby
231
+ FullSearch.configure do |config|
232
+ config.dump_schema_virtual_tables = false
233
+ end
234
+ ```
235
+
236
+ When disabled, you must run `bin/rails full_search:prepare` after every `db:schema:load` to recreate the indexes.
237
+
226
238
  ### Rake tasks
227
239
 
228
240
  | Task | Description |
@@ -233,6 +245,7 @@ bin/rails full_search:reset
233
245
  | `full_search:optimize` | Run FTS5 [`optimize`](https://www.sqlite.org/fts5.html#the_optimize_command) to merge b-tree segments. Useful after bulk updates. |
234
246
  | `full_search:backfill` | Force-rebuild FTS indexes for specified models (or all). Useful for recovery after bulk operations. |
235
247
  | `full_search:status` | Show each model's index status (`ok` / `stale`) and count of empty sourced fields. |
248
+ | `full_search:health_check` | Verify all indexes are present and current; exits `1` if any table is missing or stale. Useful for deploy gates or Docker healthchecks. |
236
249
 
237
250
  ## Background jobs
238
251
 
@@ -375,6 +388,202 @@ FullSearch.multi_search(
375
388
  )
376
389
  ```
377
390
 
391
+ ## Testing
392
+
393
+ `full_search` ships with `FullSearch::TestHelpers`, a framework-agnostic module you can include in Minitest or RSpec. It provides helpers to rebuild, reindex, and reset FTS tables from your tests.
394
+
395
+ ### Setup
396
+
397
+ Call `setup_for_tests!` once in your test boot file. It applies safe defaults:
398
+
399
+ - Disables rebuild locking (`lock_rebuilds = false`)
400
+ - Enables query-time auto-rebuild for stale indexes (`auto_rebuild_on_stale_query = true`)
401
+ - Keeps stale-query behaviour as `raise` so real config drift fails fast
402
+ - Forces Active Job to run inline so background reindex jobs execute synchronously
403
+
404
+ #### Minitest (`test/test_helper.rb`)
405
+
406
+ ```ruby
407
+ require "minitest/autorun"
408
+ require "full_search"
409
+ require "full_search/test_helpers"
410
+
411
+ class ActiveSupport::TestCase
412
+ include FullSearch::TestHelpers
413
+ end
414
+
415
+ FullSearch::TestHelpers.setup_for_tests!
416
+ ```
417
+
418
+ #### RSpec (`spec/rails_helper.rb`)
419
+
420
+ ```ruby
421
+ require "full_search"
422
+ require "full_search/test_helpers"
423
+
424
+ RSpec.configure do |config|
425
+ config.include FullSearch::TestHelpers, type: :model
426
+ end
427
+
428
+ FullSearch::TestHelpers.setup_for_tests!
429
+ ```
430
+
431
+ ### Why you must rebuild after creating data
432
+
433
+ FTS tables are updated by database triggers on normal inserts/updates, but computed `source:` fields are evaluated by Ruby during a rebuild/reindex. The safest pattern is to create your records, then call `rebuild_full_search_index(model)` before searching.
434
+
435
+ ### Available helpers
436
+
437
+ ```ruby
438
+ # Rebuild a single model's FTS table from scratch (drops and recreates it).
439
+ # Accepts a model class, symbol, or string class name.
440
+ rebuild_full_search_index(Customer)
441
+ rebuild_full_search_index(:customer)
442
+ rebuild_full_search_index("Customer")
443
+
444
+ # Re-evaluate computed source: fields only. Leaves table structure untouched.
445
+ reindex_full_search(Customer)
446
+
447
+ # Rebuild every registered search model, or only the ones passed in.
448
+ reset_full_search!
449
+ reset_full_search!(Customer, Vehicle)
450
+
451
+ # Idempotently create any missing FTS tables/triggers for registered models.
452
+ ensure_full_search_tables
453
+
454
+ # Run a block with inline Active Job, restoring the adapter afterwards.
455
+ with_full_search_async_jobs_inline do
456
+ # ReindexJob / BackfillJob execute synchronously here
457
+ end
458
+
459
+ # Run a block inside a rebuild, dropping the table at the end.
460
+ with_full_search_rebuild(Customer) do
461
+ # search and assert here
462
+ end
463
+
464
+ # Scope anonymous searchable models to a block so they don't leak into
465
+ # the global registry and affect later tests or Rake tasks.
466
+ with_full_search_models_registered do
467
+ model = Class.new(Customer) do
468
+ full_search { field :first_name, weight: 5 }
469
+ end
470
+ model.table_name = "customers"
471
+ rebuild_full_search_index(model)
472
+ # ... search and assert
473
+ end
474
+ ```
475
+
476
+ ### Minitest example
477
+
478
+ ```ruby
479
+ class CustomerSearchTest < ActiveSupport::TestCase
480
+ def setup
481
+ @account = Account.create!(name: "Acme")
482
+ @customer = Customer.create!(account: @account, first_name: "Sam")
483
+ rebuild_full_search_index(Customer)
484
+ end
485
+
486
+ def test_finds_by_first_name
487
+ results = Customer.search("Sam", filters: {account_id: @account.id})
488
+ assert_includes results.to_a, @customer
489
+ end
490
+ end
491
+ ```
492
+
493
+ ### RSpec example
494
+
495
+ ```ruby
496
+ RSpec.describe Customer, type: :model do
497
+ let(:account) { create(:account) }
498
+ let(:customer) { create(:customer, account: account, first_name: "Sam") }
499
+
500
+ before { rebuild_full_search_index(Customer) }
501
+
502
+ it "finds by first name" do
503
+ results = Customer.search("Sam", filters: {account_id: account.id})
504
+ expect(results).to include(customer)
505
+ end
506
+ end
507
+ ```
508
+
509
+ ### Advanced configuration
510
+
511
+ If you need to override the defaults set by `setup_for_tests!`, use `FullSearch::TestHelpers.configure`:
512
+
513
+ ```ruby
514
+ FullSearch::TestHelpers.configure do |config|
515
+ config.lock_rebuilds = true
516
+ config.auto_rebuild_on_stale_query = false
517
+ config.stale_query_behavior = :log_and_fallback
518
+ end
519
+ ```
520
+
521
+ ## Production readiness
522
+
523
+ `full_search` is designed for small to medium SQLite-backed Rails apps. Before running in production, review this checklist.
524
+
525
+ ### Initial deploy
526
+
527
+ 1. Run your normal database setup so application tables exist:
528
+ ```bash
529
+ bin/rails db:prepare:with_data
530
+ ```
531
+ 2. Create the FTS virtual tables and triggers:
532
+ ```bash
533
+ bin/rails full_search:prepare
534
+ ```
535
+ 3. For containerized deploys, add step 2 to your entrypoint after `db:prepare:with_data`.
536
+
537
+ ### Configuration
538
+
539
+ The generated initializer defaults to production-safe values. Do **not** change these in production:
540
+
541
+ - `auto_rebuild_schema` must be `false`. If enabled, every web/worker/console process tries to rebuild indexes on boot.
542
+ - `auto_rebuild_on_stale_query` must be `false`. A query-time rebuild under load can cause timeouts and race conditions.
543
+
544
+ If you need to change the search DSL, ship the change and then run:
545
+
546
+ ```bash
547
+ bin/rails full_search:rebuild
548
+ ```
549
+
550
+ from a single deployment step. The gem checks each model's stored config hash and only rebuilds indexes whose DSL has changed.
551
+
552
+ ### Monitoring and maintenance
553
+
554
+ - **Health check** — use the built-in Rake task for deploy gates or container healthchecks:
555
+ ```bash
556
+ bin/rails full_search:health_check
557
+ ```
558
+ It exits `0` when every registered model has a current FTS table, or `1` if any table is missing or stale.
559
+
560
+ - **Status overview** — `bin/rails full_search:status` prints `ok` / `stale` and the count of empty sourced fields per model.
561
+
562
+ - **Scheduled optimize** — queue `FullSearch::OptimizeJob` once a day during a low-traffic window to merge FTS5 b-tree segments:
563
+ ```yaml
564
+ # config/recurring.yml
565
+ full_search_optimize:
566
+ class: FullSearch::OptimizeJob
567
+ schedule: daily at 4am
568
+ description: "Merge FTS5 b-tree segments for full_search indexes"
569
+ ```
570
+
571
+ - **Recovery after bulk operations** — if a bulk operation bypassed triggers, rebuild the affected index:
572
+ ```bash
573
+ bin/rails 'full_search:backfill[customers]'
574
+ ```
575
+
576
+ ### Common errors
577
+
578
+ | Error | Meaning | Fix |
579
+ |-------|---------|-----|
580
+ | `FullSearch::MissingTableError` | The FTS table does not exist yet. | Run `bin/rails full_search:prepare`. |
581
+ | `FullSearch::ConfigChangedError` | The DSL has changed and the index is stale. | Run `bin/rails full_search:rebuild`. |
582
+
583
+ ### Locking note
584
+
585
+ `lock_rebuilds` uses a Ruby `Mutex` and only prevents concurrent rebuilds within the same process/connection. It does not coordinate across processes or hosts. Always run `full_search:rebuild` from a single deployment step.
586
+
378
587
  ## Known limitations
379
588
 
380
589
  - Queries run with `highlight: true` return an Array of records, not an `ActiveRecord::Relation`. No further chaining (`.where`, `.order`, `.limit`) is possible after highlighting is applied.
@@ -4,7 +4,8 @@ module FullSearch
4
4
  class Config
5
5
  attr_accessor :auto_rebuild_schema, :stale_query_behavior, :lock_rebuilds,
6
6
  :default_async_reindex, :default_async_source_reindex,
7
- :default_tokenizer, :auto_rebuild_on_stale_query, :min_like_prefix_length
7
+ :default_tokenizer, :auto_rebuild_on_stale_query, :min_like_prefix_length,
8
+ :dump_schema_virtual_tables
8
9
 
9
10
  def initialize
10
11
  @auto_rebuild_schema = false
@@ -15,6 +16,7 @@ module FullSearch
15
16
  @default_tokenizer = FullSearch::Constants::DEFAULT_TOKENIZER
16
17
  @auto_rebuild_on_stale_query = false
17
18
  @min_like_prefix_length = FullSearch::Constants::DEFAULT_MIN_LIKE_PREFIX_LENGTH
19
+ @dump_schema_virtual_tables = true
18
20
  end
19
21
  end
20
22
 
@@ -6,7 +6,7 @@ module FullSearch
6
6
  TWO_TYPO_MIN_LENGTH = 9
7
7
  DEFAULT_MIN_LIKE_PREFIX_LENGTH = 3
8
8
  REBUILDING_HASH = "__rebuilding__"
9
- DEFAULT_TOKENIZER = "unicode61"
9
+ DEFAULT_TOKENIZER = "porter"
10
10
  MAX_EXACT_MATCH_BOOST_IDS = 100
11
11
  end
12
12
  end
@@ -36,7 +36,7 @@ module FullSearch
36
36
  end
37
37
 
38
38
  trigram_was_created = false
39
- if dsl.typo_tolerance? && !trigram_table_exists?(model)
39
+ if trigram_table_needed?(model) && !trigram_table_exists?(model)
40
40
  FullSearch::Typo.warn_unsupported!(model) unless FullSearch::Typo.supported?(model)
41
41
  conn.execute(create_trigram_virtual_table_sql(model))
42
42
  trigram_was_created = true
@@ -70,12 +70,12 @@ module FullSearch
70
70
  conn.execute("DROP TABLE IF EXISTS #{qt(fts_table_name(model))};")
71
71
  conn.execute("DROP TABLE IF EXISTS #{qt(trigram_table_name(model))};")
72
72
  conn.execute(create_virtual_table_sql(model))
73
- if dsl.typo_tolerance?
73
+ if trigram_table_needed?(model)
74
74
  FullSearch::Typo.warn_unsupported!(model) unless FullSearch::Typo.supported?(model)
75
75
  conn.execute(create_trigram_virtual_table_sql(model))
76
76
  end
77
77
  conn.execute(backfill_sql(model))
78
- conn.execute(backfill_trigram_sql(model)) if dsl.typo_tolerance?
78
+ conn.execute(backfill_trigram_sql(model)) if trigram_table_needed?(model)
79
79
  reindex_source_fields!(model) if dsl.fields.any?(&:source)
80
80
  create_triggers!(model)
81
81
  optimize!(model)
@@ -141,7 +141,7 @@ module FullSearch
141
141
  if model.full_search_dsl.soft_delete_column
142
142
  connection.execute(soft_delete_removal_trigger_sql(model))
143
143
  end
144
- if model.full_search_dsl.typo_tolerance?
144
+ if trigram_table_needed?(model)
145
145
  connection.execute(insert_trigram_trigger_sql(model))
146
146
  connection.execute(delete_trigram_trigger_sql(model))
147
147
  connection.execute(update_trigram_trigger_sql(model))
@@ -177,7 +177,7 @@ module FullSearch
177
177
  else
178
178
  connection.adapter_name.downcase.include?("sqlite")
179
179
  end
180
- rescue
180
+ rescue ActiveRecord::ConnectionNotEstablished, NoMethodError
181
181
  connection.adapter_name.downcase.include?("sqlite")
182
182
  end
183
183
 
@@ -199,6 +199,16 @@ module FullSearch
199
199
  !table_exists?(model)
200
200
  end
201
201
 
202
+ def trigram_table_needed?(model)
203
+ model.full_search_dsl&.typo_tolerance? && model.full_search_dsl.tokenize != "trigram"
204
+ end
205
+
206
+ def trigram_table_exists?(model)
207
+ connection.execute(
208
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=#{q(trigram_table_name(model))} LIMIT 1"
209
+ ).any?
210
+ end
211
+
202
212
  private
203
213
 
204
214
  def connection
@@ -292,7 +302,7 @@ module FullSearch
292
302
  ).map { |r| r["name"] }
293
303
 
294
304
  expected = trigger_names(model)
295
- expected += trigram_trigger_names(model) if model.full_search_dsl.typo_tolerance?
305
+ expected += trigram_trigger_names(model) if trigram_table_needed?(model)
296
306
  return if (expected - existing).empty? && (existing - expected).empty?
297
307
 
298
308
  rebuild!(model)
@@ -391,12 +401,6 @@ module FullSearch
391
401
  SQL
392
402
  end
393
403
 
394
- def trigram_table_exists?(model)
395
- connection.execute(
396
- "SELECT name FROM sqlite_master WHERE type='table' AND name=#{q(trigram_table_name(model))} LIMIT 1"
397
- ).any?
398
- end
399
-
400
404
  def create_trigram_virtual_table_sql(model)
401
405
  dsl = model.full_search_dsl
402
406
  columns = (dsl.fields + dsl.filters.map { |f| FilterColumnPlaceholder.new(name: f.name) } + extra_columns(model))
@@ -11,6 +11,8 @@ module FullSearch
11
11
  private
12
12
 
13
13
  def virtual_tables(stream)
14
+ return unless FullSearch.config.dump_schema_virtual_tables
15
+
14
16
  virtual_tables = @connection.virtual_tables.reject { |name, _| ignored?(name) }
15
17
 
16
18
  if virtual_tables.any?
@@ -89,7 +89,7 @@ module FullSearch
89
89
 
90
90
  stored = begin
91
91
  FullSearch::Index.stored_config_hash(model)
92
- rescue
92
+ rescue ActiveRecord::StatementInvalid
93
93
  nil
94
94
  end
95
95
  return unless stored
@@ -146,6 +146,8 @@ module FullSearch
146
146
  return like_prefix_ids(term, candidate_limit: candidate_limit)
147
147
  end
148
148
 
149
+ return [] unless FullSearch::Index.trigram_table_exists?(model)
150
+
149
151
  trigram_table = qt(FullSearch::Index.trigram_table_name(model))
150
152
  tbl = qt(model.table_name)
151
153
 
@@ -2,16 +2,82 @@
2
2
 
3
3
  module FullSearch
4
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
5
+ class << self
6
+ def setup_for_tests!
7
+ configure do |config|
8
+ config.lock_rebuilds = false
9
+ config.auto_rebuild_on_stale_query = true
10
+ config.stale_query_behavior = :raise
11
+ end
12
+
13
+ inline_active_job_if_configured
14
+ end
15
+
16
+ def configure
17
+ yield FullSearch.config
18
+ end
19
+
20
+ private
21
+
22
+ def inline_active_job_if_configured
23
+ return unless defined?(ActiveJob::Base)
24
+
25
+ ActiveJob::Base.queue_adapter = :inline
26
+ end
27
+ end
28
+
29
+ def rebuild_full_search_index(model)
30
+ model = resolve_full_search_model(model)
7
31
  FullSearch::Index.drop!(model)
8
32
  FullSearch::Index.rebuild!(model)
9
33
  end
10
34
 
35
+ def reindex_full_search(model)
36
+ model = resolve_full_search_model(model)
37
+ FullSearch::Index.reindex_source_fields!(model)
38
+ end
39
+
40
+ def reset_full_search!(*models)
41
+ models = FullSearch.models.to_a if models.empty?
42
+ models.each { |model| rebuild_full_search_index(model) }
43
+ end
44
+
11
45
  def ensure_full_search_tables
12
46
  FullSearch.models.each do |model|
13
47
  FullSearch::Index.ensure_table!(model)
14
48
  end
15
49
  end
50
+
51
+ def with_full_search_rebuild(model)
52
+ model = resolve_full_search_model(model)
53
+ rebuild_full_search_index(model)
54
+ yield
55
+ ensure
56
+ FullSearch::Index.drop!(model)
57
+ end
58
+
59
+ def with_full_search_async_jobs_inline
60
+ original_adapter = ActiveJob::Base.queue_adapter if defined?(ActiveJob::Base)
61
+ ActiveJob::Base.queue_adapter = :inline if defined?(ActiveJob::Base)
62
+ yield
63
+ ensure
64
+ ActiveJob::Base.queue_adapter = original_adapter if defined?(ActiveJob::Base)
65
+ end
66
+
67
+ def with_full_search_models_registered(*models)
68
+ previous_registry = FullSearch.models.dup
69
+ models.each { |model| FullSearch.register_model(model) }
70
+ yield
71
+ ensure
72
+ FullSearch.models.replace(previous_registry)
73
+ end
74
+
75
+ private
76
+
77
+ def resolve_full_search_model(model)
78
+ return model if model.is_a?(Class)
79
+
80
+ model.to_s.camelize.constantize
81
+ end
16
82
  end
17
83
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FullSearch
4
- VERSION = "0.3.8"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -19,7 +19,7 @@ module FullSearch
19
19
 
20
20
  say "Running full_search:prepare to create FTS tables..."
21
21
  rake("full_search:prepare")
22
- rescue => e
22
+ rescue RuntimeError => e
23
23
  say "Skipping full_search:prepare — #{e.message}", :yellow
24
24
  say "Run `bin/rails full_search:prepare` after your database is ready.", :yellow
25
25
  end
@@ -12,4 +12,8 @@ FullSearch.configure do |config|
12
12
  # Reindex computed source: fields synchronously (false) or via background job (true).
13
13
  # Bulk imports should use FullSearch.bulk_import(Model) { ... } to defer reindexing.
14
14
  config.default_async_source_reindex = true
15
+
16
+ # Uncomment to exclude full_search virtual tables from db/schema.rb.
17
+ # When false, you must run `bin/rails full_search:prepare` after db:schema:load.
18
+ # config.dump_schema_virtual_tables = false
15
19
  end
@@ -108,4 +108,30 @@ namespace :full_search do
108
108
  puts "#{model.table_name}: #{status}#{drift_info}"
109
109
  end
110
110
  end
111
+
112
+ desc "Verify full_search indexes are present and up to date (exit 1 if unhealthy)"
113
+ task health_check: :environment do
114
+ Rails.application.eager_load!
115
+ unhealthy = []
116
+
117
+ FullSearch.sorted_models.each do |model|
118
+ if FullSearch::Index.missing_table?(model)
119
+ unhealthy << "#{model.table_name}: missing FTS table"
120
+ next
121
+ end
122
+
123
+ stored = FullSearch::Index.stored_config_hash(model)
124
+ current = model.full_search_dsl.config_hash
125
+ if stored != current
126
+ unhealthy << "#{model.table_name}: stale config hash"
127
+ end
128
+ end
129
+
130
+ if unhealthy.any?
131
+ unhealthy.each { |message| puts "[FAIL] #{message}" }
132
+ exit 1
133
+ else
134
+ puts "[OK] All full_search indexes are healthy"
135
+ end
136
+ end
111
137
  end
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.3.8
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben D'Angelo