full_search 0.3.1 → 0.3.3
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 +17 -1
- data/lib/full_search/callbacks.rb +5 -1
- data/lib/full_search/config.rb +2 -1
- data/lib/full_search/dsl.rb +12 -12
- data/lib/full_search/index.rb +10 -0
- data/lib/full_search/model.rb +11 -2
- data/lib/full_search/search.rb +12 -7
- data/lib/full_search/version.rb +1 -1
- data/lib/full_search.rb +7 -0
- data/lib/generators/full_search/install/templates/full_search.rb +2 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8c3e974821a38f57a0d71c6a2ad0260942773836c3404c9b3665bb9cc0a6d305
|
|
4
|
+
data.tar.gz: 841b5bb5c5086942f47bbf7626455d4a27d4261778dfc845e22d2d5262d0ff41
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88f20b95e78a0de07571c06560df3c9e45ac5927106dc600e6d8f4a9702611ee776d0f2c2edec9c9cfd5a26aacc5e91c727ae156043c46767ca995c4228f37b5
|
|
7
|
+
data.tar.gz: 8ba992243ac6ae15fb755d0817c75e6f8e41ec1cb4bad856c75cc9b7fe9ee3f760da92b97fcdbd1093677eaf83e93701c1f917ac82f26c650274e3891de74f6f
|
data/README.md
CHANGED
|
@@ -6,7 +6,7 @@ SQLite FTS5 full-text search for Rails/ActiveRecord. A lightweight, self-contain
|
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
9
|
-
- Ruby 3.
|
|
9
|
+
- Ruby 3.2+
|
|
10
10
|
- Rails 8.0+
|
|
11
11
|
- SQLite 3.34+ (if using typo tolerance)
|
|
12
12
|
|
|
@@ -78,6 +78,10 @@ These two operations are often confused:
|
|
|
78
78
|
- **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.
|
|
79
79
|
- **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.
|
|
80
80
|
|
|
81
|
+
### Setup lifecycle
|
|
82
|
+
|
|
83
|
+
`full_search` evaluates the DSL block and installs callbacks when the model class loads, but it does **not** create the FTS table until Rails `after_initialize` (or until you call `FullSearch.setup!` or `FullSearch::Index.rebuild!` manually). This guarantees that `source:` blocks run against fully-loaded model definitions, avoiding load-order issues where associations or methods defined later in the class body are not yet available.
|
|
84
|
+
|
|
81
85
|
> **Note on rebuild locking:** The `lock_rebuilds` option prevents concurrent rebuilds
|
|
82
86
|
> within the same process/connection. For multi-process or multi-host deployments,
|
|
83
87
|
> run `full_search:rebuild` from a single deployment step.
|
|
@@ -146,6 +150,18 @@ If you're not on Solid Queue, most job frameworks support recurring schedules vi
|
|
|
146
150
|
|
|
147
151
|
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`).
|
|
148
152
|
|
|
153
|
+
### Query-time auto-rebuild in local environments
|
|
154
|
+
|
|
155
|
+
By default, the generated initializer also enables `auto_rebuild_on_stale_query` in local environments:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
FullSearch.configure do |config|
|
|
159
|
+
config.auto_rebuild_on_stale_query = Rails.env.local?
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
When this option is `true`, the first search that detects a stale index automatically calls `FullSearch::Index.rebuild_if_needed!` and then proceeds with the query, so you don't need to restart the server or run a Rake task during iterative development. In production, this defaults to `false`; use `full_search:rebuild` or boot-time `auto_rebuild_schema` instead.
|
|
164
|
+
|
|
149
165
|
## Query operators
|
|
150
166
|
|
|
151
167
|
```ruby
|
|
@@ -53,18 +53,22 @@ module FullSearch
|
|
|
53
53
|
field = dsl.fields.find { |f| f.name == field_name }
|
|
54
54
|
return unless field&.source
|
|
55
55
|
|
|
56
|
-
value =
|
|
56
|
+
value = FullSearch::Model.evaluate_source(record, field)
|
|
57
57
|
table = qt(FullSearch::Index.fts_table_name(record.class))
|
|
58
58
|
conn = ActiveRecord::Base.connection
|
|
59
59
|
conn.execute(
|
|
60
60
|
"UPDATE #{table} SET #{qc(field.name)} = #{q(value.to_s)} WHERE rowid = #{q(record.id)}"
|
|
61
61
|
)
|
|
62
|
+
rescue => e
|
|
63
|
+
raise unless e.message.include?("no such table")
|
|
62
64
|
end
|
|
63
65
|
|
|
64
66
|
def self.remove_record!(record)
|
|
65
67
|
table = qt(FullSearch::Index.fts_table_name(record.class))
|
|
66
68
|
conn = ActiveRecord::Base.connection
|
|
67
69
|
conn.execute("DELETE FROM #{table} WHERE rowid = #{q(record.id)}")
|
|
70
|
+
rescue => e
|
|
71
|
+
raise unless e.message.include?("no such table")
|
|
68
72
|
end
|
|
69
73
|
|
|
70
74
|
def self.reindex_dependents!(parent_record, dependent_model, field)
|
data/lib/full_search/config.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module FullSearch
|
|
4
4
|
class Config
|
|
5
|
-
attr_accessor :auto_rebuild_schema, :stale_query_behavior, :lock_rebuilds, :default_async_reindex, :default_tokenizer
|
|
5
|
+
attr_accessor :auto_rebuild_schema, :stale_query_behavior, :lock_rebuilds, :default_async_reindex, :default_tokenizer, :auto_rebuild_on_stale_query
|
|
6
6
|
|
|
7
7
|
def initialize
|
|
8
8
|
@auto_rebuild_schema = false
|
|
@@ -10,6 +10,7 @@ module FullSearch
|
|
|
10
10
|
@lock_rebuilds = true
|
|
11
11
|
@default_async_reindex = true
|
|
12
12
|
@default_tokenizer = "unicode61"
|
|
13
|
+
@auto_rebuild_on_stale_query = false
|
|
13
14
|
end
|
|
14
15
|
end
|
|
15
16
|
|
data/lib/full_search/dsl.rb
CHANGED
|
@@ -21,46 +21,46 @@ module FullSearch
|
|
|
21
21
|
|
|
22
22
|
def field(name, weight: 1, source: nil, reindex_on: nil, async: FullSearch.config.default_async_reindex, as: nil, version: nil)
|
|
23
23
|
unless valid_name?(name)
|
|
24
|
-
raise InvalidFieldError, "
|
|
24
|
+
raise InvalidFieldError, "#{model_class.name}: invalid field name #{name.inspect}"
|
|
25
25
|
end
|
|
26
26
|
if as && !valid_name?(as)
|
|
27
|
-
raise InvalidFieldError, "
|
|
27
|
+
raise InvalidFieldError, "#{model_class.name}: invalid field alias (as): #{as.inspect}"
|
|
28
28
|
end
|
|
29
29
|
str = name.to_s
|
|
30
|
-
raise InvalidFieldError, "
|
|
31
|
-
raise InvalidFieldError, "
|
|
30
|
+
raise InvalidFieldError, "#{model_class.name}: duplicate field name #{name.inspect}" if fields.any? { |f| f.name == str }
|
|
31
|
+
raise InvalidFieldError, "#{model_class.name}: field name #{name.inspect} conflicts with existing filter" if filters.any? { |f| f.name == str }
|
|
32
32
|
@fields << Field.new(name: str, weight: weight.to_i, source: source, reindex_on: reindex_on&.to_s, async: async, as: as&.to_s, version: version)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def exact_match(name, source: -> { public_send(name) }, version: nil)
|
|
36
36
|
unless valid_name?(name)
|
|
37
|
-
raise InvalidFieldError, "
|
|
37
|
+
raise InvalidFieldError, "#{model_class.name}: invalid exact_match name #{name.inspect}"
|
|
38
38
|
end
|
|
39
39
|
str = name.to_s
|
|
40
|
-
raise InvalidFieldError, "
|
|
40
|
+
raise InvalidFieldError, "#{model_class.name}: duplicate exact_match name #{name.inspect}" if exact_matches.any? { |e| e.name == str }
|
|
41
41
|
@exact_matches << ExactMatch.new(name: str, source: source, version: version)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def rank_by(column, direction = :desc)
|
|
45
45
|
unless valid_name?(column)
|
|
46
|
-
raise InvalidFieldError, "
|
|
46
|
+
raise InvalidFieldError, "#{model_class.name}: invalid rank_by column #{column.inspect}"
|
|
47
47
|
end
|
|
48
48
|
dir = direction.to_s.downcase
|
|
49
49
|
unless %w[asc desc].include?(dir)
|
|
50
|
-
raise InvalidFieldError, "
|
|
50
|
+
raise InvalidFieldError, "#{model_class.name}: invalid rank_by direction #{direction.inspect}. Use :asc or :desc."
|
|
51
51
|
end
|
|
52
52
|
str = column.to_s
|
|
53
|
-
raise InvalidFieldError, "
|
|
53
|
+
raise InvalidFieldError, "#{model_class.name}: duplicate rank_by column #{column.inspect}" if rank_bys.any? { |r| r.column == str }
|
|
54
54
|
@rank_bys << RankBy.new(column: str, direction: dir.to_sym)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
def filter(name, required: false)
|
|
58
58
|
unless valid_name?(name)
|
|
59
|
-
raise InvalidFieldError, "
|
|
59
|
+
raise InvalidFieldError, "#{model_class.name}: invalid filter name #{name.inspect}"
|
|
60
60
|
end
|
|
61
61
|
str = name.to_s
|
|
62
|
-
raise InvalidFieldError, "
|
|
63
|
-
raise InvalidFieldError, "
|
|
62
|
+
raise InvalidFieldError, "#{model_class.name}: duplicate filter name #{name.inspect}" if filters.any? { |f| f.name == str }
|
|
63
|
+
raise InvalidFieldError, "#{model_class.name}: filter name #{name.inspect} conflicts with existing field" if fields.any? { |f| f.name == str }
|
|
64
64
|
@filters << Filter.new(name: str, required: required)
|
|
65
65
|
end
|
|
66
66
|
|
data/lib/full_search/index.rb
CHANGED
|
@@ -14,7 +14,13 @@ end
|
|
|
14
14
|
module FullSearch
|
|
15
15
|
class Index
|
|
16
16
|
class << self
|
|
17
|
+
def verified_tables
|
|
18
|
+
@verified_tables ||= Set.new
|
|
19
|
+
end
|
|
20
|
+
|
|
17
21
|
def ensure_table!(model)
|
|
22
|
+
return if model.respond_to?(:table_name) && verified_tables.include?(model.table_name)
|
|
23
|
+
|
|
18
24
|
sqlite!(model)
|
|
19
25
|
|
|
20
26
|
conn = connection
|
|
@@ -44,6 +50,8 @@ module FullSearch
|
|
|
44
50
|
|
|
45
51
|
store_config_hash!(model)
|
|
46
52
|
ensure_triggers!(model) if model_table_exists?(model)
|
|
53
|
+
|
|
54
|
+
verified_tables.add(model.table_name)
|
|
47
55
|
end
|
|
48
56
|
|
|
49
57
|
def rebuild!(model)
|
|
@@ -70,6 +78,7 @@ module FullSearch
|
|
|
70
78
|
create_triggers!(model)
|
|
71
79
|
optimize!(model)
|
|
72
80
|
store_config_hash!(model, rebuilt_at: Time.current)
|
|
81
|
+
verified_tables.add(model.table_name)
|
|
73
82
|
end
|
|
74
83
|
end
|
|
75
84
|
|
|
@@ -103,6 +112,7 @@ module FullSearch
|
|
|
103
112
|
end
|
|
104
113
|
|
|
105
114
|
def drop!(model)
|
|
115
|
+
verified_tables.delete(model.table_name)
|
|
106
116
|
sqlite!(model)
|
|
107
117
|
drop_triggers!(model)
|
|
108
118
|
connection.execute("DROP TABLE IF EXISTS #{qt(fts_table_name(model))};")
|
data/lib/full_search/model.rb
CHANGED
|
@@ -10,7 +10,6 @@ module FullSearch
|
|
|
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)
|
|
12
12
|
@full_search_dsl.instance_eval(&block) if block_given?
|
|
13
|
-
FullSearch::Index.ensure_table!(self)
|
|
14
13
|
FullSearch::Callbacks.install!(self)
|
|
15
14
|
include InstanceMethods
|
|
16
15
|
|
|
@@ -48,6 +47,16 @@ module FullSearch
|
|
|
48
47
|
end
|
|
49
48
|
end
|
|
50
49
|
|
|
50
|
+
def self.evaluate_source(record, field)
|
|
51
|
+
if field.source
|
|
52
|
+
record.instance_exec(&field.source)
|
|
53
|
+
else
|
|
54
|
+
record.public_send(field.name)
|
|
55
|
+
end
|
|
56
|
+
rescue NameError => e
|
|
57
|
+
raise e.class, "#{record.class.name}: full_search source block for field #{field.name.inspect} failed: #{e.message}"
|
|
58
|
+
end
|
|
59
|
+
|
|
51
60
|
module InstanceMethods
|
|
52
61
|
attr_accessor :full_search_snippet, :full_search_highlight_fields
|
|
53
62
|
|
|
@@ -56,7 +65,7 @@ module FullSearch
|
|
|
56
65
|
field = dsl.fields.find { |f| f.name == field_name.to_s || f.as == field_name.to_s }
|
|
57
66
|
return nil unless field
|
|
58
67
|
|
|
59
|
-
|
|
68
|
+
FullSearch::Model.evaluate_source(self, field)
|
|
60
69
|
end
|
|
61
70
|
end
|
|
62
71
|
end
|
data/lib/full_search/search.rb
CHANGED
|
@@ -76,13 +76,18 @@ module FullSearch
|
|
|
76
76
|
stored = FullSearch::Index.stored_config_hash(model)
|
|
77
77
|
return unless stored
|
|
78
78
|
|
|
79
|
-
if stored
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
79
|
+
return if stored == dsl.config_hash
|
|
80
|
+
|
|
81
|
+
if FullSearch.config.auto_rebuild_on_stale_query
|
|
82
|
+
FullSearch::Index.rebuild_if_needed!(model)
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
case FullSearch.config.stale_query_behavior
|
|
87
|
+
when :raise
|
|
88
|
+
raise ConfigChangedError, "FTS index for #{model.table_name} is stale; run full_search:rebuild"
|
|
89
|
+
when :log_and_fallback
|
|
90
|
+
Rails.logger.warn("[full_search] FTS index for #{model.table_name} is stale; results may be incomplete")
|
|
86
91
|
end
|
|
87
92
|
end
|
|
88
93
|
|
data/lib/full_search/version.rb
CHANGED
data/lib/full_search.rb
CHANGED
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.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ben D'Angelo
|
|
@@ -176,7 +176,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
176
176
|
requirements:
|
|
177
177
|
- - ">="
|
|
178
178
|
- !ruby/object:Gem::Version
|
|
179
|
-
version: 3.
|
|
179
|
+
version: 3.2.0
|
|
180
180
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
181
181
|
requirements:
|
|
182
182
|
- - ">="
|