full_search 0.1.3 → 0.3.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: 90ca9a454b8cacf2619c28ced3b07420fbf26ab34ddb55b79f93ce82ce69c06e
4
- data.tar.gz: e37851d2e693989a7585fe40460af0bab1ae91c9b171faca76114394b3c97799
3
+ metadata.gz: 50bb1c53fe9f326612a2413a441a37acfe48f25bf4730786de3631c7230b2572
4
+ data.tar.gz: ab53bbd57f8fd9cb464af29d37aa70cd0efb18a2835eff1b501d61a14d0de43f
5
5
  SHA512:
6
- metadata.gz: 76f1883ee4d70669bd85bfac929b2dfaf820efb40400cfaabe98fd0d7dc68ad867e551cb0cb7d292f51694e74fa0349080497adb6a6dd52154b1e80b259540e1
7
- data.tar.gz: f5e1fa063bcf8ff247ff52506f98fcacd98d47de827601a48d2070f8da58d380dc72d628089d69f63a89bc0a88dacec123c68566ec82e502defeaa76a5546958
6
+ metadata.gz: ab98601c786c02808246cf4657ef7c3912e36e0fbc429417c0ff564a3ec1e2575216fda8d64ee2ab22db32c707bcbce0915fcc3d57038059f1dfe05bde48963e
7
+ data.tar.gz: 52564a1ce083d5aa0f6cb624f8f9aa4a0bb0ac13a85216f14a857a5c2c2c744d37fa4370272679c3bd13c76eb6ec732814463cfa143bb714654243968c6c800c
data/README.md CHANGED
@@ -1,7 +1,19 @@
1
1
  # full_search
2
2
 
