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 +7 -0
- data/README.md +89 -0
- data/lib/full_search/callbacks.rb +86 -0
- data/lib/full_search/config.rb +25 -0
- data/lib/full_search/dsl.rb +94 -0
- data/lib/full_search/errors.rb +10 -0
- data/lib/full_search/exact_match.rb +22 -0
- data/lib/full_search/highlighter.rb +51 -0
- data/lib/full_search/index.rb +335 -0
- data/lib/full_search/model.rb +44 -0
- data/lib/full_search/query_parser.rb +126 -0
- data/lib/full_search/railtie.rb +28 -0
- data/lib/full_search/reindex_job.rb +15 -0
- data/lib/full_search/search.rb +113 -0
- data/lib/full_search/soft_delete.rb +12 -0
- data/lib/full_search/test_helpers.rb +17 -0
- data/lib/full_search/tokenizer.rb +15 -0
- data/lib/full_search/typo.rb +28 -0
- data/lib/full_search/version.rb +5 -0
- data/lib/full_search.rb +36 -0
- data/lib/generators/full_search/install/install_generator.rb +15 -0
- data/lib/generators/full_search/install/templates/full_search.rb +9 -0
- data/lib/tasks/full_search.rake +42 -0
- metadata +144 -0
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
|