full_search 0.3.5 → 0.3.7

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: b3b466311e2305f90987a48ff58cee59f4c81af6e960b5fcd2fe09cf845b985b
4
- data.tar.gz: 1d355e22aa06d280d984d7da1df90e57a8ca4a5f5676b7870424f2b048c051ba
3
+ metadata.gz: d25d24145bf1aef1508210eb9d6879b21218ed8760ef6a61812706720a12ac17
4
+ data.tar.gz: 94b71b2b86db367ef8fde640550ab7b5945b50829d618c5e4eb41a4d9a6e0d65
5
5
  SHA512:
6
- metadata.gz: 9489ff8c9bf04c59b6380cd1810871a39a947421754379ac6fb1c1cb36cbf7850a753d0a7354e25f38b591aeae63cbb05cd7d8c0f187bb84ba6ef191aba19755
7
- data.tar.gz: a23ce1270f3ac088b02774a43178fb277811e7df65a7d518f3fde4b8899cfb2db3f07164c72ea75ec26bb3b8ed9ef29b94d05aa53009ef1f82dd9d1bb4d14f79
6
+ metadata.gz: 4074fd91514f5f209cbf6d5f4b2fdd36eed20ccfd894e0ccaac330e25bf7e0cda0c2c689833f2fcf947df768cf0282e88d717f1a001797e901ee0ebb18ab6e1a
7
+ data.tar.gz: f16291785a67d4e9cec7e48fe407a3c4735e21711752feea5ff2ad88573ef8010732feb8eaa9a1f1c40bfb14c14f476dd5f242fb9e624ba68f1a4ec18ca077ba
data/README.md CHANGED
@@ -29,6 +29,13 @@ bundle install
29
29
  bin/rails generate full_search:install
30
30
  ```
31
31
 
32
+ This creates the initializer and automatically runs `full_search:prepare` to create FTS tables.
33
+ Use `--skip-prepare` if your database isn't ready yet:
34
+
35
+ ```bash
36
+ bin/rails generate full_search:install --skip-prepare
37
+ ```
38
+
32
39
  ## Usage
33
40
 
34
41
  ```ruby
@@ -96,6 +103,34 @@ end
96
103
 
97
104
  This is enforced at the database level — the FTS virtual table only contains rows matching the condition, and triggers skip inserts/updates for non-matching records.
98
105
 
106
+ ### Exact match
107
+
108
+ For exact-match lookups on encrypted identifiers or fields that need precise matching outside the FTS tokenizer, use `exact_match`:
109
+
110
+ ```ruby
111
+ class Customer < ApplicationRecord
112
+ full_search do
113
+ field :first_name, weight: 5
114
+ filter :account_id, required: true
115
+
116
+ exact_match :customer_number
117
+ end
118
+ end
119
+ ```
120
+
121
+ ```ruby
122
+ Customer.search("CUST-001", filters: { account_id: 1 })
123
+ # same query but only returns exact matches on customer_number
124
+ ```
125
+
126
+ For case- or punctuation-normalized SQL exact matches, pass a `normalize` lambda that transforms the query the same way the SQL expression transforms the column:
127
+
128
+ ```ruby
129
+ exact_match :license_plate,
130
+ sql: "UPPER(REPLACE(REPLACE(license_plate, ' ', ''), '-', ''))",
131
+ normalize: ->(q) { q.to_s.upcase.gsub(/[ -]/, "") }
132
+ ```
133
+
99
134
  ### Per-model operations
100
135
 
101
136
  Once a model declares `full_search`, you can call these class methods:
@@ -125,6 +160,22 @@ These two operations are often confused:
125
160
  > within the same process/connection. For multi-process or multi-host deployments,
126
161
  > run `full_search:rebuild` from a single deployment step.
127
162
 
