full_search 0.3.3 → 0.3.5
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 +61 -0
- data/lib/full_search/backfill_job.rb +14 -0
- data/lib/full_search/bulk_import.rb +35 -0
- data/lib/full_search/callbacks.rb +15 -6
- data/lib/full_search/config.rb +4 -1
- data/lib/full_search/dsl.rb +25 -9
- data/lib/full_search/exact_match.rb +24 -6
- data/lib/full_search/highlighter.rb +13 -9
- data/lib/full_search/index.rb +156 -74
- data/lib/full_search/model.rb +10 -2
- data/lib/full_search/multi_search.rb +3 -1
- data/lib/full_search/reindex_job.rb +9 -3
- data/lib/full_search/search.rb +43 -15
- data/lib/full_search/version.rb +1 -1
- data/lib/full_search.rb +10 -0
- data/lib/generators/full_search/install/templates/full_search.rb +4 -0
- data/lib/tasks/full_search.rake +10 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3b466311e2305f90987a48ff58cee59f4c81af6e960b5fcd2fe09cf845b985b
|
|
4
|
+
data.tar.gz: 1d355e22aa06d280d984d7da1df90e57a8ca4a5f5676b7870424f2b048c051ba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9489ff8c9bf04c59b6380cd1810871a39a947421754379ac6fb1c1cb36cbf7850a753d0a7354e25f38b591aeae63cbb05cd7d8c0f187bb84ba6ef191aba19755
|
|
7
|
+
data.tar.gz: a23ce1270f3ac088b02774a43178fb277811e7df65a7d518f3fde4b8899cfb2db3f07164c72ea75ec26bb3b8ed9ef29b94d05aa53009ef1f82dd9d1bb4d14f79
|
data/README.md
CHANGED
|
@@ -45,6 +45,27 @@ end
|
|
|
45
45
|
Customer.search("sam", filters: { account_id: 1 }).page(params[:page])
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
### Field aliasing with `as:`
|
|
49
|
+
|
|
50
|
+
Use `as:` to give a field a different column name in the FTS table. This is useful when
|
|
51
|
+
the model's accessor name differs from the desired search index key — for example, storing
|
|
52
|
+
a computed value (last 8 chars of VIN) in a shorter or semantically-named column:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
class Vehicle < ApplicationRecord
|
|
56
|
+
full_search do
|
|
57
|
+
field :vin_last8, weight: 2, source: -> { vin&.last(8)&.upcase }, as: :vin
|
|
58
|
+
field :make, weight: 4
|
|
59
|
+
field :model, weight: 4
|
|
60
|
+
filter :account_id, required: true
|
|
61
|
+
highlight
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The FTS table stores the value under `vin` (not `vin_last8`). Search results highlight
|
|
67
|
+
keyed as `vin`, and the `full_search_text_for(:vin)` lookup works as expected.
|
|
68
|
+
|
|
48
69
|
## Features
|
|
49
70
|
|
|
50
71
|
- Declarative `full_search` DSL with `search` / `full_search` query methods
|
|
@@ -57,6 +78,24 @@ Customer.search("sam", filters: { account_id: 1 }).page(params[:page])
|
|
|
57
78
|
- FTS5 `highlight()` support for result snippets
|
|
58
79
|
- Opt-in trigram typo/substring fallback (requires SQLite >= 3.34)
|
|
59
80
|
|
|
81
|
+
### Conditional indexing
|
|
82
|
+
|
|
83
|
+
Use `index_if` to restrict which records are included in the FTS index. Records that don't match the SQL condition are excluded from search results entirely.
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
class Customer < ApplicationRecord
|
|
87
|
+
full_search do
|
|
88
|
+
field :first_name, weight: 5
|
|
89
|
+
field :last_name, weight: 5
|
|
90
|
+
filter :account_id, required: true
|
|
91
|
+
|
|
92
|
+
index_if sql: "active = 1 AND deleted_at IS NULL"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
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
|
+
|
|
60
99
|
### Per-model operations
|
|
61
100
|
|
|
62
101
|
Once a model declares `full_search`, you can call these class methods:
|
|
@@ -126,6 +165,7 @@ bin/rails full_search:reset
|
|
|
126
165
|
| `full_search:rebuild` | Drops and recreates the FTS virtual table only when the DSL config hash has changed (safe for production). Pass model names to target specific tables. |
|
|
127
166
|
| `full_search:reset` | Force a full rebuild — drops and recreates all FTS tables regardless of config hash. Use when data may be out of sync. |
|
|
128
167
|
| `full_search:optimize` | Run FTS5 [`optimize`](https://www.sqlite.org/fts5.html#the_optimize_command) to merge b-tree segments. Useful after bulk updates. |
|
|
168
|
+
| `full_search:backfill` | Force-rebuild FTS indexes for specified models (or all). Useful for recovery after bulk operations. |
|
|
129
169
|
| `full_search:status` | Show each model's index status (`ok` / `stale`) and count of empty sourced fields. |
|
|
130
170
|
|
|
131
171
|
## Background jobs
|
|
@@ -150,6 +190,27 @@ If you're not on Solid Queue, most job frameworks support recurring schedules vi
|
|
|
150
190
|
|
|
151
191
|
Every FTS table stores a SHA256 digest of its DSL configuration in the `full_search_index_versions` table. When the DSL changes (e.g., adding a field), the stored hash no longer matches, and `rebuild!` is triggered automatically (if `auto_rebuild_schema` is true) to bring the index in line with the new definition. If `auto_rebuild_schema` is false, queries raise `ConfigChangedError` when the hash doesn't match (or log a warning and fall back if configured with `:log_and_fallback`).
|
|
152
192
|
|
|
193
|
+
### Bulk imports
|
|
194
|
+
|
|
195
|
+
When importing thousands of records, disable synchronous FTS maintenance and rebuild the index afterward:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
FullSearch.bulk_import(Customer) do
|
|
199
|
+
Customer.insert_all(records)
|
|
200
|
+
end
|
|
201
|
+
# automatically enqueues FullSearch::BackfillJob for Customer
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
While inside the block, database triggers are dropped so raw SQL operations (including `insert_all!`, `update_all`, and `delete_all`) bypass FTS table updates entirely. On exit, triggers are re-created and a `FullSearch::BackfillJob` is enqueued to rebuild the index from scratch. Reindexing of computed `source:` blocks within the block is also skipped.
|
|
205
|
+
|
|
206
|
+
You can also call `Customer.bulk_import { ... }` directly on the model.
|
|
207
|
+
|
|
208
|
+
For manual recovery outside a bulk-import block:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
bin/rails 'full_search:backfill[customers]'
|
|
212
|
+
```
|
|
213
|
+
|
|
153
214
|
### Query-time auto-rebuild in local environments
|
|
154
215
|
|
|
155
216
|
By default, the generated initializer also enables `auto_rebuild_on_stale_query` in local environments:
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module FullSearch
|
|
6
|
+
class BackfillJob < ActiveJob::Base
|
|
7
|
+
queue_as :low
|
|
8
|
+
|
|
9
|
+
def perform(model_name)
|
|
10
|
+
model = model_name.to_s.constantize
|
|
11
|
+
FullSearch::Index.rebuild!(model)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FullSearch
|
|
4
|
+
module BulkImport
|
|
5
|
+
class << self
|
|
6
|
+
def bulk_import(model)
|
|
7
|
+
start_bulk_import(model)
|
|
8
|
+
yield
|
|
9
|
+
ensure
|
|
10
|
+
end_bulk_import(model)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def start_bulk_import(model)
|
|
14
|
+
models_in_bulk_import << model
|
|
15
|
+
FullSearch::Index.drop_triggers!(model)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def end_bulk_import(model)
|
|
19
|
+
models_in_bulk_import.delete(model)
|
|
20
|
+
FullSearch::Index.create_triggers!(model)
|
|
21
|
+
FullSearch::BackfillJob.perform_later(model.name)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def bulk_importing?(model)
|
|
25
|
+
models_in_bulk_import.include?(model)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def models_in_bulk_import
|
|
31
|
+
Thread.current[:full_search_bulk_import_models] ||= Set.new
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -10,11 +10,13 @@ module FullSearch
|
|
|
10
10
|
source_fields = dsl.fields.select(&:source)
|
|
11
11
|
return if source_fields.empty?
|
|
12
12
|
|
|
13
|
-
model.
|
|
13
|
+
model.after_save_commit do
|
|
14
|
+
next if FullSearch.bulk_importing?(self.class)
|
|
14
15
|
FullSearch::Callbacks.reindex_record!(self)
|
|
15
16
|
end
|
|
16
17
|
|
|
17
|
-
model.
|
|
18
|
+
model.after_destroy_commit do
|
|
19
|
+
next if FullSearch.bulk_importing?(self.class)
|
|
18
20
|
FullSearch::Callbacks.remove_record!(self)
|
|
19
21
|
end
|
|
20
22
|
|
|
@@ -22,10 +24,12 @@ module FullSearch
|
|
|
22
24
|
next unless field.reindex_on
|
|
23
25
|
|
|
24
26
|
assoc_class = associated_class(model, field.reindex_on)
|
|
25
|
-
assoc_class&.
|
|
27
|
+
assoc_class&.after_save_commit do |record|
|
|
28
|
+
next if FullSearch.bulk_importing?(record.class)
|
|
26
29
|
FullSearch::Callbacks.reindex_dependents!(record, model, field)
|
|
27
30
|
end
|
|
28
|
-
assoc_class&.
|
|
31
|
+
assoc_class&.after_destroy_commit do |record|
|
|
32
|
+
next if FullSearch.bulk_importing?(record.class)
|
|
29
33
|
FullSearch::Callbacks.reindex_dependents!(record, model, field)
|
|
30
34
|
end
|
|
31
35
|
end
|
|
@@ -41,10 +45,15 @@ module FullSearch
|
|
|
41
45
|
dsl = record.class.full_search_dsl
|
|
42
46
|
return unless dsl
|
|
43
47
|
|
|
48
|
+
model_name = record.class.name
|
|
44
49
|
dsl.fields.each do |field|
|
|
45
50
|
next unless field.source
|
|
46
51
|
|
|
47
|
-
|
|
52
|
+
if model_name && field.async_source
|
|
53
|
+
FullSearch::ReindexJob.perform_later(model_name, record.id, field.name)
|
|
54
|
+
else
|
|
55
|
+
reindex_field!(record, field.name)
|
|
56
|
+
end
|
|
48
57
|
end
|
|
49
58
|
end
|
|
50
59
|
|
|
@@ -57,7 +66,7 @@ module FullSearch
|
|
|
57
66
|
table = qt(FullSearch::Index.fts_table_name(record.class))
|
|
58
67
|
conn = ActiveRecord::Base.connection
|
|
59
68
|
conn.execute(
|
|
60
|
-
"UPDATE #{table} SET #{qc(field.name)} = #{q(value.to_s)} WHERE rowid = #{q(record.id)}"
|
|
69
|
+
"UPDATE #{table} SET #{qc(field.as || field.name)} = #{q(value.to_s)} WHERE rowid = #{q(record.id)}"
|
|
61
70
|
)
|
|
62
71
|
rescue => e
|
|
63
72
|
raise unless e.message.include?("no such table")
|
data/lib/full_search/config.rb
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module FullSearch
|
|
4
4
|
class Config
|
|
5
|
-
attr_accessor :auto_rebuild_schema, :stale_query_behavior, :lock_rebuilds,
|
|
5
|
+
attr_accessor :auto_rebuild_schema, :stale_query_behavior, :lock_rebuilds,
|
|
6
|
+
:default_async_reindex, :default_async_source_reindex,
|
|
7
|
+
:default_tokenizer, :auto_rebuild_on_stale_query
|
|
6
8
|
|
|
7
9
|
def initialize
|
|
8
10
|
@auto_rebuild_schema = false
|
|
9
11
|
@stale_query_behavior = :raise
|
|
10
12
|
@lock_rebuilds = true
|
|
11
13
|
@default_async_reindex = true
|
|
14
|
+
@default_async_source_reindex = true
|
|
12
15
|
@default_tokenizer = "unicode61"
|
|
13
16
|
@auto_rebuild_on_stale_query = false
|
|
14
17
|
end
|
data/lib/full_search/dsl.rb
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module FullSearch
|
|
4
4
|
class Dsl
|
|
5
|
-
attr_reader :fields, :exact_matches, :filters, :model_class, :highlight_config, :rank_bys
|
|
5
|
+
attr_reader :fields, :exact_matches, :filters, :model_class, :highlight_config, :rank_bys, :index_if_sql
|
|
6
6
|
|
|
7
|
-
Field = Data.define(:name, :weight, :source, :reindex_on, :async, :as, :version)
|
|
8
|
-
ExactMatch = Data.define(:name, :source, :version)
|
|
7
|
+
Field = Data.define(:name, :weight, :source, :reindex_on, :async, :async_source, :as, :version)
|
|
8
|
+
ExactMatch = Data.define(:name, :source, :sql, :version)
|
|
9
9
|
Filter = Data.define(:name, :required)
|
|
10
10
|
RankBy = Data.define(:column, :direction)
|
|
11
11
|
|
|
@@ -19,7 +19,10 @@ module FullSearch
|
|
|
19
19
|
@soft_delete_column = nil
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def field(name, weight: 1, source: nil, reindex_on: nil,
|
|
22
|
+
def field(name, weight: 1, source: nil, reindex_on: nil,
|
|
23
|
+
async: FullSearch.config.default_async_reindex,
|
|
24
|
+
async_source: FullSearch.config.default_async_source_reindex,
|
|
25
|
+
as: nil, version: nil)
|
|
23
26
|
unless valid_name?(name)
|
|
24
27
|
raise InvalidFieldError, "#{model_class.name}: invalid field name #{name.inspect}"
|
|
25
28
|
end
|
|
@@ -29,16 +32,20 @@ module FullSearch
|
|
|
29
32
|
str = name.to_s
|
|
30
33
|
raise InvalidFieldError, "#{model_class.name}: duplicate field name #{name.inspect}" if fields.any? { |f| f.name == str }
|
|
31
34
|
raise InvalidFieldError, "#{model_class.name}: field name #{name.inspect} conflicts with existing filter" if filters.any? { |f| f.name == str }
|
|
32
|
-
@fields << Field.new(
|
|
35
|
+
@fields << Field.new(
|
|
36
|
+
name: str, weight: weight.to_i, source: source,
|
|
37
|
+
reindex_on: reindex_on&.to_s, async: async,
|
|
38
|
+
async_source: async_source, as: as&.to_s, version: version
|
|
39
|
+
)
|
|
33
40
|
end
|
|
34
41
|
|
|
35
|
-
def exact_match(name, source: -> { public_send(name) }, version: nil)
|
|
42
|
+
def exact_match(name, source: -> { public_send(name) }, sql: nil, version: nil)
|
|
36
43
|
unless valid_name?(name)
|
|
37
44
|
raise InvalidFieldError, "#{model_class.name}: invalid exact_match name #{name.inspect}"
|
|
38
45
|
end
|
|
39
46
|
str = name.to_s
|
|
40
47
|
raise InvalidFieldError, "#{model_class.name}: duplicate exact_match name #{name.inspect}" if exact_matches.any? { |e| e.name == str }
|
|
41
|
-
@exact_matches << ExactMatch.new(name: str, source: source, version: version)
|
|
48
|
+
@exact_matches << ExactMatch.new(name: str, source: source, sql: sql&.to_s, version: version)
|
|
42
49
|
end
|
|
43
50
|
|
|
44
51
|
def rank_by(column, direction = :desc)
|
|
@@ -84,6 +91,14 @@ module FullSearch
|
|
|
84
91
|
@highlight_config = {open_tag: open_tag, close_tag: close_tag}
|
|
85
92
|
end
|
|
86
93
|
|
|
94
|
+
def index_if(sql: nil)
|
|
95
|
+
@index_if_sql = sql&.to_s
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def conditional_index?
|
|
99
|
+
@index_if_sql.present?
|
|
100
|
+
end
|
|
101
|
+
|
|
87
102
|
def typo_tolerance(enabled = true, min_term_length: nil)
|
|
88
103
|
@typo_tolerance = enabled
|
|
89
104
|
@typo_tolerance_min_term_length = min_term_length || 3
|
|
@@ -105,8 +120,9 @@ module FullSearch
|
|
|
105
120
|
soft_delete_column,
|
|
106
121
|
typo_tolerance?,
|
|
107
122
|
typo_tolerance_min_term_length,
|
|
108
|
-
|
|
109
|
-
|
|
123
|
+
index_if_sql,
|
|
124
|
+
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
|
+
exact_matches.map { |e| [e.name, "proc:#{e.version}", e.sql] },
|
|
110
126
|
filters.map { |f| [f.name, f.required] },
|
|
111
127
|
rank_bys.map { |r| [r.column, r.direction] }
|
|
112
128
|
].inspect)
|
|
@@ -9,16 +9,34 @@ module FullSearch
|
|
|
9
9
|
cleaned = query.to_s.strip
|
|
10
10
|
return [] if cleaned.empty?
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
sql_matches = dsl.exact_matches.select(&:sql)
|
|
13
|
+
ruby_matches = dsl.exact_matches.reject(&:sql)
|
|
14
14
|
|
|
15
15
|
ids = []
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
|
|
17
|
+
if sql_matches.any?
|
|
18
|
+
base = model.all
|
|
19
|
+
filters.each { |name, value| base = base.where(name => value) }
|
|
20
|
+
|
|
21
|
+
conditions = sql_matches.map do |em|
|
|
22
|
+
"(#{em.sql}) = #{model.connection.quote(cleaned)}"
|
|
23
|
+
end.join(" OR ")
|
|
24
|
+
|
|
25
|
+
ids += base.where(conditions).pluck(:id)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if ruby_matches.any?
|
|
29
|
+
base = model.all
|
|
30
|
+
filters.each { |name, value| base = base.where(name => value) }
|
|
31
|
+
|
|
32
|
+
base.find_each do |record|
|
|
33
|
+
ruby_matches.each do |em|
|
|
34
|
+
value = record.instance_exec(&em.source)
|
|
35
|
+
ids << record.id if value.to_s.casecmp?(cleaned)
|
|
36
|
+
end
|
|
20
37
|
end
|
|
21
38
|
end
|
|
39
|
+
|
|
22
40
|
ids.uniq
|
|
23
41
|
end
|
|
24
42
|
end
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module FullSearch
|
|
4
4
|
class Highlighter
|
|
5
5
|
class << self
|
|
6
|
-
def apply!(records, model, query)
|
|
7
|
-
snippets = build_snippets(model, query)
|
|
6
|
+
def apply!(records, model, query, record_ids: nil)
|
|
7
|
+
snippets = build_snippets(model, query, record_ids: record_ids)
|
|
8
8
|
if snippets.values.all?(&:nil?) && records.any?
|
|
9
9
|
snippets = manual_snippets(records, model, query)
|
|
10
10
|
end
|
|
@@ -12,8 +12,8 @@ module FullSearch
|
|
|
12
12
|
records
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def apply_fields!(records, model, query)
|
|
16
|
-
fields = build_field_snippets(model, query)
|
|
15
|
+
def apply_fields!(records, model, query, record_ids: nil)
|
|
16
|
+
fields = build_field_snippets(model, query, record_ids: record_ids)
|
|
17
17
|
if fields.values.all?(&:empty?) && records.any?
|
|
18
18
|
fields = manual_field_snippets(records, model, query)
|
|
19
19
|
end
|
|
@@ -28,8 +28,8 @@ module FullSearch
|
|
|
28
28
|
|
|
29
29
|
private
|
|
30
30
|
|
|
31
|
-
def build_snippets(model, query)
|
|
32
|
-
rows = highlight_rows(model, query)
|
|
31
|
+
def build_snippets(model, query, record_ids: nil)
|
|
32
|
+
rows = highlight_rows(model, query, record_ids: record_ids)
|
|
33
33
|
cols = model.full_search_dsl.fields.map(&:name)
|
|
34
34
|
|
|
35
35
|
rows.to_h do |row|
|
|
@@ -38,8 +38,8 @@ module FullSearch
|
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
def build_field_snippets(model, query)
|
|
42
|
-
rows = highlight_rows(model, query)
|
|
41
|
+
def build_field_snippets(model, query, record_ids: nil)
|
|
42
|
+
rows = highlight_rows(model, query, record_ids: record_ids)
|
|
43
43
|
dsl = model.full_search_dsl
|
|
44
44
|
fields = dsl.fields
|
|
45
45
|
open_tag = (dsl.highlight_config || {open_tag: "<mark>"})[:open_tag]
|
|
@@ -175,7 +175,7 @@ module FullSearch
|
|
|
175
175
|
1
|
|
176
176
|
end
|
|
177
177
|
|
|
178
|
-
def highlight_rows(model, query)
|
|
178
|
+
def highlight_rows(model, query, record_ids: nil)
|
|
179
179
|
dsl = model.full_search_dsl
|
|
180
180
|
config = dsl.highlight_config || {open_tag: "<mark>", close_tag: "</mark>"}
|
|
181
181
|
match_expr = QueryParser.to_match_expression(QueryParser.parse(query))
|
|
@@ -195,6 +195,10 @@ module FullSearch
|
|
|
195
195
|
WHERE #{table} MATCH #{q(match_expr)}
|
|
196
196
|
SQL
|
|
197
197
|
|
|
198
|
+
if record_ids.present?
|
|
199
|
+
sql += " AND rowid IN (#{record_ids.map { |id| connection.quote(id) }.join(", ")})"
|
|
200
|
+
end
|
|
201
|
+
|
|
198
202
|
connection.execute(sql)
|
|
199
203
|
end
|
|
200
204
|
|
data/lib/full_search/index.rb
CHANGED
|
@@ -46,9 +46,9 @@ module FullSearch
|
|
|
46
46
|
conn.execute(backfill_sql(model)) if fts_was_created
|
|
47
47
|
conn.execute(backfill_trigram_sql(model)) if trigram_was_created
|
|
48
48
|
reindex_source_fields!(model) if dsl.fields.any?(&:source)
|
|
49
|
+
store_config_hash!(model)
|
|
49
50
|
end
|
|
50
51
|
|
|
51
|
-
store_config_hash!(model)
|
|
52
52
|
ensure_triggers!(model) if model_table_exists?(model)
|
|
53
53
|
|
|
54
54
|
verified_tables.add(model.table_name)
|
|
@@ -106,8 +106,33 @@ module FullSearch
|
|
|
106
106
|
end
|
|
107
107
|
|
|
108
108
|
def reindex_source_fields!(model)
|
|
109
|
+
dsl = model.full_search_dsl
|
|
110
|
+
return unless dsl
|
|
111
|
+
|
|
109
112
|
model.find_each do |record|
|
|
110
|
-
|
|
113
|
+
dsl.fields.each do |field|
|
|
114
|
+
FullSearch::Callbacks.reindex_field!(record, field.name) if field.source
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def create_triggers!(model)
|
|
120
|
+
connection.execute(insert_trigger_sql(model))
|
|
121
|
+
connection.execute(delete_trigger_sql(model))
|
|
122
|
+
connection.execute(update_trigger_sql(model))
|
|
123
|
+
if model.full_search_dsl.soft_delete_column
|
|
124
|
+
connection.execute(soft_delete_removal_trigger_sql(model))
|
|
125
|
+
end
|
|
126
|
+
if model.full_search_dsl.typo_tolerance?
|
|
127
|
+
connection.execute(insert_trigram_trigger_sql(model))
|
|
128
|
+
connection.execute(delete_trigram_trigger_sql(model))
|
|
129
|
+
connection.execute(update_trigram_trigger_sql(model))
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def drop_triggers!(model)
|
|
134
|
+
(trigger_names(model) + trigram_trigger_names(model)).each do |name|
|
|
135
|
+
connection.execute("DROP TRIGGER IF EXISTS #{qt(name)};")
|
|
111
136
|
end
|
|
112
137
|
end
|
|
113
138
|
|
|
@@ -163,6 +188,10 @@ module FullSearch
|
|
|
163
188
|
connection.quote_table_name(name)
|
|
164
189
|
end
|
|
165
190
|
|
|
191
|
+
def column_name(col)
|
|
192
|
+
(col.respond_to?(:as) && col.as) ? col.as : col.name
|
|
193
|
+
end
|
|
194
|
+
|
|
166
195
|
def qc(name)
|
|
167
196
|
connection.quote_column_name(name)
|
|
168
197
|
end
|
|
@@ -197,26 +226,30 @@ module FullSearch
|
|
|
197
226
|
|
|
198
227
|
def create_virtual_table_sql(model)
|
|
199
228
|
dsl = model.full_search_dsl
|
|
200
|
-
columns = (dsl.fields + dsl.filters.map { |f| FilterColumnPlaceholder.new(name: f.name) })
|
|
201
|
-
column_list = columns.map { |c| (c.respond_to?(:unindexed?) && c.unindexed?) ? "#{qc(c
|
|
229
|
+
columns = (dsl.fields + dsl.filters.map { |f| FilterColumnPlaceholder.new(name: f.name) } + extra_columns(model))
|
|
230
|
+
column_list = columns.map { |c| (c.respond_to?(:unindexed?) && c.unindexed?) ? "#{qc(column_name(c))} UNINDEXED" : qc(column_name(c)) }.join(", ")
|
|
202
231
|
|
|
203
232
|
"CREATE VIRTUAL TABLE #{qt(fts_table_name(model))} USING fts5(#{column_list}, tokenize='#{dsl.tokenize}');"
|
|
204
233
|
end
|
|
205
234
|
|
|
206
235
|
def backfill_sql(model)
|
|
207
236
|
dsl = model.full_search_dsl
|
|
208
|
-
cols = (dsl.fields + dsl.filters)
|
|
237
|
+
cols = (dsl.fields + dsl.filters + extra_columns(model))
|
|
209
238
|
select = cols.map do |c|
|
|
210
|
-
if c.respond_to?(:
|
|
239
|
+
if c.respond_to?(:unindexed?) && c.name == "indexed"
|
|
240
|
+
"'1'"
|
|
241
|
+
elsif c.respond_to?(:source) && c.source
|
|
211
242
|
source_value_sql(c.source)
|
|
212
243
|
else
|
|
213
244
|
"#{qt(model.table_name)}.#{qc(c.name)}"
|
|
214
245
|
end
|
|
215
246
|
end.join(", ")
|
|
216
247
|
|
|
248
|
+
where_clause = dsl.conditional_index? ? " WHERE (#{dsl.index_if_sql})" : ""
|
|
249
|
+
|
|
217
250
|
<<~SQL
|
|
218
|
-
INSERT INTO #{qt(fts_table_name(model))}(rowid, #{cols.map { |c| qc(c
|
|
219
|
-
SELECT #{qt(model.table_name)}.id, #{select} FROM #{qt(model.table_name)};
|
|
251
|
+
INSERT INTO #{qt(fts_table_name(model))}(rowid, #{cols.map { |c| qc(column_name(c)) }.join(", ")})
|
|
252
|
+
SELECT #{qt(model.table_name)}.id, #{select} FROM #{qt(model.table_name)}#{where_clause};
|
|
220
253
|
SQL
|
|
221
254
|
end
|
|
222
255
|
|
|
@@ -225,6 +258,8 @@ module FullSearch
|
|
|
225
258
|
end
|
|
226
259
|
|
|
227
260
|
def ensure_triggers!(model)
|
|
261
|
+
return if FullSearch.bulk_importing?(model)
|
|
262
|
+
|
|
228
263
|
existing = connection.execute(
|
|
229
264
|
"SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name=#{q(model.table_name)}"
|
|
230
265
|
).map { |r| r["name"] }
|
|
@@ -236,26 +271,6 @@ module FullSearch
|
|
|
236
271
|
rebuild!(model)
|
|
237
272
|
end
|
|
238
273
|
|
|
239
|
-
def create_triggers!(model)
|
|
240
|
-
connection.execute(insert_trigger_sql(model))
|
|
241
|
-
connection.execute(delete_trigger_sql(model))
|
|
242
|
-
connection.execute(update_trigger_sql(model))
|
|
243
|
-
if model.full_search_dsl.soft_delete_column
|
|
244
|
-
connection.execute(soft_delete_removal_trigger_sql(model))
|
|
245
|
-
end
|
|
246
|
-
if model.full_search_dsl.typo_tolerance?
|
|
247
|
-
connection.execute(insert_trigram_trigger_sql(model))
|
|
248
|
-
connection.execute(delete_trigram_trigger_sql(model))
|
|
249
|
-
connection.execute(update_trigram_trigger_sql(model))
|
|
250
|
-
end
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
def drop_triggers!(model)
|
|
254
|
-
(trigger_names(model) + trigram_trigger_names(model)).each do |name|
|
|
255
|
-
connection.execute("DROP TRIGGER IF EXISTS #{qt(name)};")
|
|
256
|
-
end
|
|
257
|
-
end
|
|
258
|
-
|
|
259
274
|
def trigger_names(model)
|
|
260
275
|
base = fts_table_name(model)
|
|
261
276
|
names = %W[#{base}_ai #{base}_ad #{base}_au]
|
|
@@ -270,15 +285,30 @@ module FullSearch
|
|
|
270
285
|
|
|
271
286
|
def insert_trigger_sql(model)
|
|
272
287
|
dsl = model.full_search_dsl
|
|
273
|
-
cols = dsl.fields + dsl.filters
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
288
|
+
cols = dsl.fields + dsl.filters + extra_columns(model)
|
|
289
|
+
cols_str = col_names(cols)
|
|
290
|
+
fts_table = qt(fts_table_name(model))
|
|
291
|
+
tbl = qt(model.table_name)
|
|
292
|
+
|
|
293
|
+
if dsl.conditional_index?
|
|
294
|
+
select_parts = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
|
|
295
|
+
<<~SQL
|
|
296
|
+
CREATE TRIGGER #{qt(trigger_names(model).first)} AFTER INSERT ON #{tbl}
|
|
297
|
+
BEGIN
|
|
298
|
+
INSERT INTO #{fts_table}(rowid, #{cols_str})
|
|
299
|
+
SELECT new.id, #{select_parts} FROM #{tbl} WHERE #{tbl}.id = new.id AND (#{dsl.index_if_sql});
|
|
300
|
+
END;
|
|
301
|
+
SQL
|
|
302
|
+
else
|
|
303
|
+
values = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
|
|
304
|
+
<<~SQL
|
|
305
|
+
CREATE TRIGGER #{qt(trigger_names(model).first)} AFTER INSERT ON #{tbl}
|
|
306
|
+
BEGIN
|
|
307
|
+
INSERT INTO #{fts_table}(rowid, #{col_names(cols)})
|
|
308
|
+
VALUES (new.id, #{values});
|
|
309
|
+
END;
|
|
310
|
+
SQL
|
|
311
|
+
end
|
|
282
312
|
end
|
|
283
313
|
|
|
284
314
|
def delete_trigger_sql(model)
|
|
@@ -291,21 +321,34 @@ module FullSearch
|
|
|
291
321
|
|
|
292
322
|
def update_trigger_sql(model)
|
|
293
323
|
dsl = model.full_search_dsl
|
|
294
|
-
cols = dsl.fields + dsl.filters
|
|
295
|
-
values = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
|
|
324
|
+
cols = dsl.fields + dsl.filters + extra_columns(model)
|
|
296
325
|
cols_str = col_names(cols)
|
|
297
326
|
fts_table = qt(fts_table_name(model))
|
|
327
|
+
tbl = qt(model.table_name)
|
|
298
328
|
|
|
299
329
|
when_clause = SoftDelete.active_update_clause(model)
|
|
300
330
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
331
|
+
if dsl.conditional_index?
|
|
332
|
+
select_parts = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
|
|
333
|
+
<<~SQL
|
|
334
|
+
CREATE TRIGGER #{qt(trigger_names(model)[2])} AFTER UPDATE ON #{qt(model.table_name)} #{when_clause}
|
|
335
|
+
BEGIN
|
|
336
|
+
DELETE FROM #{fts_table} WHERE rowid = old.id;
|
|
337
|
+
INSERT INTO #{fts_table}(rowid, #{cols_str})
|
|
338
|
+
SELECT new.id, #{select_parts} FROM #{tbl} WHERE #{tbl}.id = new.id AND (#{dsl.index_if_sql});
|
|
339
|
+
END;
|
|
340
|
+
SQL
|
|
341
|
+
else
|
|
342
|
+
values = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
|
|
343
|
+
<<~SQL
|
|
344
|
+
CREATE TRIGGER #{qt(trigger_names(model)[2])} AFTER UPDATE ON #{qt(model.table_name)} #{when_clause}
|
|
345
|
+
BEGIN
|
|
346
|
+
DELETE FROM #{fts_table} WHERE rowid = old.id;
|
|
347
|
+
INSERT INTO #{fts_table}(rowid, #{cols_str})
|
|
348
|
+
VALUES (new.id, #{values});
|
|
349
|
+
END;
|
|
350
|
+
SQL
|
|
351
|
+
end
|
|
309
352
|
end
|
|
310
353
|
|
|
311
354
|
def soft_delete_removal_trigger_sql(model)
|
|
@@ -329,40 +372,59 @@ module FullSearch
|
|
|
329
372
|
|
|
330
373
|
def create_trigram_virtual_table_sql(model)
|
|
331
374
|
dsl = model.full_search_dsl
|
|
332
|
-
columns = (dsl.fields + dsl.filters.map { |f| FilterColumnPlaceholder.new(name: f.name) })
|
|
333
|
-
column_list = columns.map { |c| (c.respond_to?(:unindexed?) && c.unindexed?) ? "#{qc(c
|
|
375
|
+
columns = (dsl.fields + dsl.filters.map { |f| FilterColumnPlaceholder.new(name: f.name) } + extra_columns(model))
|
|
376
|
+
column_list = columns.map { |c| (c.respond_to?(:unindexed?) && c.unindexed?) ? "#{qc(column_name(c))} UNINDEXED" : qc(column_name(c)) }.join(", ")
|
|
334
377
|
|
|
335
378
|
"CREATE VIRTUAL TABLE #{qt(trigram_table_name(model))} USING fts5(#{column_list}, tokenize='trigram');"
|
|
336
379
|
end
|
|
337
380
|
|
|
338
381
|
def backfill_trigram_sql(model)
|
|
339
382
|
dsl = model.full_search_dsl
|
|
340
|
-
cols = (dsl.fields + dsl.filters)
|
|
383
|
+
cols = (dsl.fields + dsl.filters + extra_columns(model))
|
|
341
384
|
select = cols.map do |c|
|
|
342
|
-
if c.respond_to?(:
|
|
385
|
+
if c.respond_to?(:unindexed?) && c.name == "indexed"
|
|
386
|
+
"'1'"
|
|
387
|
+
elsif c.respond_to?(:source) && c.source
|
|
343
388
|
source_value_sql(c.source)
|
|
344
389
|
else
|
|
345
390
|
"#{qt(model.table_name)}.#{qc(c.name)}"
|
|
346
391
|
end
|
|
347
392
|
end.join(", ")
|
|
348
393
|
|
|
394
|
+
where_clause = dsl.conditional_index? ? " WHERE (#{dsl.index_if_sql})" : ""
|
|
395
|
+
|
|
349
396
|
<<~SQL
|
|
350
|
-
INSERT INTO #{qt(trigram_table_name(model))}(rowid, #{cols.map { |c| qc(c
|
|
351
|
-
SELECT #{qt(model.table_name)}.id, #{select} FROM #{qt(model.table_name)};
|
|
397
|
+
INSERT INTO #{qt(trigram_table_name(model))}(rowid, #{cols.map { |c| qc(column_name(c)) }.join(", ")})
|
|
398
|
+
SELECT #{qt(model.table_name)}.id, #{select} FROM #{qt(model.table_name)}#{where_clause};
|
|
352
399
|
SQL
|
|
353
400
|
end
|
|
354
401
|
|
|
355
402
|
def insert_trigram_trigger_sql(model)
|
|
356
403
|
dsl = model.full_search_dsl
|
|
357
|
-
cols = dsl.fields + dsl.filters
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
404
|
+
cols = dsl.fields + dsl.filters + extra_columns(model)
|
|
405
|
+
cols_str = col_names(cols)
|
|
406
|
+
trigram_table = qt(trigram_table_name(model))
|
|
407
|
+
tbl = qt(model.table_name)
|
|
408
|
+
|
|
409
|
+
if dsl.conditional_index?
|
|
410
|
+
select_parts = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
|
|
411
|
+
<<~SQL
|
|
412
|
+
CREATE TRIGGER #{qt(trigram_trigger_names(model).first)} AFTER INSERT ON #{tbl}
|
|
413
|
+
BEGIN
|
|
414
|
+
INSERT INTO #{trigram_table}(rowid, #{cols_str})
|
|
415
|
+
SELECT new.id, #{select_parts} FROM #{tbl} WHERE #{tbl}.id = new.id AND (#{dsl.index_if_sql});
|
|
416
|
+
END;
|
|
417
|
+
SQL
|
|
418
|
+
else
|
|
419
|
+
values = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
|
|
420
|
+
<<~SQL
|
|
421
|
+
CREATE TRIGGER #{qt(trigram_trigger_names(model).first)} AFTER INSERT ON #{tbl}
|
|
422
|
+
BEGIN
|
|
423
|
+
INSERT INTO #{trigram_table}(rowid, #{col_names(cols)})
|
|
424
|
+
VALUES (new.id, #{values});
|
|
425
|
+
END;
|
|
426
|
+
SQL
|
|
427
|
+
end
|
|
366
428
|
end
|
|
367
429
|
|
|
368
430
|
def delete_trigram_trigger_sql(model)
|
|
@@ -375,29 +437,49 @@ module FullSearch
|
|
|
375
437
|
|
|
376
438
|
def update_trigram_trigger_sql(model)
|
|
377
439
|
dsl = model.full_search_dsl
|
|
378
|
-
cols = dsl.fields + dsl.filters
|
|
379
|
-
values = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
|
|
440
|
+
cols = dsl.fields + dsl.filters + extra_columns(model)
|
|
380
441
|
cols_str = col_names(cols)
|
|
381
442
|
trigram_table = qt(trigram_table_name(model))
|
|
443
|
+
tbl = qt(model.table_name)
|
|
382
444
|
|
|
383
445
|
when_clause = SoftDelete.active_update_clause(model)
|
|
384
446
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
447
|
+
if dsl.conditional_index?
|
|
448
|
+
select_parts = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
|
|
449
|
+
<<~SQL
|
|
450
|
+
CREATE TRIGGER #{qt(trigram_trigger_names(model)[2])} AFTER UPDATE ON #{qt(model.table_name)} #{when_clause}
|
|
451
|
+
BEGIN
|
|
452
|
+
DELETE FROM #{trigram_table} WHERE rowid = old.id;
|
|
453
|
+
INSERT INTO #{trigram_table}(rowid, #{cols_str})
|
|
454
|
+
SELECT new.id, #{select_parts} FROM #{tbl} WHERE #{tbl}.id = new.id AND (#{dsl.index_if_sql});
|
|
455
|
+
END;
|
|
456
|
+
SQL
|
|
457
|
+
else
|
|
458
|
+
values = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
|
|
459
|
+
<<~SQL
|
|
460
|
+
CREATE TRIGGER #{qt(trigram_trigger_names(model)[2])} AFTER UPDATE ON #{qt(model.table_name)} #{when_clause}
|
|
461
|
+
BEGIN
|
|
462
|
+
DELETE FROM #{trigram_table} WHERE rowid = old.id;
|
|
463
|
+
INSERT INTO #{trigram_table}(rowid, #{cols_str})
|
|
464
|
+
VALUES (new.id, #{values});
|
|
465
|
+
END;
|
|
466
|
+
SQL
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def extra_columns(model)
|
|
471
|
+
dsl = model.full_search_dsl
|
|
472
|
+
dsl&.conditional_index? ? [FilterColumnPlaceholder.new(name: "indexed")] : []
|
|
393
473
|
end
|
|
394
474
|
|
|
395
475
|
def col_names(cols)
|
|
396
|
-
cols.map { |c| qc(c
|
|
476
|
+
cols.map { |c| qc(column_name(c)) }.join(", ")
|
|
397
477
|
end
|
|
398
478
|
|
|
399
479
|
def column_ref(col, prefix:)
|
|
400
|
-
if col.respond_to?(:
|
|
480
|
+
if col.respond_to?(:unindexed?) && col.name == "indexed"
|
|
481
|
+
"'1'"
|
|
482
|
+
elsif col.respond_to?(:source) && col.source
|
|
401
483
|
"''"
|
|
402
484
|
else
|
|
403
485
|
"#{prefix}.#{qc(col.name)}"
|
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, &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, &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).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).relation
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
@@ -39,6 +39,14 @@ module FullSearch
|
|
|
39
39
|
def reindex!
|
|
40
40
|
FullSearch::Index.reindex_source_fields!(self)
|
|
41
41
|
end
|
|
42
|
+
|
|
43
|
+
def bulk_import(&block)
|
|
44
|
+
FullSearch.bulk_import(self, &block)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def backfill_search_index!
|
|
48
|
+
FullSearch::BackfillJob.perform_later(name)
|
|
49
|
+
end
|
|
42
50
|
end
|
|
43
51
|
|
|
44
52
|
included do |base|
|
|
@@ -27,7 +27,9 @@ module FullSearch
|
|
|
27
27
|
limit: raw_limit,
|
|
28
28
|
offset: offset,
|
|
29
29
|
highlight: group[:highlight],
|
|
30
|
-
highlight_fields: group[:highlight_fields]
|
|
30
|
+
highlight_fields: group[:highlight_fields],
|
|
31
|
+
matching_strategy: group[:matching_strategy],
|
|
32
|
+
per_strategy_limit: group[:per_strategy_limit]
|
|
31
33
|
)
|
|
32
34
|
|
|
33
35
|
relation = group[:scope].call(relation) if group[:scope]
|
|
@@ -4,12 +4,18 @@ require "active_job"
|
|
|
4
4
|
|
|
5
5
|
module FullSearch
|
|
6
6
|
class ReindexJob < ActiveJob::Base
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
queue_as :low
|
|
8
|
+
|
|
9
|
+
def perform(model_name, record_id, field_name = nil)
|
|
10
|
+
model = model_name.is_a?(Class) ? model_name : model_name.to_s.constantize
|
|
9
11
|
record = model.find_by(id: record_id)
|
|
10
12
|
return unless record
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
if field_name
|
|
15
|
+
FullSearch::Callbacks.reindex_field!(record, field_name)
|
|
16
|
+
else
|
|
17
|
+
FullSearch::Callbacks.reindex_record!(record)
|
|
18
|
+
end
|
|
13
19
|
end
|
|
14
20
|
end
|
|
15
21
|
end
|
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
|
|
5
|
+
attr_reader :model, :query, :filters, :include_soft_deleted, :limit, :offset, :highlight, :highlight_fields, :matching_strategy, :per_strategy_limit
|
|
6
6
|
|
|
7
|
-
def initialize(model, query, filters:, include_soft_deleted:, limit:, offset:, highlight: false, highlight_fields: false, matching_strategy: nil)
|
|
7
|
+
def initialize(model, query, filters:, include_soft_deleted:, limit:, offset:, highlight: false, highlight_fields: false, matching_strategy: nil, per_strategy_limit: nil)
|
|
8
8
|
@model = model
|
|
9
9
|
@query = query.to_s.strip
|
|
10
10
|
@filters = filters
|
|
@@ -14,6 +14,7 @@ module FullSearch
|
|
|
14
14
|
@highlight = highlight
|
|
15
15
|
@highlight_fields = highlight_fields
|
|
16
16
|
@matching_strategy = matching_strategy
|
|
17
|
+
@per_strategy_limit = per_strategy_limit
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
MIN_TERM_LENGTH = 3
|
|
@@ -28,8 +29,8 @@ module FullSearch
|
|
|
28
29
|
parsed = QueryParser.parse(query)
|
|
29
30
|
exact_ids = ExactMatch.ids_for(model, query, filters)
|
|
30
31
|
primary_ids = fts_match_ids(parsed)
|
|
31
|
-
fallback_ids = (dsl.typo_tolerance? && matching_strategy != "all") ? trigram_match_ids(parsed, primary_ids) : []
|
|
32
|
-
fuzzy_ids = (dsl.typo_tolerance? && matching_strategy != "all" && primary_ids.empty? && fallback_ids.empty?) ? fuzzy_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) : []
|
|
33
34
|
|
|
34
35
|
all_ids = (exact_ids + primary_ids + fallback_ids + fuzzy_ids).uniq
|
|
35
36
|
return model.none if all_ids.empty?
|
|
@@ -42,9 +43,13 @@ module FullSearch
|
|
|
42
43
|
rel = apply_ranking(rel, all_ids, exact_ids)
|
|
43
44
|
|
|
44
45
|
if highlight
|
|
45
|
-
|
|
46
|
+
records = rel.to_a
|
|
47
|
+
Highlighter.apply!(records, model, query, record_ids: records.map(&:id))
|
|
48
|
+
records
|
|
46
49
|
elsif highlight_fields
|
|
47
|
-
|
|
50
|
+
records = rel.to_a
|
|
51
|
+
Highlighter.apply_fields!(records, model, query, record_ids: records.map(&:id))
|
|
52
|
+
records
|
|
48
53
|
else
|
|
49
54
|
rel
|
|
50
55
|
end
|
|
@@ -109,10 +114,12 @@ module FullSearch
|
|
|
109
114
|
"AND #{fts_table}.#{qc(name)} = #{q(value)}"
|
|
110
115
|
end.join(" ")
|
|
111
116
|
|
|
112
|
-
|
|
117
|
+
indexed_filter = dsl.conditional_index? ? " AND #{fts_table}.indexed = '1'" : ""
|
|
118
|
+
|
|
119
|
+
connection.execute("#{sql} #{filter_conditions}#{indexed_filter}").map { |r| r["id"] }
|
|
113
120
|
end
|
|
114
121
|
|
|
115
|
-
def trigram_match_ids(parsed, primary_ids)
|
|
122
|
+
def trigram_match_ids(parsed, primary_ids, candidate_limit: nil)
|
|
116
123
|
return [] if primary_ids.any?
|
|
117
124
|
|
|
118
125
|
match_expr = QueryParser.to_match_expression(parsed)
|
|
@@ -122,7 +129,7 @@ module FullSearch
|
|
|
122
129
|
return [] if term.nil?
|
|
123
130
|
|
|
124
131
|
if term.length < dsl.typo_tolerance_min_term_length.to_i
|
|
125
|
-
return like_prefix_ids(term)
|
|
132
|
+
return like_prefix_ids(term, candidate_limit: candidate_limit)
|
|
126
133
|
end
|
|
127
134
|
|
|
128
135
|
trigram_table = qt(FullSearch::Index.trigram_table_name(model))
|
|
@@ -139,10 +146,14 @@ module FullSearch
|
|
|
139
146
|
"AND #{trigram_table}.#{qc(name)} = #{q(value)}"
|
|
140
147
|
end.join(" ")
|
|
141
148
|
|
|
142
|
-
|
|
149
|
+
indexed_filter = dsl.conditional_index? ? " AND #{trigram_table}.indexed = '1'" : ""
|
|
150
|
+
|
|
151
|
+
sql += filter_conditions + indexed_filter
|
|
152
|
+
order_and_limit!(sql, tbl, candidate_limit)
|
|
153
|
+
connection.execute(sql).map { |r| r["id"] }
|
|
143
154
|
end
|
|
144
155
|
|
|
145
|
-
def like_prefix_ids(term)
|
|
156
|
+
def like_prefix_ids(term, candidate_limit: nil)
|
|
146
157
|
column_fields = dsl.fields.select { |f| f.source.nil? }
|
|
147
158
|
source_fields = dsl.fields.select { |f| f.source }
|
|
148
159
|
tbl = qt(model.table_name)
|
|
@@ -152,6 +163,8 @@ module FullSearch
|
|
|
152
163
|
soft_delete_clause = "AND #{tbl}.#{qc(dsl.soft_delete_column)} IS NULL"
|
|
153
164
|
end
|
|
154
165
|
|
|
166
|
+
indexed_clause = dsl.conditional_index? ? "AND (#{dsl.index_if_sql}) " : ""
|
|
167
|
+
|
|
155
168
|
ids = []
|
|
156
169
|
|
|
157
170
|
if column_fields.any?
|
|
@@ -166,9 +179,10 @@ module FullSearch
|
|
|
166
179
|
sql = <<~SQL
|
|
167
180
|
SELECT #{tbl}.id
|
|
168
181
|
FROM #{tbl}
|
|
169
|
-
WHERE (#{like_conditions}) #{filter_conditions} #{soft_delete_clause}
|
|
182
|
+
WHERE (#{like_conditions}) #{filter_conditions} #{soft_delete_clause} #{indexed_clause}
|
|
170
183
|
SQL
|
|
171
184
|
|
|
185
|
+
order_and_limit!(sql, tbl, candidate_limit)
|
|
172
186
|
ids = connection.execute(sql).map { |r| r["id"] }
|
|
173
187
|
return ids if ids.any?
|
|
174
188
|
end
|
|
@@ -183,20 +197,23 @@ module FullSearch
|
|
|
183
197
|
"AND #{fts_table}.#{qc(name)} = #{q(value)}"
|
|
184
198
|
end.join(" ")
|
|
185
199
|
|
|
200
|
+
fts_indexed = dsl.conditional_index? ? " AND #{fts_table}.indexed = '1'" : ""
|
|
201
|
+
|
|
186
202
|
sql = <<~SQL
|
|
187
203
|
SELECT #{tbl}.id
|
|
188
204
|
FROM #{fts_table}
|
|
189
205
|
JOIN #{tbl} ON #{tbl}.id = #{fts_table}.rowid
|
|
190
|
-
WHERE (#{like_conditions}) #{filter_conditions} #{soft_delete_clause}
|
|
206
|
+
WHERE (#{like_conditions}) #{filter_conditions} #{soft_delete_clause}#{fts_indexed}
|
|
191
207
|
SQL
|
|
192
208
|
|
|
209
|
+
order_and_limit!(sql, tbl, candidate_limit)
|
|
193
210
|
ids = connection.execute(sql).map { |r| r["id"] }
|
|
194
211
|
end
|
|
195
212
|
|
|
196
213
|
ids
|
|
197
214
|
end
|
|
198
215
|
|
|
199
|
-
def fuzzy_match_ids(parsed)
|
|
216
|
+
def fuzzy_match_ids(parsed, candidate_limit: nil)
|
|
200
217
|
term = parsed.last
|
|
201
218
|
return [] if term.nil?
|
|
202
219
|
|
|
@@ -217,6 +234,8 @@ module FullSearch
|
|
|
217
234
|
soft_delete_clause = "AND #{tbl}.#{qc(dsl.soft_delete_column)} IS NULL"
|
|
218
235
|
end
|
|
219
236
|
|
|
237
|
+
indexed_clause = dsl.conditional_index? ? "AND (#{dsl.index_if_sql}) " : ""
|
|
238
|
+
|
|
220
239
|
filter_conditions = filters.map do |name, value|
|
|
221
240
|
"AND #{tbl}.#{qc(name)} = #{q(value)}"
|
|
222
241
|
end.join(" ")
|
|
@@ -228,12 +247,21 @@ module FullSearch
|
|
|
228
247
|
sql = <<~SQL
|
|
229
248
|
SELECT #{tbl}.id
|
|
230
249
|
FROM #{tbl}
|
|
231
|
-
WHERE (#{conditions}) #{filter_conditions} #{soft_delete_clause}
|
|
250
|
+
WHERE (#{conditions}) #{filter_conditions} #{soft_delete_clause} #{indexed_clause}
|
|
232
251
|
SQL
|
|
233
252
|
|
|
253
|
+
order_and_limit!(sql, tbl, candidate_limit)
|
|
234
254
|
connection.execute(sql).map { |r| r["id"] }
|
|
235
255
|
end
|
|
236
256
|
|
|
257
|
+
def order_and_limit!(sql, tbl, candidate_limit)
|
|
258
|
+
order_parts = dsl.rank_bys.map do |rank_by|
|
|
259
|
+
"#{tbl}.#{qc(rank_by.column)} #{rank_by.direction.to_s.upcase} NULLS LAST"
|
|
260
|
+
end
|
|
261
|
+
sql << " ORDER BY #{order_parts.join(", ")}" if order_parts.any?
|
|
262
|
+
sql << " LIMIT #{candidate_limit.to_i}" if candidate_limit
|
|
263
|
+
end
|
|
264
|
+
|
|
237
265
|
def max_allowed_typos(length)
|
|
238
266
|
min_length = dsl.typo_tolerance_min_term_length.to_i
|
|
239
267
|
return -1 if length < min_length
|
data/lib/full_search/version.rb
CHANGED
data/lib/full_search.rb
CHANGED
|
@@ -22,6 +22,8 @@ require "full_search/soft_delete"
|
|
|
22
22
|
require "full_search/callbacks"
|
|
23
23
|
require "full_search/reindex_job"
|
|
24
24
|
require "full_search/optimize_job"
|
|
25
|
+
require "full_search/bulk_import"
|
|
26
|
+
require "full_search/backfill_job"
|
|
25
27
|
require "full_search/test_helpers"
|
|
26
28
|
require "full_search/multi_search"
|
|
27
29
|
require "full_search/schema_dumper_patch"
|
|
@@ -54,6 +56,14 @@ module FullSearch
|
|
|
54
56
|
Callbacks.install!(model)
|
|
55
57
|
end
|
|
56
58
|
end
|
|
59
|
+
|
|
60
|
+
def bulk_import(model, &block)
|
|
61
|
+
BulkImport.bulk_import(model, &block)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def bulk_importing?(model)
|
|
65
|
+
BulkImport.bulk_importing?(model)
|
|
66
|
+
end
|
|
57
67
|
end
|
|
58
68
|
end
|
|
59
69
|
|
|
@@ -8,4 +8,8 @@ FullSearch.configure do |config|
|
|
|
8
8
|
config.stale_query_behavior = Rails.env.production? ? :log_and_fallback : :raise
|
|
9
9
|
|
|
10
10
|
config.lock_rebuilds = true
|
|
11
|
+
|
|
12
|
+
# Reindex computed source: fields synchronously (false) or via background job (true).
|
|
13
|
+
# Bulk imports should use FullSearch.bulk_import(Model) { ... } to defer reindexing.
|
|
14
|
+
config.default_async_source_reindex = true
|
|
11
15
|
end
|
data/lib/tasks/full_search.rake
CHANGED
|
@@ -40,6 +40,16 @@ namespace :full_search do
|
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
desc "Backfill FTS indexes for given models (or all)"
|
|
44
|
+
task :backfill, [:models] => :environment do |_t, args|
|
|
45
|
+
Rails.application.eager_load!
|
|
46
|
+
|
|
47
|
+
resolve_full_search_models(args).each do |model|
|
|
48
|
+
FullSearch::BackfillJob.perform_now(model.name)
|
|
49
|
+
puts "Backfilled #{FullSearch::Index.fts_table_name(model)}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
43
53
|
desc "Optimize full_search indexes"
|
|
44
54
|
task optimize: :environment do
|
|
45
55
|
Rails.application.eager_load!
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: full_search
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ben D'Angelo
|
|
@@ -140,6 +140,8 @@ extra_rdoc_files: []
|
|
|
140
140
|
files:
|
|
141
141
|
- README.md
|
|
142
142
|
- lib/full_search.rb
|
|
143
|
+
- lib/full_search/backfill_job.rb
|
|
144
|
+
- lib/full_search/bulk_import.rb
|
|
143
145
|
- lib/full_search/callbacks.rb
|
|
144
146
|
- lib/full_search/config.rb
|
|
145
147
|
- lib/full_search/distance.rb
|