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 +4 -4
- data/README.md +87 -0
- data/lib/full_search/backfill_job.rb +1 -0
- data/lib/full_search/callbacks.rb +31 -13
- data/lib/full_search/config.rb +3 -2
- data/lib/full_search/constants.rb +12 -0
- data/lib/full_search/dsl.rb +26 -7
- data/lib/full_search/exact_match.rb +2 -1
- data/lib/full_search/highlighter.rb +3 -3
- data/lib/full_search/index.rb +53 -32
- data/lib/full_search/index_cache.rb +24 -0
- data/lib/full_search/instrumentation.rb +9 -0
- data/lib/full_search/model.rb +2 -2
- data/lib/full_search/multi_search.rb +3 -3
- data/lib/full_search/search.rb +47 -38
- data/lib/full_search/version.rb +1 -1
- data/lib/full_search.rb +9 -2
- data/lib/generators/full_search/install/install_generator.rb +13 -0
- data/lib/tasks/full_search.rake +4 -4
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a86070d05b32fc24ce6ac007a33c673c3831760ce76554bbb9d636e6e0c6be0f
|
|
4
|
+
data.tar.gz: 293b0be9931c7ffa5de9971aa52b2bf9014c2b0816d1364f839ccba57af8f814
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
79
|
+
conn = connection_for(record.class)
|
|
78
80
|
conn.execute("DELETE FROM #{table} WHERE rowid = #{q(record.id)}")
|
|
79
|
-
rescue => e
|
|
80
|
-
|
|
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 =
|
|
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.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
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
|
|
@@ -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 =
|
|
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
|
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,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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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 ||
|
|
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
|
-
|
|
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 >=
|
|
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 <
|
|
174
|
-
return 2 if length >=
|
|
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
|
|
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
|
|
@@ -64,25 +65,28 @@ module FullSearch
|
|
|
64
65
|
create_metadata_table!
|
|
65
66
|
|
|
66
67
|
with_rebuild_lock(model) do
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
498
|
-
|
|
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(
|
|
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
|
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,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 =
|
|
22
|
+
MIN_TERM_LENGTH = FullSearch::Constants::MIN_TERM_LENGTH
|
|
21
23
|
|
|
22
24
|
def relation
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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 >=
|
|
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
|
-
|
|
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))
|
data/lib/full_search/version.rb
CHANGED
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
|
-
|
|
57
|
+
sorted_models.each { |model| Index.optimize!(model) }
|
|
51
58
|
end
|
|
52
59
|
|
|
53
60
|
def setup!
|
|
54
|
-
|
|
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
|
data/lib/tasks/full_search.rake
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|