full_search 0.3.6 → 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: 01b6db287587e57e3bc4f7226eff10257303eadf30c6a6a64e53be1e2e52afb5
4
- data.tar.gz: 01ec5e01cf2d479245338c5a7ab23f778a9f953c78c7a0a1ab4f428a4472c98c
3
+ metadata.gz: d25d24145bf1aef1508210eb9d6879b21218ed8760ef6a61812706720a12ac17
4
+ data.tar.gz: 94b71b2b86db367ef8fde640550ab7b5945b50829d618c5e4eb41a4d9a6e0d65
5
5
  SHA512:
6
- metadata.gz: 6a3bd6143a47be3e7abd89df28506f247ee562205372ee9b5bedf6064d463413e212b7bca67dffb3e980531963e55984750adfed5504cf70b7e0dc7fa250185b
7
- data.tar.gz: 3b793c3bfd872cf1a247a18c030ade28433d16d40bc71438d35dc79be2cd306d15ec79f0afc371c0866ef9feeca8d3292ed9f8af35c9bb6c572b8073be88a12b
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:
@@ -288,6 +323,44 @@ end
288
323
 
289
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.
290
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
+
291
364
  ## Known limitations
292
365
 
293
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
 
@@ -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,12 @@ 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
175
180
  end
176
181
 
177
182
  def missing_table?(model)
@@ -207,9 +212,11 @@ module FullSearch
207
212
  end
208
213
 
209
214
  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?
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
213
220
  end
214
221
 
215
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
@@ -84,7 +87,7 @@ module FullSearch
84
87
 
85
88
  stored = begin
86
89
  FullSearch::Index.stored_config_hash(model)
87
- rescue StandardError
90
+ rescue
88
91
  nil
89
92
  end
90
93
  return unless stored
@@ -137,6 +140,7 @@ module FullSearch
137
140
  return [] if term.nil?
138
141
 
139
142
  if term.length < dsl.typo_tolerance_min_term_length.to_i
143
+ return [] if term.length < dsl.min_like_prefix_length
140
144
  return like_prefix_ids(term, candidate_limit: candidate_limit)
141
145
  end
142
146
 
@@ -162,6 +166,8 @@ module FullSearch
162
166
  end
163
167
 
164
168
  def like_prefix_ids(term, candidate_limit: nil)
169
+ return [] if term.to_s.length < dsl.min_like_prefix_length
170
+
165
171
  column_fields = dsl.fields.select { |f| f.source.nil? }
166
172
  source_fields = dsl.fields.select { |f| f.source }
167
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.6"
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
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.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