163
+ ### First-deploy setup
164
+
165
+ After `db:prepare` creates your application tables, FTS virtual tables like `customers_fts` do not yet exist. Run `full_search:prepare` to create them:
166
+
167
+ ```bash
168
+ bin/rails full_search:prepare
169
+ ```
170
+
171
+ For Docker-based deployments, add this to your entrypoint script after `db:prepare:with_data`:
172
+
173
+ ```bash
174
+ ./bin/rails full_search:prepare
175
+ ```
176
+
177
+ The `full_search:prepare` task is idempotent — it only creates missing tables and installs triggers. It will not drop or rebuild existing indexes, making it safe to run on every deploy.
178
+
128
179
  ### Auto-rebuild on app load
129
180
 
130
181
  When `auto_rebuild_schema` is enabled (default in the generated initializer), the railtie hooks into Rails `after_initialize` and:
@@ -162,6 +213,7 @@ bin/rails full_search:reset
162
213
 
163
214
  | Task | Description |
164
215
  |------|-------------|
216
+ | `full_search:prepare` | Idempotent setup — creates missing FTS tables and triggers without dropping existing ones. Safe to run on every deploy. Add to your Docker entrypoint after `db:prepare:with_data` for first-deploy automation. |
165
217
  | `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. |
166
218
  | `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. |
167
219
  | `full_search:optimize` | Run FTS5 [`optimize`](https://www.sqlite.org/fts5.html#the_optimize_command) to merge b-tree segments. Useful after bulk updates. |
@@ -223,6 +275,17 @@ end
223
275
 
224
276
  When this option is `true`, the first search that detects a stale index automatically calls `FullSearch::Index.rebuild_if_needed!` and then proceeds with the query, so you don't need to restart the server or run a Rake task during iterative development. In production, this defaults to `false`; use `full_search:rebuild` or boot-time `auto_rebuild_schema` instead.
225
277
 
278
+ ### Missing table error
279
+
280
+ When the FTS table does not exist and a search is attempted, `full_search` raises `MissingTableError` with a clear message:
281
+
282
+ ```
283
+ FullSearch::MissingTableError: FTS table `customers_fts` does not exist.
284
+ Run `bin/rails full_search:prepare` to create it.
285
+ ```
286
+
287
+ This mirrors how Rails handles missing database tables — you see the error, run the task, and move on. Add `full_search:prepare` to your deploy pipeline or Docker entrypoint after `db:prepare:with_data` to prevent the error in production.
288
+
226
289
  ## Query operators
227
290
 
228
291
  ```ruby
@@ -260,6 +323,44 @@ end
260
323
 
261
324
  `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.
262
325
 
326
+ You can disable the expensive `LIKE 'term%'` fallback for very short queries:
327
+
328
+ ```ruby
329
+ full_search do
330
+ typo_tolerance
331
+ min_like_prefix_length 3
332
+ end
333
+ ```
334
+
335
+ ## Multi-search
336
+
337
+ Search across multiple models in a single call with `FullSearch.multi_search`:
338
+
339
+ ```ruby
340
+ results = FullSearch.multi_search(
341
+ query: "Honda",
342
+ groups: [
343
+ {key: :customers, label: "Customers", model: Customer, filters: {account_id: 1}},
344
+ {key: :vehicles, label: "Vehicles", model: Vehicle, filters: {account_id: 1}}
345
+ ]
346
+ )
347
+
348
+ results[:customers] #=> [#<Customer...>, ...]
349
+ results[:vehicles] #=> [#<Vehicle...>, ...]
350
+ ```
351
+
352
+ Pass `includes:` to eager-load associations for each group:
353
+
354
+ ```ruby
355
+ FullSearch.multi_search(
356
+ query: "Honda",
357
+ groups: [
358
+ {key: :vehicles, label: "Vehicles", model: Vehicle,
359
+ filters: {account_id: 1}, includes: [:customer]}
360
+ ]
361
+ )
362
+ ```
363
+
263
364
  ## Known limitations
264
365
 
265
366
  - 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,7 @@ 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
7
+ :default_tokenizer, :auto_rebuild_on_stale_query, :min_like_prefix_length
8
8
 
9
9
  def initialize
10
10
  @auto_rebuild_schema = false
@@ -14,6 +14,7 @@ module FullSearch
14
14
  @default_async_source_reindex = true
15
15
  @default_tokenizer = "unicode61"
16
16
  @auto_rebuild_on_stale_query = false
17
+ @min_like_prefix_length = 3
17
18
  end
18
19
  end
19
20
 
@@ -5,7 +5,7 @@ module FullSearch
5
5
  attr_reader :fields, :exact_matches, :filters, :model_class, :highlight_config, :rank_bys, :index_if_sql
6
6
 
7
7
  Field = Data.define(:name, :weight, :source, :reindex_on, :async, :async_source, :as, :version)
8
- ExactMatch = Data.define(:name, :source, :sql, :version)
8
+ ExactMatch = Data.define(:name, :source, :sql, :version, :normalize)
9
9
  Filter = Data.define(:name, :required)
10
10
  RankBy = Data.define(:column, :direction)
11
11
 
@@ -39,13 +39,13 @@ module FullSearch
39
39
  )
