full_search 0.3.9 → 0.4.1

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: 1fb6eb3fd61b593c1cbea4a28c2db11d73ed11abd422f1685d6e31422cec6607
4
+ data.tar.gz: c8553a64b3e0b111e311478c448d6f9de5f826b122d3c3f64cd22b92b6020f25
5
5
  SHA512:
6
- metadata.gz: 1d91fea14e22584bca2ecdd617119068a54ef60d3f8d79d9109c5e51a6f8b4cd562f0a37e34a3e0ce01ec4dda29ebe5e0ef85d7ef7a51a8d5a922efa59086fdc
7
- data.tar.gz: 2089966acfa5bc274cde458cb27a7a739e70493d75027b507794ffa905048f772f2e72d8a213c8ad779f312424c8509181cf57ae1aeaccf252a9522e690099a4
6
+ metadata.gz: 71fe8b5515334c6eaf826022f34c415459365856ae0a38115dde0a984e23d8cd0dfae90ece9d05b07605c7942e2a2edf4d98dd0fdb35a3e0ecaaf4d2f0f86a2e
7
+ data.tar.gz: df84b3bcffde7393428589dc3bed5c92a7b50974ea027829c20c800760a2b69b7bbf3a3170e96fef232892d5e1461cb230715e6e2ec06a01d4dc46cf9b4750f2
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.
@@ -5,7 +5,7 @@ module FullSearch
5
5
  attr_accessor :auto_rebuild_schema, :stale_query_behavior, :lock_rebuilds,
6
6
  :default_async_reindex, :default_async_source_reindex,
7
7
  :default_tokenizer, :auto_rebuild_on_stale_query, :min_like_prefix_length,
8
- :dump_schema_virtual_tables
8
+ :dump_schema_virtual_tables, :auto_rebuild_missing_tables
9
9
 
10
10
  def initialize
11
11
  @auto_rebuild_schema = false
@@ -17,6 +17,7 @@ module FullSearch
17
17
  @auto_rebuild_on_stale_query = false
18
18
  @min_like_prefix_length = FullSearch::Constants::DEFAULT_MIN_LIKE_PREFIX_LENGTH
19
19
  @dump_schema_virtual_tables = true
20
+ @auto_rebuild_missing_tables = false
20
21
  end
21
22
  end
22
23
 
@@ -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
 
@@ -84,12 +84,17 @@ module FullSearch
84
84
 
85
85
  def check_stale_config!
86
86
  if FullSearch::Index.missing_table?(model)
87
+ if FullSearch.config.auto_rebuild_missing_tables
88
+ FullSearch::Index.rebuild!(model)
89
+ return
90
+ end
91
+
87
92
  raise MissingTableError, "FTS table `#{FullSearch::Index.fts_table_name(model)}` does not exist. Run `bin/rails full_search:prepare` to create it."
88
93
  end
89
94
 
90
95
  stored = begin
91
96
  FullSearch::Index.stored_config_hash(model)
92
- rescue
97
+ rescue ActiveRecord::StatementInvalid
93
98
  nil
94
99
  end
95
100
  return unless stored
@@ -2,16 +2,83 @@
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
+ config.auto_rebuild_missing_tables = true
12
+ end
13
+
14
+ inline_active_job_if_configured
15
+ end
16
+
17
+ def configure
18
+ yield FullSearch.config
19
+ end
20
+
21
+ private
22
+
23
+ def inline_active_job_if_configured
24
+ return unless defined?(ActiveJob::Base)
25
+
26
+ ActiveJob::Base.queue_adapter = :inline
27
+ end
28
+ end
29
+
30
+ def rebuild_full_search_index(model)
31
+ model = resolve_full_search_model(model)
7
32
  FullSearch::Index.drop!(model)
8
33
  FullSearch::Index.rebuild!(model)
9
34
  end
10
35
 
36
+ def reindex_full_search(model)
37
+ model = resolve_full_search_model(model)
38
+ FullSearch::Index.reindex_source_fields!(model)
39
+ end
40
+
41
+ def reset_full_search!(*models)
42
+ models = FullSearch.models.to_a if models.empty?
43
+ models.each { |model| rebuild_full_search_index(model) }
44
+ end
45
+
11
46
  def ensure_full_search_tables
12
47
  FullSearch.models.each do |model|
13
48
  FullSearch::Index.ensure_table!(model)
14
49
  end
15
50
  end
51
+
52
+ def with_full_search_rebuild(model)
53
+ model = resolve_full_search_model(model)
54
+ rebuild_full_search_index(model)
55
+ yield
56
+ ensure
57
+ FullSearch::Index.drop!(model)
58
+ end
59
+
60
+ def with_full_search_async_jobs_inline
61
+ original_adapter = ActiveJob::Base.queue_adapter if defined?(ActiveJob::Base)
62
+ ActiveJob::Base.queue_adapter = :inline if defined?(ActiveJob::Base)
63
+ yield
64
+ ensure
65
+ ActiveJob::Base.queue_adapter = original_adapter if defined?(ActiveJob::Base)
66
+ end
67
+
68
+ def with_full_search_models_registered(*models)
69
+ previous_registry = FullSearch.models.dup
70
+ models.each { |model| FullSearch.register_model(model) }
71
+ yield
72
+ ensure
73
+ FullSearch.models.replace(previous_registry)
74
+ end
75
+
76
+ private
77
+
78
+ def resolve_full_search_model(model)
79
+ return model if model.is_a?(Class)
80
+
81
+ model.to_s.camelize.constantize
82
+ end
16
83
  end
17
84
  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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben D'Angelo