full_search 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a18ce04c25c9a2670d1ad9394594a277b78201e74e11585c63e1848d6d6cf343
4
+ data.tar.gz: 4d170670fd73af2e350ee427fce766658769612ea592772b29089ac395bc7b0c
5
+ SHA512:
6
+ metadata.gz: d0276bef97a441548574747ddd7aeb67609b390bad8a8864618231cfac30d41f24e2d01cb55205b070d5c0f2249afbf4b7f0b133d51acb396e2a61a528923b18
7
+ data.tar.gz: 6e7808b9b6f3e8ccb8e3964c3494f42336c18975f6c79c335a397927aaf6ef3d920f84dd5548000182ddac652b376f71c1751c090f0c65191ccb62e4627dd5a7
data/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # full_search
2
+
3
+ SQLite FTS5 full-text search for Rails/ActiveRecord. A lightweight, self-contained alternative to `pg_search` for apps already running on SQLite.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "full_search"
11
+ ```
12
+
13
+ Run:
14
+
15
+ ```bash
16
+ bundle install
17
+ bin/rails generate full_search:install
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```ruby
23
+ class Customer < ApplicationRecord
24
+ full_search do
25
+ field :first_name, weight: 5
26
+ field :last_name, weight: 5
27
+ filter :account_id, required: true
28
+ end
29
+ end
30
+ ```
31
+
32
+ ```ruby
33
+ Customer.full_search("sam", filters: { account_id: 1 }).page(params[:page])
34
+ ```
35
+
36
+ ## Features
37
+
38
+ - Declarative `full_search` DSL
39
+ - SQLite FTS5 backed
40
+ - Required-filter enforcement for multi-tenant apps
41
+ - Exact-match queries for encrypted identifiers
42
+ - Soft-delete awareness
43
+ - Automatic table creation and schema-drift detection
44
+ - Phrase, exclusion, and OR query operators
45
+ - FTS5 `highlight()` support for result snippets
46
+ - Opt-in trigram typo/substring fallback (requires SQLite >= 3.34)
47
+ - Rake tasks: `full_search:rebuild`, `full_search:optimize`, `full_search:status`
48
+
49
+ ## Query operators
50
+
51
+ ```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
55
+ ```
56
+
57
+ ## Highlighting
58
+
59
+ ```ruby
60
+ class Customer < ApplicationRecord
61
+ full_search do
62
+ field :first_name, weight: 5
63
+ highlight open_tag: "<mark>", close_tag: "</mark>"
64
+ end
65
+ end
66
+ ```
67
+
68
+ ```ruby
69
+ Customer.full_search("sam", filters: { account_id: 1 }, highlight: true)
70
+ # each result has #full_search_snippet
71
+ ```
72
+
73
+ ## Typo tolerance
74
+
75
+ ```ruby
76
+ class Customer < ApplicationRecord
77
+ full_search do
78
+ field :first_name, weight: 5
79
+ typo_tolerance
80
+ end
81
+ end
82
+ ```
83
+
84
+ `typo_tolerance` uses an FTS5 trigram shadow table as a fallback when the primary index returns no results. It is substring matching, not edit-distance correction, and requires SQLite >= 3.34.
85
+
86
+ ## Known limitations
87
+
88
+ - Queries run with `highlight: true` return an Array of records, not an `ActiveRecord::Relation`. No further chaining (`.where`, `.order`, `.limit`) is possible after highlighting is applied.
89
+ - Per-model only; no built-in multi-model aggregator
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullSearch
4
+ module Callbacks
5
+ def self.install!(model)
6
+ dsl = model.full_search_dsl
7
+
8
+ source_fields = dsl.fields.select(&:source)
9
+ return if source_fields.empty?
10
+
11
+ model.after_save do
12
+ FullSearch::Callbacks.reindex_record!(self)
13
+ end
14
+
15
+ model.after_destroy do
16
+ FullSearch::Callbacks.remove_record!(self)
17
+ end
18
+
19
+ dsl.fields.each do |field|
20
+ next unless field.reindex_on
21
+
22
+ assoc_class = associated_class(model, field.reindex_on)
23
+ assoc_class&.after_save do |record|
24
+ FullSearch::Callbacks.reindex_dependents!(record, model, field)
25
+ end
26
+ assoc_class&.after_destroy do |record|
27
+ FullSearch::Callbacks.reindex_dependents!(record, model, field)
28
+ end
29
+ end
30
+ end
31
+
32
+ def self.reindex_record!(record)
33
+ dsl = record.class.full_search_dsl
34
+ return unless dsl
35
+
36
+ dsl.fields.each do |field|
37
+ next unless field.source
38
+
39
+ reindex_field!(record, field.name)
40
+ end
41
+ end
42
+
43
+ def self.reindex_field!(record, field_name)
44
+ dsl = record.class.full_search_dsl
45
+ field = dsl.fields.find { |f| f.name == field_name }
46
+ return unless field&.source
47
+
48
+ value = record.instance_exec(&field.source)
49
+ table = FullSearch::Index.fts_table_name(record.class)
50
+ quoted_value = ActiveRecord::Base.connection.quote(value.to_s)
51
+ ActiveRecord::Base.connection.execute(
52
+ "UPDATE #{table} SET #{field.name} = #{quoted_value} WHERE rowid = #{record.id}"
53
+ )
54
+ end
55
+
56
+ def self.remove_record!(record)
57
+ table = FullSearch::Index.fts_table_name(record.class)
58
+ ActiveRecord::Base.connection.execute("DELETE FROM #{table} WHERE rowid = #{record.id}")
59
+ end
60
+
61
+ def self.reindex_dependents!(parent_record, dependent_model, field)
62
+ fk = association_key(dependent_model, field.reindex_on)
63
+ sql = "SELECT id FROM #{dependent_model.table_name} WHERE #{fk} = #{parent_record.id}"
64
+ dependent_ids = ActiveRecord::Base.connection.execute(sql).map { |r| r["id"] }
65
+
66
+ dependent_ids.each do |dep_id|
67
+ if field.async
68
+ FullSearch::ReindexJob.perform_later(dependent_model.name, dep_id, field.name)
69
+ else
70
+ dependent = dependent_model.find_by(id: dep_id)
71
+ reindex_field!(dependent, field.name) if dependent
72
+ end
73
+ end
74
+ end
75
+
76
+ def self.associated_class(model, association_name)
77
+ reflection = model.reflect_on_association(association_name.to_sym)
78
+ reflection&.klass
79
+ end
80
+
81
+ def self.association_key(model, association_name)
82
+ reflection = model.reflect_on_association(association_name.to_sym)
83
+ reflection&.foreign_key&.to_s || "#{association_name}_id"
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullSearch
4
+ class Config
5
+ attr_accessor :auto_manage_schema, :stale_query_behavior, :lock_rebuilds, :default_async_reindex, :default_tokenizer
6
+
7
+ def initialize
8
+ @auto_manage_schema = false
9
+ @stale_query_behavior = :raise
10
+ @lock_rebuilds = true
11
+ @default_async_reindex = true
12
+ @default_tokenizer = "unicode61"
13
+ end
14
+ end
15
+
16
+ class << self
17
+ def config
18
+ @config ||= Config.new
19
+ end
20
+
21
+ def configure
22
+ yield config
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullSearch
4
+ class Dsl
5
+ attr_reader :fields, :exact_matches, :filters, :model_class, :tokenize, :highlight_config
6
+
7
+ Field = Data.define(:name, :weight, :source, :reindex_on, :async)
8
+ ExactMatch = Data.define(:name, :source)
9
+ Filter = Data.define(:name, :required)
10
+
11
+ def initialize(model_class)
12
+ @model_class = model_class
13
+ @fields = []
14
+ @exact_matches = []
15
+ @filters = []
16
+ @tokenize = FullSearch.config.default_tokenizer
17
+ @soft_delete_column = nil
18
+ end
19
+
20
+ def field(name, weight: 1, source: nil, reindex_on: nil, async: FullSearch.config.default_async_reindex)
21
+ unless valid_name?(name)
22
+ raise InvalidFieldError, "Invalid field name: #{name.inspect}"
23
+ end
24
+ @fields << Field.new(name: name.to_s, weight: weight.to_i, source: source, reindex_on: reindex_on&.to_s, async: async)
25
+ end
26
+
27
+ def exact_match(name, source: -> { public_send(name) })
28
+ unless valid_name?(name)
29
+ raise InvalidFieldError, "Invalid exact_match name: #{name.inspect}"
30
+ end
31
+ @exact_matches << ExactMatch.new(name: name.to_s, source: source)
32
+ end
33
+
34
+ def filter(name, required: false)
35
+ unless valid_name?(name)
36
+ raise InvalidFieldError, "Invalid filter name: #{name.inspect}"
37
+ end
38
+ @filters << Filter.new(name: name.to_s, required: required)
39
+ end
40
+
41
+ def soft_delete_column(name = :_no_arg_)
42
+ if name == :_no_arg_
43
+ @soft_delete_column
44
+ else
45
+ @soft_delete_column = name.to_s
46
+ end
47
+ end
48
+
49
+ def tokenize(value = :_no_arg_)
50
+ if value == :_no_arg_
51
+ @tokenize
52
+ else
53
+ @tokenize = Tokenizer.validate!(value)
54
+ end
55
+ end
56
+
57
+ def highlight(open_tag: "<mark>", close_tag: "</mark>")
58
+ @highlight_config = { open_tag: open_tag, close_tag: close_tag }
59
+ end
60
+
61
+ def typo_tolerance(enabled = true, min_term_length: nil)
62
+ @typo_tolerance = enabled
63
+ @typo_tolerance_min_term_length = min_term_length || 3
64
+ end
65
+
66
+ def typo_tolerance?
67
+ !!@typo_tolerance
68
+ end
69
+
70
+ def typo_tolerance_min_term_length
71
+ @typo_tolerance_min_term_length || 3
72
+ end
73
+
74
+ def config_hash
75
+ require "digest"
76
+ Digest::SHA256.hexdigest([
77
+ model_class.table_name,
78
+ tokenize,
79
+ soft_delete_column,
80
+ typo_tolerance?,
81
+ typo_tolerance_min_term_length,
82
+ fields.map { |f| [f.name, f.weight, f.source.nil? ? "column" : "source", f.reindex_on, f.async] },
83
+ exact_matches.map { |e| [e.name] },
84
+ filters.map { |f| [f.name, f.required] }
85
+ ].inspect)
86
+ end
87
+
88
+ private
89
+
90
+ def valid_name?(name)
91
+ name.to_s.match?(/\A[a-zA-Z_]\w*\z/)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullSearch
4
+ class Error < StandardError; end
5
+ class MissingRequiredFilterError < Error; end
6
+ class ConfigChangedError < Error; end
7
+ class InvalidFieldError < Error; end
8
+ class NotConfiguredError < Error; end
9
+ class UnsupportedDatabaseError < Error; end
10
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullSearch
4
+ class ExactMatch
5
+ def self.ids_for(model, query, filters)
6
+ dsl = model.full_search_dsl
7
+ return [] if dsl.exact_matches.empty?
8
+
9
+ cleaned = query.to_s.strip
10
+ return [] if cleaned.empty?
11
+
12
+ relation = model.all
13
+ filters.each { |name, value| relation = relation.where(name => value) }
14
+
15
+ conditions = dsl.exact_matches.map do |em|
16
+ relation.model.arel_table[em.name].eq(cleaned)
17
+ end
18
+
19
+ relation.where(conditions.reduce(:or)).pluck(:id)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullSearch
4
+ class Highlighter
5
+ def self.apply!(records, model, query)
6
+ dsl = model.full_search_dsl
7
+ config = dsl.highlight_config
8
+ return records unless config
9
+
10
+ match_expr = QueryParser.to_match_expression(QueryParser.parse(query))
11
+ return records if match_expr.empty?
12
+
13
+ table = FullSearch::Index.fts_table_name(model)
14
+
15
+ content_cols = dsl.fields.map(&:name)
16
+ return records if content_cols.empty?
17
+
18
+ highlight_parts = content_cols.each_with_index.map do |col, idx|
19
+ "highlight(#{table}, #{idx}, #{quote(config[:open_tag])}, #{quote(config[:close_tag])}) AS #{col}_snippet"
20
+ end.join(", ")
21
+
22
+ sql = <<~SQL
23
+ SELECT rowid, #{highlight_parts}
24
+ FROM #{table}
25
+ WHERE #{table} MATCH #{quote(match_expr)}
26
+ SQL
27
+
28
+ rows = connection.execute(sql)
29
+
30
+ snippets = rows.map do |r|
31
+ [r["rowid"], content_cols.map { |col| r["#{col}_snippet"] }.compact.join(" ").strip]
32
+ end.to_h
33
+
34
+ records.each do |record|
35
+ record.full_search_snippet = snippets[record.id]
36
+ end
37
+
38
+ records
39
+ end
40
+
41
+ private
42
+
43
+ def self.connection
44
+ ActiveRecord::Base.connection
45
+ end
46
+
47
+ def self.quote(value)
48
+ connection.quote(value)
49
+ end
50
+ end
51
+ end