3
+ [![CI](https://github.com/bendangelo/full_search/actions/workflows/ci.yml/badge.svg)](https://github.com/bendangelo/full_search/actions/workflows/ci.yml)
4
+
3
5
  SQLite FTS5 full-text search for Rails/ActiveRecord. A lightweight, self-contained alternative to `pg_search` for apps already running on SQLite.
4
6
 
7
+ ## Requirements
8
+
9
+ - Ruby 3.1+
10
+ - Rails 8.0+
11
+ - SQLite 3.34+ (if using typo tolerance)
12
+
13
+ ## When to use
14
+
15
+ `full_search` is designed for apps with **100,000 records or fewer per table** that want full-text search without running a separate service. If you're on SQLite and need keyword search, phrase matching, typo-tolerant substring queries, or result highlighting, this gem gives you full-text search suitable for small to medium SQLite-backed apps with zero infrastructure — no Elasticsearch, no Meilisearch, no Sidekiq queue.
16
+
5
17
  ## Installation
6
18
 
7
19
  Add to your Gemfile:
@@ -30,12 +42,12 @@ end
30
42
  ```
31
43
 
32
44
  ```ruby
33
- Customer.full_search("sam", filters: { account_id: 1 }).page(params[:page])
45
+ Customer.search("sam", filters: { account_id: 1 }).page(params[:page])
34
46
  ```
35
47
 
36
48
  ## Features
37
49
 
38
- - Declarative `full_search` DSL
50
+ - Declarative `full_search` DSL with `search` / `full_search` query methods
39
51
  - SQLite FTS5 backed
40
52
  - Required-filter enforcement for multi-tenant apps
41
53
  - Exact-match queries for encrypted identifiers
@@ -44,14 +56,102 @@ Customer.full_search("sam", filters: { account_id: 1 }).page(params[:page])
44
56
  - Phrase, exclusion, and OR query operators
45
57
  - FTS5 `highlight()` support for result snippets
46
58
  - Opt-in trigram typo/substring fallback (requires SQLite >= 3.34)
47
- - Rake tasks: `full_search:rebuild`, `full_search:optimize`, `full_search:status`
59
+
60
+ ### Per-model operations
61
+
62
+ Once a model declares `full_search`, you can call these class methods:
63
+
64
+ | Method | What it does |
65
+ |--------|-------------|
66
+ | `Customer.rebuild!` | Force-drop and recreate the model's FTS table, backfill from the source table, and reinstall triggers. |
67
+ | `Customer.reindex!` | Re-evaluates computed `source:` fields for every record and updates the FTS table in-place. Table structure is untouched. |
68
+ | `Customer.optimize!` | Runs FTS5 `optimize` on the model's index to merge b-tree segments. |
69
+
70
+ ## Index management
71
+
72
+ FTS indexes are SQLite virtual tables (`customers_fts`, `vehicles_fts`, etc.) that mirror your model tables. They stay in sync via database triggers on `INSERT` / `UPDATE` / `DELETE`.
73
+
74
+ ### Rebuild vs reindex
75
+
76
+ These two operations are often confused:
77
+
78
+ - **Rebuild** (`full_search:rebuild` / `Customer.rebuild!`) — drops and recreates the FTS virtual table. Needed when the DSL changes (fields added/removed, tokenizer changed, etc.). The table is re-created from scratch, backfilled, triggers re-installed, and the index is optimized.
79
+ - **Reindex** (`Customer.reindex!` / `FullSearch::Index.reindex_source_fields!`) — updates existing FTS rows with fresh values from computed `source:` fields only. The table structure is untouched. Database triggers cover regular column changes automatically; only Ruby-evaluated `source:` blocks need an explicit reindex.
80
+
81
+ > **Note on rebuild locking:** The `lock_rebuilds` option prevents concurrent rebuilds
82
+ > within the same process/connection. For multi-process or multi-host deployments,
83
+ > run `full_search:rebuild` from a single deployment step.
84
+
85
+ ### Auto-rebuild on app load
86
+
87
+ When `auto_rebuild_schema` is enabled (default in the generated initializer), the railtie hooks into Rails `after_initialize` and:
88
+
89
+ 1. Creates missing FTS tables for every model using `full_search`
90
+ 2. Compares each table's stored config hash against the current DSL
91
+ 3. Rebuilds the index automatically when the DSL changes (new fields, different tokenizer, etc.)
92
+
93
+ ```ruby
94
+ # config/initializers/full_search.rb
95
+ FullSearch.configure do |config|
96
+ config.auto_rebuild_schema = true
97
+ end
98
+ ```
99
+
100
+ ### In production
101
+
102
+ Auto-rebuild runs on every Rails process boot (web, worker, console). For zero-downtime deploys where old processes still serve traffic, or if you prefer explicit control, set `auto_rebuild_schema` to `false` and run the rebuild task manually:
103
+
104
+ ```bash
105
+ # Rebuild only indexes whose DSL has changed (fast, safe for production)
106
+ bin/rails full_search:rebuild
107
+
108
+ # Target specific models by table name
109
+ bin/rails 'full_search:rebuild[customers,vehicles]'
110
+ ```
111
+
112
+ The rebuild task checks each model's stored config hash against the current DSL and only drops/recreates indexes when they differ. For a full forced reset (drops and recreates all FTS tables regardless):
113
+
114
+ ```bash
115
+ bin/rails full_search:reset
116
+ ```
117
+
118
+ ### Rake tasks
119
+
120
+ | Task | Description |
121
+ |------|-------------|
122
+ | `full_search:rebuild` | Drops and recreates the FTS virtual table only when the DSL config hash has changed (safe for production). Pass model names to target specific tables. |
123
+ | `full_search:reset` | Force a full rebuild — drops and recreates all FTS tables regardless of config hash. Use when data may be out of sync. |
124
+ | `full_search:optimize` | Run FTS5 [`optimize`](https://www.sqlite.org/fts5.html#the_optimize_command) to merge b-tree segments. Useful after bulk updates. |
125
+ | `full_search:status` | Show each model's index status (`ok` / `stale`) and count of empty sourced fields. |
126
+
127
+ ## Background jobs
128
+
129
+ ### Scheduled optimization
130
+
131
+ FTS5 b-tree segments accumulate over time as rows are inserted, updated, and deleted. Running `optimize` periodically merges these segments, keeping queries fast. For most apps, once a day during a low-traffic window is sufficient. Apps with heavy write volume may benefit from every few hours.
132
+
133
+ The gem ships `FullSearch::OptimizeJob` ready to use. Schedule it with Solid Queue's `recurring.yml`:
134
+
135
+ ```yaml
136
+ # config/recurring.yml
137
+ full_search_optimize:
138
+ class: FullSearch::OptimizeJob
139
+ schedule: daily at 4am
140
+ description: "Merge FTS5 b-tree segments for full_search indexes"
141
+ ```
142
+
143
+ If you're not on Solid Queue, most job frameworks support recurring schedules via gems like `sidekiq-cron` or `whenever`.
144
+
145
+ ### Config hash drift detection
146
+
147
+ Every FTS table stores a SHA256 digest of its DSL configuration in the `full_search_index_versions` table. When the DSL changes (e.g., adding a field), the stored hash no longer matches, and `rebuild!` is triggered automatically (if `auto_rebuild_schema` is true) to bring the index in line with the new definition. If `auto_rebuild_schema` is false, queries raise `ConfigChangedError` when the hash doesn't match (or log a warning and fall back if configured with `:log_and_fallback`).
48
148
 
49
149
  ## Query operators
50
150
 
51
151
  ```ruby
52
- Customer.full_search('"Sam Smith"', filters: { account_id: 1 }) # phrase
53
- Customer.full_search("honda -civic", filters: { account_id: 1 }) # exclusion
54
- Customer.full_search("honda OR toyota", filters: { account_id: 1 }) # OR
152
+ Customer.search('"Sam Smith"', filters: { account_id: 1 }) # phrase
153
+ Customer.search("honda -civic", filters: { account_id: 1 }) # exclusion
154
+ Customer.search("honda OR toyota", filters: { account_id: 1 }) # OR
55
155
  ```
56
156
 
57
157
  ## Highlighting
@@ -66,7 +166,7 @@ end
66
166
  ```
67
167
 
68
168
  ```ruby
69
- Customer.full_search("sam", filters: { account_id: 1 }, highlight: true)
169
+ Customer.search("sam", filters: { account_id: 1 }, highlight: true)
70
170
  # each result has #full_search_snippet
71
171
  ```
72
172
 
@@ -81,9 +181,15 @@ class Customer < ApplicationRecord
81
181
  end
82
182
  ```
83
183
 
84
- `typo_tolerance` uses an FTS5 trigram shadow table as a fallback when the primary index returns no results. It is substring matching, not edit-distance correction, and requires SQLite >= 3.34.
184
+ `typo_tolerance` uses an FTS5 trigram shadow table as a fallback when the primary index returns no results. It is substring/trigram fallback, not edit-distance correction, and requires SQLite >= 3.34.
85
185
 
86
186
  ## Known limitations
87
187
 
88
188
  - 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.
89
- - Per-model only; no built-in multi-model aggregator
189
+ - Per-model only; `multi_search` returns grouped results, not a unified ranked list
190
+ - No built-in multi-model aggregator
191
+
192
+ ## Security
193
+
194
+ If you discover a security vulnerability, please email the maintainer directly at
195
+ the address listed in the gemspec. Do not file a public issue.
@@ -3,6 +3,8 @@
3
3
  module FullSearch
4
4
  module Callbacks
5
5
  def self.install!(model)
6
+ return if model.instance_variable_get(:@__full_search_callbacks_installed)
7
+
6
8
  dsl = model.full_search_dsl
7
9
 
8
10
  source_fields = dsl.fields.select(&:source)
@@ -27,6 +29,12 @@ module FullSearch
27
29
  FullSearch::Callbacks.reindex_dependents!(record, model, field)
28
30
  end
29
31
  end
32
+
33
+ model.instance_variable_set(:@__full_search_callbacks_installed, true)
34
+ end
35
+
36
+ def self.reset_installed_flag!(model)
37
+ model.instance_variable_set(:@__full_search_callbacks_installed, false)
30
38
  end
31
39
 
32
40
  def self.reindex_record!(record)
@@ -46,22 +54,24 @@ module FullSearch
46
54
  return unless field&.source
47
55
 
48
56
  value = record.instance_exec(&field.source)
49
- table = FullSearch::Index.fts_table_name(record.class)
50
- quoted_value = ActiveRecord::Base.connection.quote(value.to_s)
51
- ActiveRecord::Base.connection.execute(
52
- "UPDATE #{table} SET #{field.name} = #{quoted_value} WHERE rowid = #{record.id}"
57
+ table = qt(FullSearch::Index.fts_table_name(record.class))
58
+ conn = ActiveRecord::Base.connection
59
+ conn.execute(
60
+ "UPDATE #{table} SET #{qc(field.name)} = #{q(value.to_s)} WHERE rowid = #{q(record.id)}"
53
61
  )
54
62
  end
55
63
 
56
64
  def self.remove_record!(record)
57
- table = FullSearch::Index.fts_table_name(record.class)
58
- ActiveRecord::Base.connection.execute("DELETE FROM #{table} WHERE rowid = #{record.id}")
65
+ table = qt(FullSearch::Index.fts_table_name(record.class))
66
+ conn = ActiveRecord::Base.connection
67
+ conn.execute("DELETE FROM #{table} WHERE rowid = #{q(record.id)}")
59
68
  end
60
69
 
61
70
  def self.reindex_dependents!(parent_record, dependent_model, field)
62
71
  fk = association_key(dependent_model, field.reindex_on)
63
- sql = "SELECT id FROM #{dependent_model.table_name} WHERE #{fk} = #{parent_record.id}"
64
- dependent_ids = ActiveRecord::Base.connection.execute(sql).map { |r| r["id"] }
72
+ conn = ActiveRecord::Base.connection
73
+ sql = "SELECT id FROM #{qt(dependent_model.table_name)} WHERE #{qc(fk)} = #{q(parent_record.id)}"
74
+ dependent_ids = conn.execute(sql).map { |r| r["id"] }
65
75
 
66
76
  dependent_ids.each do |dep_id|
67
77
  if field.async
@@ -73,6 +83,14 @@ module FullSearch
73
83
  end
74
84
  end
75
85
 
86
+ class << self
87
+ include FullSearch::Quoting
88
+ end
89
+
90
+ def self.connection
91
+ ActiveRecord::Base.connection
92
+ end
93
+
76
94
  def self.associated_class(model, association_name)
77
95
  reflection = model.reflect_on_association(association_name.to_sym)
78
96
  reflection&.klass
@@ -2,10 +2,10 @@
2
2
 
3
3
  module FullSearch
4
4
  class Config
5
- attr_accessor :auto_manage_schema, :stale_query_behavior, :lock_rebuilds, :default_async_reindex, :default_tokenizer
5
+ attr_accessor :auto_rebuild_schema, :stale_query_behavior, :lock_rebuilds, :default_async_reindex, :default_tokenizer
6
6
 
7
7
  def initialize
8
- @auto_manage_schema = false
8
+ @auto_rebuild_schema = false
9
9
  @stale_query_behavior = :raise
10
10
  @lock_rebuilds = true
11
11
  @default_async_reindex = true
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullSearch
4
+ module Distance
5
+ def self.damerau_levenshtein(a, b)
6
+ a_len = a.length
7
+ b_len = b.length
8
+ return a_len if b_len == 0
9
+ return b_len if a_len == 0
10
+
11
+ d = Array.new(a_len + 1) { Array.new(b_len + 1, 0) }
12
+ (0..a_len).each { |i| d[i][0] = i }
13
+ (0..b_len).each { |j| d[0][j] = j }
14
+
15
+ (1..a_len).each do |i|
16
+ (1..b_len).each do |j|
17
+ cost = (a[i - 1] == b[j - 1]) ? 0 : 1
18
+ d[i][j] = [
19
+ d[i - 1][j] + 1,
20
+ d[i][j - 1] + 1,
21
+ d[i - 1][j - 1] + cost
22
+ ].min
23
+
24
+ if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1]
25
+ d[i][j] = [d[i][j], d[i - 2][j - 2] + 1].min
26
+ end
27
+ end
28
+ end
29
+
30
+ d[a_len][b_len]
31
+ end
32
+ end
33
+ end
@@ -2,10 +2,10 @@
2
2
 
3
3
  module FullSearch
4
4
  class Dsl
5
- attr_reader :fields, :exact_matches, :filters, :model_class, :tokenize, :highlight_config, :rank_bys
5
+ attr_reader :fields, :exact_matches, :filters, :model_class, :highlight_config, :rank_bys
6
6
 
7
- Field = Data.define(:name, :weight, :source, :reindex_on, :async)
8
- ExactMatch = Data.define(:name, :source)
7
+ Field = Data.define(:name, :weight, :source, :reindex_on, :async, :as, :version)
8
+ ExactMatch = Data.define(:name, :source, :version)
9
9
  Filter = Data.define(:name, :required)
10
10
  RankBy = Data.define(:column, :direction)
11
11
 
@@ -19,32 +19,49 @@ module FullSearch
19
19
  @soft_delete_column = nil
20
20
  end
21
21
 
22
- def field(name, weight: 1, source: nil, reindex_on: nil, async: FullSearch.config.default_async_reindex)
22
+ def field(name, weight: 1, source: nil, reindex_on: nil, async: FullSearch.config.default_async_reindex, as: nil, version: nil)
23
23
  unless valid_name?(name)
24
24
  raise InvalidFieldError, "Invalid field name: #{name.inspect}"
25
25
  end
26
- @fields << Field.new(name: name.to_s, weight: weight.to_i, source: source, reindex_on: reindex_on&.to_s, async: async)
26
+ if as && !valid_name?(as)
27
+ raise InvalidFieldError, "Invalid field alias (as): #{as.inspect}"
28
+ end
29
+ str = name.to_s
30
+ raise InvalidFieldError, "Duplicate field name: #{name.inspect}" if fields.any? { |f| f.name == str }
31
+ raise InvalidFieldError, "Field name conflicts with filter: #{name.inspect}" if filters.any? { |f| f.name == str }
32
+ @fields << Field.new(name: str, weight: weight.to_i, source: source, reindex_on: reindex_on&.to_s, async: async, as: as&.to_s, version: version)
27
33
  end
28
34
 
29
- def exact_match(name, source: -> { public_send(name) })
35
+ def exact_match(name, source: -> { public_send(name) }, version: nil)
30
36
  unless valid_name?(name)
31
37
  raise InvalidFieldError, "Invalid exact_match name: #{name.inspect}"
32
38
  end
33
- @exact_matches << ExactMatch.new(name: name.to_s, source: source)
39
+ str = name.to_s
40
+ raise InvalidFieldError, "Duplicate exact_match name: #{name.inspect}" if exact_matches.any? { |e| e.name == str }
41
+ @exact_matches << ExactMatch.new(name: str, source: source, version: version)
34
42
  end
35
43
 
36
44
  def rank_by(column, direction = :desc)
37
45
  unless valid_name?(column)
38
46
  raise InvalidFieldError, "Invalid rank_by column: #{column.inspect}"
39
47
  end
40
- @rank_bys << RankBy.new(column: column.to_s, direction: direction.to_sym)
48
+ dir = direction.to_s.downcase
49
+ unless %w[asc desc].include?(dir)
50
+ raise InvalidFieldError, "Invalid rank_by direction: #{direction.inspect}. Use :asc or :desc."
51
+ end
52
+ str = column.to_s
53
+ raise InvalidFieldError, "Duplicate rank_by column: #{column.inspect}" if rank_bys.any? { |r| r.column == str }
54
+ @rank_bys << RankBy.new(column: str, direction: dir.to_sym)
41
55
  end
42
56
 
43
57
  def filter(name, required: false)
44
58
  unless valid_name?(name)
45
59
  raise InvalidFieldError, "Invalid filter name: #{name.inspect}"
46
60
  end
47
- @filters << Filter.new(name: name.to_s, required: required)
61
+ str = name.to_s
62
+ raise InvalidFieldError, "Duplicate filter name: #{name.inspect}" if filters.any? { |f| f.name == str }
63
+ raise InvalidFieldError, "Filter name conflicts with field: #{name.inspect}" if fields.any? { |f| f.name == str }
64
+ @filters << Filter.new(name: str, required: required)
48
65
  end
49
66
 
50
67
  def soft_delete_column(name = :_no_arg_)
@@ -64,7 +81,7 @@ module FullSearch
64
81
  end
65
82
 
66
83
  def highlight(open_tag: "<mark>", close_tag: "</mark>")
67
- @highlight_config = { open_tag: open_tag, close_tag: close_tag }
84
+ @highlight_config = {open_tag: open_tag, close_tag: close_tag}
68
85
  end
69
86
 
70
87
  def typo_tolerance(enabled = true, min_term_length: nil)
@@ -88,8 +105,8 @@ module FullSearch
88
105
  soft_delete_column,
89
106
  typo_tolerance?,
90
107
  typo_tolerance_min_term_length,
91
- fields.map { |f| [f.name, f.weight, f.source.nil? ? "column" : "source", f.reindex_on, f.async] },
92
- exact_matches.map { |e| [e.name] },
108
+ fields.map { |f| [f.name, f.weight, f.source.nil? ? "column" : "proc:#{f.version}", f.reindex_on, f.async, f.as] },
109
+ exact_matches.map { |e| [e.name, "proc:#{e.version}"] },
93
110
  filters.map { |f| [f.name, f.required] },
94
111
  rank_bys.map { |r| [r.column, r.direction] }
95
112
  ].inspect)
@@ -3,8 +3,10 @@
3
3
  module FullSearch
4
4
  class Error < StandardError; end
5
5
  class MissingRequiredFilterError < Error; end
6
+ class UnknownFilterError < Error; end
6
7
  class ConfigChangedError < Error; end
7
8
  class InvalidFieldError < Error; end
8
9
  class NotConfiguredError < Error; end
9
10
  class UnsupportedDatabaseError < Error; end
11
+ class InvalidQueryError < Error; end
10
12
  end
@@ -12,11 +12,14 @@ module FullSearch
12
12
  relation = model.all
13
13
  filters.each { |name, value| relation = relation.where(name => value) }
14
14
 
15
- conditions = dsl.exact_matches.map do |em|
16
- relation.model.arel_table[em.name].eq(cleaned)
15
+ ids = []
16
+ relation.find_each do |record|
17
+ dsl.exact_matches.each do |em|
18
+ value = record.instance_exec(&em.source)
19
+ ids << record.id if value.to_s.casecmp?(cleaned)
20
+ end
17
21
  end
18
-
19
- relation.where(conditions.reduce(:or)).pluck(:id)
22
+ ids.uniq
20
23
  end
21
24
  end
22
25
  end