full_search 0.3.6 → 0.3.8

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: 01b6db287587e57e3bc4f7226eff10257303eadf30c6a6a64e53be1e2e52afb5
4
- data.tar.gz: 01ec5e01cf2d479245338c5a7ab23f778a9f953c78c7a0a1ab4f428a4472c98c
3
+ metadata.gz: a86070d05b32fc24ce6ac007a33c673c3831760ce76554bbb9d636e6e0c6be0f
4
+ data.tar.gz: 293b0be9931c7ffa5de9971aa52b2bf9014c2b0816d1364f839ccba57af8f814
5
5
  SHA512:
6
- metadata.gz: 6a3bd6143a47be3e7abd89df28506f247ee562205372ee9b5bedf6064d463413e212b7bca67dffb3e980531963e55984750adfed5504cf70b7e0dc7fa250185b
7
- data.tar.gz: 3b793c3bfd872cf1a247a18c030ade28433d16d40bc71438d35dc79be2cd306d15ec79f0afc371c0866ef9feeca8d3292ed9f8af35c9bb6c572b8073be88a12b
6
+ metadata.gz: cd9854cc22d14bd739723e5011a831fd34d0e523ef6a6df09bf45fa1f3a7880c466f4ba121eb07d9d92daf65449edf74b5e0ddc42dc15b521ea34e78b496be3a
7
+ data.tar.gz: f704e822cee03ad98f2ed96dbc802e3e5b777df659ecdc3d8187ace6088494ab318b6f1598c74b840697909b465e539a76ee9f758d6a709041d9e0b198d45129
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:
@@ -158,6 +193,20 @@ end
158
193
 
159
194
  ### In production
160
195
 
196
+ **`auto_rebuild_schema`** — Must be `false` in production. The generated initializer defaults to `Rails.env.local?`, so it's already off. If enabled, every process (web, worker, console) attempts schema rebuilds on boot, which is unnecessary and can cause issues during zero-downtime deploys where old and new processes overlap.
197
+
198
+ **`auto_rebuild_on_stale_query`** — Must be `false` in production. A query-time rebuild is dangerous — it can cause timeouts or race conditions under load. The generated initializer defaults to `Rails.env.local?`, keeping it off in production.
199
+
200
+ **`lock_rebuilds`** — This option uses a Ruby `Mutex` and only prevents concurrent rebuilds within the same process. It does **not** coordinate across processes or hosts. Multi-process or multi-host deployments must run `full_search:rebuild` from a single deployment step.
201
+
202
+ **Docker entrypoint** — For containerized environments, add `full_search:prepare` after `db:prepare:with_data`:
203
+
204
+ ```bash
205
+ ./bin/rails full_search:prepare
206
+ ```
207
+
208
+ `full_search:prepare` is idempotent — it only creates missing FTS tables and installs triggers, making it safe to run on every deploy.
209
+
161
210
  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:
162
211
 
163
212
  ```bash
@@ -288,6 +337,44 @@ end
288
337
 
289
338
  `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.
290
339
 
340
+ You can disable the expensive `LIKE 'term%'` fallback for very short queries:
341
+
342
+ ```ruby
343
+ full_search do
344
+ typo_tolerance
345
+ min_like_prefix_length 3
346
+ end
347
+ ```
348
+
349
+ ## Multi-search
350
+
351
+ Search across multiple models in a single call with `FullSearch.multi_search`:
352
+
353
+ ```ruby
354
+ results = FullSearch.multi_search(
355
+ query: "Honda",
356
+ groups: [
357
+ {key: :customers, label: "Customers", model: Customer, filters: {account_id: 1}},
358
+ {key: :vehicles, label: "Vehicles", model: Vehicle, filters: {account_id: 1}}
359
+ ]
360
+ )
361
+
362
+ results[:customers] #=> [#<Customer...>, ...]
363
+ results[:vehicles] #=> [#<Vehicle...>, ...]
364
+ ```
365
+
366
+ Pass `includes:` to eager-load associations for each group:
367
+
368
+ ```ruby
369
+ FullSearch.multi_search(
370
+ query: "Honda",
371
+ groups: [
372
+ {key: :vehicles, label: "Vehicles", model: Vehicle,
373
+ filters: {account_id: 1}, includes: [:customer]}
374
+ ]
375
+ )
376
+ ```
377
+
291
378
  ## Known limitations
