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 +4 -4
- data/README.md +73 -0
- data/lib/full_search/config.rb +2 -1
- data/lib/full_search/dsl.rb +13 -4
- data/lib/full_search/exact_match.rb +2 -1
- data/lib/full_search/index.rb +14 -7
- data/lib/full_search/index_cache.rb +24 -0
- data/lib/full_search/model.rb +2 -2
- data/lib/full_search/multi_search.rb +3 -3
- data/lib/full_search/search.rb +11 -5
- data/lib/full_search/version.rb +1 -1
- data/lib/full_search.rb +1 -0
- data/lib/generators/full_search/install/install_generator.rb +13 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d25d24145bf1aef1508210eb9d6879b21218ed8760ef6a61812706720a12ac17
|
|
4
|
+
data.tar.gz: 94b71b2b86db367ef8fde640550ab7b5945b50829d618c5e4eb41a4d9a6e0d65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/lib/full_search/config.rb
CHANGED
|
@@ -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
|
|
data/lib/full_search/dsl.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
data/lib/full_search/index.rb
CHANGED
|
@@ -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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
data/lib/full_search/model.rb
CHANGED
|
@@ -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
|
data/lib/full_search/search.rb
CHANGED
|
@@ -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
|
|
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)
|
data/lib/full_search/version.rb
CHANGED
data/lib/full_search.rb
CHANGED
|
@@ -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.
|
|
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
|