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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 866994a5c7f55cea4237fb2edcbf59f1f7c8c32772c6830df737bcbea4a6f1c6
4
- data.tar.gz: 5a11ccf1c48abd1050288bbb7107b8ade82132e009ac23ed5803ed153675f535
3
+ metadata.gz: 8c3e974821a38f57a0d71c6a2ad0260942773836c3404c9b3665bb9cc0a6d305
4
+ data.tar.gz: 841b5bb5c5086942f47bbf7626455d4a27d4261778dfc845e22d2d5262d0ff41
5
5
  SHA512:
6
- metadata.gz: 35eb2afacd021899e68eb808b65a6f323db3d66770940471aa94d9c3238e41c409c678ac79d82dc609dcafaf3117d937895fa8effc9a8e665790a8625ef1f231
7
- data.tar.gz: 5f1f25fe9e2c268e4f6b95c988ddb7958c34649fcf53aa941d0b19d1b605a5b4345864bb7206b597fc509b9802a110db6c3900a40fe1ab3d3ce1468db28aef85
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.1+
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 = record.instance_exec(&field.source)
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)
@@ -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
 
@@ -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, "Invalid field name: #{name.inspect}"
24
+ raise InvalidFieldError, "#{model_class.name}: invalid field name #{name.inspect}"
25
25
  end
26
26
  if as && !valid_name?(as)
27
- raise InvalidFieldError, "Invalid field alias (as): #{as.inspect}"
27
+ raise InvalidFieldError, "#{model_class.name}: invalid field alias (as): #{as.inspect}"
28
28
  end
29
29
  str = name.to_s
30
- raise InvalidFieldError, "Duplicate field name: #{name.inspect}" if fields.any? { |f| f.name == str }
31
- raise InvalidFieldError, "Field name conflicts with filter: #{name.inspect}" if filters.any? { |f| f.name == str }
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, "Invalid exact_match name: #{name.inspect}"
37
+ raise InvalidFieldError, "#{model_class.name}: invalid exact_match name #{name.inspect}"
38
38
  end
39
39
  str = name.to_s
40
- raise InvalidFieldError, "Duplicate exact_match name: #{name.inspect}" if exact_matches.any? { |e| e.name == str }
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, "Invalid rank_by column: #{column.inspect}"
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, "Invalid rank_by direction: #{direction.inspect}. Use :asc or :desc."
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, "Duplicate rank_by column: #{column.inspect}" if rank_bys.any? { |r| r.column == str }
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, "Invalid filter name: #{name.inspect}"
59
+ raise InvalidFieldError, "#{model_class.name}: invalid filter name #{name.inspect}"
60
60
  end
61
61
  str = name.to_s
62
- raise InvalidFieldError, "Duplicate filter name: #{name.inspect}" if filters.any? { |f| f.name == str }
63
- raise InvalidFieldError, "Filter name conflicts with field: #{name.inspect}" if fields.any? { |f| f.name == str }
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
 
@@ -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))};")
@@ -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
- field.source ? instance_exec(&field.source) : public_send(field.name)
68
+ FullSearch::Model.evaluate_source(self, field)
60
69
  end
61
70
  end
62
71
  end
@@ -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 != dsl.config_hash
80
- case FullSearch.config.stale_query_behavior
81
- when :raise
82
- raise ConfigChangedError, "FTS index for #{model.table_name} is stale; run full_search:rebuild"
83
- when :log_and_fallback
84
- Rails.logger.warn("[full_search] FTS index for #{model.table_name} is stale; results may be incomplete")
85
- end
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FullSearch
4
- VERSION = "0.3.1"
4
+ VERSION = "0.3.3"
5
5
  end
data/lib/full_search.rb CHANGED
@@ -47,6 +47,13 @@ module FullSearch
47
47
  def optimize!
48
48
  models.each { |model| Index.optimize!(model) }
49
49
  end
50
+
51
+ def setup!
52
+ models.each do |model|
53
+ Index.ensure_table!(model)
54
+ Callbacks.install!(model)
55
+ end
56
+ end
50
57
  end
51
58
  end
52
59
 
@@ -3,6 +3,8 @@
3
3
  FullSearch.configure do |config|
4
4
  config.auto_rebuild_schema = Rails.env.local?
5
5
 
6
+ config.auto_rebuild_on_stale_query = Rails.env.local?
7
+
6
8
  config.stale_query_behavior = Rails.env.production? ? :log_and_fallback : :raise
7
9
 
8
10
  config.lock_rebuilds = true
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.1
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.1.0
179
+ version: 3.2.0
180
180
  required_rubygems_version: !ruby/object:Gem::Requirement
181
181
  requirements:
182
182
  - - ">="