40
40
  end
41
41
 
42
- def exact_match(name, source: -> { public_send(name) }, sql: nil, version: nil)
42
+ def exact_match(name, source: -> { public_send(name) }, sql: nil, normalize: nil, version: nil)
43
43
  unless valid_name?(name)
44
44
  raise InvalidFieldError, "#{model_class.name}: invalid exact_match name #{name.inspect}"
45
45
  end
46
46
  str = name.to_s
47
47
  raise InvalidFieldError, "#{model_class.name}: duplicate exact_match name #{name.inspect}" if exact_matches.any? { |e| e.name == str }
48
- @exact_matches << ExactMatch.new(name: str, source: source, sql: sql&.to_s, version: version)
48
+ @exact_matches << ExactMatch.new(name: str, source: source, sql: sql&.to_s, normalize: normalize, version: version)
49
49
  end
50
50
 
51
51
  def rank_by(column, direction = :desc)
@@ -99,6 +99,14 @@ module FullSearch
99
99
  @index_if_sql.present?
100
100
  end
101
101
 
102
+ def min_like_prefix_length(value = :_no_arg_)
103
+ if value == :_no_arg_
104
+ @min_like_prefix_length ||= FullSearch.config.min_like_prefix_length
105
+ else
106
+ @min_like_prefix_length = value.to_i
107
+ end
108
+ end
109
+
102
110
  def typo_tolerance(enabled = true, min_term_length: nil)
103
111
  @typo_tolerance = enabled
104
112
  @typo_tolerance_min_term_length = min_term_length || 3
@@ -124,7 +132,8 @@ module FullSearch
124
132
  fields.map { |f| [f.name, f.weight, f.source.nil? ? "column" : "proc:#{f.version}", f.reindex_on, f.async, f.async_source, f.as] },
125
133
  exact_matches.map { |e| [e.name, "proc:#{e.version}", e.sql] },
126
134
  filters.map { |f| [f.name, f.required] },
127
- rank_bys.map { |r| [r.column, r.direction] }
135
+ rank_bys.map { |r| [r.column, r.direction] },
136
+ min_like_prefix_length
128
137
  ].inspect)
129
138
  end
130
139
 
@@ -8,5 +8,6 @@ module FullSearch
8
8
  class InvalidFieldError < Error; end
9
9
  class NotConfiguredError < Error; end
10
10
  class UnsupportedDatabaseError < Error; end
11
+ class MissingTableError < Error; end
11
12
  class InvalidQueryError < Error; end
12
13
  end
@@ -19,7 +19,8 @@ module FullSearch
19
19
  filters.each { |name, value| base = base.where(name => value) }
20
20
 
21
21
  conditions = sql_matches.map do |em|
22
- "(#{em.sql}) = #{model.connection.quote(cleaned)}"
22
+ value = em.normalize ? em.normalize.call(cleaned) : cleaned
23
+ "(#{em.sql}) = #{model.connection.quote(value)}"
23
24
  end.join(" OR ")