292
379
 
293
380
  - 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.
@@ -8,6 +8,7 @@ module FullSearch
8
8
 
9
9
  def perform(model_name)
10
10
  model = model_name.to_s.constantize
11
+ FullSearch::Index.sqlite!(model)
11
12
  FullSearch::Index.rebuild!(model)
12
13
  end
13
14
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "full_search/errors"
4
+
3
5
  module FullSearch
4
6
  module Callbacks
5
7
  def self.install!(model)
@@ -64,34 +66,35 @@ module FullSearch
64
66
 
65
67
  value = FullSearch::Model.evaluate_source(record, field)
66
68
  table = qt(FullSearch::Index.fts_table_name(record.class))
67
- conn = ActiveRecord::Base.connection
69
+ conn = connection_for(record.class)
68
70
  conn.execute(
69
71
  "UPDATE #{table} SET #{qc(field.as || field.name)} = #{q(value.to_s)} WHERE rowid = #{q(record.id)}"
70
72
  )
71
- rescue => e
72
- raise unless e.message.include?("no such table")
73
+ rescue ActiveRecord::StatementInvalid => e
74
+ raise_missing_table_or_original(e, record.class)
73
75
  end
74
76
 
75
77
  def self.remove_record!(record)
76
78
  table = qt(FullSearch::Index.fts_table_name(record.class))
77
- conn = ActiveRecord::Base.connection
79
+ conn = connection_for(record.class)
78
80
  conn.execute("DELETE FROM #{table} WHERE rowid = #{q(record.id)}")
79
- rescue => e
80
- raise unless e.message.include?("no such table")
81
+ rescue ActiveRecord::StatementInvalid => e
82
+ raise_missing_table_or_original(e, record.class)
81
83
  end
82
84
 
83
85
  def self.reindex_dependents!(parent_record, dependent_model, field)
84
86
  fk = association_key(dependent_model, field.reindex_on)
85
- conn = ActiveRecord::Base.connection
87
+ conn = connection_for(dependent_model)
86
88
  sql = "SELECT id FROM #{qt(dependent_model.table_name)} WHERE #{qc(fk)} = #{q(parent_record.id)}"
87
89
  dependent_ids = conn.execute(sql).map { |r| r["id"] }
88
90
 
89
- dependent_ids.each do |dep_id|
90
- if field.async
91
- FullSearch::ReindexJob.perform_later(dependent_model.name, dep_id, field.name)
92
- else
93
- dependent = dependent_model.find_by(id: dep_id)
94
- reindex_field!(dependent, field.name) if dependent
91
+ return if dependent_ids.empty?
92
+
93
+ if field.async
94
+ dependent_ids.each { |id| FullSearch::ReindexJob.perform_later(dependent_model.name, id, field.name) }
95
+ else
96
+ dependent_model.where(id: dependent_ids).find_each(batch_size: 500) do |record|
97
+ reindex_field!(record, field.name)
95
98
  end
96
99
  end
97
100
  end
@@ -113,5 +116,20 @@ module FullSearch
113
116
  reflection = model.reflect_on_association(association_name.to_sym)
114
117
  reflection&.foreign_key&.to_s || "#{association_name}_id"
115
118
  end
119
+
120
+ def self.connection_for(klass)
121
+ klass.connection
122
+ rescue NoMethodError
123
+ ActiveRecord::Base.connection
124
+ end
125
+
126
+ def self.raise_missing_table_or_original(error, model_class)
127
+ if error.message.include?("no such table")
128
+ FullSearch::Instrumentation.instrument("missing_table", model: model_class.name, error: error.message)
129
+ return
130
+ end
131
+ raise error
132
+ end
133
+ private_class_method :raise_missing_table_or_original
116
134
  end
117
135
  end
@@ -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
@@ -12,8 +12,9 @@ module FullSearch
12
12
  @lock_rebuilds = true
13
13
  @default_async_reindex = true
14
14
  @default_async_source_reindex = true
15
- @default_tokenizer = "unicode61"
15
+ @default_tokenizer = FullSearch::Constants::DEFAULT_TOKENIZER
16
16
  @auto_rebuild_on_stale_query = false
