activerecord-searchable 0.1.2

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: 849c4ea5440e942aba0f1f2615df5640b4cb3ee50a22531d66c0e7e9f8d8376c
4
+ data.tar.gz: 16428cca11f8da34698b8ea9fbd9542b206b06e2e54621358bebe3fd37314263
5
+ SHA512:
6
+ metadata.gz: 5e86d34575d64f15438a07aeef1254ccd2a187cc4b4b27f3c674cb76960033f9de83e046f52a32ea12e6b2aa48b4b108f0cd631289b28c42583b136053eaf4d3
7
+ data.tar.gz: c379ffb9f084dbc666d9966523489e86e607c2cdf3996108d6eeeb58761ac49f070b54daceb79bf7b04ba1eaf725550fc379956b609237bb0182bd45fa989be3
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2025 Matt Lins
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,368 @@
1
+ # ActiveRecord::Searchable
2
+
3
+ Database-native full-text search for Rails 8+ using SQLite FTS5, with zero external dependencies.
4
+
5
+ ## Features
6
+
7
+ - **Zero Dependencies**: Uses SQLite's built-in FTS5 extension
8
+ - **Rails Native**: Feels like ActiveRecord, works with existing scopes
9
+ - **Automatic Sync**: Search index updates via callbacks, always consistent
10
+ - **Query Sanitization**: Handles invalid FTS syntax gracefully
11
+ - **Highlighting & Snippets**: Built-in support for match highlighting
12
+ - **BM25 Ranking**: Relevance scoring with configurable field weights
13
+
14
+ ## Requirements
15
+
16
+ - Rails 8.0+ (for `create_virtual_table` support)
17
+ - Ruby 3.2+
18
+ - SQLite 3.8.0+ with FTS5 extension
19
+
20
+ ## Installation
21
+
22
+ Add to your Gemfile:
23
+
24
+ ```ruby
25
+ gem "activerecord-searchable"
26
+ ```
27
+
28
+ Run:
29
+
30
+ ```bash
31
+ bundle install
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ### 1. Add searchable to your model
37
+
38
+ ```ruby
39
+ class Article < ApplicationRecord
40
+ include ActiveRecord::Searchable
41
+
42
+ searchable do
43
+ field :title, weight: 2.0 # Title matches ranked 2x higher
44
+ field :body
45
+ end
46
+ end
47
+ ```
48
+
49
+ ### 2. Generate the migration
50
+
51
+ ```bash
52
+ rails generate searchable:migration Article
53
+ ```
54
+
55
+ This creates:
56
+
57
+ ```ruby
58
+ class CreateArticleSearchIndex < ActiveRecord::Migration[8.0]
59
+ def change
60
+ create_virtual_table :article_search_index, :fts5, [
61
+ "title",
62
+ "body",
63
+ "tokenize='porter'"
64
+ ]
65
+
66
+ Article.reindex_all
67
+ end
68
+ end
69
+ ```
70
+
71
+ ### 3. Run the migration
72
+
73
+ ```bash
74
+ rails db:migrate
75
+ ```
76
+
77
+ ### 4. Search!
78
+
79
+ ```ruby
80
+ # Basic search
81
+ Article.search("ruby on rails")
82
+
83
+ # With match highlighting
84
+ results = Article.search("ruby on rails", matches: true)
85
+ results.first.title_highlight # => "Building with <mark>Ruby on Rails</mark>"
86
+ results.first.body_snippet # => "...learn <mark>Ruby on Rails</mark>..."
87
+
88
+ # Chain with other scopes
89
+ Article.published.search("rails").where(author: current_user).limit(10)
90
+ ```
91
+
92
+ ## Configuration
93
+
94
+ ### Field Options
95
+
96
+ ```ruby
97
+ searchable do
98
+ # Basic field
99
+ field :title
100
+ end
101
+
102
+ searchable do
103
+ # Field with weight (for ranking)
104
+ field :title, weight: 2.0
105
+ # Field with custom extraction method
106
+ field :body, via: :searchable_content
107
+ end
108
+
109
+ searchable do
110
+ # Multiple fields
111
+ field :title, weight: 2.0
112
+ field :body
113
+ field :author_name, via: :author_full_name
114
+ end
115
+ ```
116
+
117
+ ### Custom Content Extraction
118
+
119
+ Use `via:` to extract content from a method instead of an attribute:
120
+
121
+ ```ruby
122
+ class Message < ApplicationRecord
123
+ include ActiveRecord::Searchable
124
+
125
+ has_rich_text :body
126
+
127
+ searchable do
128
+ field :body, via: :plain_text_body
129
+ end
130
+
131
+ def plain_text_body
132
+ body.to_plain_text
133
+ end
134
+ end
135
+ ```
136
+
137
+ ### Conditional Indexing
138
+
139
+ Define a `searchable?` method to control which records get indexed:
140
+
141
+ ```ruby
142
+ class Article < ApplicationRecord
143
+ include ActiveRecord::Searchable
144
+
145
+ searchable do
146
+ field :title
147
+ field :body
148
+ end
149
+
150
+ def searchable?
151
+ published? && body.present?
152
+ end
153
+ end
154
+ ```
155
+
156
+ ## Searching
157
+
158
+ ### Basic Search
159
+
160
+ ```ruby
161
+ Article.search("ruby programming")
162
+ ```
163
+
164
+ Returns an `ActiveRecord::Relation` with matching records, ordered by relevance (BM25).
165
+
166
+ ### Search with Matches
167
+
168
+ ```ruby
169
+ # Smart defaults - first field highlighted, second field snippeted
170
+ results = Article.search("ruby programming", matches: true)
171
+ results.first.title_highlight # Full content with <mark> tags
172
+ results.first.body_snippet # ~20 word excerpt with <mark> tags
173
+
174
+ # Explicit control - specify which fields use highlight vs snippet
175
+ results = Article.search("ruby programming", matches: {
176
+ highlight: [:title, :tags],
177
+ snippet: [:body, :comments]
178
+ })
179
+ results.first.title_highlight # Full title with <mark> tags
180
+ results.first.tags_highlight # Full tags with <mark> tags
181
+ results.first.body_snippet # Excerpt of body with <mark> tags
182
+ results.first.comments_snippet # Excerpt of comments with <mark> tags
183
+
184
+ # Only highlights, no snippets
185
+ results = Article.search("ruby", matches: { highlight: [:title] })
186
+ results.first.title_highlight # Full title with <mark> tags
187
+ # No snippet columns generated
188
+
189
+ # No matches
190
+ results = Article.search("ruby")
191
+ # No highlight or snippet columns, just filtered/ranked records
192
+ ```
193
+
194
+ Match columns use different suffixes based on the function:
195
+ - `_highlight` - Full content with `<mark>` tags (SQLite `highlight()` function)
196
+ - `_snippet` - Excerpt (~20 words) with `<mark>` tags (SQLite `snippet()` function)
197
+
198
+ **Smart defaults** (`matches: true`):
199
+ - First field → `_highlight`
200
+ - Second field → `_snippet`
201
+ - Other fields → no match columns
202
+
203
+ ### Query Sanitization
204
+
205
+ The gem automatically sanitizes queries to prevent FTS syntax errors:
206
+
207
+ ```ruby
208
+ Article.search('"unbalanced quote') # Works - quotes removed
209
+ Article.search('test & invalid') # Works - invalid chars removed
210
+ Article.search('') # Returns none (empty relation)
211
+ ```
212
+
213
+ ### Chaining Scopes
214
+
215
+ Search integrates seamlessly with ActiveRecord:
216
+
217
+ ```ruby
218
+ Article.published
219
+ .search("rails")
220
+ .where(author: current_user)
221
+ .order(created_at: :desc)
222
+ .limit(20)
223
+ ```
224
+
225
+ ## Index Management
226
+
227
+ ### Automatic Updates
228
+
229
+ The search index updates automatically via callbacks:
230
+
231
+ ```ruby
232
+ article = Article.create!(title: "Test", body: "Content") # Indexed
233
+ article.update!(title: "Updated") # Re-indexed
234
+ article.destroy # Removed from index
235
+ ```
236
+
237
+ ### Manual Reindexing
238
+
239
+ Reindex a single record:
240
+
241
+ ```ruby
242
+ article.reindex
243
+ ```
244
+
245
+ Reindex all records:
246
+
247
+ ```ruby
248
+ Article.reindex_all
249
+ ```
250
+
251
+ The `reindex_all` method processes records in batches (1000 at a time by default) to prevent memory issues with large datasets. This is safe for tables with millions of records.
252
+
253
+ ### Custom Reindexing
254
+
255
+ For advanced needs like background jobs, progress tracking, or custom batch sizes, override `reindex_all`:
256
+
257
+ ```ruby
258
+ class Article < ApplicationRecord
259
+ include ActiveRecord::Searchable
260
+
261
+ searchable do
262
+ field :title
263
+ field :body
264
+ end
265
+
266
+ # Queue reindexing jobs in the background
267
+ def self.reindex_all
268
+ find_each do |article|
269
+ ReindexJob.perform_later(article.id)
270
+ end
271
+ end
272
+ end
273
+ ```
274
+
275
+ ## How It Works
276
+
277
+ ### FTS5 Virtual Tables
278
+
279
+ The gem creates a virtual table using SQLite's FTS5 extension:
280
+
281
+ ```sql
282
+ CREATE VIRTUAL TABLE article_search_index USING fts5(
283
+ title,
284
+ body,
285
+ tokenize='porter'
286
+ );
287
+ ```
288
+
289
+ ### Lifecycle Callbacks
290
+
291
+ When you include `ActiveRecord::Searchable`, the gem adds callbacks:
292
+
293
+ - `after_create_commit` → Insert into FTS table
294
+ - `after_update_commit` → Update FTS table (or insert if missing)
295
+ - `after_destroy_commit` → Delete from FTS table
296
+
297
+ ### Search Queries
298
+
299
+ Search generates SQL like:
300
+
301
+ ```sql
302
+ SELECT articles.*
303
+ FROM articles
304
+ JOIN article_search_index ON articles.id = article_search_index.rowid
305
+ WHERE article_search_index MATCH 'ruby programming'
306
+ ORDER BY bm25(article_search_index, 2.0, 1.0)
307
+ ```
308
+
309
+ With matches:
310
+
311
+ ```sql
312
+ SELECT articles.*,
313
+ highlight(article_search_index, 0, '<mark>', '</mark>') as title_highlight,
314
+ snippet(article_search_index, 1, '<mark>', '</mark>', '...', 20) as body_snippet
315
+ FROM articles
316
+ JOIN article_search_index ON articles.id = article_search_index.rowid
317
+ WHERE article_search_index MATCH 'ruby programming'
318
+ ORDER BY bm25(article_search_index, 2.0, 1.0)
319
+ ```
320
+
321
+ ## Advanced Usage
322
+
323
+ ### Field Weights & Ranking
324
+
325
+ Field weights control relevance ranking. Higher weights = higher rank for matches:
326
+
327
+ ```ruby
328
+ searchable do
329
+ field :title, weight: 3.0 # Title matches rank highest
330
+ field :summary, weight: 2.0 # Summary matches rank medium
331
+ field :body, weight: 1.0 # Body matches rank lowest
332
+ end
333
+ ```
334
+
335
+ Uses SQLite's BM25 algorithm for scoring.
336
+
337
+ ### Porter Stemming
338
+
339
+ The gem uses Porter stemming by default (via `tokenize='porter'`). This means:
340
+
341
+ - "running" matches "run"
342
+ - "developer" matches "develop"
343
+ - "testing" matches "test"
344
+
345
+ ### Transaction Safety
346
+
347
+ Index updates happen in `after_commit` callbacks, ensuring the FTS table stays consistent with your data even if transactions roll back.
348
+
349
+ ## Roadmap
350
+
351
+ - PostgreSQL adapter (using `tsvector` and `tsquery`)
352
+ - MySQL adapter (using `FULLTEXT` indexes)
353
+ - Advanced query syntax support (FTS5 operators: AND, OR, NOT, prefix matching)
354
+ - Multi-language support
355
+
356
+ ## Credits
357
+
358
+ Inspired by [37signals ONCE products](https://once.com):
359
+ - [Campfire](https://once.com/campfire) - Team chat
360
+ - [Writebook](https://once.com/writebook) - Publishing platform
361
+
362
+ ## Contributing
363
+
364
+ Bug reports and pull requests welcome on GitHub.
365
+
366
+ ## License
367
+
368
+ MIT License. See MIT-LICENSE for details.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ t.verbose = true
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Searchable
5
+ class Adapter
6
+ def self.for(connection)
7
+ case connection.adapter_name
8
+ when /sqlite/i
9
+ Adapters::SQLite.new(connection)
10
+ else
11
+ raise NotImplementedError,
12
+ "Search not supported for #{connection.adapter_name}. " \
13
+ "Currently only SQLite with FTS5 is supported."
14
+ end
15
+ end
16
+
17
+ # Abstract methods - must be implemented by subclasses
18
+ def join_clause(config)
19
+ raise NotImplementedError, "#{self.class} must implement ##{__method__}"
20
+ end
21
+
22
+ def where_clause(config)
23
+ raise NotImplementedError, "#{self.class} must implement ##{__method__}"
24
+ end
25
+
26
+ def select_clause_for_highlights(config)
27
+ raise NotImplementedError, "#{self.class} must implement ##{__method__}"
28
+ end
29
+
30
+ def ranking_order_clause(config)
31
+ raise NotImplementedError, "#{self.class} must implement ##{__method__}"
32
+ end
33
+
34
+ def insert_sql(config, record_id, values)
35
+ raise NotImplementedError, "#{self.class} must implement ##{__method__}"
36
+ end
37
+
38
+ def update_sql(config, record_id, values)
39
+ raise NotImplementedError, "#{self.class} must implement ##{__method__}"
40
+ end
41
+
42
+ def delete_sql(config, record_id)
43
+ raise NotImplementedError, "#{self.class} must implement ##{__method__}"
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Searchable
5
+ module Adapters
6
+ class SQLite < Adapter
7
+ # Number of words to include in snippet output (context around match)
8
+ SNIPPET_WORD_COUNT = 20
9
+
10
+ # HTML tags used to mark highlighted matches in search results
11
+ HIGHLIGHT_START_TAG = "<mark>"
12
+ HIGHLIGHT_END_TAG = "</mark>"
13
+
14
+ attr_reader :connection
15
+
16
+ def initialize(connection)
17
+ @connection = connection
18
+ end
19
+
20
+ def join_clause(config)
21
+ table = config.model_class.table_name
22
+ search_table = config.search_table_name
23
+ "JOIN #{search_table} ON #{table}.id = #{search_table}.rowid"
24
+ end
25
+
26
+ def where_clause(config)
27
+ "#{config.search_table_name} MATCH ?"
28
+ end
29
+
30
+ def select_clause_for_matches(config, matches)
31
+ clauses = []
32
+
33
+ matches[:highlight].each do |field_name|
34
+ clause = build_match_clause(config, field_name, :highlight)
35
+ clauses << clause if clause
36
+ end
37
+
38
+ matches[:snippet].each do |field_name|
39
+ clause = build_match_clause(config, field_name, :snippet)
40
+ clauses << clause if clause
41
+ end
42
+
43
+ clauses
44
+ end
45
+
46
+ def ranking_order_clause(config)
47
+ weights = config.weights.join(", ")
48
+ Arel.sql("bm25(#{config.search_table_name}, #{weights})")
49
+ end
50
+
51
+ def insert_sql(config, record_id, values)
52
+ columns = ["rowid", *config.field_names].join(", ")
53
+ placeholders = (["?"] * (values.length + 1)).join(", ")
54
+ [
55
+ "INSERT INTO #{config.search_table_name}(#{columns}) VALUES (#{placeholders})",
56
+ record_id,
57
+ *values
58
+ ]
59
+ end
60
+
61
+ def update_sql(config, record_id, values)
62
+ set_clause = config.field_names.map { |name| "#{name} = ?" }.join(", ")
63
+ [
64
+ "UPDATE #{config.search_table_name} SET #{set_clause} WHERE rowid = ?",
65
+ *values,
66
+ record_id
67
+ ]
68
+ end
69
+
70
+ def delete_sql(config, record_id)
71
+ [
72
+ "DELETE FROM #{config.search_table_name} WHERE rowid = ?",
73
+ record_id
74
+ ]
75
+ end
76
+
77
+ def execute_with_result(connection, sql_array, model_class)
78
+ connection.execute(model_class.sanitize_sql(sql_array))
79
+ connection.raw_connection.changes.positive?
80
+ end
81
+
82
+ private
83
+
84
+ def build_match_clause(config, field_name, function_type)
85
+ field_index = config.fields.index { |f| f.name == field_name }
86
+ return nil unless field_index
87
+
88
+ case function_type
89
+ when :highlight
90
+ args = "#{config.search_table_name}, #{field_index}, '#{HIGHLIGHT_START_TAG}', '#{HIGHLIGHT_END_TAG}'"
91
+ "highlight(#{args}) as #{field_name}_highlight"
92
+ when :snippet
93
+ args = "#{config.search_table_name}, #{field_index}, '#{HIGHLIGHT_START_TAG}', '#{HIGHLIGHT_END_TAG}', '...', #{SNIPPET_WORD_COUNT}"
94
+ "snippet(#{args}) as #{field_name}_snippet"
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Searchable
5
+ class Configuration
6
+ attr_reader :fields, :model_class
7
+
8
+ def initialize(model_class)
9
+ @model_class = model_class
10
+ @fields = []
11
+ end
12
+
13
+ def field(name, weight: 1.0, via: nil)
14
+ @fields << Field.new(name: name, weight: weight, via: via)
15
+ end
16
+
17
+ def search_table_name
18
+ "#{model_class.table_name.singularize}_search_index"
19
+ end
20
+
21
+ def field_names
22
+ fields.map(&:name)
23
+ end
24
+
25
+ def weights
26
+ fields.map(&:weight)
27
+ end
28
+
29
+ def extract_content(record, field)
30
+ method_name = field.via || field.name
31
+ value = record.public_send(method_name)
32
+ value.to_s
33
+ rescue NoMethodError => e
34
+ raise ArgumentError, "Cannot extract content for field '#{field.name}': #{e.message}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Searchable
5
+ Field = Data.define(:name, :weight, :via) do
6
+ def initialize(name:, weight: 1.0, via: nil)
7
+ super(name: name, weight: weight, via: via)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Searchable
5
+ class IndexManager
6
+ attr_reader :model_class, :config, :adapter
7
+
8
+ def initialize(model_class, config, adapter)
9
+ @model_class = model_class
10
+ @config = config
11
+ @adapter = adapter
12
+ end
13
+
14
+ def create(record)
15
+ validate_schema!
16
+ values = extract_values(record)
17
+ sql = adapter.insert_sql(config, record.id, values)
18
+ execute_sql(sql)
19
+ end
20
+
21
+ def update(record)
22
+ validate_schema!
23
+ values = extract_values(record)
24
+ sql = adapter.update_sql(config, record.id, values)
25
+
26
+ # Atomic upsert pattern: Try UPDATE first (common case), fall back to INSERT if needed.
27
+ # This is more efficient than INSERT OR REPLACE, which would always do DELETE+INSERT.
28
+ # The transaction ensures no other process can INSERT between our UPDATE and INSERT attempts,
29
+ # preventing race conditions while maintaining optimal performance for the update-heavy path.
30
+ model_class.transaction do
31
+ updated = execute_sql(sql)
32
+ create(record) unless updated
33
+ end
34
+ end
35
+
36
+ def delete(record)
37
+ sql = adapter.delete_sql(config, record.id)
38
+ execute_sql(sql)
39
+ end
40
+
41
+ def reindex_all
42
+ model_class.find_each(&:reindex)
43
+ end
44
+
45
+ private
46
+
47
+ def validate_schema!
48
+ expected_fields = config.field_names.map(&:to_s)
49
+ actual_fields = get_table_columns
50
+
51
+ return if expected_fields.sort == actual_fields.sort
52
+
53
+ raise SchemaError, build_schema_error_message(expected_fields, actual_fields)
54
+ end
55
+
56
+ def get_table_columns
57
+ # Query SQLite's PRAGMA table_info to get column names from the FTS virtual table
58
+ result = model_class.connection.execute(
59
+ "PRAGMA table_info(#{config.search_table_name})"
60
+ )
61
+ result.map { |row| row["name"] }
62
+ rescue ActiveRecord::StatementInvalid
63
+ # Table doesn't exist yet
64
+ []
65
+ end
66
+
67
+ def build_schema_error_message(expected, actual)
68
+ <<~MSG.strip
69
+ FTS table schema mismatch detected for #{config.search_table_name}.
70
+
71
+ Expected fields: #{expected.join(', ')}
72
+ Actual fields: #{actual.join(', ')}
73
+
74
+ FTS5 virtual tables cannot be altered. To fix this:
75
+
76
+ 1. Generate a migration to drop the old table:
77
+ rails generate migration Drop#{config.search_table_name.camelize}
78
+
79
+ 2. Edit the migration to drop the table:
80
+ def change
81
+ drop_table :#{config.search_table_name}
82
+ end
83
+
84
+ 3. Run the migration:
85
+ rails db:migrate
86
+
87
+ 4. Regenerate the search table with updated fields:
88
+ rails generate searchable:migration #{model_class.name}
89
+ rails db:migrate
90
+ MSG
91
+ end
92
+
93
+ def extract_values(record)
94
+ config.fields.map do |field|
95
+ config.extract_content(record, field)
96
+ end
97
+ end
98
+
99
+ def execute_sql(sql)
100
+ adapter.execute_with_result(
101
+ model_class.connection,
102
+ sql,
103
+ model_class
104
+ )
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Searchable
5
+ class QueryBuilder
6
+ attr_reader :model_class, :config, :adapter
7
+
8
+ def initialize(model_class, config, adapter)
9
+ @model_class = model_class
10
+ @config = config
11
+ @adapter = adapter
12
+ end
13
+
14
+ def build_search_scope(relation, terms, matches: false)
15
+ sanitized_terms = sanitize_query(terms)
16
+
17
+ return relation.none if sanitized_terms.nil?
18
+
19
+ scope = relation
20
+ .joins(adapter.join_clause(config))
21
+ .where(adapter.where_clause(config), sanitized_terms)
22
+
23
+ # Only set select clause when we need extra columns for highlighting/snippets
24
+ # This allows ActiveRecord's .count to work correctly (it generates COUNT(*))
25
+ scope = scope.select(select_clause(matches)) if select_clause(matches)
26
+
27
+ scope.order(adapter.ranking_order_clause(config))
28
+ end
29
+
30
+ def sanitize_query(terms)
31
+ terms = terms.to_s
32
+ terms = remove_invalid_characters(terms)
33
+ terms = remove_unbalanced_quotes(terms)
34
+ terms = terms.strip.gsub(/\s+/, " ") # Normalize whitespace
35
+ terms.presence
36
+ end
37
+
38
+ private
39
+
40
+ def parse_matches(matches)
41
+ case matches
42
+ when true
43
+ # Smart defaults: first field highlight, second field snippet
44
+ {
45
+ highlight: [config.fields[0]&.name].compact,
46
+ snippet: [config.fields[1]&.name].compact
47
+ }
48
+ when Hash
49
+ highlight_fields = Array(matches[:highlight])
50
+ snippet_fields = Array(matches[:snippet])
51
+
52
+ # Validate all fields exist
53
+ all_fields = highlight_fields + snippet_fields
54
+ valid_field_names = config.fields.map(&:name)
55
+ invalid_fields = all_fields - valid_field_names
56
+
57
+ if invalid_fields.any?
58
+ raise ArgumentError, "Invalid field names: #{invalid_fields.join(', ')}"
59
+ end
60
+
61
+ {
62
+ highlight: highlight_fields,
63
+ snippet: snippet_fields
64
+ }
65
+ when false, nil
66
+ nil
67
+ else
68
+ raise ArgumentError, "matches must be true, false, nil, or a Hash"
69
+ end
70
+ end
71
+
72
+ def select_clause(matches)
73
+ parsed_matches = parse_matches(matches)
74
+
75
+ if parsed_matches
76
+ [
77
+ "#{model_class.table_name}.*",
78
+ *adapter.select_clause_for_matches(config, parsed_matches)
79
+ ]
80
+ else
81
+ # Return nil to let ActiveRecord use default select behavior
82
+ # This allows .count to work correctly (generates COUNT(*) instead of COUNT(table.*))
83
+ nil
84
+ end
85
+ end
86
+
87
+ def remove_invalid_characters(terms)
88
+ # Allow only word characters (\w = letters, digits, underscore) and double quotes
89
+ # This prevents FTS5 syntax errors from special characters while preserving phrase searches
90
+ terms.gsub(/[^\w"]/, " ")
91
+ end
92
+
93
+ def remove_unbalanced_quotes(terms)
94
+ if terms.count('"').even?
95
+ terms
96
+ else
97
+ terms.gsub('"', " ")
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Searchable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ # Validate Rails version
9
+ if defined?(Rails) && Rails.respond_to?(:version) && Rails.version < "8.0"
10
+ raise "activerecord-searchable requires Rails 8.0+ for create_virtual_table support"
11
+ end
12
+
13
+ class_attribute :search_configuration, default: Configuration.new(self)
14
+
15
+ after_create_commit :create_in_search_index, if: :should_index?
16
+ after_update_commit :update_in_search_index, if: :should_index?
17
+ after_destroy_commit :remove_from_search_index
18
+ end
19
+
20
+ class_methods do
21
+ def searchable(&block)
22
+ config = Configuration.new(self)
23
+ config.instance_eval(&block)
24
+ self.search_configuration = config
25
+
26
+ if config.fields.empty?
27
+ raise "No searchable fields configured. Add fields in the searchable block for #{name}"
28
+ end
29
+ end
30
+
31
+ def search(terms, matches: false)
32
+ QueryBuilder.new(self, search_configuration, adapter)
33
+ .build_search_scope(all, terms, matches: matches)
34
+ end
35
+
36
+ def reindex_all
37
+ all.find_each(&:reindex)
38
+ end
39
+
40
+ private
41
+
42
+ def adapter
43
+ @adapter ||= Adapter.for(connection)
44
+ end
45
+ end
46
+
47
+ def reindex
48
+ if should_index?
49
+ update_in_search_index
50
+ else
51
+ remove_from_search_index
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def should_index?
58
+ return true unless respond_to?(:searchable?)
59
+ searchable?
60
+ end
61
+
62
+ def create_in_search_index
63
+ index_manager.create(self)
64
+ end
65
+
66
+ def update_in_search_index
67
+ index_manager.update(self)
68
+ end
69
+
70
+ def remove_from_search_index
71
+ index_manager.delete(self)
72
+ end
73
+
74
+ def index_manager
75
+ IndexManager.new(self.class, self.class.search_configuration, adapter)
76
+ end
77
+
78
+ def adapter
79
+ self.class.send(:adapter)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Searchable
5
+ VERSION = "0.1.2"
6
+ end
7
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support/concern"
5
+
6
+ require_relative "activerecord/searchable/version"
7
+ require_relative "activerecord/searchable/field"
8
+ require_relative "activerecord/searchable/configuration"
9
+ require_relative "activerecord/searchable/adapter"
10
+ require_relative "activerecord/searchable/adapters/sqlite"
11
+ require_relative "activerecord/searchable/query_builder"
12
+ require_relative "activerecord/searchable/index_manager"
13
+ require_relative "activerecord/searchable/searchable"
14
+
15
+ module ActiveRecord
16
+ module Searchable
17
+ class SchemaError < StandardError; end
18
+ end
19
+ end
20
+
21
+ # Auto-include not enabled by default - users must explicitly include
22
+ # This gives them control over which models get search functionality
@@ -0,0 +1,20 @@
1
+ Description:
2
+ Generates a migration to create the FTS5 virtual table for a searchable model.
3
+ Reads the searchable configuration from the model to determine which fields to index.
4
+
5
+ Example:
6
+ rails generate searchable:migration Article
7
+
8
+ This will create:
9
+ db/migrate/YYYYMMDDHHMMSS_create_article_search_index.rb
10
+
11
+ The model must already have a searchable block defined:
12
+
13
+ class Article < ApplicationRecord
14
+ include ActiveRecord::Searchable
15
+
16
+ searchable do
17
+ field :title, weight: 2.0
18
+ field :body
19
+ end
20
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module Searchable
7
+ module Generators
8
+ class MigrationGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path('templates', __dir__)
12
+
13
+ argument :model_name, type: :string, required: true
14
+
15
+ def create_migration_file
16
+ validate_model_name!
17
+ validate_rails_version!
18
+ validate_model_exists!
19
+ validate_searchable_config!
20
+
21
+ migration_template(
22
+ "migration.rb.tt",
23
+ "db/migrate/create_#{search_table_name}.rb",
24
+ migration_version: migration_version
25
+ )
26
+ end
27
+
28
+ private
29
+
30
+ def model_class
31
+ @model_class ||= model_name.camelize.constantize
32
+ end
33
+
34
+ def search_config
35
+ @search_config ||= model_class.search_configuration
36
+ end
37
+
38
+ def search_table_name
39
+ search_config.search_table_name
40
+ end
41
+
42
+ def field_names
43
+ search_config.field_names
44
+ end
45
+
46
+ def migration_class_name
47
+ search_table_name.camelize
48
+ end
49
+
50
+ def migration_version
51
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
52
+ end
53
+
54
+ def validate_model_name!
55
+ unless model_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
56
+ raise Thor::Error, "Invalid model name: #{model_name}. Must be a valid Ruby class name (e.g., Article, BlogPost)."
57
+ end
58
+ end
59
+
60
+ def validate_rails_version!
61
+ if Rails.version < "8.0"
62
+ raise "activerecord-searchable requires Rails 8.0+ for create_virtual_table support. " \
63
+ "Current version: #{Rails.version}"
64
+ end
65
+ end
66
+
67
+ def validate_model_exists!
68
+ model_name.camelize.constantize
69
+ rescue NameError
70
+ raise "Model #{model_name.camelize} not found. Please create the model first."
71
+ end
72
+
73
+ def validate_searchable_config!
74
+ unless model_class.respond_to?(:search_configuration)
75
+ output_example_config
76
+ raise Thor::Error, "No searchable configuration found in #{model_class.name}"
77
+ end
78
+
79
+ if search_config.fields.empty?
80
+ say "Error: No fields defined in searchable block for #{model_class.name}", :red
81
+ raise Thor::Error, "Cannot generate migration without searchable fields"
82
+ end
83
+ end
84
+
85
+ def output_example_config
86
+ say "\nNo searchable configuration found in #{model_class.name}.", :red
87
+ say "\nAdd this to app/models/#{model_name.underscore}.rb:\n", :yellow
88
+ say <<~RUBY
89
+
90
+ include ActiveRecord::Searchable
91
+
92
+ searchable do
93
+ field :title, weight: 2.0
94
+ field :body
95
+ end
96
+ RUBY
97
+ say "\nThen run this generator again.\n", :yellow
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,16 @@
1
+ class Create<%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ create_virtual_table :<%= search_table_name %>, :fts5, [
4
+ <% field_names.each do |field| -%>
5
+ "<%= field %>",
6
+ <% end -%>
7
+ "tokenize='porter'"
8
+ ]
9
+
10
+ <%= model_name.camelize %>.reindex_all
11
+ end
12
+
13
+ def down
14
+ drop_table :<%= search_table_name %>
15
+ end
16
+ end
metadata ADDED
@@ -0,0 +1,145 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord-searchable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: ruby
6
+ authors:
7
+ - Matt Lins
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '8.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: sqlite3
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '2.1'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '2.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: minitest
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '5.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '5.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '13.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '13.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: railties
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '8.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '8.0'
96
+ description: Add full-text search to Active Record models using SQLite FTS5, with
97
+ zero external dependencies
98
+ email:
99
+ - mattlins@gmail.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - MIT-LICENSE
105
+ - README.md
106
+ - Rakefile
107
+ - lib/activerecord-searchable.rb
108
+ - lib/activerecord/searchable/adapter.rb
109
+ - lib/activerecord/searchable/adapters/sqlite.rb
110
+ - lib/activerecord/searchable/configuration.rb
111
+ - lib/activerecord/searchable/field.rb
112
+ - lib/activerecord/searchable/index_manager.rb
113
+ - lib/activerecord/searchable/query_builder.rb
114
+ - lib/activerecord/searchable/searchable.rb
115
+ - lib/activerecord/searchable/version.rb
116
+ - lib/generators/searchable/USAGE
117
+ - lib/generators/searchable/migration_generator.rb
118
+ - lib/generators/searchable/templates/migration.rb.tt
119
+ homepage: https://github.com/mattlins/activerecord-searchable
120
+ licenses:
121
+ - MIT
122
+ metadata:
123
+ allowed_push_host: https://rubygems.org
124
+ homepage_uri: https://github.com/mattlins/activerecord-searchable
125
+ source_code_uri: https://github.com/mattlins/activerecord-searchable
126
+ changelog_uri: https://github.com/mattlins/activerecord-searchable/blob/main/CHANGELOG.md
127
+ rubygems_mfa_required: 'true'
128
+ rdoc_options: []
129
+ require_paths:
130
+ - lib
131
+ required_ruby_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: 3.2.0
136
+ required_rubygems_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '0'
141
+ requirements: []
142
+ rubygems_version: 3.6.9
143
+ specification_version: 4
144
+ summary: Database-native full-text search for Rails 8+
145
+ test_files: []