24
25
 
25
26
  ids += base.where(conditions).pluck(:id)
@@ -55,6 +55,7 @@ module FullSearch
55
55
  end
56
56
 
57
57
  def rebuild!(model)
58
+ IndexCache.clear!
58
59
  sqlite!(model)
59
60
 
60
61
  dsl = model.full_search_dsl
@@ -83,6 +84,7 @@ module FullSearch
83
84
  end
84
85
 
85
86
  def rebuild_if_needed!(model)
87
+ IndexCache.clear!
86
88
  sqlite!(model)
87
89
 
88
90
  dsl = model.full_search_dsl
@@ -137,6 +139,7 @@ module FullSearch
137
139
  end
138
140
 
139
141
  def drop!(model)
142
+ IndexCache.clear!
140
143
  verified_tables.delete(model.table_name)
141
144
  sqlite!(model)
142
145
  drop_triggers!(model)
@@ -168,10 +171,16 @@ module FullSearch
168
171
  end
169
172
 
170
173
  def stored_config_hash(model)
171
- row = connection.execute(
172
- "SELECT config_hash FROM full_search_index_versions WHERE table_name=#{q(model.table_name)}"
173
- ).first
174
- row&.[]("config_hash")
174
+ IndexCache.fetch("stored_config_hash:#{model.table_name}") do
175
+ row = connection.execute(
176
+ "SELECT config_hash FROM full_search_index_versions WHERE table_name=#{q(model.table_name)}"
177
+ ).first
178
+ row&.[]("config_hash")
179
+ end
180
+ end
181
+
182
+ def missing_table?(model)
183
+ !table_exists?(model)
175
184
  end
176
185
 
177
186
  private
@@ -203,9 +212,11 @@ module FullSearch
203
212
  end
204
213
 
205
214
  def table_exists?(model)
206
- connection.execute(
207
- "SELECT name FROM sqlite_master WHERE type='table' AND name=#{q(fts_table_name(model))} LIMIT 1"
208
- ).any?
215
+ IndexCache.fetch("table_exists:#{model.table_name}") do
216
+ connection.execute(
217
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=#{q(fts_table_name(model))} LIMIT 1"
218
+ ).any?
219
+ end
209
220
  end
210
221
 
211
222
  def create_metadata_table!
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullSearch
4
+ class IndexCache
5
+ def self.with_cache
6
+ already_cached = !Thread.current[:full_search_index_cache].nil?
7
+ Thread.current[:full_search_index_cache] ||= {}
8
+ yield
9
+ ensure
10
+ Thread.current[:full_search_index_cache] = nil unless already_cached
11
+ end
12
+
13
+ def self.clear!
14
+ Thread.current[:full_search_index_cache] = nil
15
+ end
16
+
17
+ def self.fetch(key)
18
+ cache = Thread.current[:full_search_index_cache]
19
+ return yield unless cache
20
+ return cache[key] if cache.key?(key)
21
+ cache[key] = yield
22
+ end
23
+ end
24
+ end
@@ -5,7 +5,7 @@ module FullSearch
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  class_methods do
8
- def full_search(query_or_options = nil, filters: {}, include_soft_deleted: false, limit: nil, offset: nil, highlight: false, highlight_fields: false, matching_strategy: nil, per_strategy_limit: nil, &block)
8
+ def full_search(query_or_options = nil, filters: {}, include_soft_deleted: false, limit: nil, offset: nil, highlight: false, highlight_fields: false, matching_strategy: nil, per_strategy_limit: nil, scope: nil, includes: nil, &block)
9
9
  if block_given? || query_or_options.is_a?(Hash)
10
10
  @full_search_dsl ||= FullSearch::Dsl.new(self)
11
11
  @full_search_dsl.tokenize(query_or_options[:tokenize]) if query_or_options.is_a?(Hash) && query_or_options.key?(:tokenize)
