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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 025c498951cfbd1e531bb07c3994af9b195bf0411bfe65b65ff96aacedbc157a
4
- data.tar.gz: f1c0d8eb49892d94f567ab162e8c94576b8f574848077d95d5942236d51b215a
3
+ metadata.gz: 339422f4765fd1cd96ef55e9d026d782e10b08c2c6d1636d6e2e13454cf12b51
4
+ data.tar.gz: fc5b3a6105d9359162aebb6d14dddf6763e0e0e301351157a1c1c278386d3355
5
5
  SHA512:
6
- metadata.gz: d3ea481bc2b665046432f415a6e892b4735b1aedee46716ac271b51cb873c490b002280000b02a92ba3369b8e3c6a27b98d8da18b876b4269a6a516cc51262b4
7
- data.tar.gz: 93e2ed7136f1b20d7bb7025891d29c015b61db0d427c527eb4345e6bfd94ec0968562aba8528590737d89cbad2988c4d029ad114f3f18e6467d5a19f3ff8c4b2
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.full_search("sam", filters: { account_id: 1 }).page(params[:page])
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
- - Rake tasks: `full_search:rebuild`, `full_search:optimize`, `full_search:status`
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.full_search('"Sam Smith"', filters: { account_id: 1 }) # phrase
53
- Customer.full_search("honda -civic", filters: { account_id: 1 }) # exclusion
54
- Customer.full_search("honda OR toyota", filters: { account_id: 1 }) # OR
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.full_search("sam", filters: { account_id: 1 }, highlight: true)
157
+ Customer.search("sam", filters: { account_id: 1 }, highlight: true)
70
158
  # each result has #full_search_snippet
71
159
  ```
72
160
 
@@ -2,15 +2,16 @@
2
2
 
3
3
  module FullSearch
4
4
  class Config
5
- attr_accessor :auto_manage_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
6
6
 
7
7
  def initialize
8
- @auto_manage_schema = false
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
@@ -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
- @fields << Field.new(name: name.to_s, weight: weight.to_i, source: source, reindex_on: reindex_on&.to_s, async: async)
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" : "source", f.reindex_on, f.async] },
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
- conditions = dsl.exact_matches.map do |em|
16
- relation.model.arel_table[em.name].eq(cleaned)
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
- relation.where(conditions.reduce(:or)).pluck(:id)
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
- records.each { |record| record.full_search_highlight_fields = fields[record.id] || {} }
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
- cols = dsl.fields.map(&:name)
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 = cols.each_with_object({}) do |col, hash|
37
- snippet = row["#{col}_snippet"].to_s.strip
38
- hash[col.to_s] = snippet if snippet.include?(open_tag)
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>" }
@@ -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)};")
@@ -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)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module FullSearch
6
+ class OptimizeJob < ActiveJob::Base
7
+ queue_as :low
8
+
9
+ def perform
10
+ FullSearch.optimize!
11
+ end
12
+ end
13
+ end
@@ -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.auto_manage_schema
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.ensure_table!(model)
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
@@ -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
- like_conditions = column_fields.map do |field|
112
- "#{connection.quote_table_name(model.table_name)}.#{connection.quote_column_name(field.name)} LIKE #{connection.quote("#{term}%")}"
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 (#{like_conditions})
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FullSearch
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
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
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  FullSearch.configure do |config|
4
- config.auto_manage_schema = Rails.env.local?
4
+ config.auto_rebuild_schema = Rails.env.local?
5
5
 
6
6
  config.stale_query_behavior = Rails.env.production? ? :log_and_fallback : :raise
7
7
 
@@ -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 full_search indexes"
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
- models = if args[:models]
9
- args[:models].split(",").map do |name|
10
- klass = name.singularize.camelize.constantize rescue nil
11
- klass || FullSearch.models.find { |m| m.table_name == name }
12
- end.compact
13
- else
14
- FullSearch.models
15
- end
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
- models.each do |model|
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 "Rebuilt #{FullSearch::Index.fts_table_name(model)}"
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
- puts "#{model.table_name}: #{status} (stored=#{stored}, current=#{current})"
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.1.2
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