17
+ @min_like_prefix_length = FullSearch::Constants::DEFAULT_MIN_LIKE_PREFIX_LENGTH
17
18
  end
18
19
  end
19
20
 
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullSearch
4
+ module Constants
5
+ MIN_TERM_LENGTH = 3
6
+ TWO_TYPO_MIN_LENGTH = 9
7
+ DEFAULT_MIN_LIKE_PREFIX_LENGTH = 3
8
+ REBUILDING_HASH = "__rebuilding__"
9
+ DEFAULT_TOKENIZER = "unicode61"
10
+ MAX_EXACT_MATCH_BOOST_IDS = 100
11
+ end
12
+ end
@@ -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,15 @@ 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
+ sql_str = sql&.to_s
49
+ validate_sql_fragment!(sql_str, "exact_match sql:") if sql_str
50
+ @exact_matches << ExactMatch.new(name: str, source: source, sql: sql_str, normalize: normalize, version: version)
49
51
  end
50
52
 
51
53
  def rank_by(column, direction = :desc)
@@ -92,16 +94,26 @@ module FullSearch
92
94
  end
93
95
 
94
96
  def index_if(sql: nil)
95
- @index_if_sql = sql&.to_s
97
+ sql_str = sql&.to_s
98
+ validate_sql_fragment!(sql_str, "index_if sql:") if sql_str
99
+ @index_if_sql = sql_str
96
100
  end
97
101
 
98
102
  def conditional_index?
99
103
  @index_if_sql.present?
100
104
  end
101
105
 
106
+ def min_like_prefix_length(value = :_no_arg_)
107
+ if value == :_no_arg_
108
+ @min_like_prefix_length ||= FullSearch.config.min_like_prefix_length
109
+ else
110
+ @min_like_prefix_length = value.to_i
111
+ end
112
+ end
113
+
102
114
  def typo_tolerance(enabled = true, min_term_length: nil)
103
115
  @typo_tolerance = enabled
104
- @typo_tolerance_min_term_length = min_term_length || 3
116
+ @typo_tolerance_min_term_length = min_term_length || FullSearch::Constants::MIN_TERM_LENGTH
105
117
  end
106
118
 
107
119
  def typo_tolerance?
@@ -109,7 +121,7 @@ module FullSearch
109
121
  end
110
122
 
111
123
  def typo_tolerance_min_term_length
112
- @typo_tolerance_min_term_length || 3
124
+ @typo_tolerance_min_term_length || FullSearch::Constants::MIN_TERM_LENGTH
113
125
  end
114
126
 
115
127
  def config_hash
@@ -124,12 +136,19 @@ module FullSearch
124
136
  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
137
  exact_matches.map { |e| [e.name, "proc:#{e.version}", e.sql] },
126
138
  filters.map { |f| [f.name, f.required] },
127
- rank_bys.map { |r| [r.column, r.direction] }
139
+ rank_bys.map { |r| [r.column, r.direction] },
140
+ min_like_prefix_length
128
141
  ].inspect)
129
142
  end
130
143
 
131
144
  private
132
145
 
146
+ def validate_sql_fragment!(sql, option_name)
147
+ if sql.match?(/;|--|\/\*|\*\//)
148
+ raise InvalidFieldError, "#{model_class.name}: #{option_name} contains unsafe characters"
149
+ end
150
+ end
151
+
133
152
  def valid_name?(name)
134
153
  name.to_s.match?(/\A[a-zA-Z_]\w*\z/)
135
154
  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)
@@ -59,7 +59,7 @@ module FullSearch
59
59
  length = query.length
60
60
  min_length = dsl.typo_tolerance_min_term_length.to_i
61
61
  return nil if length < min_length
62
- return 2 if length >= 9
62
+ return 2 if length >= FullSearch::Constants::TWO_TYPO_MIN_LENGTH
63
63
  1
64
64
  end
65
65
 
@@ -170,8 +170,8 @@ module FullSearch
170
170
  end
171
171
 
172
172
  def max_allowed_typos(length)
173
- return -1 if length < 3
174
- return 2 if length >= 9
173
+ return -1 if length < FullSearch::Constants::MIN_TERM_LENGTH
174
+ return 2 if length >= FullSearch::Constants::TWO_TYPO_MIN_LENGTH
175
175
  1
