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.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +942 -0
  4. data/Rakefile +75 -0
  5. data/app/jobs/active_search/base_job.rb +17 -0
  6. data/app/jobs/active_search/reindex_job.rb +19 -0
  7. data/app/jobs/active_search/remove_job.rb +20 -0
  8. data/lib/active_search/batch.rb +68 -0
  9. data/lib/active_search/capabilities.rb +121 -0
  10. data/lib/active_search/conditions.rb +142 -0
  11. data/lib/active_search/configuration.rb +245 -0
  12. data/lib/active_search/document.rb +84 -0
  13. data/lib/active_search/engine.rb +57 -0
  14. data/lib/active_search/errors.rb +29 -0
  15. data/lib/active_search/highlighting/field_options.rb +159 -0
  16. data/lib/active_search/highlighting/options.rb +60 -0
  17. data/lib/active_search/highlighting.rb +47 -0
  18. data/lib/active_search/hit.rb +58 -0
  19. data/lib/active_search/index/definition.rb +42 -0
  20. data/lib/active_search/index/field.rb +107 -0
  21. data/lib/active_search/index/querying.rb +16 -0
  22. data/lib/active_search/index/schema.rb +123 -0
  23. data/lib/active_search/index.rb +272 -0
  24. data/lib/active_search/index_reflection.rb +108 -0
  25. data/lib/active_search/indexable.rb +132 -0
  26. data/lib/active_search/indexing_callbacks.rb +87 -0
  27. data/lib/active_search/log_subscriber.rb +55 -0
  28. data/lib/active_search/model.rb +110 -0
  29. data/lib/active_search/page.rb +91 -0
  30. data/lib/active_search/query/normalization.rb +268 -0
  31. data/lib/active_search/query/validation.rb +201 -0
  32. data/lib/active_search/query.rb +344 -0
  33. data/lib/active_search/query_context.rb +59 -0
  34. data/lib/active_search/resultable.rb +24 -0
  35. data/lib/active_search/results.rb +182 -0
  36. data/lib/active_search/routing.rb +76 -0
  37. data/lib/active_search/schema/creation_plan.rb +19 -0
  38. data/lib/active_search/schema/creation_refused.rb +6 -0
  39. data/lib/active_search/schema/inspection.rb +54 -0
  40. data/lib/active_search/schema/observation.rb +11 -0
  41. data/lib/active_search/schema/observation_cache.rb +25 -0
  42. data/lib/active_search/schema/requirement.rb +22 -0
  43. data/lib/active_search/schema/verification.rb +89 -0
  44. data/lib/active_search/schema.rb +91 -0
  45. data/lib/active_search/score.rb +14 -0
  46. data/lib/active_search/source/base.rb +56 -0
  47. data/lib/active_search/source/polymorphic.rb +88 -0
  48. data/lib/active_search/source/record.rb +76 -0
  49. data/lib/active_search/store_adapters/base.rb +406 -0
  50. data/lib/active_search/store_adapters/database/document_class.rb +84 -0
  51. data/lib/active_search/store_adapters/database/migration_source.rb +97 -0
  52. data/lib/active_search/store_adapters/database/query_building.rb +168 -0
  53. data/lib/active_search/store_adapters/database.rb +500 -0
  54. data/lib/active_search/store_adapters/elastic/query_building.rb +141 -0
  55. data/lib/active_search/store_adapters/elastic/response_parsing.rb +67 -0
  56. data/lib/active_search/store_adapters/elastic.rb +319 -0
  57. data/lib/active_search/store_adapters/elasticsearch.rb +36 -0
  58. data/lib/active_search/store_adapters/index_lifecycle.rb +131 -0
  59. data/lib/active_search/store_adapters/manticore/query_building.rb +142 -0
  60. data/lib/active_search/store_adapters/manticore/response_parsing.rb +51 -0
  61. data/lib/active_search/store_adapters/manticore/serialization.rb +58 -0
  62. data/lib/active_search/store_adapters/manticore.rb +281 -0
  63. data/lib/active_search/store_adapters/meilisearch/query_building.rb +188 -0
  64. data/lib/active_search/store_adapters/meilisearch/response_parsing.rb +38 -0
  65. data/lib/active_search/store_adapters/meilisearch.rb +330 -0
  66. data/lib/active_search/store_adapters/mysql/query_building.rb +101 -0
  67. data/lib/active_search/store_adapters/mysql.rb +86 -0
  68. data/lib/active_search/store_adapters/opensearch.rb +20 -0
  69. data/lib/active_search/store_adapters/postgresql/highlighting.rb +41 -0
  70. data/lib/active_search/store_adapters/postgresql/query_building.rb +50 -0
  71. data/lib/active_search/store_adapters/postgresql.rb +126 -0
  72. data/lib/active_search/store_adapters/redis_search/query_building.rb +269 -0
  73. data/lib/active_search/store_adapters/redis_search/response_parsing.rb +83 -0
  74. data/lib/active_search/store_adapters/redis_search/serialization.rb +34 -0
  75. data/lib/active_search/store_adapters/redis_search.rb +240 -0
  76. data/lib/active_search/store_adapters/solr/query_building.rb +220 -0
  77. data/lib/active_search/store_adapters/solr/response_parsing.rb +56 -0
  78. data/lib/active_search/store_adapters/solr.rb +285 -0
  79. data/lib/active_search/store_adapters/sqlite/highlighting.rb +43 -0
  80. data/lib/active_search/store_adapters/sqlite/query_building.rb +91 -0
  81. data/lib/active_search/store_adapters/sqlite/response_parsing.rb +54 -0
  82. data/lib/active_search/store_adapters/sqlite.rb +182 -0
  83. data/lib/active_search/store_adapters/typesense/highlighting.rb +21 -0
  84. data/lib/active_search/store_adapters/typesense/query_building.rb +222 -0
  85. data/lib/active_search/store_adapters/typesense/response_parsing.rb +47 -0
  86. data/lib/active_search/store_adapters/typesense/serialization.rb +30 -0
  87. data/lib/active_search/store_adapters/typesense.rb +207 -0
  88. data/lib/active_search/total.rb +35 -0
  89. data/lib/active_search/type/boolean.rb +9 -0
  90. data/lib/active_search/type/boundary_date_time.rb +12 -0
  91. data/lib/active_search/type/canonical.rb +20 -0
  92. data/lib/active_search/type/date.rb +16 -0
  93. data/lib/active_search/type/date_time.rb +20 -0
  94. data/lib/active_search/type/epoch_microseconds_date_time.rb +30 -0
  95. data/lib/active_search/type/epoch_seconds_date.rb +22 -0
  96. data/lib/active_search/type/float.rb +17 -0
  97. data/lib/active_search/type/integer.rb +24 -0
  98. data/lib/active_search/type/multiple.rb +41 -0
  99. data/lib/active_search/type/string.rb +20 -0
  100. data/lib/active_search/version.rb +4 -0
  101. data/lib/active_search.rb +288 -0
  102. data/lib/generators/active_search/document/document_generator.rb +80 -0
  103. data/lib/generators/active_search/document/templates/migration.rb.tt +1 -0
  104. data/lib/generators/active_search/document/templates/model.rb.tt +7 -0
  105. data/lib/generators/active_search/index/USAGE +31 -0
  106. data/lib/generators/active_search/index/index_generator.rb +151 -0
  107. data/lib/generators/active_search/install/USAGE +20 -0
  108. data/lib/generators/active_search/install/install_generator.rb +41 -0
  109. data/lib/generators/active_search/install/templates/search.rb.tt +6 -0
  110. data/lib/generators/active_search/install/templates/search.yml.tt +33 -0
  111. data/lib/rails-active_search.rb +2 -0
  112. data/lib/tasks/active_search_tasks.rake +118 -0
  113. 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