rails-active_search 0.0.1 → 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 +4 -4
- data/MIT-LICENSE +20 -0
- data/README.md +942 -0
- data/Rakefile +75 -0
- data/app/jobs/active_search/base_job.rb +17 -0
- data/app/jobs/active_search/reindex_job.rb +19 -0
- data/app/jobs/active_search/remove_job.rb +20 -0
- data/lib/active_search/batch.rb +68 -0
- data/lib/active_search/capabilities.rb +121 -0
- data/lib/active_search/conditions.rb +142 -0
- data/lib/active_search/configuration.rb +245 -0
- data/lib/active_search/document.rb +84 -0
- data/lib/active_search/engine.rb +57 -0
- data/lib/active_search/errors.rb +29 -0
- data/lib/active_search/highlighting/field_options.rb +159 -0
- data/lib/active_search/highlighting/options.rb +60 -0
- data/lib/active_search/highlighting.rb +47 -0
- data/lib/active_search/hit.rb +58 -0
- data/lib/active_search/index/definition.rb +42 -0
- data/lib/active_search/index/field.rb +107 -0
- data/lib/active_search/index/querying.rb +16 -0
- data/lib/active_search/index/schema.rb +123 -0
- data/lib/active_search/index.rb +272 -0
- data/lib/active_search/index_reflection.rb +108 -0
- data/lib/active_search/indexable.rb +132 -0
- data/lib/active_search/indexing_callbacks.rb +87 -0
- data/lib/active_search/log_subscriber.rb +55 -0
- data/lib/active_search/model.rb +110 -0
- data/lib/active_search/page.rb +91 -0
- data/lib/active_search/query/normalization.rb +268 -0
- data/lib/active_search/query/validation.rb +201 -0
- data/lib/active_search/query.rb +344 -0
- data/lib/active_search/query_context.rb +59 -0
- data/lib/active_search/resultable.rb +24 -0
- data/lib/active_search/results.rb +182 -0
- data/lib/active_search/routing.rb +76 -0
- data/lib/active_search/schema/creation_plan.rb +19 -0
- data/lib/active_search/schema/creation_refused.rb +6 -0
- data/lib/active_search/schema/inspection.rb +54 -0
- data/lib/active_search/schema/observation.rb +11 -0
- data/lib/active_search/schema/observation_cache.rb +25 -0
- data/lib/active_search/schema/requirement.rb +22 -0
- data/lib/active_search/schema/verification.rb +89 -0
- data/lib/active_search/schema.rb +91 -0
- data/lib/active_search/score.rb +14 -0
- data/lib/active_search/source/base.rb +56 -0
- data/lib/active_search/source/polymorphic.rb +88 -0
- data/lib/active_search/source/record.rb +76 -0
- data/lib/active_search/store_adapters/base.rb +406 -0
- data/lib/active_search/store_adapters/database/document_class.rb +84 -0
- data/lib/active_search/store_adapters/database/migration_source.rb +97 -0
- data/lib/active_search/store_adapters/database/query_building.rb +168 -0
- data/lib/active_search/store_adapters/database.rb +500 -0
- data/lib/active_search/store_adapters/elastic/query_building.rb +141 -0
- data/lib/active_search/store_adapters/elastic/response_parsing.rb +67 -0
- data/lib/active_search/store_adapters/elastic.rb +319 -0
- data/lib/active_search/store_adapters/elasticsearch.rb +36 -0
- data/lib/active_search/store_adapters/index_lifecycle.rb +131 -0
- data/lib/active_search/store_adapters/manticore/query_building.rb +142 -0
- data/lib/active_search/store_adapters/manticore/response_parsing.rb +51 -0
- data/lib/active_search/store_adapters/manticore/serialization.rb +58 -0
- data/lib/active_search/store_adapters/manticore.rb +281 -0
- data/lib/active_search/store_adapters/meilisearch/query_building.rb +188 -0
- data/lib/active_search/store_adapters/meilisearch/response_parsing.rb +38 -0
- data/lib/active_search/store_adapters/meilisearch.rb +330 -0
- data/lib/active_search/store_adapters/mysql/query_building.rb +101 -0
- data/lib/active_search/store_adapters/mysql.rb +86 -0
- data/lib/active_search/store_adapters/opensearch.rb +20 -0
- data/lib/active_search/store_adapters/postgresql/highlighting.rb +41 -0
- data/lib/active_search/store_adapters/postgresql/query_building.rb +50 -0
- data/lib/active_search/store_adapters/postgresql.rb +126 -0
- data/lib/active_search/store_adapters/redis_search/query_building.rb +269 -0
- data/lib/active_search/store_adapters/redis_search/response_parsing.rb +83 -0
- data/lib/active_search/store_adapters/redis_search/serialization.rb +34 -0
- data/lib/active_search/store_adapters/redis_search.rb +240 -0
- data/lib/active_search/store_adapters/solr/query_building.rb +220 -0
- data/lib/active_search/store_adapters/solr/response_parsing.rb +56 -0
- data/lib/active_search/store_adapters/solr.rb +285 -0
- data/lib/active_search/store_adapters/sqlite/highlighting.rb +43 -0
- data/lib/active_search/store_adapters/sqlite/query_building.rb +91 -0
- data/lib/active_search/store_adapters/sqlite/response_parsing.rb +54 -0
- data/lib/active_search/store_adapters/sqlite.rb +182 -0
- data/lib/active_search/store_adapters/typesense/highlighting.rb +21 -0
- data/lib/active_search/store_adapters/typesense/query_building.rb +222 -0
- data/lib/active_search/store_adapters/typesense/response_parsing.rb +47 -0
- data/lib/active_search/store_adapters/typesense/serialization.rb +30 -0
- data/lib/active_search/store_adapters/typesense.rb +207 -0
- data/lib/active_search/total.rb +35 -0
- data/lib/active_search/type/boolean.rb +9 -0
- data/lib/active_search/type/boundary_date_time.rb +12 -0
- data/lib/active_search/type/canonical.rb +20 -0
- data/lib/active_search/type/date.rb +16 -0
- data/lib/active_search/type/date_time.rb +20 -0
- data/lib/active_search/type/epoch_microseconds_date_time.rb +30 -0
- data/lib/active_search/type/epoch_seconds_date.rb +22 -0
- data/lib/active_search/type/float.rb +17 -0
- data/lib/active_search/type/integer.rb +24 -0
- data/lib/active_search/type/multiple.rb +41 -0
- data/lib/active_search/type/string.rb +20 -0
- data/lib/active_search/version.rb +4 -0
- data/lib/active_search.rb +288 -0
- data/lib/generators/active_search/document/document_generator.rb +80 -0
- data/lib/generators/active_search/document/templates/migration.rb.tt +1 -0
- data/lib/generators/active_search/document/templates/model.rb.tt +7 -0
- data/lib/generators/active_search/index/USAGE +31 -0
- data/lib/generators/active_search/index/index_generator.rb +151 -0
- data/lib/generators/active_search/install/USAGE +20 -0
- data/lib/generators/active_search/install/install_generator.rb +41 -0
- data/lib/generators/active_search/install/templates/search.rb.tt +6 -0
- data/lib/generators/active_search/install/templates/search.yml.tt +33 -0
- data/lib/rails-active_search.rb +2 -0
- data/lib/tasks/active_search_tasks.rake +118 -0
- metadata +144 -8
data/README.md
ADDED
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
# Active Search
|
|
2
|
+
|
|
3
|
+
Active Search implements search for Rails. It provides a common interface for searching Active Record
|
|
4
|
+
models across multiple search backends.
|
|
5
|
+
|
|
6
|
+
This is **alpha** software. The API is not fixed and may have backward incompatible changes in future.
|
|
7
|
+
|
|
8
|
+
**Supported adapters:**
|
|
9
|
+
- **Database:** SQLite FTS5, PostgreSQL tsvector, MySQL FULLTEXT
|
|
10
|
+
- **Search engines:** Elasticsearch, OpenSearch, Solr, Meilisearch, Typesense, Manticore, Redis Search
|
|
11
|
+
|
|
12
|
+
## Contents
|
|
13
|
+
|
|
14
|
+
- [Installation](#installation)
|
|
15
|
+
- [Quick start](#quick-start)
|
|
16
|
+
- [Configuration](#configuration)
|
|
17
|
+
- [Named adapters](#named-adapters)
|
|
18
|
+
- [Index prefix](#index-prefix)
|
|
19
|
+
- [Database adapters](#database-adapters)
|
|
20
|
+
- [Search engine adapters](#search-engine-adapters)
|
|
21
|
+
- [Indexing](#indexing)
|
|
22
|
+
- [Index definitions](#index-definitions)
|
|
23
|
+
- [Collection fields](#collection-fields)
|
|
24
|
+
- [Polymorphic indexes](#polymorphic-indexes)
|
|
25
|
+
- [Models](#models)
|
|
26
|
+
- [Manual indexing](#manual-indexing)
|
|
27
|
+
- [Searching](#searching)
|
|
28
|
+
- [Queries](#queries)
|
|
29
|
+
- [Filtering](#filtering)
|
|
30
|
+
- [Highlighting](#highlighting)
|
|
31
|
+
- [Results](#results)
|
|
32
|
+
- [Loading records](#loading-records)
|
|
33
|
+
- [Routing](#routing)
|
|
34
|
+
- [Native queries](#native-queries)
|
|
35
|
+
- [Adapters](#adapters)
|
|
36
|
+
- [Capabilities](#capabilities)
|
|
37
|
+
- [Documented divergences](#documented-divergences)
|
|
38
|
+
- [Verified versions](#verified-versions)
|
|
39
|
+
- [Custom adapters](#custom-adapters)
|
|
40
|
+
- [Commands](#commands)
|
|
41
|
+
- [Instrumentation](#instrumentation)
|
|
42
|
+
- [Limitations](#limitations)
|
|
43
|
+
- [License](#license)
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
Add the gem:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
gem "rails-active_search"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For a search engine, add its client gem:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
gem "elasticsearch", "~> 8.0" # match your server's major version
|
|
57
|
+
gem "opensearch-ruby"
|
|
58
|
+
gem "rsolr" # Solr
|
|
59
|
+
gem "meilisearch"
|
|
60
|
+
gem "typesense"
|
|
61
|
+
gem "redis" # Redis Search
|
|
62
|
+
# Manticore needs no gem
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The Elasticsearch client must match the server's major version. See
|
|
66
|
+
[Verified versions](#verified-versions).
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
**1. Configure the adapter.** Create `config/search.yml`:
|
|
71
|
+
|
|
72
|
+
```yaml
|
|
73
|
+
test:
|
|
74
|
+
adapter: sqlite
|
|
75
|
+
|
|
76
|
+
production:
|
|
77
|
+
adapter: elasticsearch
|
|
78
|
+
hosts:
|
|
79
|
+
- host: search.example.com
|
|
80
|
+
port: 9200
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**2. Define an index.** Declare it in `config/search.rb`:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
ActiveSearch.define_index(:articles) do
|
|
87
|
+
text :title
|
|
88
|
+
text :content
|
|
89
|
+
integer :account_id
|
|
90
|
+
string :status
|
|
91
|
+
datetime :published_at
|
|
92
|
+
boolean :featured
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**3. Connect the model.** Add `has_search`:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class Article < ApplicationRecord
|
|
100
|
+
has_search
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
See [Models](#models).
|
|
105
|
+
|
|
106
|
+
**4. Search.**
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
results = Article.search("rails").results
|
|
110
|
+
|
|
111
|
+
results.each do |article|
|
|
112
|
+
article.title # the record
|
|
113
|
+
article.hit.score
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Configuration
|
|
118
|
+
|
|
119
|
+
`config/search.yml` sets the adapter for each environment, as in the [Quick start](#quick-start).
|
|
120
|
+
The migrations and connection settings for each adapter follow.
|
|
121
|
+
|
|
122
|
+
### Named adapters
|
|
123
|
+
|
|
124
|
+
An environment can have several stores, one per key. Set `store_name:` on an index to use one of
|
|
125
|
+
them. Without it, the index uses `default`.
|
|
126
|
+
|
|
127
|
+
```yaml
|
|
128
|
+
production:
|
|
129
|
+
default:
|
|
130
|
+
adapter: elasticsearch
|
|
131
|
+
hosts:
|
|
132
|
+
- host: search.example.com
|
|
133
|
+
port: 9200
|
|
134
|
+
legacy:
|
|
135
|
+
adapter: mysql
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
ActiveSearch.define_index(:legacy_articles, store_name: :legacy) { ... }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Index prefix
|
|
143
|
+
|
|
144
|
+
`index_prefix` is added to the front of every index name on that store. Use it when two
|
|
145
|
+
environments share one search engine. The install generator sets `development_` and `test_` for
|
|
146
|
+
the search engine adapters.
|
|
147
|
+
|
|
148
|
+
```yaml
|
|
149
|
+
development:
|
|
150
|
+
adapter: elasticsearch
|
|
151
|
+
index_prefix: development_
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Database adapters
|
|
155
|
+
|
|
156
|
+
A database adapter stores each index in a table with a column per filterable field and a full-text
|
|
157
|
+
index over the `text` fields (see [Index definitions](#index-definitions)). A `multiple:` field is a
|
|
158
|
+
JSON column, `jsonb` on PostgreSQL. The application owns the migration. The [document generator](#commands) writes it.
|
|
159
|
+
|
|
160
|
+
**SQLite** uses a table for the fields and an FTS5 virtual table for the text:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
class CreateArticleSearch < ActiveRecord::Migration[8.0]
|
|
164
|
+
def change
|
|
165
|
+
create_table :article_documents do |t|
|
|
166
|
+
t.string :article_id, null: false
|
|
167
|
+
t.integer :account_id
|
|
168
|
+
t.string :status
|
|
169
|
+
end
|
|
170
|
+
add_index :article_documents, :article_id, unique: true
|
|
171
|
+
create_virtual_table :article_documents_fts, :fts5, [:title, :content]
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
FTS5 does not stem by default, so `company` does not find "companies". Add the `porter` tokenizer
|
|
177
|
+
to stem:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
create_virtual_table :article_documents_fts, :fts5,
|
|
181
|
+
[ :title, :content, "tokenize='porter'" ]
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**PostgreSQL** uses tsvector columns with GIN indexes:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
create_table :article_documents do |t|
|
|
188
|
+
t.string :article_id, null: false
|
|
189
|
+
t.text :title, :content
|
|
190
|
+
t.tsvector :title_vector, :content_vector
|
|
191
|
+
t.integer :account_id
|
|
192
|
+
end
|
|
193
|
+
add_index :article_documents, :title_vector, using: :gin
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**MySQL** uses a FULLTEXT index:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
add_index :article_documents, [:title, :content], type: :fulltext
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
MySQL matches a FULLTEXT index by its whole column list. A search that names a subset of the
|
|
203
|
+
fields (see [Queries](#queries)) needs its own index over exactly those columns.
|
|
204
|
+
|
|
205
|
+
### Search engine adapters
|
|
206
|
+
|
|
207
|
+
Each adapter has its own connection settings:
|
|
208
|
+
|
|
209
|
+
```yaml
|
|
210
|
+
# Elasticsearch or OpenSearch
|
|
211
|
+
production:
|
|
212
|
+
adapter: elasticsearch
|
|
213
|
+
hosts:
|
|
214
|
+
- host: localhost
|
|
215
|
+
port: 9200
|
|
216
|
+
|
|
217
|
+
# Meilisearch
|
|
218
|
+
production:
|
|
219
|
+
adapter: meilisearch
|
|
220
|
+
url: http://localhost:7700
|
|
221
|
+
api_key: your_master_key
|
|
222
|
+
|
|
223
|
+
# Typesense
|
|
224
|
+
production:
|
|
225
|
+
adapter: typesense
|
|
226
|
+
nodes:
|
|
227
|
+
- host: localhost
|
|
228
|
+
port: 8108
|
|
229
|
+
protocol: http
|
|
230
|
+
api_key: your_api_key
|
|
231
|
+
|
|
232
|
+
# Solr
|
|
233
|
+
production:
|
|
234
|
+
adapter: solr
|
|
235
|
+
url: http://localhost:8983/solr
|
|
236
|
+
|
|
237
|
+
# Manticore
|
|
238
|
+
production:
|
|
239
|
+
adapter: manticore
|
|
240
|
+
host: localhost
|
|
241
|
+
port: 9308
|
|
242
|
+
|
|
243
|
+
# Redis Search
|
|
244
|
+
production:
|
|
245
|
+
adapter: redis_search
|
|
246
|
+
host: localhost
|
|
247
|
+
port: 6379
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Then create each index. See [Commands](#commands).
|
|
251
|
+
|
|
252
|
+
The Elasticsearch and OpenSearch clients log to `Rails.logger`. Set
|
|
253
|
+
`config.active_search.logger` to send their logging elsewhere.
|
|
254
|
+
|
|
255
|
+
## Indexing
|
|
256
|
+
|
|
257
|
+
### Index definitions
|
|
258
|
+
|
|
259
|
+
Declare each index in `config/search.rb`. A `text` field is searched. The other types are filtered
|
|
260
|
+
and sorted, and a `string` field holds one exact value. Declare the type your application writes.
|
|
261
|
+
ActiveSearch casts each value to it when it builds a document.
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
ActiveSearch.define_index(:articles) do
|
|
265
|
+
text :title
|
|
266
|
+
text :content
|
|
267
|
+
string :status
|
|
268
|
+
integer :account_id
|
|
269
|
+
float :price
|
|
270
|
+
boolean :featured
|
|
271
|
+
datetime :published_at
|
|
272
|
+
date :published_on
|
|
273
|
+
end
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Each index loads its results through a source. By default that is the class named by the singular
|
|
277
|
+
of the index name. Set `source:` to name another class, or `polymorphic: true` to load
|
|
278
|
+
several. On a database adapter, set `document_class:` to use your own model for the index table
|
|
279
|
+
in place of the generated one.
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
ActiveSearch.define_index(:articles) { ... } # Article
|
|
283
|
+
ActiveSearch.define_index(:namespaced_tests, source: "Article") { ... } # Article
|
|
284
|
+
ActiveSearch.define_index(:records, polymorphic: true) { ... } # several classes
|
|
285
|
+
ActiveSearch.define_index(:records, document_class: "Search::RecordDocument") { ... }
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Collection fields
|
|
289
|
+
|
|
290
|
+
Set `multiple: true` on an `integer`, `string` or `datetime` field to store a list of values:
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
ActiveSearch.define_index(:topics) do
|
|
294
|
+
text :subject
|
|
295
|
+
integer :folder_ids, multiple: true
|
|
296
|
+
string :labels, multiple: true
|
|
297
|
+
end
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
A filter matches documents whose list contains any of the given values, the same as `IN` on a
|
|
301
|
+
single value:
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
# topics in folder 4, folder 9, or both
|
|
305
|
+
ActiveSearch.index(:topics).filter(folder_ids: [ 4, 9 ])
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
A `Range` filter matches when any element is in the range. Check `supports_collection_ranges?`
|
|
309
|
+
first. A collection field cannot be sorted on.
|
|
310
|
+
|
|
311
|
+
### Polymorphic indexes
|
|
312
|
+
|
|
313
|
+
To index several models in one index, declare it `polymorphic: true`:
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
# config/search.rb
|
|
317
|
+
ActiveSearch.define_index(:records, polymorphic: true) do
|
|
318
|
+
text :title
|
|
319
|
+
text :body
|
|
320
|
+
integer :account_id
|
|
321
|
+
end
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Two extra fields, `record_type` and `record_id`, record each document's class and id. They are
|
|
325
|
+
named after the singular of the index name. To use another name, pass it as the option:
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
ActiveSearch.define_index(:records, polymorphic: :searchable) { ... } # searchable_type, searchable_id
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
You can declare these two fields yourself, as single-value `string` fields only. The id is stored
|
|
332
|
+
as text so that models with different id types can share the index.
|
|
333
|
+
|
|
334
|
+
`Post.search` on this index filters on `record_type: "Post"`. A filter you add on `record_type`
|
|
335
|
+
narrows this and cannot widen it. `ActiveSearch.index(:records)` searches every model.
|
|
336
|
+
|
|
337
|
+
Connect each model with `has_search index:`:
|
|
338
|
+
|
|
339
|
+
```ruby
|
|
340
|
+
class Post < ApplicationRecord
|
|
341
|
+
has_search index: :records, serializer: :to_content_document
|
|
342
|
+
|
|
343
|
+
def to_content_document
|
|
344
|
+
{ title: headline, body: body, account_id: account_id }
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
class Page < ApplicationRecord
|
|
349
|
+
has_search index: :records,
|
|
350
|
+
serializer: ->(page) { { title: page.title, body: page.content, account_id: page.account_id } }
|
|
351
|
+
end
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### Models
|
|
355
|
+
|
|
356
|
+
`has_search` connects a model to an index. Without `index:`, the index is the one named after the
|
|
357
|
+
table. It adds callbacks that reindex the record after every create and update, whatever changed,
|
|
358
|
+
and remove it after destroy. It adds three methods to the model:
|
|
359
|
+
|
|
360
|
+
- **`.search("query")`** returns a chainable `Query`. Call it without text for a filter-only query
|
|
361
|
+
- **`.suppress_indexing`** and **`.indexing_suppressed?`**
|
|
362
|
+
- **`#hit`**, the search metadata of a loaded result
|
|
363
|
+
|
|
364
|
+
```ruby
|
|
365
|
+
class Article < ApplicationRecord
|
|
366
|
+
has_search
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
Article.search("query")
|
|
370
|
+
ActiveSearch.index(:articles).filter(status: "published")
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
`suppress_indexing` turns the callbacks off for saves inside the block:
|
|
374
|
+
|
|
375
|
+
```ruby
|
|
376
|
+
articles = Article.where(status: :draft).to_a
|
|
377
|
+
Article.suppress_indexing do
|
|
378
|
+
articles.each { |article| article.update!(status: :archived) }
|
|
379
|
+
end
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
When the index fields do not match the model's attributes, give `has_search` a serializer that
|
|
383
|
+
returns the document hash:
|
|
384
|
+
|
|
385
|
+
```ruby
|
|
386
|
+
class Product < ApplicationRecord
|
|
387
|
+
has_search serializer: ->(r) {
|
|
388
|
+
{ name: r.name, description: r.description, price: r.price&.to_f,
|
|
389
|
+
category: r.category, author_name: r.author&.name }
|
|
390
|
+
}
|
|
391
|
+
end
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Or name a method:
|
|
395
|
+
|
|
396
|
+
```ruby
|
|
397
|
+
has_search serializer: :to_search_document
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
A model can be in several indexes. `.search` uses the first one declared, or the one with
|
|
401
|
+
`default: true`. Pass `index:` to search another:
|
|
402
|
+
|
|
403
|
+
```ruby
|
|
404
|
+
class Article < ApplicationRecord
|
|
405
|
+
has_search index: :articles
|
|
406
|
+
has_search index: :records, serializer: :to_content_document
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
Article.search("query") # :articles
|
|
410
|
+
Article.search("query", index: :records) # :records
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
`touch` does not reindex unless `reindex_on_touch` is set on the index:
|
|
414
|
+
|
|
415
|
+
```ruby
|
|
416
|
+
class Topic < ApplicationRecord
|
|
417
|
+
has_search reindex_on_touch: true
|
|
418
|
+
end
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
`touch_all` and `increment!(touch: true)` do not run commit callbacks, so they do not reindex.
|
|
422
|
+
Call `reindex` after them.
|
|
423
|
+
|
|
424
|
+
The other `has_search` options:
|
|
425
|
+
|
|
426
|
+
- **`async: false`** -- write inline instead of through Active Job
|
|
427
|
+
- **Guard options:** `if`, `unless`, `add_if`, `add_unless`, `remove_if`, `remove_unless`
|
|
428
|
+
- **`scope:`** -- the relation used to load results. See [Loading records](#loading-records)
|
|
429
|
+
|
|
430
|
+
`if` and `unless` apply to both the add on save and the remove on destroy. `add_if` and
|
|
431
|
+
`add_unless` replace them for the add. `remove_if` and `remove_unless` replace them for the remove.
|
|
432
|
+
A saved record that no longer passes its add guard is removed, and only `remove_if` and
|
|
433
|
+
`remove_unless` are checked for that removal.
|
|
434
|
+
|
|
435
|
+
### Manual indexing
|
|
436
|
+
|
|
437
|
+
`add` and `remove` write at once. `reindex_later` and `remove_later` enqueue an Active Job.
|
|
438
|
+
`remove_later` reads the id before the job runs, so it works after the record is destroyed.
|
|
439
|
+
|
|
440
|
+
```ruby
|
|
441
|
+
index = ActiveSearch.index(:articles)
|
|
442
|
+
index.add(article)
|
|
443
|
+
index.remove(article)
|
|
444
|
+
index.reindex_later(article)
|
|
445
|
+
index.remove_later(article)
|
|
446
|
+
|
|
447
|
+
has_search async: false # write inline for this model
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
`reindex_later` checks the guards when the job runs, so it can remove the document instead of
|
|
451
|
+
adding it.
|
|
452
|
+
|
|
453
|
+
`reindex` applies every `has_search` declaration on the record. It adds the record where the guards
|
|
454
|
+
pass and removes it where they fail. The `after_commit` callback calls it, and you can call it
|
|
455
|
+
directly:
|
|
456
|
+
|
|
457
|
+
```ruby
|
|
458
|
+
card.reindex
|
|
459
|
+
|
|
460
|
+
Card.find_each(&:reindex) # repair after an index loss
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
`ActiveSearch.index(:cards).add(card)` ignores the guards. After a change to what qualifies, use
|
|
464
|
+
`reindex`, so that records that no longer qualify are removed.
|
|
465
|
+
|
|
466
|
+
`reindex` uses each declaration's `async:` setting. An asynchronous index gets a job, and the
|
|
467
|
+
others are written inline.
|
|
468
|
+
|
|
469
|
+
`batch` collects writes and sends them to the store in groups of at most `max_size`:
|
|
470
|
+
|
|
471
|
+
```ruby
|
|
472
|
+
ActiveSearch.index(:articles).batch(max_size: 500) do |batch|
|
|
473
|
+
Article.find_each { |article| batch.add(article) }
|
|
474
|
+
end
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`remove_by_filter` removes every document that matches a filter and returns the count. Use it
|
|
478
|
+
after a `delete_all`, which runs no callbacks and so removes nothing from the index:
|
|
479
|
+
|
|
480
|
+
```ruby
|
|
481
|
+
ActiveSearch.index(:searchable).remove_by_filter(account_id: 1) # => 42
|
|
482
|
+
ActiveSearch.index(:records).remove_by_filter(account_id: 1, record_type: "Post")
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
It takes the same filter arguments as `filter`. It has no model type filter, so on a polymorphic
|
|
486
|
+
index add the type field, as in the second example. An empty filter raises.
|
|
487
|
+
|
|
488
|
+
## Searching
|
|
489
|
+
|
|
490
|
+
### Queries
|
|
491
|
+
|
|
492
|
+
Each query method returns a new `Query`. Chain them in any order, and call `.results` to run the
|
|
493
|
+
search. A `Query` has no `each`, `map` or `count`. `.results` returns the page.
|
|
494
|
+
|
|
495
|
+
```ruby
|
|
496
|
+
ActiveSearch.index(:articles)
|
|
497
|
+
.search("ruby programming")
|
|
498
|
+
.filter(account_id: 1)
|
|
499
|
+
.highlight(title: true)
|
|
500
|
+
.sort(published_at: :desc)
|
|
501
|
+
.limit(20)
|
|
502
|
+
.offset(40)
|
|
503
|
+
.results
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
`operator: :and` requires every term. `fields:` limits the search to the named `text` fields:
|
|
507
|
+
|
|
508
|
+
```ruby
|
|
509
|
+
Article.search("ruby rails", operator: :and)
|
|
510
|
+
Article.search("ruby", fields: [ :title ])
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
`operator` is not supported everywhere. Check `supports_operator?`. Without it, each engine uses
|
|
514
|
+
its own default.
|
|
515
|
+
|
|
516
|
+
### Filtering
|
|
517
|
+
|
|
518
|
+
`filter` narrows the search by a field's value, a list of values or a range:
|
|
519
|
+
|
|
520
|
+
```ruby
|
|
521
|
+
Article.search("rails").filter(account_id: 1)
|
|
522
|
+
Article.search("rails").filter(status: "published", account_id: 1)
|
|
523
|
+
ActiveSearch.index(:articles).filter(status: ["published", "draft"])
|
|
524
|
+
ActiveSearch.index(:articles).filter(views: 100..) # >= 100
|
|
525
|
+
ActiveSearch.index(:articles).filter(views: ..100) # <= 100
|
|
526
|
+
ActiveSearch.index(:articles).filter(views: ...100) # < 100
|
|
527
|
+
ActiveSearch.index(:articles).filter(price: 10..50) # between 10 and 50
|
|
528
|
+
ActiveSearch.index(:articles).filter(created_at: 1.week.ago..)
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
To filter on a missing value, pass `nil`. Check `supports_missing_filters?` first:
|
|
532
|
+
|
|
533
|
+
```ruby
|
|
534
|
+
Article.search("rails").filter(status: nil) # absent
|
|
535
|
+
Article.search("rails").filter(status: [ "draft", nil ]) # draft or absent
|
|
536
|
+
Article.search("rails").reject(status: nil) # present
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
`reject` excludes matches:
|
|
540
|
+
|
|
541
|
+
```ruby
|
|
542
|
+
ActiveSearch.index(:articles).reject(status: "draft") # status != draft
|
|
543
|
+
ActiveSearch.index(:articles).reject(status: ["draft", "archived"]) # NOT IN
|
|
544
|
+
ActiveSearch.index(:articles).reject(views: 100..200) # NOT BETWEEN
|
|
545
|
+
|
|
546
|
+
# Combined with regular filters
|
|
547
|
+
ActiveSearch.index(:articles)
|
|
548
|
+
.filter(account_id: 1)
|
|
549
|
+
.filter(views: 100..)
|
|
550
|
+
.reject(status: "draft")
|
|
551
|
+
.results
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
Several conditions in one `reject` are negated together, like `where.not` in Active Record:
|
|
555
|
+
|
|
556
|
+
```ruby
|
|
557
|
+
reject(status: "draft", featured: true) # not (draft and featured)
|
|
558
|
+
reject(status: "draft").reject(featured: true) # neither draft nor featured
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
Several values for one field are one condition, so each of them is excluded.
|
|
562
|
+
|
|
563
|
+
`filter_any` takes an Array of alternatives and requires one of them. Each alternative is a Hash,
|
|
564
|
+
and its conditions apply together.
|
|
565
|
+
|
|
566
|
+
```ruby
|
|
567
|
+
# featured, or published this week
|
|
568
|
+
ActiveSearch.index(:articles).filter_any([
|
|
569
|
+
{ featured: true },
|
|
570
|
+
{ status: "published", published_at: 1.week.ago.. }
|
|
571
|
+
])
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
An empty list matches nothing. An alternative with no conditions raises. `filter_any` combines with
|
|
575
|
+
the other filters through AND:
|
|
576
|
+
|
|
577
|
+
```ruby
|
|
578
|
+
ActiveSearch.index(:articles)
|
|
579
|
+
.filter(account_id: 1)
|
|
580
|
+
.filter_any([ { featured: true }, { status: "published" } ])
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
### Highlighting
|
|
584
|
+
|
|
585
|
+
```ruby
|
|
586
|
+
results = Article.search("ruby").highlight(true).results
|
|
587
|
+
|
|
588
|
+
results.each do |result|
|
|
589
|
+
result.hit.highlight(:title) # => "<mark>Ruby</mark> Programming Guide"
|
|
590
|
+
result.hit.highlight(:content) # => nil, because nothing in content matched
|
|
591
|
+
end
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
A field with no match has no highlight, and `hit.highlight` returns `nil` for it on every adapter.
|
|
595
|
+
Fall back to the field value when you want one:
|
|
596
|
+
|
|
597
|
+
```ruby
|
|
598
|
+
result.hit.highlight(:content) || result.content
|
|
599
|
+
|
|
600
|
+
# Per-field with snippets and custom markers
|
|
601
|
+
Article.search("ruby").highlight(
|
|
602
|
+
title: { markers: ["<em>", "</em>"] },
|
|
603
|
+
content: { snippet: { words: 15 } } # or { characters: 150 }
|
|
604
|
+
)
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
`highlight` takes `format`, `markers` and `snippet`. Any other key raises. Set backend options
|
|
608
|
+
through `native`.
|
|
609
|
+
|
|
610
|
+
Different options on different fields need `supports_highlight_per_field_markers?` and
|
|
611
|
+
`supports_highlight_per_field_snippets?`. A field with no snippet counts as a different snippet
|
|
612
|
+
setting:
|
|
613
|
+
|
|
614
|
+
```ruby
|
|
615
|
+
# Needs supports_highlight_per_field_markers?
|
|
616
|
+
Article.search("ruby").highlight(title: { markers: ["<em>", "</em>"] },
|
|
617
|
+
content: { markers: ["<b>", "</b>"] })
|
|
618
|
+
|
|
619
|
+
# Both need supports_highlight_per_field_snippets?
|
|
620
|
+
Article.search("ruby").highlight(title: { snippet: { words: 5 } },
|
|
621
|
+
content: { snippet: { words: 15 } })
|
|
622
|
+
|
|
623
|
+
Article.search("ruby").highlight(title: true,
|
|
624
|
+
content: { snippet: { words: 15 } })
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
### Results
|
|
628
|
+
|
|
629
|
+
```ruby
|
|
630
|
+
results = Article.search("rails").highlight.results
|
|
631
|
+
|
|
632
|
+
results.total # documents that matched, across all pages
|
|
633
|
+
results.total_exact? # false when the total is an estimate or a lower bound
|
|
634
|
+
results.partial? # true when the store stopped early
|
|
635
|
+
results.dropped # hits on this page with no record to load
|
|
636
|
+
results.size # records on this page
|
|
637
|
+
results.empty?
|
|
638
|
+
results.next_page? # true when the store has hits beyond this page
|
|
639
|
+
|
|
640
|
+
results.each do |article|
|
|
641
|
+
article.hit.score
|
|
642
|
+
article.hit.highlight(:title) # nil unless .highlight was called
|
|
643
|
+
article.hit.fields[:status]
|
|
644
|
+
end
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
Each result is the record. `hit.fields` holds every declared field, and `hit_fields` limits it:
|
|
648
|
+
|
|
649
|
+
```ruby
|
|
650
|
+
results = Article.search("rails").hit_fields(:status).results
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
`total` counts index hits. `size` counts the records loaded for this page. Some stores report an
|
|
654
|
+
estimate or a lower bound, and then `total_exact?` is false.
|
|
655
|
+
|
|
656
|
+
Use `next_page?` to page. `false` means the store returned no further hits. On a `partial?` page
|
|
657
|
+
there can still be matches the store did not reach.
|
|
658
|
+
|
|
659
|
+
`partial?` is true when the store stopped early, for example on a timeout. Then `total` may be low
|
|
660
|
+
and `next_page?` may be false with matches unseen.
|
|
661
|
+
|
|
662
|
+
```ruby
|
|
663
|
+
results = Article.search("rails").results
|
|
664
|
+
render_warning if results.partial?
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
A query without `limit` uses `config.active_search.default_limit`, which is 25.
|
|
668
|
+
|
|
669
|
+
```ruby
|
|
670
|
+
# config/initializers/active_search.rb
|
|
671
|
+
Rails.application.config.active_search.default_limit = 50
|
|
672
|
+
Rails.application.config.active_search.default_limit = nil # no limit, each store applies its own
|
|
673
|
+
|
|
674
|
+
# No limit for one query:
|
|
675
|
+
query.limit(nil).to_native_query
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
### Loading records
|
|
679
|
+
|
|
680
|
+
A search returns document ids, and the source loads the records with `Model.where(id: ids)`. Set
|
|
681
|
+
`scope:` on the model or on the query to load them through another relation:
|
|
682
|
+
|
|
683
|
+
```ruby
|
|
684
|
+
class Recording < ApplicationRecord
|
|
685
|
+
has_search scope: -> { not_removed }
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
Recording.search("q") # the model's default
|
|
689
|
+
Recording.search("q", scope: Recording.preload(:creator)) # this query only
|
|
690
|
+
Topic.search("q", scope: identity.accessible_topics) # a relation built per request
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
The query's relation is merged into the model's default with `ActiveRecord::Relation#merge`.
|
|
694
|
+
|
|
695
|
+
A scope does not filter the search. The store chooses the page of hits first, and the scope only
|
|
696
|
+
loads them. Hits the scope excludes are counted in `results.dropped`.
|
|
697
|
+
|
|
698
|
+
### Routing
|
|
699
|
+
|
|
700
|
+
On Elasticsearch and OpenSearch, `route_by` stores each document on a shard chosen by a field, and a search that filters on that
|
|
701
|
+
field reads only that shard:
|
|
702
|
+
|
|
703
|
+
```ruby
|
|
704
|
+
# config/search.rb
|
|
705
|
+
ActiveSearch.define_index(:articles, route_by: :account_id) do
|
|
706
|
+
text :title
|
|
707
|
+
integer :account_id
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
Article.search("q").filter(account_id: 1).results # one shard
|
|
711
|
+
Article.search("q").filter(account_id: [ 1, 2 ]).results # two shards
|
|
712
|
+
Article.search("q").filter(account_id: 1..5).results # QueryError
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
A negated filter, or a `filter_any` with an alternative that does not name the field, reads every
|
|
716
|
+
shard. `Query#routing` returns the route, or nil.
|
|
717
|
+
|
|
718
|
+
Do not change a record's `route_by` value. The old document stays on the old shard, and a later
|
|
719
|
+
destroy does not reach it.
|
|
720
|
+
|
|
721
|
+
Test routing on an index with more than one shard. With one shard, a wrong route still returns
|
|
722
|
+
everything.
|
|
723
|
+
|
|
724
|
+
### Native queries
|
|
725
|
+
|
|
726
|
+
`to_native_query` returns the request as built. `native` takes a block to change it before it is
|
|
727
|
+
sent.
|
|
728
|
+
|
|
729
|
+
> **Treat a `native` block as trusted code.** Its return value is sent to the backend without
|
|
730
|
+
> validation. It can remove [the model's type filter](#polymorphic-indexes). Never pass untrusted
|
|
731
|
+
> parameters into it.
|
|
732
|
+
|
|
733
|
+
**Search engine adapters** return a hash:
|
|
734
|
+
|
|
735
|
+
```ruby
|
|
736
|
+
ActiveSearch.index(:articles).search("ruby").to_native_query
|
|
737
|
+
# => { query: { bool: { must: { simple_query_string: {...} } } } }
|
|
738
|
+
|
|
739
|
+
# Modify the query before execution
|
|
740
|
+
ActiveSearch.index(:articles).search("ruby").native { |query|
|
|
741
|
+
query[:timeout] = "5s"
|
|
742
|
+
query
|
|
743
|
+
}.results
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
**Database adapters** (SQLite, PostgreSQL, MySQL) return an Active Record relation on the document
|
|
747
|
+
model. To merge it into a query on your model, join the document table first. Document ids are
|
|
748
|
+
stored as strings, so the join casts. On MySQL cast to `CHAR`:
|
|
749
|
+
|
|
750
|
+
```ruby
|
|
751
|
+
search = ActiveSearch.index(:articles).search("ruby").to_native_query
|
|
752
|
+
doc_table = search.model.table_name
|
|
753
|
+
|
|
754
|
+
Article
|
|
755
|
+
.select("articles.*")
|
|
756
|
+
.joins("INNER JOIN #{doc_table} ON #{doc_table}.article_id = CAST(articles.id AS TEXT)")
|
|
757
|
+
.merge(search)
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
## Adapters
|
|
761
|
+
|
|
762
|
+
### Capabilities
|
|
763
|
+
|
|
764
|
+
Every adapter supports text search, exact, negative, IN and range filters, sorting, pagination and
|
|
765
|
+
hit fields. The rest depends on the adapter. Check the index's capabilities. An unsupported operation raises
|
|
766
|
+
`UnsupportedOperationError`.
|
|
767
|
+
|
|
768
|
+
```ruby
|
|
769
|
+
capabilities = ActiveSearch.index(:articles).capabilities
|
|
770
|
+
capabilities.supports_highlighting?
|
|
771
|
+
capabilities.supports_operator?
|
|
772
|
+
capabilities.supports_missing_filters?
|
|
773
|
+
capabilities.supports_search_subfields?
|
|
774
|
+
capabilities.supports_search_string_fields?
|
|
775
|
+
capabilities.supports_collection_ranges? # a range over a multiple: field
|
|
776
|
+
capabilities.supports_snippet_unit?(:words) # or :characters
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
| Adapter | highlight | snippet units | per-field markers | per-field snippets | `operator:` | missing filters | subfields | string-field search | collection ranges |
|
|
780
|
+
|---|---|---|---|---|---|---|---|---|---|
|
|
781
|
+
| Elasticsearch | yes | characters | yes | yes | yes | yes | yes | yes | yes |
|
|
782
|
+
| OpenSearch | yes | characters | yes | yes | yes | yes | yes | yes | yes |
|
|
783
|
+
| Solr | yes | characters | yes | yes | yes | yes | no | no | yes |
|
|
784
|
+
| Meilisearch | yes | words | yes | yes | no | yes | no | no | no |
|
|
785
|
+
| Typesense | yes | words | yes | no | yes | no | no | no | no |
|
|
786
|
+
| PostgreSQL | yes | words | yes | yes | no | yes | no | no | yes |
|
|
787
|
+
| SQLite | yes | words | yes | yes | no | yes | no | no | yes |
|
|
788
|
+
| MySQL | no | none | no | no | no | yes | no | no | no |
|
|
789
|
+
| Redis Search | yes | none | no | no | no | yes | no | no | no |
|
|
790
|
+
| Manticore | yes | characters | no | no | no | no | no | no | yes |
|
|
791
|
+
|
|
792
|
+
### Documented divergences
|
|
793
|
+
|
|
794
|
+
Each of these is a property of the backend, and each is asserted in the test suite.
|
|
795
|
+
|
|
796
|
+
- **Redis Search needs 2.10 or later, query dialect 2, and `INDEXMISSING` on every filterable
|
|
797
|
+
field.** Dialect 2 provides `ismissing()`, which is used for missing-value filters and for an
|
|
798
|
+
empty filter list.
|
|
799
|
+
- **Typesense approximates `operator: :or`.** It maps to `drop_tokens_threshold`, so terms are
|
|
800
|
+
dropped only when the full match finds fewer than 100 documents.
|
|
801
|
+
- **Solr does not distinguish `""` from an absent field.** It indexes no term for an empty string,
|
|
802
|
+
so an existence filter does not find it. `false` and zero are unaffected. An empty collection
|
|
803
|
+
also reads back as absent. Every other adapter keeps the two apart.
|
|
804
|
+
- **SQLite sorts relevance ascending.** FTS5 rank is negative, and the best match is the most
|
|
805
|
+
negative.
|
|
806
|
+
- **Redis Search filters integers as doubles**, so values above 53 bits are not distinct in a
|
|
807
|
+
filter. They are stored and read back intact. Declare such a field as `string` if you filter on
|
|
808
|
+
it.
|
|
809
|
+
- **Redis Search indexes every collection as TAG.** The values are joined with U+001F, so the
|
|
810
|
+
`FT.CREATE` schema must declare `SEPARATOR "\x1f"` on the field, and a value containing U+001F
|
|
811
|
+
is not supported. `[ "" ]` and `[]` both read back as `[]`.
|
|
812
|
+
- **Manticore has no NULL.** An absent text field reads back as `""`. Every other adapter returns
|
|
813
|
+
`nil`.
|
|
814
|
+
- **Manticore stores an integer collection as a multi-value attribute, which is a set.**
|
|
815
|
+
`(3, 1, 3)` reads back as `1, 3`. Other collection types go in a `json` column, which keeps order
|
|
816
|
+
and duplicates.
|
|
817
|
+
|
|
818
|
+
### Verified versions
|
|
819
|
+
|
|
820
|
+
Every claim in this README is tested against the versions in `docker-compose.yml`, pinned by
|
|
821
|
+
digest.
|
|
822
|
+
|
|
823
|
+
| Store | Version under test |
|
|
824
|
+
|---|---|
|
|
825
|
+
| Elasticsearch | `9.5.2`, `8.19.20` **and** `7.17.18` |
|
|
826
|
+
| OpenSearch | `3.8.0` |
|
|
827
|
+
| Solr | `9.10.1` |
|
|
828
|
+
| Meilisearch | `v1.53.1` |
|
|
829
|
+
| Typesense | `30.2` |
|
|
830
|
+
| Redis Stack | `7.4.0-v8` |
|
|
831
|
+
| Manticore | `29.0.2` |
|
|
832
|
+
| MySQL | `8.4` |
|
|
833
|
+
| PostgreSQL | `18` |
|
|
834
|
+
| SQLite | whatever the `sqlite3` gem bundles |
|
|
835
|
+
|
|
836
|
+
Each Elasticsearch major is tested with its own client, because a client only works with its own
|
|
837
|
+
major.
|
|
838
|
+
|
|
839
|
+
For a version not in the table, run the shared adapter tests in
|
|
840
|
+
`test/store_adapters/adapter_test.rb` against it.
|
|
841
|
+
|
|
842
|
+
### Custom adapters
|
|
843
|
+
|
|
844
|
+
To write a source or an adapter of your own, see [docs/custom_adapters.md](docs/custom_adapters.md).
|
|
845
|
+
|
|
846
|
+
## Commands
|
|
847
|
+
|
|
848
|
+
```bash
|
|
849
|
+
rails active_search:install # Write config/search.yml and config/search.rb (ADAPTER=name)
|
|
850
|
+
rails active_search:status # What exists, and what to do next
|
|
851
|
+
rails active_search:health # Ping each store; database adapters answer without a connection
|
|
852
|
+
rails active_search:verify # Does each index provide what its declaration needs
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
`install` writes sqlite for development and test. Pass `ADAPTER` for another:
|
|
856
|
+
|
|
857
|
+
```bash
|
|
858
|
+
rails active_search:install ADAPTER=elasticsearch
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
Each adapter defaults to localhost on its own port. Typesense has no default key, so set
|
|
862
|
+
`TYPESENSE_API_KEY`. See [Configuration](#configuration) for the other settings.
|
|
863
|
+
|
|
864
|
+
To declare an index and connect its model:
|
|
865
|
+
|
|
866
|
+
```bash
|
|
867
|
+
rails generate active_search:index Article title body:text status:string views:integer
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
This writes the `define_index` block into `config/search.rb` and adds `has_search` to the model.
|
|
871
|
+
On a database adapter it also writes the document model and its migration. A field with no type
|
|
872
|
+
is `text`, and a third segment is a modifier:
|
|
873
|
+
|
|
874
|
+
```bash
|
|
875
|
+
rails generate active_search:index Article commenters:string:multiple
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
`status` and `verify` take `INDEX=name` for one index. `status` takes `COUNT=1` to count the
|
|
879
|
+
documents in each index.
|
|
880
|
+
|
|
881
|
+
`verify` exits non-zero when anything is wrong, so it can run in CI or before a deploy:
|
|
882
|
+
|
|
883
|
+
| Code | Outcome | Meaning |
|
|
884
|
+
|---|---|---|
|
|
885
|
+
| 0 | compatible | Every index provides what its declaration needs |
|
|
886
|
+
| 1 | incompatible | The index exists but a declared field is missing or unusable |
|
|
887
|
+
| 2 | missing | The index does not exist |
|
|
888
|
+
| 3 | unavailable | The store did not answer in time |
|
|
889
|
+
| 4 | unsupported | The adapter cannot inspect itself, so nothing was checked |
|
|
890
|
+
| 5 | error | The check raised, or no index is declared |
|
|
891
|
+
|
|
892
|
+
A run over several indexes exits with the worst code. Codes 3, 4 and 5 mean the check did not run.
|
|
893
|
+
|
|
894
|
+
To create an index on a search engine:
|
|
895
|
+
|
|
896
|
+
```bash
|
|
897
|
+
rails active_search:index:create INDEX=articles
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
This creates the fields in the declaration, and is safe to run again. Set analyzers through the
|
|
901
|
+
engine's own API. Solr needs its core created with Solr's tools first.
|
|
902
|
+
|
|
903
|
+
On a database adapter, generate the document model and its migration, then migrate:
|
|
904
|
+
|
|
905
|
+
```bash
|
|
906
|
+
rails generate active_search:document articles
|
|
907
|
+
rails db:migrate
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
## Instrumentation
|
|
911
|
+
|
|
912
|
+
Five `ActiveSupport::Notifications` events:
|
|
913
|
+
|
|
914
|
+
| Event | Payload |
|
|
915
|
+
|---|---|
|
|
916
|
+
| `search.active_search` | `index`, `store_name`, `query_length`, `total`, `total_relation`, `partial_results`, and `store_metrics` |
|
|
917
|
+
| `add.active_search` | `index`, `store_name`, `document_id` |
|
|
918
|
+
| `remove.active_search` | `index`, `store_name`, `document_id` |
|
|
919
|
+
| `flush_batch.active_search` | `index`, `store_name`, `operations` |
|
|
920
|
+
| `remove_by_filter.active_search` | `index`, `store_name`, `removed` |
|
|
921
|
+
|
|
922
|
+
`store_name` is the key from `config/search.yml`, or `:default`. `total_relation` is `:equal`,
|
|
923
|
+
`:lower_bound` or `:estimate`. Duration comes from the event.
|
|
924
|
+
|
|
925
|
+
The search payload carries `query_length`, not the query text. `ActiveSearch.filter_attributes`
|
|
926
|
+
lists payload values to filter. It defaults to `config.filter_parameters`.
|
|
927
|
+
|
|
928
|
+
## Limitations
|
|
929
|
+
|
|
930
|
+
- **Query syntax.** There is no common syntax. Each adapter sends the query string to its engine,
|
|
931
|
+
with the escaping that engine needs.
|
|
932
|
+
- **Index management.** `index:create` builds a search engine index. Solr cores and database
|
|
933
|
+
tables are created outside it. See [Commands](#commands).
|
|
934
|
+
- **Analyzers.** Stemming, synonyms and other text analysis are set in the engine. On SQLite, see
|
|
935
|
+
the FTS5 tokenizer under [Configuration](#configuration).
|
|
936
|
+
- **Advanced features.** No aggregations, nested documents, grouping or autocomplete.
|
|
937
|
+
- **Redis Search.** A document whose every field is nil is not stored, so a missing-value filter
|
|
938
|
+
does not find it. The next write with any field present stores it.
|
|
939
|
+
|
|
940
|
+
## License
|
|
941
|
+
|
|
942
|
+
MIT License
|