176
176
  end
177
177
 
@@ -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
@@ -64,25 +65,28 @@ module FullSearch
64
65
  create_metadata_table!
65
66
 
66
67
  with_rebuild_lock(model) do
67
- drop_triggers!(model)
68
- conn.execute("DROP TABLE IF EXISTS #{qt(fts_table_name(model))};")
69
- conn.execute("DROP TABLE IF EXISTS #{qt(trigram_table_name(model))};")
70
- conn.execute(create_virtual_table_sql(model))
71
- if dsl.typo_tolerance?
72
- FullSearch::Typo.warn_unsupported!(model) unless FullSearch::Typo.supported?(model)
73
- conn.execute(create_trigram_virtual_table_sql(model))
68
+ FullSearch::Instrumentation.instrument("rebuild", model: model.name) do
69
+ drop_triggers!(model)
70
+ conn.execute("DROP TABLE IF EXISTS #{qt(fts_table_name(model))};")
71
+ conn.execute("DROP TABLE IF EXISTS #{qt(trigram_table_name(model))};")
72
+ conn.execute(create_virtual_table_sql(model))
73
+ if dsl.typo_tolerance?
74
+ FullSearch::Typo.warn_unsupported!(model) unless FullSearch::Typo.supported?(model)
75
+ conn.execute(create_trigram_virtual_table_sql(model))
76
+ end
77
+ conn.execute(backfill_sql(model))
78
+ conn.execute(backfill_trigram_sql(model)) if dsl.typo_tolerance?
79
+ reindex_source_fields!(model) if dsl.fields.any?(&:source)
80
+ create_triggers!(model)
81
+ optimize!(model)
82
+ store_config_hash!(model, rebuilt_at: Time.current)
83
+ verified_tables.add(model.table_name)
74
84
  end
75
- conn.execute(backfill_sql(model))
76
- conn.execute(backfill_trigram_sql(model)) if dsl.typo_tolerance?
77
- reindex_source_fields!(model) if dsl.fields.any?(&:source)
78
- create_triggers!(model)
79
- optimize!(model)
80
- store_config_hash!(model, rebuilt_at: Time.current)
81
- verified_tables.add(model.table_name)
82
85
  end
83
86
  end
84
87
 
85
88
  def rebuild_if_needed!(model)
89
+ IndexCache.clear!
86
90
  sqlite!(model)
87
91
 
88
92
  dsl = model.full_search_dsl
@@ -101,21 +105,35 @@ module FullSearch
101
105
  end
102
106
 
103
107
  def optimize!(model)
104
- sqlite!(model)
105
- connection.execute("INSERT INTO #{qt(fts_table_name(model))}(#{qt(fts_table_name(model))}) VALUES('optimize');")
108
+ FullSearch::Instrumentation.instrument("optimize", model: model.name) do
109
+ sqlite!(model)
110
+ connection.execute("INSERT INTO #{qt(fts_table_name(model))}(#{qt(fts_table_name(model))}) VALUES('optimize');")
111
+ end
106
112
  end
107
113
 
108
114
  def reindex_source_fields!(model)
109
115
  dsl = model.full_search_dsl
110
116
  return unless dsl
111
117
 
112
- model.find_each do |record|
113
- dsl.fields.each do |field|
114
- FullSearch::Callbacks.reindex_field!(record, field.name) if field.source
115
- end
118
+ source_fields = dsl.fields.select(&:source)
119
+ return if source_fields.empty?
120
+
121
+ model.find_each(batch_size: 500) do |record|
122
+ pairs = source_fields.to_h { |f| [column_name(f), FullSearch::Model.evaluate_source(record, f).to_s] }
123
+ next if pairs.empty?
124
+ upsert_source_row!(model, record.id, pairs)
116
125
  end
117
126
  end
118
127
 
128
+ def upsert_source_row!(model, rowid, values)
129
+ table = qt(fts_table_name(model))
130
+ sets = values.map { |name, value| "#{qc(name)} = #{q(value)}" }.join(", ")
131
+
132
+ connection.execute(<<~SQL)
133
+ UPDATE #{table} SET #{sets} WHERE rowid = #{q(rowid)};
134
+ SQL
135
+ end
136
+
119
137
  def create_triggers!(model)
