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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c3e974821a38f57a0d71c6a2ad0260942773836c3404c9b3665bb9cc0a6d305
4
- data.tar.gz: 841b5bb5c5086942f47bbf7626455d4a27d4261778dfc845e22d2d5262d0ff41
3
+ metadata.gz: b3b466311e2305f90987a48ff58cee59f4c81af6e960b5fcd2fe09cf845b985b
4
+ data.tar.gz: 1d355e22aa06d280d984d7da1df90e57a8ca4a5f5676b7870424f2b048c051ba
5
5
  SHA512:
6
- metadata.gz: 88f20b95e78a0de07571c06560df3c9e45ac5927106dc600e6d8f4a9702611ee776d0f2c2edec9c9cfd5a26aacc5e91c727ae156043c46767ca995c4228f37b5
7
- data.tar.gz: 8ba992243ac6ae15fb755d0817c75e6f8e41ec1cb4bad856c75cc9b7fe9ee3f760da92b97fcdbd1093677eaf83e93701c1f917ac82f26c650274e3891de74f6f
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.after_save do
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.after_destroy do
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&.after_save do |record|
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&.after_destroy do |record|
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
- reindex_field!(record, field.name)
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")
@@ -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, :default_async_reindex, :default_tokenizer, :auto_rebuild_on_stale_query
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
@@ -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, async: FullSearch.config.default_async_reindex, as: nil, version: 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(name: str, weight: weight.to_i, source: source, reindex_on: reindex_on&.to_s, async: async, as: as&.to_s, version: version)
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
- fields.map { |f| [f.name, f.weight, f.source.nil? ? "column" : "proc:#{f.version}", f.reindex_on, f.async, f.as] },
109
- exact_matches.map { |e| [e.name, "proc:#{e.version}"] },
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
- relation = model.all
13
- filters.each { |name, value| relation = relation.where(name => value) }
12
+ sql_matches = dsl.exact_matches.select(&:sql)
13
+ ruby_matches = dsl.exact_matches.reject(&:sql)
14
14
 
15
15
  ids = []
16
- relation.find_each do |record|
17
- dsl.exact_matches.each do |em|
18
- value = record.instance_exec(&em.source)
19
- ids << record.id if value.to_s.casecmp?(cleaned)
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
 
@@ -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
- FullSearch::Callbacks.reindex_record!(record)
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.name)} UNINDEXED" : qc(c.name) }.join(", ")
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?(:source) && c.source
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.name) }.join(", ")})
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
- values = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
275
-
276
- <<~SQL
277
- CREATE TRIGGER #{qt(trigger_names(model).first)} AFTER INSERT ON #{qt(model.table_name)} BEGIN
278
- INSERT INTO #{qt(fts_table_name(model))}(rowid, #{col_names(cols)})
279
- VALUES (new.id, #{values});
280
- END;
281
- SQL
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
- <<~SQL
302
- CREATE TRIGGER #{qt(trigger_names(model)[2])} AFTER UPDATE ON #{qt(model.table_name)} #{when_clause}
303
- BEGIN
304
- DELETE FROM #{fts_table} WHERE rowid = old.id;
305
- INSERT INTO #{fts_table}(rowid, #{cols_str})
306
- VALUES (new.id, #{values});
307
- END;
308
- SQL
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.name)} UNINDEXED" : qc(c.name) }.join(", ")
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?(:source) && c.source
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.name) }.join(", ")})
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
- values = cols.map { |c| column_ref(c, prefix: "new") }.join(", ")
359
-
360
- <<~SQL
361
- CREATE TRIGGER #{qt(trigram_trigger_names(model).first)} AFTER INSERT ON #{qt(model.table_name)} BEGIN
362
- INSERT INTO #{qt(trigram_table_name(model))}(rowid, #{col_names(cols)})
363
- VALUES (new.id, #{values});
364
- END;
365
- SQL
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
- <<~SQL
386
- CREATE TRIGGER #{qt(trigram_trigger_names(model)[2])} AFTER UPDATE ON #{qt(model.table_name)} #{when_clause}
387
- BEGIN
388
- DELETE FROM #{trigram_table} WHERE rowid = old.id;
389
- INSERT INTO #{trigram_table}(rowid, #{cols_str})
390
- VALUES (new.id, #{values});
391
- END;
392
- SQL
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.name) }.join(", ")
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?(:source) && col.source
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)}"
@@ -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
- def perform(model_name, record_id, field_name)
8
- model = model_name.constantize
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
- FullSearch::Callbacks.reindex_field!(record, field_name)
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
@@ -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
- Highlighter.apply!(rel.to_a, model, query)
46
+ records = rel.to_a
47
+ Highlighter.apply!(records, model, query, record_ids: records.map(&:id))
48
+ records
46
49
  elsif highlight_fields
47
- Highlighter.apply_fields!(rel.to_a, model, query)
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
- connection.execute("#{sql} #{filter_conditions}").map { |r| r["id"] }
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
- connection.execute("#{sql} #{filter_conditions}").map { |r| r["id"] }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FullSearch
4
- VERSION = "0.3.3"
4
+ VERSION = "0.3.5"
5
5
  end
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
@@ -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.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