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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +368 -0
- data/Rakefile +11 -0
- data/lib/activerecord/searchable/adapter.rb +47 -0
- data/lib/activerecord/searchable/adapters/sqlite.rb +100 -0
- data/lib/activerecord/searchable/configuration.rb +38 -0
- data/lib/activerecord/searchable/field.rb +11 -0
- data/lib/activerecord/searchable/index_manager.rb +108 -0
- data/lib/activerecord/searchable/query_builder.rb +102 -0
- data/lib/activerecord/searchable/searchable.rb +82 -0
- data/lib/activerecord/searchable/version.rb +7 -0
- data/lib/activerecord-searchable.rb +22 -0
- data/lib/generators/searchable/USAGE +20 -0
- data/lib/generators/searchable/migration_generator.rb +101 -0
- data/lib/generators/searchable/templates/migration.rb.tt +16 -0
- metadata +145 -0
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,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,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,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: []
|