120
138
  connection.execute(insert_trigger_sql(model))
121
139
  connection.execute(delete_trigger_sql(model))
@@ -137,6 +155,7 @@ module FullSearch
137
155
  end
138
156
 
139
157
  def drop!(model)
158
+ IndexCache.clear!
140
159
  verified_tables.delete(model.table_name)
141
160
  sqlite!(model)
142
161
  drop_triggers!(model)
@@ -168,10 +187,12 @@ module FullSearch
168
187
  end
169
188
 
170
189
  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")
190
+ IndexCache.fetch("stored_config_hash:#{model.table_name}") do
191
+ row = connection.execute(
192
+ "SELECT config_hash FROM full_search_index_versions WHERE table_name=#{q(model.table_name)}"
193
+ ).first
194
+ row&.[]("config_hash")
195
+ end
175
196
  end
176
197
 
177
198
  def missing_table?(model)
@@ -207,9 +228,11 @@ module FullSearch
207
228
  end
208
229
 
209
230
  def table_exists?(model)
210
- connection.execute(
211
- "SELECT name FROM sqlite_master WHERE type='table' AND name=#{q(fts_table_name(model))} LIMIT 1"
212
- ).any?
231
+ IndexCache.fetch("table_exists:#{model.table_name}") do
232
+ connection.execute(
233
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=#{q(fts_table_name(model))} LIMIT 1"
234
+ ).any?
235
+ end
213
236
  end
214
237
 
215
238
  def create_metadata_table!
@@ -494,15 +517,13 @@ module FullSearch
494
517
  # For multi-process or multi-host deployments, run full_search:rebuild from a single
495
518
  # deployment step. See README.
496
519
  def with_rebuild_lock(model)
497
- if FullSearch.config.lock_rebuilds
498
- connection.transaction do
520
+ connection.transaction do
521
+ if FullSearch.config.lock_rebuilds
499
522
  connection.execute(
500
- "INSERT INTO full_search_index_versions (table_name, config_hash, rebuilt_at) VALUES (#{q(model.table_name)}, #{q("__rebuilding__")}, datetime('now'))
523
+ "INSERT INTO full_search_index_versions (table_name, config_hash, rebuilt_at) VALUES (#{q(model.table_name)}, #{q(FullSearch::Constants::REBUILDING_HASH)}, datetime('now'))
501
524
  ON CONFLICT(table_name) DO UPDATE SET config_hash=excluded.config_hash;"
502
525
  )
503
- yield
504
526
  end
505
- else
506
527
  yield
507
528
  end
508
529
  end
@@ -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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullSearch
4
+ module Instrumentation
5
+ def self.instrument(name, payload = {}, &block)
6
+ ActiveSupport::Notifications.instrument("full_search.#{name}", payload, &block)
7
+ end
8
+ end
9
+ 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,43 +15,48 @@ 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
- MIN_TERM_LENGTH = 3
22
+ MIN_TERM_LENGTH = FullSearch::Constants::MIN_TERM_LENGTH
21
23
 
22
24
  def relation