@@ -16,7 +16,7 @@ module FullSearch
16
16
  FullSearch.register_model(self)
17
17
  @full_search_dsl
18
18
  else
19
- FullSearch::Search.new(self, query_or_options, filters: filters, include_soft_deleted: include_soft_deleted, limit: limit, offset: offset, highlight: highlight, highlight_fields: highlight_fields, matching_strategy: matching_strategy, per_strategy_limit: per_strategy_limit).relation
19
+ FullSearch::Search.new(self, query_or_options, filters: filters, include_soft_deleted: include_soft_deleted, limit: limit, offset: offset, highlight: highlight, highlight_fields: highlight_fields, matching_strategy: matching_strategy, per_strategy_limit: per_strategy_limit, scope: scope, includes: includes).relation
20
20
  end
21
21
  end
22
22
 
@@ -29,11 +29,11 @@ module FullSearch
29
29
  highlight: group[:highlight],
30
30
  highlight_fields: group[:highlight_fields],
31
31
  matching_strategy: group[:matching_strategy],
32
- per_strategy_limit: group[:per_strategy_limit]
32
+ per_strategy_limit: group[:per_strategy_limit],
33
+ scope: group[:scope],
34
+ includes: group[:includes]
33
35
  )
34
36
 
35
- relation = group[:scope].call(relation) if group[:scope]
36
-
37
37
  records = relation.to_a
38
38
  has_more = records.size > limit
39
39
  records = records.first(limit) if has_more
@@ -2,9 +2,9 @@
2
2
 
3
3
  module FullSearch
4
4
  class Search
5
- attr_reader :model, :query, :filters, :include_soft_deleted, :limit, :offset, :highlight, :highlight_fields, :matching_strategy, :per_strategy_limit
5
+ attr_reader :model, :query, :filters, :include_soft_deleted, :limit, :offset, :highlight, :highlight_fields, :matching_strategy, :per_strategy_limit, :scope, :includes
6
6
 
7
- def initialize(model, query, filters:, include_soft_deleted:, limit:, offset:, highlight: false, highlight_fields: false, matching_strategy: nil, per_strategy_limit: nil)
7
+ def initialize(model, query, filters:, include_soft_deleted:, limit:, offset:, highlight: false, highlight_fields: false, matching_strategy: nil, per_strategy_limit: nil, scope: nil, includes: nil)
8
8
  @model = model
9
9
  @query = query.to_s.strip
10
10
  @filters = filters
@@ -15,6 +15,8 @@ module FullSearch
15
15
  @highlight_fields = highlight_fields
16
16
  @matching_strategy = matching_strategy
17
17
  @per_strategy_limit = per_strategy_limit
18
+ @scope = scope
19
+ @includes = includes
18
20
  end
19
21
 
20
22
  MIN_TERM_LENGTH = 3
@@ -37,10 +39,11 @@ module FullSearch
37
39
 
38
40
  rel = model.where(id: all_ids)
39
41
  rel = rel.where(model.arel_table[dsl.soft_delete_column].eq(nil)) if dsl.soft_delete_column && !include_soft_deleted
42
+ rel = apply_ranking(rel, all_ids, exact_ids)
43
+ rel = scope.call(rel) if scope
40
44
  rel = rel.limit(limit) if limit
41
45
  rel = rel.offset(offset) if offset
42
-
43
- rel = apply_ranking(rel, all_ids, exact_ids)
46
+ rel = rel.includes(includes) if includes
44
47
 
45
48
  if highlight
46
49
  records = rel.to_a
@@ -78,7 +81,15 @@ module FullSearch
78
81
  end
79
82
 
80
83
  def check_stale_config!
81
- stored = FullSearch::Index.stored_config_hash(model)
84
+ if FullSearch::Index.missing_table?(model)
85
+ raise MissingTableError, "FTS table `#{FullSearch::Index.fts_table_name(model)}` does not exist. Run `bin/rails full_search:prepare` to create it."
86
+ end
87
+
88
+ stored = begin
89
+ FullSearch::Index.stored_config_hash(model)
90
+ rescue
91
+ nil
92
+ end
82
93
  return unless stored
