full_search 0.3.9 → 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: d8865ec65e8e55ecc1648ab56cd7f65f77ea7a05b6928f0771492a1e2ed1bf68
4
- data.tar.gz: 8bbe570d9fa72cd9ed8d567174501807bc4b4d1726fcc4600e857219c3d59b2c
3
+ metadata.gz: 8175568a013e8a29d107a5b3bceebc7e9be6b9c85f56d8ef178d3c2c72a73678
4
+ data.tar.gz: d77fe210358656fe1b471baeb374c27d2f493544739d4ec326c06aee3dddadf1
5
5
  SHA512:
6
- metadata.gz: 1d91fea14e22584bca2ecdd617119068a54ef60d3f8d79d9109c5e51a6f8b4cd562f0a37e34a3e0ce01ec4dda29ebe5e0ef85d7ef7a51a8d5a922efa59086fdc
7
- data.tar.gz: 2089966acfa5bc274cde458cb27a7a739e70493d75027b507794ffa905048f772f2e72d8a213c8ad779f312424c8509181cf57ae1aeaccf252a9522e690099a4
6
+ metadata.gz: 6e786b098bc3cd046151835f11bbf98b4a5213feb46e70a0ae6872a4f24bba6eb22ec5e8a17ebe045328eaab1d2016682fda67e47c5582fb73ae72b262c7bf08
7
+ data.tar.gz: 4d5284fe9b059ee7010ff099cd88e494beffe1804084f29be86921bd895059cc98dd567fdf9c09ef20a2ec9af99badef3416f6085bb3f85e019f44657028244a
data/README.md CHANGED
@@ -245,6 +245,7 @@ When disabled, you must run `bin/rails full_search:prepare` after every `db:sche
245
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. |
246
246
  | `full_search:backfill` | Force-rebuild FTS indexes for specified models (or all). Useful for recovery after bulk operations. |
247
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. |
248
249
 
249
250
  ## Background jobs
250
251
 
@@ -387,6 +388,202 @@ FullSearch.multi_search(
387
388
  )
388
389
  ```
389
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
+
390
587
  ## Known limitations
391
588
 
392
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.
@@ -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
 
@@ -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
@@ -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.9"
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
@@ -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.9
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben D'Angelo