23
- validate_filter_keys!
24
- validate_required_filters!
25
- check_stale_config!
26
-
27
- return model.none if dsl.tokenize == "trigram" && query.length < MIN_TERM_LENGTH && !dsl.typo_tolerance?
28
-
29
- parsed = QueryParser.parse(query)
30
- exact_ids = ExactMatch.ids_for(model, query, filters)
31
- primary_ids = fts_match_ids(parsed)
32
- fallback_ids = (dsl.typo_tolerance? && matching_strategy != "all") ? trigram_match_ids(parsed, primary_ids, candidate_limit: per_strategy_limit) : []
33
- fuzzy_ids = (dsl.typo_tolerance? && matching_strategy != "all" && primary_ids.empty? && fallback_ids.empty?) ? fuzzy_match_ids(parsed, candidate_limit: per_strategy_limit) : []
34
-
35
- all_ids = (exact_ids + primary_ids + fallback_ids + fuzzy_ids).uniq
36
- return model.none if all_ids.empty?
37
-
38
- rel = model.where(id: all_ids)
39
- rel = rel.where(model.arel_table[dsl.soft_delete_column].eq(nil)) if dsl.soft_delete_column && !include_soft_deleted
40
- rel = rel.limit(limit) if limit
41
- rel = rel.offset(offset) if offset
42
-
43
- rel = apply_ranking(rel, all_ids, exact_ids)
44
-
45
- if highlight
46
- records = rel.to_a
47
- Highlighter.apply!(records, model, query, record_ids: records.map(&:id))
48
- records
49
- elsif highlight_fields
50
- records = rel.to_a
51
- Highlighter.apply_fields!(records, model, query, record_ids: records.map(&:id))
52
- records
53
- else
54
- rel
25
+ FullSearch::Instrumentation.instrument("query", model: model.name, query: query) do
26
+ validate_filter_keys!
27
+ validate_required_filters!
28
+ check_stale_config!
29
+
30
+ return model.none if dsl.tokenize == "trigram" && query.length < MIN_TERM_LENGTH && !dsl.typo_tolerance?
31
+
32
+ parsed = QueryParser.parse(query)
33
+ exact_ids = ExactMatch.ids_for(model, query, filters)
34
+ primary_ids = fts_match_ids(parsed)
35
+ fallback_ids = (dsl.typo_tolerance? && matching_strategy != "all") ? trigram_match_ids(parsed, primary_ids, candidate_limit: per_strategy_limit) : []
36
+ fuzzy_ids = (dsl.typo_tolerance? && matching_strategy != "all" && primary_ids.empty? && fallback_ids.empty?) ? fuzzy_match_ids(parsed, candidate_limit: per_strategy_limit) : []
37
+
38
+ all_ids = (exact_ids + primary_ids + fallback_ids + fuzzy_ids).uniq
39
+ return model.none if all_ids.empty?
40
+
41
+ rel = model.where(id: all_ids)
42
+ rel = rel.where(model.arel_table[dsl.soft_delete_column].eq(nil)) if dsl.soft_delete_column && !include_soft_deleted
43
+ rel = apply_ranking(rel, all_ids, exact_ids)
44
+ rel = scope.call(rel) if scope
45
+ rel = rel.limit(limit) if limit
46
+ rel = rel.offset(offset) if offset
47
+ rel = rel.includes(includes) if includes
48
+
49
+ if highlight
50
+ records = rel.to_a
51
+ Highlighter.apply!(records, model, query, record_ids: records.map(&:id))
52
+ records
53
+ elsif highlight_fields
54
+ records = rel.to_a
55
+ Highlighter.apply_fields!(records, model, query, record_ids: records.map(&:id))
56
+ records
57
+ else
58
+ rel
59
+ end
55
60
  end
56
61
  end
57
62
 
@@ -84,7 +89,7 @@ module FullSearch
84
89
 
85
90
  stored = begin
86
91
  FullSearch::Index.stored_config_hash(model)
87
- rescue StandardError
92
+ rescue
88
93
  nil
89
94
  end
90
95
  return unless stored
@@ -137,6 +142,7 @@ module FullSearch
137
142
  return [] if term.nil?
138
143
 
139
144
  if term.length < dsl.typo_tolerance_min_term_length.to_i
145
+ return [] if term.length < dsl.min_like_prefix_length
140
146
  return like_prefix_ids(term, candidate_limit: candidate_limit)
141
147
  end
142
148
 
@@ -162,6 +168,8 @@ module FullSearch
162
168
  end
163
169
 
164
170
  def like_prefix_ids(term, candidate_limit: nil)
171
+ return [] if term.to_s.length < dsl.min_like_prefix_length
172
+
165
173
  column_fields = dsl.fields.select { |f| f.source.nil? }
166
174
  source_fields = dsl.fields.select { |f| f.source }
167
175
  tbl = qt(model.table_name)
@@ -273,7 +281,7 @@ module FullSearch
273
281
  def max_allowed_typos(length)
274
282
  min_length = dsl.typo_tolerance_min_term_length.to_i
275
283
  return -1 if length < min_length
276
- return 2 if length >= 9
284
+ return 2 if length >= FullSearch::Constants::TWO_TYPO_MIN_LENGTH
277
285
  1
278
286
  end
279
287
 
@@ -299,7 +307,8 @@ module FullSearch
299
307
  tbl = qt(model.table_name)
300
308
 
301
309
  if exact_ids.any?