83
94
 
84
95
  return if stored == dsl.config_hash
@@ -129,6 +140,7 @@ module FullSearch
129
140
  return [] if term.nil?
130
141
 
131
142
  if term.length < dsl.typo_tolerance_min_term_length.to_i
143
+ return [] if term.length < dsl.min_like_prefix_length
132
144
  return like_prefix_ids(term, candidate_limit: candidate_limit)
133
145
  end
134
146
 
@@ -154,6 +166,8 @@ module FullSearch
154
166
  end
155
167
 
156
168
  def like_prefix_ids(term, candidate_limit: nil)
169
+ return [] if term.to_s.length < dsl.min_like_prefix_length
170
+
157
171
  column_fields = dsl.fields.select { |f| f.source.nil? }
158
172
  source_fields = dsl.fields.select { |f| f.source }
159
173
  tbl = qt(model.table_name)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FullSearch
4
- VERSION = "0.3.5"
4
+ VERSION = "0.3.7"
5
5
  end
data/lib/full_search.rb CHANGED
@@ -4,6 +4,7 @@ require "active_record"
4
4
  require "active_support"
5
5
  require "active_support/concern"
6
6
 
7
+ require "full_search/index_cache"
7
8
  require "full_search/version"
8
9
  require "full_search/config"
9
10
  require "full_search/distance"
@@ -7,9 +7,22 @@ module FullSearch
7
7
  class InstallGenerator < Rails::Generators::Base
8
8
  source_root File.expand_path("templates", __dir__)
9
9
 
10
+ class_option :skip_prepare, type: :boolean, default: false,
11
+ desc: "Skip automatic full_search:prepare"
12
+
10
13
  def create_initializer
11
14
  template "full_search.rb", "config/initializers/full_search.rb"
12
15
  end
16
+
17
+ def prepare_indexes
18
+ return if options[:skip_prepare]
19
+
20
+ say "Running full_search:prepare to create FTS tables..."
21
+ rake("full_search:prepare")
22
+ rescue => e
23
+ say "Skipping full_search:prepare — #{e.message}", :yellow
24
+ say "Run `bin/rails full_search:prepare` after your database is ready.", :yellow
25
+ end
13
26
  end
14
27
  end
15
28
  end
@@ -17,6 +17,29 @@ def resolve_full_search_models(args)
17
17
  end
18
18
 
19
19
  namespace :full_search do
20
+ desc "Create full_search FTS tables and triggers if missing (idempotent, safe for first deploy)"
21
+ task :prepare, [:models] => :environment do |_t, args|
22
+ Rails.application.eager_load!
23
+
24
+ models = if args[:models]
25
+ args[:models].split(",").map do |name|
26
+ klass = begin
27
+ name.singularize.camelize.constantize
28
+ rescue NameError, LoadError
29
+ FullSearch.models.find { |m| m.table_name == name }
30
+ end
31
+ klass
32
+ end.compact
33
+ else
34
+ FullSearch.models
35
+ end
36
+
37
+ FullSearch.setup!
38
+ models.each do |model|
39
+ puts "Prepared #{FullSearch::Index.fts_table_name(model)}"
40
+ end
41
+ end
42
+
20
43
  desc "Rebuild indexes when DSL has changed (checks config hash; safe for production)"
21
44
  task :rebuild, [:models] => :environment do |_t, args|
22
45
  Rails.application.eager_load!
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.5
4
+ version: 0.3.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben D'Angelo
@@ -150,6 +150,7 @@ files:
150
150
  - lib/full_search/exact_match.rb
151
151
  - lib/full_search/highlighter.rb
152
152
  - lib/full_search/index.rb
153
+ - lib/full_search/index_cache.rb
153
154
  - lib/full_search/model.rb
154
155
  - lib/full_search/multi_search.rb
155
156
  - lib/full_search/optimize_job.rb