full_search 0.1.2 → 0.2.0
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 +95 -7
- data/lib/full_search/config.rb +3 -2
- data/lib/full_search/dsl.rb +8 -5
- data/lib/full_search/exact_match.rb +12 -3
- data/lib/full_search/highlighter.rb +74 -5
- data/lib/full_search/index.rb +35 -1
- data/lib/full_search/model.rb +21 -3
- data/lib/full_search/optimize_job.rb +13 -0
- data/lib/full_search/railtie.rb +2 -8
- data/lib/full_search/search.rb +118 -7
- data/lib/full_search/version.rb +1 -1
- data/lib/full_search.rb +5 -0
- data/lib/generators/full_search/install/templates/full_search.rb +1 -1
- data/lib/tasks/full_search.rake +41 -13
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 339422f4765fd1cd96ef55e9d026d782e10b08c2c6d1636d6e2e13454cf12b51
|
|
4
|
+
data.tar.gz: fc5b3a6105d9359162aebb6d14dddf6763e0e0e301351157a1c1c278386d3355
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 38a981537301ec24ec8ee6d38d1174e771b5dbdb3ebc4fa3e0bea857c60d3a440156e5cf9b1ae43be068c616f65381f4866d6294aa9aa4dab5a3e662cb3bfe44
|
|
7
|
+
data.tar.gz: 1c1af3ef0948200256f76013f66da00e1764379dbbc07ac8d90264e1a55b9e416218e8569675179736b59821b288aa9ce6fe24fccea48079f60847f127129451
|
data/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
SQLite FTS5 full-text search for Rails/ActiveRecord. A lightweight, self-contained alternative to `pg_search` for apps already running on SQLite.
|
|
4
4
|
|
|
5
|
+
## When to use
|
|
6
|
+
|
|
7
|
+
`full_search` is designed for apps with **100,000 records or fewer per table** that want full-text search without running a separate service. If you're on SQLite and need keyword search, phrase matching, typo-tolerant substring queries, or result highlighting, this gem gives you production-quality search with zero infrastructure — no Elasticsearch, no Meilisearch, no Sidekiq queue.
|
|
8
|
+
|
|
5
9
|
## Installation
|
|
6
10
|
|
|
7
11
|
Add to your Gemfile:
|
|
@@ -30,12 +34,12 @@ end
|
|
|
30
34
|
```
|
|
31
35
|
|
|
32
36
|
```ruby
|
|
33
|
-
Customer.
|
|
37
|
+
Customer.search("sam", filters: { account_id: 1 }).page(params[:page])
|
|
34
38
|
```
|
|
35
39
|
|
|
36
40
|
## Features
|
|
37
41
|
|
|
38
|
-
- Declarative `full_search` DSL
|
|
42
|
+
- Declarative `full_search` DSL with `search` / `full_search` query methods
|
|
39
43
|
- SQLite FTS5 backed
|
|
40
44
|
- Required-filter enforcement for multi-tenant apps
|
|
41
45
|
- Exact-match queries for encrypted identifiers
|
|
@@ -44,14 +48,98 @@ Customer.full_search("sam", filters: { account_id: 1 }).page(params[:page])
|
|
|
44
48
|
- Phrase, exclusion, and OR query operators
|
|
45
49
|
- FTS5 `highlight()` support for result snippets
|
|
46
50
|
- Opt-in trigram typo/substring fallback (requires SQLite >= 3.34)
|
|
47
|
-
|
|
51
|
+
|
|
52
|
+
### Per-model operations
|
|
53
|
+
|
|
54
|
+
Once a model declares `full_search`, you can call these class methods:
|
|
55
|
+
|
|
56
|
+
| Method | What it does |
|
|
57
|
+
|--------|-------------|
|
|
58
|
+
| `Customer.rebuild!` | Force-drop and recreate the model's FTS table, backfill from the source table, and reinstall triggers. |
|
|
59
|
+
| `Customer.reindex!` | Re-evaluates computed `source:` fields for every record and updates the FTS table in-place. Table structure is untouched. |
|
|
60
|
+
| `Customer.optimize!` | Runs FTS5 `optimize` on the model's index to merge b-tree segments. |
|
|
61
|
+
|
|
62
|
+
## Index management
|
|
63
|
+
|
|
64
|
+
FTS indexes are SQLite virtual tables (`customers_fts`, `vehicles_fts`, etc.) that mirror your model tables. They stay in sync via database triggers on `INSERT` / `UPDATE` / `DELETE`.
|
|
65
|
+
|
|
66
|
+
### Rebuild vs reindex
|
|
67
|
+
|
|
68
|
+
These two operations are often confused:
|
|
69
|
+
|
|
70
|
+
- **Rebuild** (`full_search:rebuild` / `Customer.rebuild!`) — drops and recreates the FTS virtual table. Needed when the DSL changes (fields added/removed, tokenizer changed, etc.). The table is re-created from scratch, backfilled, triggers re-installed, and the index is optimized.
|
|
71
|
+
- **Reindex** (`Customer.reindex!` / `FullSearch::Index.reindex_source_fields!`) — updates existing FTS rows with fresh values from computed `source:` fields only. The table structure is untouched. Database triggers cover regular column changes automatically; only Ruby-evaluated `source:` blocks need an explicit reindex.
|
|
72
|
+
|
|
73
|
+
### Auto-rebuild on app load
|
|
74
|
+
|
|
75
|
+
When `auto_rebuild_schema` is enabled (default in the generated initializer), the railtie hooks into Rails `after_initialize` and:
|
|
76
|
+
|
|
77
|
+
1. Creates missing FTS tables for every model using `full_search`
|
|
78
|
+
2. Compares each table's stored config hash against the current DSL
|
|
79
|
+
3. Rebuilds the index automatically when the DSL changes (new fields, different tokenizer, etc.)
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
# config/initializers/full_search.rb
|
|
83
|
+
FullSearch.configure do |config|
|
|
84
|
+
config.auto_rebuild_schema = true
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### In production
|
|
89
|
+
|
|
90
|
+
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:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# Rebuild only indexes whose DSL has changed (fast, safe for production)
|
|
94
|
+
bin/rails full_search:rebuild
|
|
95
|
+
|
|
96
|
+
# Target specific models by table name
|
|
97
|
+
bin/rails 'full_search:rebuild[customers,vehicles]'
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The rebuild task checks each model's stored config hash against the current DSL and only drops/recreates indexes when they differ. For a full forced reset (drops and recreates all FTS tables regardless):
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
bin/rails full_search:reset
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Rake tasks
|
|
107
|
+
|
|
108
|
+
| Task | Description |
|
|
109
|
+
|------|-------------|
|
|
110
|
+
| `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. |
|
|
111
|
+
| `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. |
|
|
112
|
+
| `full_search:optimize` | Run FTS5 [`optimize`](https://www.sqlite.org/fts5.html#the_optimize_command) to merge b-tree segments. Useful after bulk updates. |
|
|
113
|
+
| `full_search:status` | Show each model's index status (`ok` / `stale`) and count of empty sourced fields. |
|
|
114
|
+
|
|
115
|
+
## Background jobs
|
|
116
|
+
|
|
117
|
+
### Scheduled optimization
|
|
118
|
+
|
|
119
|
+
FTS5 b-tree segments accumulate over time as rows are inserted, updated, and deleted. Running `optimize` periodically merges these segments, keeping queries fast. For most apps, once a day during a low-traffic window is sufficient. Apps with heavy write volume may benefit from every few hours.
|
|
120
|
+
|
|
121
|
+
The gem ships `FullSearch::OptimizeJob` ready to use. Schedule it with Solid Queue's `recurring.yml`:
|
|
122
|
+
|
|
123
|
+
```yaml
|
|
124
|
+
# config/recurring.yml
|
|
125
|
+
full_search_optimize:
|
|
126
|
+
class: FullSearch::OptimizeJob
|
|
127
|
+
schedule: daily at 4am
|
|
128
|
+
description: "Merge FTS5 b-tree segments for full_search indexes"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
If you're not on Solid Queue, most job frameworks support recurring schedules via gems like `sidekiq-cron` or `whenever`.
|
|
132
|
+
|
|
133
|
+
### Config hash drift detection
|
|
134
|
+
|
|
135
|
+
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 still run but raise `ConfigChangedError` when the hash doesn't match.
|
|
48
136
|
|
|
49
137
|
## Query operators
|
|
50
138
|
|
|
51
139
|
```ruby
|
|
52
|
-
Customer.
|
|
53
|
-
Customer.
|
|
54
|
-
Customer.
|
|
140
|
+
Customer.search('"Sam Smith"', filters: { account_id: 1 }) # phrase
|
|
141
|
+
Customer.search("honda -civic", filters: { account_id: 1 }) # exclusion
|
|
142
|
+
Customer.search("honda OR toyota", filters: { account_id: 1 }) # OR
|
|
55
143
|
```
|
|
56
144
|
|
|
57
145
|
## Highlighting
|
|
@@ -66,7 +154,7 @@ end
|
|
|
66
154
|
```
|
|
67
155
|
|
|
68
156
|
```ruby
|
|
69
|
-
Customer.
|
|
157
|
+
Customer.search("sam", filters: { account_id: 1 }, highlight: true)
|
|
70
158
|
# each result has #full_search_snippet
|
|
71
159
|
```
|
|
72
160
|
|
data/lib/full_search/config.rb
CHANGED
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module FullSearch
|
|
4
4
|
class Config
|
|
5
|
-
attr_accessor :
|
|
5
|
+
attr_accessor :auto_rebuild_schema, :stale_query_behavior, :lock_rebuilds, :default_async_reindex, :default_tokenizer
|
|
6
6
|
|
|
7
7
|
def initialize
|
|
8
|
-
@
|
|
8
|
+
@auto_rebuild_schema = false
|
|
9
9
|
@stale_query_behavior = :raise
|
|
10
10
|
@lock_rebuilds = true
|
|
11
11
|
@default_async_reindex = true
|
|
12
12
|
@default_tokenizer = "unicode61"
|
|
13
13
|
end
|
|
14
|
+
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
class << self
|
data/lib/full_search/dsl.rb
CHANGED
|
@@ -4,7 +4,7 @@ module FullSearch
|
|
|
4
4
|
class Dsl
|
|
5
5
|
attr_reader :fields, :exact_matches, :filters, :model_class, :tokenize, :highlight_config, :rank_bys
|
|
6
6
|
|
|
7
|
-
Field = Data.define(:name, :weight, :source, :reindex_on, :async)
|
|
7
|
+
Field = Data.define(:name, :weight, :source, :reindex_on, :async, :as)
|
|
8
8
|
ExactMatch = Data.define(:name, :source)
|
|
9
9
|
Filter = Data.define(:name, :required)
|
|
10
10
|
RankBy = Data.define(:column, :direction)
|
|
@@ -19,11 +19,14 @@ module FullSearch
|
|
|
19
19
|
@soft_delete_column = nil
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def field(name, weight: 1, source: nil, reindex_on: nil, async: FullSearch.config.default_async_reindex)
|
|
22
|
+
def field(name, weight: 1, source: nil, reindex_on: nil, async: FullSearch.config.default_async_reindex, as: nil)
|
|
23
23
|
unless valid_name?(name)
|
|
24
24
|
raise InvalidFieldError, "Invalid field name: #{name.inspect}"
|
|
25
25
|
end
|
|
26
|
-
|
|
26
|
+
if as && !valid_name?(as)
|
|
27
|
+
raise InvalidFieldError, "Invalid field alias (as): #{as.inspect}"
|
|
28
|
+
end
|
|
29
|
+
@fields << Field.new(name: name.to_s, weight: weight.to_i, source: source, reindex_on: reindex_on&.to_s, async: async, as: as&.to_s)
|
|
27
30
|
end
|
|
28
31
|
|
|
29
32
|
def exact_match(name, source: -> { public_send(name) })
|
|
@@ -88,8 +91,8 @@ module FullSearch
|
|
|
88
91
|
soft_delete_column,
|
|
89
92
|
typo_tolerance?,
|
|
90
93
|
typo_tolerance_min_term_length,
|
|
91
|
-
fields.map { |f| [f.name, f.weight, f.source.nil? ? "column" :
|
|
92
|
-
exact_matches.map { |e| [e.name] },
|
|
94
|
+
fields.map { |f| [f.name, f.weight, f.source.nil? ? "column" : f.source.source_location&.join(":"), f.reindex_on, f.async, f.as] },
|
|
95
|
+
exact_matches.map { |e| [e.name, e.source&.source_location&.join(":")] },
|
|
93
96
|
filters.map { |f| [f.name, f.required] },
|
|
94
97
|
rank_bys.map { |r| [r.column, r.direction] }
|
|
95
98
|
].inspect)
|
|
@@ -12,11 +12,20 @@ module FullSearch
|
|
|
12
12
|
relation = model.all
|
|
13
13
|
filters.each { |name, value| relation = relation.where(name => value) }
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
exact_ids = dsl.exact_matches.flat_map do |em|
|
|
16
|
+
value = exact_match_value(em, cleaned)
|
|
17
|
+
next [] if value.nil? || value.to_s.empty?
|
|
18
|
+
|
|
19
|
+
relation.where(em.name => value).pluck(:id)
|
|
17
20
|
end
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
exact_ids.uniq
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.exact_match_value(em, query)
|
|
26
|
+
fake = Object.new
|
|
27
|
+
fake.define_singleton_method(em.name) { query }
|
|
28
|
+
fake.instance_exec(&em.source)
|
|
20
29
|
end
|
|
21
30
|
end
|
|
22
31
|
end
|
|
@@ -4,13 +4,24 @@ module FullSearch
|
|
|
4
4
|
class Highlighter
|
|
5
5
|
def self.apply!(records, model, query)
|
|
6
6
|
snippets = build_snippets(model, query)
|
|
7
|
+
if snippets.values.all?(&:nil?) && records.any?
|
|
8
|
+
snippets = manual_snippets(records, model, query)
|
|
9
|
+
end
|
|
7
10
|
records.each { |record| record.full_search_snippet = snippets[record.id] }
|
|
8
11
|
records
|
|
9
12
|
end
|
|
10
13
|
|
|
11
14
|
def self.apply_fields!(records, model, query)
|
|
12
15
|
fields = build_field_snippets(model, query)
|
|
13
|
-
|
|
16
|
+
if fields.empty? && records.any?
|
|
17
|
+
fields = manual_field_snippets(records, model, query)
|
|
18
|
+
end
|
|
19
|
+
exact_fields = exact_match_field_snippets(records, model, query)
|
|
20
|
+
records.each do |record|
|
|
21
|
+
merged = fields[record.id] || {}
|
|
22
|
+
exact_fields.fetch(record.id, {}).each { |k, v| merged[k] ||= v }
|
|
23
|
+
record.full_search_highlight_fields = merged
|
|
24
|
+
end
|
|
14
25
|
records
|
|
15
26
|
end
|
|
16
27
|
|
|
@@ -29,18 +40,76 @@ module FullSearch
|
|
|
29
40
|
def self.build_field_snippets(model, query)
|
|
30
41
|
rows = highlight_rows(model, query)
|
|
31
42
|
dsl = model.full_search_dsl
|
|
32
|
-
|
|
43
|
+
fields = dsl.fields
|
|
33
44
|
open_tag = (dsl.highlight_config || { open_tag: "<mark>" })[:open_tag]
|
|
34
45
|
|
|
35
46
|
rows.to_h do |row|
|
|
36
|
-
snippets =
|
|
37
|
-
snippet = row["#{
|
|
38
|
-
|
|
47
|
+
snippets = fields.each_with_object({}) do |field, hash|
|
|
48
|
+
snippet = row["#{field.name}_snippet"].to_s.strip
|
|
49
|
+
key = field.as || field.name
|
|
50
|
+
hash[key] = snippet if snippet.include?(open_tag)
|
|
39
51
|
end
|
|
40
52
|
[row["rowid"], snippets]
|
|
41
53
|
end
|
|
42
54
|
end
|
|
43
55
|
|
|
56
|
+
def self.manual_snippets(records, model, query)
|
|
57
|
+
dsl = model.full_search_dsl
|
|
58
|
+
config = dsl.highlight_config || { open_tag: "<mark>", close_tag: "</mark>" }
|
|
59
|
+
cols = dsl.fields.map(&:name)
|
|
60
|
+
|
|
61
|
+
records.to_h do |record|
|
|
62
|
+
text = cols.map { |col| record.full_search_text_for(col).to_s }.join(" ").strip
|
|
63
|
+
highlighted = manual_highlight(text, query, config)
|
|
64
|
+
[record.id, highlighted.presence]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.manual_field_snippets(records, model, query)
|
|
69
|
+
dsl = model.full_search_dsl
|
|
70
|
+
config = dsl.highlight_config || { open_tag: "<mark>", close_tag: "</mark>" }
|
|
71
|
+
fields = dsl.fields
|
|
72
|
+
|
|
73
|
+
records.to_h do |record|
|
|
74
|
+
snippets = fields.each_with_object({}) do |field, hash|
|
|
75
|
+
value = record.full_search_text_for(field.name).to_s
|
|
76
|
+
highlighted = manual_highlight(value, query, config)
|
|
77
|
+
key = field.as || field.name
|
|
78
|
+
hash[key] = highlighted if highlighted.include?(config[:open_tag])
|
|
79
|
+
end
|
|
80
|
+
[record.id, snippets]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.exact_match_field_snippets(records, model, query)
|
|
85
|
+
dsl = model.full_search_dsl
|
|
86
|
+
return {} if dsl.exact_matches.empty?
|
|
87
|
+
|
|
88
|
+
config = dsl.highlight_config || { open_tag: "<mark>", close_tag: "</mark>" }
|
|
89
|
+
|
|
90
|
+
records.to_h do |record|
|
|
91
|
+
snippets = dsl.exact_matches.each_with_object({}) do |em, hash|
|
|
92
|
+
value = exact_match_field_value(em, record)
|
|
93
|
+
highlighted = manual_highlight(value.to_s, query, config)
|
|
94
|
+
hash[em.name.to_s] = highlighted if highlighted.include?(config[:open_tag])
|
|
95
|
+
end
|
|
96
|
+
[record.id, snippets]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.exact_match_field_value(em, record)
|
|
101
|
+
if em.source
|
|
102
|
+
record.instance_exec(&em.source)
|
|
103
|
+
else
|
|
104
|
+
record.public_send(em.name)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.manual_highlight(text, query, config)
|
|
109
|
+
return text if text.empty? || query.empty?
|
|
110
|
+
text.gsub(/#{Regexp.escape(query)}/i, "#{config[:open_tag]}\\0#{config[:close_tag]}")
|
|
111
|
+
end
|
|
112
|
+
|
|
44
113
|
def self.highlight_rows(model, query)
|
|
45
114
|
dsl = model.full_search_dsl
|
|
46
115
|
config = dsl.highlight_config || { open_tag: "<mark>", close_tag: "</mark>" }
|
data/lib/full_search/index.rb
CHANGED
|
@@ -14,17 +14,26 @@ module FullSearch
|
|
|
14
14
|
|
|
15
15
|
create_metadata_table!
|
|
16
16
|
|
|
17
|
+
table_was_created = false
|
|
17
18
|
unless table_exists?(model)
|
|
18
19
|
conn.execute(create_virtual_table_sql(model))
|
|
20
|
+
table_was_created = true
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
if dsl.typo_tolerance? && !trigram_table_exists?(model)
|
|
22
24
|
FullSearch::Typo.warn_unsupported! unless FullSearch::Typo.supported?
|
|
23
25
|
conn.execute(create_trigram_virtual_table_sql(model))
|
|
26
|
+
table_was_created = true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if table_was_created
|
|
30
|
+
conn.execute(backfill_sql(model))
|
|
31
|
+
conn.execute(backfill_trigram_sql(model)) if dsl.typo_tolerance?
|
|
32
|
+
reindex_source_fields!(model) if dsl.fields.any?(&:source)
|
|
33
|
+
store_config_hash!(model)
|
|
24
34
|
end
|
|
25
35
|
|
|
26
36
|
ensure_triggers!(model) if model_table_exists?(model)
|
|
27
|
-
store_config_hash!(model)
|
|
28
37
|
end
|
|
29
38
|
|
|
30
39
|
def rebuild!(model)
|
|
@@ -47,16 +56,41 @@ module FullSearch
|
|
|
47
56
|
end
|
|
48
57
|
conn.execute(backfill_sql(model))
|
|
49
58
|
conn.execute(backfill_trigram_sql(model)) if dsl.typo_tolerance?
|
|
59
|
+
reindex_source_fields!(model) if dsl.fields.any?(&:source)
|
|
50
60
|
create_triggers!(model)
|
|
51
61
|
optimize!(model)
|
|
52
62
|
store_config_hash!(model, rebuilt_at: Time.current)
|
|
53
63
|
end
|
|
54
64
|
end
|
|
55
65
|
|
|
66
|
+
def rebuild_if_needed!(model)
|
|
67
|
+
return false unless FullSearch::Index.sqlite?
|
|
68
|
+
|
|
69
|
+
dsl = model.full_search_dsl
|
|
70
|
+
return false unless dsl
|
|
71
|
+
|
|
72
|
+
create_metadata_table!
|
|
73
|
+
ensure_table!(model)
|
|
74
|
+
|
|
75
|
+
stored = stored_config_hash(model)
|
|
76
|
+
if stored && stored == dsl.config_hash
|
|
77
|
+
false
|
|
78
|
+
else
|
|
79
|
+
rebuild!(model)
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
56
84
|
def optimize!(model)
|
|
57
85
|
connection.execute("INSERT INTO #{fts_table_name(model)}(#{fts_table_name(model)}) VALUES('optimize');")
|
|
58
86
|
end
|
|
59
87
|
|
|
88
|
+
def reindex_source_fields!(model)
|
|
89
|
+
model.find_each do |record|
|
|
90
|
+
FullSearch::Callbacks.reindex_record!(record)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
60
94
|
def drop!(model)
|
|
61
95
|
drop_triggers!(model)
|
|
62
96
|
connection.execute("DROP TABLE IF EXISTS #{fts_table_name(model)};")
|
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, &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, &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.models << self unless FullSearch.models.include?(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).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).relation
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
@@ -27,6 +27,24 @@ module FullSearch
|
|
|
27
27
|
def full_search_ids(query, filters: {}, include_soft_deleted: false, limit: 1000)
|
|
28
28
|
full_search(query, filters: filters, include_soft_deleted: include_soft_deleted, limit: limit).pluck(:id)
|
|
29
29
|
end
|
|
30
|
+
|
|
31
|
+
def rebuild!
|
|
32
|
+
FullSearch::Index.rebuild!(self)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def optimize!
|
|
36
|
+
FullSearch::Index.optimize!(self)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reindex!
|
|
40
|
+
FullSearch::Index.reindex_source_fields!(self)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
included do |base|
|
|
45
|
+
unless base.respond_to?(:search)
|
|
46
|
+
base.singleton_class.alias_method :search, :full_search
|
|
47
|
+
end
|
|
30
48
|
end
|
|
31
49
|
|
|
32
50
|
module InstanceMethods
|
|
@@ -34,7 +52,7 @@ module FullSearch
|
|
|
34
52
|
|
|
35
53
|
def full_search_text_for(field_name)
|
|
36
54
|
dsl = self.class.full_search_dsl
|
|
37
|
-
field = dsl.fields.find { |f| f.name == field_name.to_s }
|
|
55
|
+
field = dsl.fields.find { |f| f.name == field_name.to_s || f.as == field_name.to_s }
|
|
38
56
|
return nil unless field
|
|
39
57
|
|
|
40
58
|
field.source ? instance_exec(&field.source) : public_send(field.name)
|
data/lib/full_search/railtie.rb
CHANGED
|
@@ -10,16 +10,10 @@ module FullSearch
|
|
|
10
10
|
|
|
11
11
|
initializer "full_search.ensure_tables" do
|
|
12
12
|
config.after_initialize do
|
|
13
|
-
next unless FullSearch.config.
|
|
13
|
+
next unless FullSearch.config.auto_rebuild_schema
|
|
14
14
|
ActiveSupport.on_load(:active_record) do
|
|
15
15
|
FullSearch.models.each do |model|
|
|
16
|
-
FullSearch::Index.
|
|
17
|
-
next unless FullSearch.config.auto_manage_schema == true
|
|
18
|
-
|
|
19
|
-
stored = FullSearch::Index.stored_config_hash(model)
|
|
20
|
-
if stored && stored != model.full_search_dsl.config_hash
|
|
21
|
-
FullSearch::Index.rebuild!(model)
|
|
22
|
-
end
|
|
16
|
+
FullSearch::Index.rebuild_if_needed!(model)
|
|
23
17
|
end
|
|
24
18
|
end
|
|
25
19
|
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
|
|
5
|
+
attr_reader :model, :query, :filters, :include_soft_deleted, :limit, :offset, :highlight, :highlight_fields, :matching_strategy
|
|
6
6
|
|
|
7
|
-
def initialize(model, query, filters:, include_soft_deleted:, limit:, offset:, highlight: false, highlight_fields: false)
|
|
7
|
+
def initialize(model, query, filters:, include_soft_deleted:, limit:, offset:, highlight: false, highlight_fields: false, matching_strategy: nil)
|
|
8
8
|
@model = model
|
|
9
9
|
@query = query.to_s.strip
|
|
10
10
|
@filters = filters
|
|
@@ -13,6 +13,7 @@ module FullSearch
|
|
|
13
13
|
@offset = offset
|
|
14
14
|
@highlight = highlight
|
|
15
15
|
@highlight_fields = highlight_fields
|
|
16
|
+
@matching_strategy = matching_strategy
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def relation
|
|
@@ -21,9 +22,10 @@ module FullSearch
|
|
|
21
22
|
parsed = QueryParser.parse(query)
|
|
22
23
|
exact_ids = ExactMatch.ids_for(model, query, filters)
|
|
23
24
|
primary_ids = fts_match_ids(parsed)
|
|
24
|
-
fallback_ids = dsl.typo_tolerance? ? trigram_match_ids(parsed, primary_ids) : []
|
|
25
|
+
fallback_ids = dsl.typo_tolerance? && matching_strategy != "all" ? trigram_match_ids(parsed, primary_ids) : []
|
|
26
|
+
fuzzy_ids = dsl.typo_tolerance? && matching_strategy != "all" && primary_ids.empty? && fallback_ids.empty? ? fuzzy_match_ids(parsed) : []
|
|
25
27
|
|
|
26
|
-
all_ids = (exact_ids + primary_ids + fallback_ids).uniq
|
|
28
|
+
all_ids = (exact_ids + primary_ids + fallback_ids + fuzzy_ids).uniq
|
|
27
29
|
return model.none if all_ids.empty?
|
|
28
30
|
|
|
29
31
|
rel = model.where(id: all_ids)
|
|
@@ -105,17 +107,76 @@ module FullSearch
|
|
|
105
107
|
end
|
|
106
108
|
|
|
107
109
|
def like_prefix_ids(term)
|
|
110
|
+
column_fields = dsl.fields.select { |f| f.source.nil? }
|
|
111
|
+
source_fields = dsl.fields.select { |f| f.source }
|
|
112
|
+
|
|
113
|
+
ids = []
|
|
114
|
+
|
|
115
|
+
if column_fields.any?
|
|
116
|
+
like_conditions = column_fields.map do |field|
|
|
117
|
+
"#{connection.quote_table_name(model.table_name)}.#{connection.quote_column_name(field.name)} LIKE #{connection.quote("#{term}%")}"
|
|
118
|
+
end.join(" OR ")
|
|
119
|
+
|
|
120
|
+
sql = <<~SQL
|
|
121
|
+
SELECT #{model.table_name}.id
|
|
122
|
+
FROM #{model.table_name}
|
|
123
|
+
WHERE (#{like_conditions})
|
|
124
|
+
SQL
|
|
125
|
+
|
|
126
|
+
filter_conditions = filters.map do |name, value|
|
|
127
|
+
"AND #{model.table_name}.#{name} = #{connection.quote(value)}"
|
|
128
|
+
end.join(" ")
|
|
129
|
+
|
|
130
|
+
ids = connection.execute("#{sql} #{filter_conditions}").map { |r| r["id"] }
|
|
131
|
+
return ids if ids.any?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
if source_fields.any?
|
|
135
|
+
fts_table = FullSearch::Index.fts_table_name(model)
|
|
136
|
+
like_conditions = source_fields.map do |field|
|
|
137
|
+
"#{fts_table}.#{field.name} LIKE #{connection.quote("#{term}%")}"
|
|
138
|
+
end.join(" OR ")
|
|
139
|
+
|
|
140
|
+
sql = <<~SQL
|
|
141
|
+
SELECT #{model.table_name}.id
|
|
142
|
+
FROM #{fts_table}
|
|
143
|
+
JOIN #{model.table_name} ON #{model.table_name}.id = #{fts_table}.rowid
|
|
144
|
+
WHERE (#{like_conditions})
|
|
145
|
+
SQL
|
|
146
|
+
|
|
147
|
+
filter_conditions = filters.map do |name, value|
|
|
148
|
+
"AND #{fts_table}.#{name} = #{connection.quote(value)}"
|
|
149
|
+
end.join(" ")
|
|
150
|
+
|
|
151
|
+
ids = connection.execute("#{sql} #{filter_conditions}").map { |r| r["id"] }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
ids
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def fuzzy_match_ids(parsed)
|
|
158
|
+
term = parsed.last rescue nil
|
|
159
|
+
return [] if term.nil?
|
|
160
|
+
|
|
161
|
+
term_str = term.is_a?(Array) ? extract_last_term_string(term) : term.to_s
|
|
162
|
+
return [] if term_str.empty?
|
|
163
|
+
|
|
164
|
+
max_typos = max_allowed_typos(term_str.length)
|
|
165
|
+
return [] if max_typos < 0
|
|
166
|
+
|
|
108
167
|
column_fields = dsl.fields.select { |f| f.source.nil? }
|
|
109
168
|
return [] if column_fields.empty?
|
|
110
169
|
|
|
111
|
-
|
|
112
|
-
|
|
170
|
+
register_levenshtein!
|
|
171
|
+
|
|
172
|
+
conditions = column_fields.map do |field|
|
|
173
|
+
"levenshtein(LOWER(#{connection.quote_table_name(model.table_name)}.#{connection.quote_column_name(field.name)}), #{connection.quote(term_str.downcase)}) <= #{max_typos}"
|
|
113
174
|
end.join(" OR ")
|
|
114
175
|
|
|
115
176
|
sql = <<~SQL
|
|
116
177
|
SELECT #{model.table_name}.id
|
|
117
178
|
FROM #{model.table_name}
|
|
118
|
-
WHERE (#{
|
|
179
|
+
WHERE (#{conditions})
|
|
119
180
|
SQL
|
|
120
181
|
|
|
121
182
|
filter_conditions = filters.map do |name, value|
|
|
@@ -125,6 +186,56 @@ module FullSearch
|
|
|
125
186
|
connection.execute("#{sql} #{filter_conditions}").map { |r| r["id"] }
|
|
126
187
|
end
|
|
127
188
|
|
|
189
|
+
def max_allowed_typos(length)
|
|
190
|
+
min_length = dsl.typo_tolerance_min_term_length.to_i
|
|
191
|
+
return -1 if length < min_length
|
|
192
|
+
return 2 if length >= 9
|
|
193
|
+
1
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def extract_last_term_string(terms)
|
|
197
|
+
last = terms.last
|
|
198
|
+
last.is_a?(Array) ? last.last.to_s : last.to_s
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def register_levenshtein!
|
|
202
|
+
return if @levenshtein_registered
|
|
203
|
+
|
|
204
|
+
raw = connection.raw_connection
|
|
205
|
+
raw.create_function("levenshtein", 2) do |func, s1, s2|
|
|
206
|
+
func.result = damerau_levenshtein(s1.to_s, s2.to_s)
|
|
207
|
+
end
|
|
208
|
+
@levenshtein_registered = true
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def damerau_levenshtein(a, b)
|
|
212
|
+
a_len = a.length
|
|
213
|
+
b_len = b.length
|
|
214
|
+
return a_len if b_len == 0
|
|
215
|
+
return b_len if a_len == 0
|
|
216
|
+
|
|
217
|
+
d = Array.new(a_len + 1) { Array.new(b_len + 1, 0) }
|
|
218
|
+
(0..a_len).each { |i| d[i][0] = i }
|
|
219
|
+
(0..b_len).each { |j| d[0][j] = j }
|
|
220
|
+
|
|
221
|
+
(1..a_len).each do |i|
|
|
222
|
+
(1..b_len).each do |j|
|
|
223
|
+
cost = a[i - 1] == b[j - 1] ? 0 : 1
|
|
224
|
+
d[i][j] = [
|
|
225
|
+
d[i - 1][j] + 1,
|
|
226
|
+
d[i][j - 1] + 1,
|
|
227
|
+
d[i - 1][j - 1] + cost
|
|
228
|
+
].min
|
|
229
|
+
|
|
230
|
+
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1]
|
|
231
|
+
d[i][j] = [d[i][j], d[i - 2][j - 2] + 1].min
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
d[a_len][b_len]
|
|
237
|
+
end
|
|
238
|
+
|
|
128
239
|
def apply_ranking(rel, all_ids, exact_ids)
|
|
129
240
|
return rel if all_ids.empty?
|
|
130
241
|
|
data/lib/full_search/version.rb
CHANGED
data/lib/full_search.rb
CHANGED
|
@@ -19,6 +19,7 @@ require "full_search/search"
|
|
|
19
19
|
require "full_search/soft_delete"
|
|
20
20
|
require "full_search/callbacks"
|
|
21
21
|
require "full_search/reindex_job"
|
|
22
|
+
require "full_search/optimize_job"
|
|
22
23
|
require "full_search/test_helpers"
|
|
23
24
|
require "full_search/multi_search"
|
|
24
25
|
|
|
@@ -31,6 +32,10 @@ module FullSearch
|
|
|
31
32
|
def models
|
|
32
33
|
@models ||= []
|
|
33
34
|
end
|
|
35
|
+
|
|
36
|
+
def optimize!
|
|
37
|
+
models.each { |model| Index.optimize!(model) }
|
|
38
|
+
end
|
|
34
39
|
end
|
|
35
40
|
end
|
|
36
41
|
|
data/lib/tasks/full_search.rake
CHANGED
|
@@ -1,30 +1,45 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
def resolve_full_search_models(args)
|
|
4
|
+
if args[:models]
|
|
5
|
+
args[:models].split(",").map do |name|
|
|
6
|
+
klass = name.singularize.camelize.constantize rescue nil
|
|
7
|
+
klass || FullSearch.models.find { |m| m.table_name == name }
|
|
8
|
+
end.compact
|
|
9
|
+
else
|
|
10
|
+
FullSearch.models
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
3
14
|
namespace :full_search do
|
|
4
|
-
desc "Rebuild
|
|
15
|
+
desc "Rebuild indexes when DSL has changed (checks config hash; safe for production)"
|
|
5
16
|
task :rebuild, [:models] => :environment do |_t, args|
|
|
6
17
|
Rails.application.eager_load!
|
|
7
18
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
resolve_full_search_models(args).each do |model|
|
|
20
|
+
if FullSearch::Index.rebuild_if_needed!(model)
|
|
21
|
+
puts "Rebuilt #{FullSearch::Index.fts_table_name(model)}"
|
|
22
|
+
else
|
|
23
|
+
puts "#{FullSearch::Index.fts_table_name(model)} is current"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
16
27
|
|
|
17
|
-
|
|
28
|
+
desc "Force reset indexes (drops and recreates all FTS tables)"
|
|
29
|
+
task :reset, [:models] => :environment do |_t, args|
|
|
30
|
+
Rails.application.eager_load!
|
|
31
|
+
|
|
32
|
+
resolve_full_search_models(args).each do |model|
|
|
18
33
|
FullSearch::Index.rebuild!(model)
|
|
19
|
-
puts "
|
|
34
|
+
puts "Reset #{FullSearch::Index.fts_table_name(model)}"
|
|
20
35
|
end
|
|
21
36
|
end
|
|
22
37
|
|
|
23
38
|
desc "Optimize full_search indexes"
|
|
24
39
|
task optimize: :environment do
|
|
25
40
|
Rails.application.eager_load!
|
|
41
|
+
FullSearch.optimize!
|
|
26
42
|
FullSearch.models.each do |model|
|
|
27
|
-
FullSearch::Index.optimize!(model)
|
|
28
43
|
puts "Optimized #{FullSearch::Index.fts_table_name(model)}"
|
|
29
44
|
end
|
|
30
45
|
end
|
|
@@ -36,7 +51,20 @@ namespace :full_search do
|
|
|
36
51
|
stored = FullSearch::Index.stored_config_hash(model)
|
|
37
52
|
current = model.full_search_dsl.config_hash
|
|
38
53
|
status = stored == current ? "ok" : "stale"
|
|
39
|
-
|
|
54
|
+
source_fields = model.full_search_dsl.fields.select(&:source).map(&:name)
|
|
55
|
+
|
|
56
|
+
drift_info = if source_fields.any?
|
|
57
|
+
empty_count = ActiveRecord::Base.connection.execute(<<~SQL).first["c"]
|
|
58
|
+
SELECT COUNT(*) AS c
|
|
59
|
+
FROM #{FullSearch::Index.fts_table_name(model)}
|
|
60
|
+
WHERE #{source_fields.map { |c| "#{c} = ''" }.join(' OR ')}
|
|
61
|
+
SQL
|
|
62
|
+
" | empty source fields: #{empty_count}"
|
|
63
|
+
else
|
|
64
|
+
""
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
puts "#{model.table_name}: #{status}#{drift_info}"
|
|
40
68
|
end
|
|
41
69
|
end
|
|
42
70
|
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ben D'Angelo
|
|
@@ -93,6 +93,20 @@ dependencies:
|
|
|
93
93
|
- - "~>"
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
95
|
version: '13.0'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: irb
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '0'
|
|
96
110
|
description: Declarative full-text search for Rails apps backed by SQLite FTS5
|
|
97
111
|
executables: []
|
|
98
112
|
extensions: []
|
|
@@ -109,6 +123,7 @@ files:
|
|
|
109
123
|
- lib/full_search/index.rb
|
|
110
124
|
- lib/full_search/model.rb
|
|
111
125
|
- lib/full_search/multi_search.rb
|
|
126
|
+
- lib/full_search/optimize_job.rb
|
|
112
127
|
- lib/full_search/query_parser.rb
|
|
113
128
|
- lib/full_search/railtie.rb
|
|
114
129
|
- lib/full_search/reindex_job.rb
|