302
- order_parts << "CASE #{tbl}.id #{exact_ids.map { |id| "WHEN #{q(id)} THEN 0" }.join(" ")} ELSE 1 END"
310
+ boost_ids = exact_ids.first(FullSearch::Constants::MAX_EXACT_MATCH_BOOST_IDS)
311
+ order_parts << "CASE #{tbl}.id #{boost_ids.map { |id| "WHEN #{q(id)} THEN 0" }.join(" ")} ELSE 1 END"
303
312
  end
304
313
 
305
314
  fts_table = qt(FullSearch::Index.fts_table_name(model))
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FullSearch
4
- VERSION = "0.3.6"
4
+ VERSION = "0.3.8"
5
5
  end
data/lib/full_search.rb CHANGED
@@ -4,6 +4,9 @@ require "active_record"
4
4
  require "active_support"
5
5
  require "active_support/concern"
6
6
 
7
+ require "full_search/constants"
8
+ require "full_search/index_cache"
9
+ require "full_search/instrumentation"
7
10
  require "full_search/version"
8
11
  require "full_search/config"
9
12
  require "full_search/distance"
@@ -38,6 +41,10 @@ module FullSearch
38
41
  @models ||= Set.new
39
42
  end
40
43
 
44
+ def sorted_models
45
+ models.sort_by { |m| m.table_name.to_s }
46
+ end
47
+
41
48
  def register_model(model)
42
49
  models << model
43
50
  end
@@ -47,11 +54,11 @@ module FullSearch
47
54
  end
48
55
 
49
56
  def optimize!
50
- models.each { |model| Index.optimize!(model) }
57
+ sorted_models.each { |model| Index.optimize!(model) }
51
58
  end
52
59
 
53
60
  def setup!
54
- models.each do |model|
61
+ sorted_models.each do |model|
55
62
  Index.ensure_table!(model)
56
63
  Callbacks.install!(model)
57
64
  end
@@ -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
@@ -12,7 +12,7 @@ def resolve_full_search_models(args)
12
12
  klass || FullSearch.models.find { |m| m.table_name == name }
13
13
  end.compact
14
14
  else
15
- FullSearch.models
15
+ FullSearch.sorted_models
16
16
  end
17
17
  end
18
18
 
@@ -31,7 +31,7 @@ namespace :full_search do
31
31
  klass
32
32
  end.compact
33
33
  else
34
- FullSearch.models
34
+ FullSearch.sorted_models
35
35
  end
36
36
 
37
37
  FullSearch.setup!
@@ -77,7 +77,7 @@ namespace :full_search do
77
77
  task optimize: :environment do
78
78
  Rails.application.eager_load!
79
79
  FullSearch.optimize!
80
- FullSearch.models.each do |model|
80
+ FullSearch.sorted_models.each do |model|
81
81
  puts "Optimized #{FullSearch::Index.fts_table_name(model)}"
82
82
  end
83
83
  end
@@ -85,7 +85,7 @@ namespace :full_search do
85
85
  desc "Show full_search index status"
86
86
  task status: :environment do
87
87
  Rails.application.eager_load!
88
- FullSearch.models.each do |model|
88
+ FullSearch.sorted_models.each do |model|
89
89
  stored = FullSearch::Index.stored_config_hash(model)
90
90
  current = model.full_search_dsl.config_hash
91
91
  status = (stored == current) ? "ok" : "stale"
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.6
4
+ version: 0.3.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben D'Angelo
@@ -144,12 +144,15 @@ files:
144
144
  - lib/full_search/bulk_import.rb
145
145
  - lib/full_search/callbacks.rb
146
146
  - lib/full_search/config.rb
147
+ - lib/full_search/constants.rb
147
148
  - lib/full_search/distance.rb
148
149
  - lib/full_search/dsl.rb
149
150
  - lib/full_search/errors.rb
150
151
  - lib/full_search/exact_match.rb
151
152
  - lib/full_search/highlighter.rb
152
153
  - lib/full_search/index.rb
154
+ - lib/full_search/index_cache.rb
155
+ - lib/full_search/instrumentation.rb
153
156
  - lib/full_search/model.rb
154
157
  - lib/full_search/multi_search.rb
155
158
  - lib/full_search/optimize_job.rb