typesense_model 0.2.0 → 0.2.1
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/CHANGELOG.md +42 -0
- data/README.md +96 -0
- data/lib/typesense_model/active_record_extension.rb +78 -18
- data/lib/typesense_model/base.rb +163 -21
- data/lib/typesense_model/configuration.rb +19 -11
- data/lib/typesense_model/schema.rb +2 -6
- data/lib/typesense_model/search.rb +33 -9
- data/lib/typesense_model/sync_job.rb +23 -0
- data/lib/typesense_model/version.rb +3 -1
- data/lib/typesense_model.rb +30 -2
- metadata +23 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5ca8300fbc0ea8d083208fe48333fae67c3bba9eadfd97e6041caf9c0717d361
|
|
4
|
+
data.tar.gz: e0dfdcdd67c114ebbf7fd036a9d28751c50a78c429ccacb877705b3b41fe0863
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3b7a4f1590e40d57bc31ea9c5f68d553a979b2d52da2d2e0ca0240654d0ee7e049215242f4f6d6cc0e2043c91e827f07fafe69f437a20774114eed731f2a96fa
|
|
7
|
+
data.tar.gz: c0ade3dcec5121716a425aa12f864c41275e3f1f29d3e88792459ae81dce7313fd98eb454bd0642f8b35a9d2b7b3f89967a8139f4033f13db02937aaf0d481ec
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- RSpec test suite covering schema, search, base (including the
|
|
12
|
+
`update_collection` schema-diff logic) and the ActiveRecord extension.
|
|
13
|
+
- GitHub Actions CI: unit specs across Ruby 3.1–3.3 plus an integration job
|
|
14
|
+
against a live Typesense service container.
|
|
15
|
+
- `Gemfile`, `Rakefile` and `.rspec` for a standard Bundler/RSpec dev workflow.
|
|
16
|
+
- Highlighting and match metadata on search results (`highlights`, `text_match`,
|
|
17
|
+
`hits_with_meta`) and grouped results support (`grouped_hits`).
|
|
18
|
+
- `Base.multi_search` for issuing several searches in a single request.
|
|
19
|
+
- Configurable `TypesenseModel.logger` used by the sync callbacks.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- `activesupport` requirement raised to `>= 6.0`; minimum Ruby raised to `3.1`.
|
|
23
|
+
- Sync/remove callbacks now log failures via `TypesenseModel.logger` even outside
|
|
24
|
+
Rails (previously errors were silently swallowed in non-Rails contexts) and
|
|
25
|
+
rescue `Typesense::Error` specifically instead of all exceptions.
|
|
26
|
+
- `Configuration#client` and `TypesenseProxy.for` memoization are now
|
|
27
|
+
thread-safe.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- The gem now requires ActiveSupport string inflections, so standalone (non-Rails)
|
|
31
|
+
usage of `collection_name` defaults no longer raises `NoMethodError`.
|
|
32
|
+
- The instance `#delete` method was defined under `private` and silently returned
|
|
33
|
+
`nil` via `method_missing`; it is now public and returns a proper boolean.
|
|
34
|
+
- Gemspec `homepage`/`source_code_uri`/`changelog_uri` corrected to the real
|
|
35
|
+
repository; added `rubygems_mfa_required` metadata.
|
|
36
|
+
|
|
37
|
+
## [0.2.0]
|
|
38
|
+
|
|
39
|
+
- ActiveRecord integration via `uses_typesense` with auto-sync callbacks.
|
|
40
|
+
- Standalone `TypesenseModel::Base` models.
|
|
41
|
+
- Schema definition, diff-based `update_collection`, bulk import and search with
|
|
42
|
+
Pagy-compatible results.
|
data/README.md
CHANGED
|
@@ -202,6 +202,102 @@ results = Product.import_all_to_typesense(
|
|
|
202
202
|
# results => { success: 500, failed: 0, errors: [...] }
|
|
203
203
|
```
|
|
204
204
|
|
|
205
|
+
## Advanced search
|
|
206
|
+
|
|
207
|
+
### Highlights, scores and grouped results
|
|
208
|
+
|
|
209
|
+
`search` returns a `SearchResults` that, beyond enumerating records, exposes the
|
|
210
|
+
search metadata Typesense returns:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
results = Product.search("running shoe")
|
|
214
|
+
|
|
215
|
+
# Each record paired with its highlights and relevance score
|
|
216
|
+
results.hits_with_meta.each do |hit|
|
|
217
|
+
hit[:record] # => Product-like document
|
|
218
|
+
hit[:highlights] # => [{ "field" => "title", ... }]
|
|
219
|
+
hit[:text_match] # => relevance score
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# When searching with group_by:
|
|
223
|
+
grouped = Product.search("shoe", group_by: "brand")
|
|
224
|
+
grouped.grouped_hits.each do |group|
|
|
225
|
+
group[:group_key] # => ["Nike"]
|
|
226
|
+
group[:hits] # => [records...]
|
|
227
|
+
end
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Geo and vector search parameters are passed straight through as options, e.g.
|
|
231
|
+
`Product.search("*", filter_by: "location:(48.8,2.3,5 km)")`.
|
|
232
|
+
|
|
233
|
+
### Multi-search
|
|
234
|
+
|
|
235
|
+
Issue several queries in a single request:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
results = Product.multi_search([
|
|
239
|
+
{ q: "shoe" },
|
|
240
|
+
{ q: "boot", filter_by: "price:>100" }
|
|
241
|
+
])
|
|
242
|
+
results.first.total_hits
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Synonyms and overrides (curation)
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
Product.upsert_synonym("coat-synonyms", synonyms: %w[coat jacket parka])
|
|
249
|
+
Product.upsert_override("promote-nike", rule: { query: "shoe", match: "exact" },
|
|
250
|
+
includes: [{ id: "1", position: 1 }])
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Async syncing
|
|
254
|
+
|
|
255
|
+
Pass `async: true` to perform the Typesense write in an ActiveJob instead of
|
|
256
|
+
inline in the `after_save`/`after_destroy` callbacks:
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
class Product < ApplicationRecord
|
|
260
|
+
uses_typesense collection: "products", async: true do |s|
|
|
261
|
+
s.field :id, :string
|
|
262
|
+
s.field :title, :string
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Requires ActiveJob; the job (`TypesenseModel::SyncJob`) reloads the record and
|
|
268
|
+
syncs it on whatever queue adapter your app is configured with.
|
|
269
|
+
|
|
270
|
+
## Configuration
|
|
271
|
+
|
|
272
|
+
Failures in the background sync callbacks are logged rather than raised. By
|
|
273
|
+
default they go to `Rails.logger` (or `$stderr` outside Rails); override with:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
TypesenseModel.logger = MyLogger.new
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Development
|
|
280
|
+
|
|
281
|
+
After checking out the repo, install dependencies and run the test suite:
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
bundle install
|
|
285
|
+
bundle exec rake # runs the unit specs (Typesense client is mocked)
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Integration specs talk to a real Typesense server and are skipped by default.
|
|
289
|
+
To run them, start a local Typesense instance and set `TYPESENSE_INTEGRATION`:
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
TYPESENSE_INTEGRATION=1 TYPESENSE_API_KEY=test-key bundle exec rspec --tag integration
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
To build the gem locally:
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
gem build typesense_model.gemspec
|
|
299
|
+
```
|
|
300
|
+
|
|
205
301
|
## License
|
|
206
302
|
|
|
207
303
|
Available as open source under the MIT License.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module TypesenseModel
|
|
2
4
|
module ActiveRecordExtension
|
|
3
5
|
def self.included(base)
|
|
@@ -6,9 +8,10 @@ module TypesenseModel
|
|
|
6
8
|
|
|
7
9
|
module ClassMethods
|
|
8
10
|
# Usage: uses_typesense collection: 'plugs', model_json: :as_json, schema: ->(s) { s.field :id, :string }
|
|
9
|
-
def uses_typesense(collection: nil, model_json: :as_json_typesense, schema: nil, &block)
|
|
11
|
+
def uses_typesense(collection: nil, model_json: :as_json_typesense, schema: nil, async: false, &block)
|
|
10
12
|
@_typesense_collection_name = collection || name.underscore.pluralize
|
|
11
13
|
@_typesense_model_json_method = model_json
|
|
14
|
+
@_typesense_async = async
|
|
12
15
|
|
|
13
16
|
if schema
|
|
14
17
|
@_typesense_schema = Schema.new
|
|
@@ -30,6 +33,10 @@ module TypesenseModel
|
|
|
30
33
|
@_typesense_model_json_method
|
|
31
34
|
end
|
|
32
35
|
|
|
36
|
+
define_singleton_method(:typesense_async?) do
|
|
37
|
+
@_typesense_async
|
|
38
|
+
end
|
|
39
|
+
|
|
33
40
|
define_singleton_method(:search) do |query, options = {}|
|
|
34
41
|
proxy = TypesenseProxy.for(self)
|
|
35
42
|
proxy.search(query, options)
|
|
@@ -59,10 +66,30 @@ module TypesenseModel
|
|
|
59
66
|
end
|
|
60
67
|
end
|
|
61
68
|
|
|
69
|
+
# Enqueue an async sync via ActiveJob. Kept as a module method so it can be
|
|
70
|
+
# stubbed in tests and so the job lookup lives in one place.
|
|
71
|
+
def self.enqueue_sync(class_name, id, action)
|
|
72
|
+
unless defined?(ActiveJob::Base) && defined?(TypesenseModel::SyncJob)
|
|
73
|
+
raise TypesenseModel::Error, "async: true requires ActiveJob to be available"
|
|
74
|
+
end
|
|
75
|
+
TypesenseModel::SyncJob.perform_later(class_name, id, action.to_s)
|
|
76
|
+
end
|
|
77
|
+
|
|
62
78
|
# Instance methods for callbacks
|
|
63
79
|
def sync_to_typesense
|
|
64
80
|
return unless self.class.respond_to?(:typesense_model_json_method)
|
|
65
|
-
|
|
81
|
+
|
|
82
|
+
if self.class.respond_to?(:typesense_async?) && self.class.typesense_async?
|
|
83
|
+
ActiveRecordExtension.enqueue_sync(self.class.name, id.to_s, :upsert)
|
|
84
|
+
else
|
|
85
|
+
sync_to_typesense_now
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Synchronously write this record's document to Typesense.
|
|
90
|
+
def sync_to_typesense_now
|
|
91
|
+
return unless self.class.respond_to?(:typesense_model_json_method)
|
|
92
|
+
|
|
66
93
|
json_method = self.class.typesense_model_json_method
|
|
67
94
|
document_data = if json_method.is_a?(Proc)
|
|
68
95
|
json_method.call(self)
|
|
@@ -73,17 +100,28 @@ module TypesenseModel
|
|
|
73
100
|
proxy = TypesenseProxy.for(self.class)
|
|
74
101
|
sanitized = proxy.send(:sanitize_document, stringify_keys(document_data))
|
|
75
102
|
proxy.client.collections[proxy.collection_name].documents.upsert(sanitized)
|
|
76
|
-
rescue => e
|
|
77
|
-
|
|
103
|
+
rescue Typesense::Error => e
|
|
104
|
+
TypesenseModel.logger.error("Failed to sync #{self.class.name}##{id} to Typesense: #{e.message}")
|
|
78
105
|
end
|
|
79
106
|
|
|
80
107
|
def remove_from_typesense
|
|
81
108
|
return unless self.class.respond_to?(:typesense_model_json_method)
|
|
82
|
-
|
|
109
|
+
|
|
110
|
+
if self.class.respond_to?(:typesense_async?) && self.class.typesense_async?
|
|
111
|
+
ActiveRecordExtension.enqueue_sync(self.class.name, id.to_s, :remove)
|
|
112
|
+
else
|
|
113
|
+
remove_from_typesense_now
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Synchronously delete this record's document from Typesense.
|
|
118
|
+
def remove_from_typesense_now
|
|
119
|
+
return unless self.class.respond_to?(:typesense_model_json_method)
|
|
120
|
+
|
|
83
121
|
proxy = TypesenseProxy.for(self.class)
|
|
84
122
|
proxy.client.collections[proxy.collection_name].documents[id].delete
|
|
85
|
-
rescue => e
|
|
86
|
-
|
|
123
|
+
rescue Typesense::Error => e
|
|
124
|
+
TypesenseModel.logger.error("Failed to remove #{self.class.name}##{id} from Typesense: #{e.message}")
|
|
87
125
|
end
|
|
88
126
|
|
|
89
127
|
# Default JSON method for Typesense
|
|
@@ -98,27 +136,49 @@ module TypesenseModel
|
|
|
98
136
|
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
99
137
|
end
|
|
100
138
|
|
|
101
|
-
#
|
|
139
|
+
# Adapter that maps an AR model class into a TypesenseModel::Base-like class.
|
|
140
|
+
#
|
|
141
|
+
# A dedicated proxy subclass is built and memoized per AR model so that the
|
|
142
|
+
# collection name and schema never clobber each other across models or
|
|
143
|
+
# threads -- each subclass carries its own class-level state.
|
|
102
144
|
class TypesenseProxy < TypesenseModel::Base
|
|
103
145
|
class << self
|
|
146
|
+
PROXY_MUTEX = Mutex.new
|
|
147
|
+
|
|
104
148
|
def for(ar_class)
|
|
105
|
-
@
|
|
106
|
-
|
|
149
|
+
@proxies ||= {}
|
|
150
|
+
return @proxies[ar_class.name] if @proxies.key?(ar_class.name)
|
|
107
151
|
|
|
108
|
-
|
|
109
|
-
@
|
|
152
|
+
PROXY_MUTEX.synchronize do
|
|
153
|
+
@proxies[ar_class.name] ||= build_proxy(ar_class)
|
|
110
154
|
end
|
|
111
|
-
|
|
112
|
-
self
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def ar_class
|
|
116
|
-
@ar_class
|
|
117
155
|
end
|
|
118
156
|
|
|
119
157
|
def client
|
|
120
158
|
TypesenseModel.configuration.client
|
|
121
159
|
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def build_proxy(ar_class)
|
|
164
|
+
resolved_collection =
|
|
165
|
+
if ar_class.respond_to?(:typesense_collection_name)
|
|
166
|
+
ar_class.typesense_collection_name
|
|
167
|
+
else
|
|
168
|
+
ar_class.name.underscore.pluralize
|
|
169
|
+
end
|
|
170
|
+
resolved_schema = ar_class.typesense_schema if ar_class.respond_to?(:typesense_schema)
|
|
171
|
+
|
|
172
|
+
Class.new(self) do
|
|
173
|
+
@ar_class = ar_class
|
|
174
|
+
collection_name(resolved_collection)
|
|
175
|
+
@_schema_definition = resolved_schema
|
|
176
|
+
|
|
177
|
+
class << self
|
|
178
|
+
attr_reader :ar_class
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
122
182
|
end
|
|
123
183
|
end
|
|
124
184
|
end
|
data/lib/typesense_model/base.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module TypesenseModel
|
|
2
4
|
class Base
|
|
3
5
|
class << self
|
|
@@ -35,6 +37,39 @@ module TypesenseModel
|
|
|
35
37
|
Search.new(self, query, options).execute
|
|
36
38
|
end
|
|
37
39
|
|
|
40
|
+
# Perform several searches in a single request.
|
|
41
|
+
#
|
|
42
|
+
# @param searches [Array<Hash>] each a set of Typesense search params;
|
|
43
|
+
# `collection` defaults to this model's collection, and `q`/`query_by`
|
|
44
|
+
# fall back the same way as #search (pass `query_by` explicitly when
|
|
45
|
+
# targeting a different collection).
|
|
46
|
+
# @param common_params [Hash] params applied to every search.
|
|
47
|
+
# @return [Array<SearchResults>] one result set per search, in order.
|
|
48
|
+
def multi_search(searches, common_params = {})
|
|
49
|
+
payload = Array(searches).map do |params|
|
|
50
|
+
params = params.transform_keys(&:to_sym)
|
|
51
|
+
{
|
|
52
|
+
collection: params[:collection] || collection_name,
|
|
53
|
+
q: params[:q] || '*',
|
|
54
|
+
query_by: params[:query_by] || default_query_by
|
|
55
|
+
}.merge(params.except(:collection, :q, :query_by))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
response = client.multi_search.perform({ searches: payload }, common_params)
|
|
59
|
+
(response['results'] || []).map { |result| SearchResults.new(result, self) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Comma-separated list of indexed string fields (excluding id) used as the
|
|
63
|
+
# default `query_by` when a search doesn't specify one.
|
|
64
|
+
def default_query_by
|
|
65
|
+
return '' unless schema_definition
|
|
66
|
+
|
|
67
|
+
schema_definition.fields
|
|
68
|
+
.select { |f| f[:index] && f[:type] == 'string' && f[:name] != 'id' }
|
|
69
|
+
.map { |f| f[:name] }
|
|
70
|
+
.join(',')
|
|
71
|
+
end
|
|
72
|
+
|
|
38
73
|
# Create the collection in Typesense
|
|
39
74
|
def create_collection(force = false)
|
|
40
75
|
delete_collection if force
|
|
@@ -60,18 +95,50 @@ module TypesenseModel
|
|
|
60
95
|
false
|
|
61
96
|
end
|
|
62
97
|
|
|
63
|
-
# Update the collection schema in Typesense
|
|
98
|
+
# Update the collection schema in Typesense to match the model schema.
|
|
99
|
+
#
|
|
100
|
+
# Typesense only accepts a `fields` diff on update: a field can be added,
|
|
101
|
+
# or dropped (`drop: true`), and a change is expressed as a drop followed
|
|
102
|
+
# by a re-add. Re-sending an existing, unchanged field raises an error, so
|
|
103
|
+
# we diff the desired schema against the live collection and send only the
|
|
104
|
+
# additions, modifications, and removals. The implicit `id` field cannot
|
|
105
|
+
# be altered and is always skipped.
|
|
64
106
|
def update_collection
|
|
65
107
|
return create_collection unless collection_exists?
|
|
66
108
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
109
|
+
live_fields = (retrieve_collection&.dig('fields') || []).each_with_object({}) do |f, h|
|
|
110
|
+
h[f['name'].to_s] = f
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
desired_fields = (schema_definition.to_hash[:fields] || []).reject do |f|
|
|
114
|
+
(f[:name] || f['name']).to_s == 'id'
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
changes = []
|
|
118
|
+
desired_names = []
|
|
71
119
|
|
|
72
|
-
|
|
120
|
+
desired_fields.each do |field|
|
|
121
|
+
name = (field[:name] || field['name']).to_s
|
|
122
|
+
desired_names << name
|
|
123
|
+
existing = live_fields[name]
|
|
73
124
|
|
|
74
|
-
|
|
125
|
+
if existing.nil?
|
|
126
|
+
changes << field
|
|
127
|
+
elsif field_changed?(field, existing)
|
|
128
|
+
changes << { 'name' => name, 'drop' => true }
|
|
129
|
+
changes << field
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Drop fields that exist in Typesense but are no longer in the schema.
|
|
134
|
+
live_fields.each_key do |name|
|
|
135
|
+
next if name == 'id' || name == '.*'
|
|
136
|
+
changes << { 'name' => name, 'drop' => true } unless desired_names.include?(name)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
return retrieve_collection if changes.empty?
|
|
140
|
+
|
|
141
|
+
client.collections[collection_name].update(fields: changes)
|
|
75
142
|
end
|
|
76
143
|
|
|
77
144
|
# Create or update collection
|
|
@@ -105,6 +172,14 @@ module TypesenseModel
|
|
|
105
172
|
.documents
|
|
106
173
|
.import(sanitized_documents, options)
|
|
107
174
|
|
|
175
|
+
# Typesense returns one result hash per document. Guard against an
|
|
176
|
+
# unexpected non-array shape (e.g. an error payload) so we never blow up
|
|
177
|
+
# in the tally below.
|
|
178
|
+
unless response.is_a?(Array)
|
|
179
|
+
return { success: 0, failed: sanitized_documents.size,
|
|
180
|
+
errors: [{ code: nil, error: "Unexpected import response: #{response.inspect}", document: nil }] }
|
|
181
|
+
end
|
|
182
|
+
|
|
108
183
|
results = response.each_with_object({ success: 0, failed: 0, errors: [] }) do |result, counts|
|
|
109
184
|
if result['success']
|
|
110
185
|
counts[:success] += 1
|
|
@@ -129,7 +204,7 @@ module TypesenseModel
|
|
|
129
204
|
# @param import_options [Hash] Options to pass to the import method
|
|
130
205
|
# @return [Hash] { success: Integer, failed: Integer }
|
|
131
206
|
def import_from_model(model_class, batch_size, transform_method = :as_json, preloads = nil, import_options = {})
|
|
132
|
-
total_results = { success: 0, failed: 0 }
|
|
207
|
+
total_results = { success: 0, failed: 0, errors: [] }
|
|
133
208
|
|
|
134
209
|
transformer = transform_method.is_a?(Proc) ? transform_method : ->(record) { record.send(transform_method) }
|
|
135
210
|
|
|
@@ -139,12 +214,10 @@ module TypesenseModel
|
|
|
139
214
|
relation.find_in_batches(batch_size: batch_size) do |batch|
|
|
140
215
|
documents = batch.map(&transformer)
|
|
141
216
|
results = import(documents, import_options)
|
|
142
|
-
|
|
217
|
+
|
|
143
218
|
total_results[:success] += results[:success]
|
|
144
219
|
total_results[:failed] += results[:failed]
|
|
145
|
-
|
|
146
|
-
(total_results[:errors] ||= []).concat(results[:errors])
|
|
147
|
-
end
|
|
220
|
+
total_results[:errors].concat(results[:errors]) if results[:errors].is_a?(Array)
|
|
148
221
|
end
|
|
149
222
|
|
|
150
223
|
total_results
|
|
@@ -159,11 +232,47 @@ module TypesenseModel
|
|
|
159
232
|
false
|
|
160
233
|
end
|
|
161
234
|
|
|
162
|
-
# Delete multiple records by query
|
|
235
|
+
# Delete multiple records by query. Returns the Typesense response
|
|
236
|
+
# (e.g. { "num_deleted" => N }); when the collection is missing, returns
|
|
237
|
+
# { "num_deleted" => 0 } for symmetry with the singular #delete.
|
|
163
238
|
def delete_by(filter_by)
|
|
164
239
|
client.collections[collection_name]
|
|
165
240
|
.documents
|
|
166
241
|
.delete({ filter_by: filter_by })
|
|
242
|
+
rescue Typesense::Error::ObjectNotFound
|
|
243
|
+
{ "num_deleted" => 0 }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# --- Synonyms -------------------------------------------------------
|
|
247
|
+
# Thin wrappers over the Typesense synonyms API for this collection.
|
|
248
|
+
def upsert_synonym(id, synonym)
|
|
249
|
+
client.collections[collection_name].synonyms.upsert(id, synonym)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def synonyms
|
|
253
|
+
client.collections[collection_name].synonyms.retrieve
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def delete_synonym(id)
|
|
257
|
+
client.collections[collection_name].synonyms[id].delete
|
|
258
|
+
rescue Typesense::Error::ObjectNotFound
|
|
259
|
+
false
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# --- Overrides (curation) ------------------------------------------
|
|
263
|
+
# Thin wrappers over the Typesense overrides API for this collection.
|
|
264
|
+
def upsert_override(id, override)
|
|
265
|
+
client.collections[collection_name].overrides.upsert(id, override)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def overrides
|
|
269
|
+
client.collections[collection_name].overrides.retrieve
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def delete_override(id)
|
|
273
|
+
client.collections[collection_name].overrides[id].delete
|
|
274
|
+
rescue Typesense::Error::ObjectNotFound
|
|
275
|
+
false
|
|
167
276
|
end
|
|
168
277
|
|
|
169
278
|
private
|
|
@@ -181,6 +290,38 @@ module TypesenseModel
|
|
|
181
290
|
sanitized['id'] = sanitized['id'].to_s if sanitized.key?('id')
|
|
182
291
|
sanitized
|
|
183
292
|
end
|
|
293
|
+
|
|
294
|
+
# Field attributes that meaningfully affect the Typesense schema. Used to
|
|
295
|
+
# decide whether a live field differs from the desired definition.
|
|
296
|
+
FIELD_DIFF_ATTRS = %i[type facet optional index sort].freeze
|
|
297
|
+
|
|
298
|
+
# Numeric/boolean types are sortable by default in Typesense; strings are not.
|
|
299
|
+
DEFAULT_SORTABLE_TYPES = %w[int32 int64 float bool].freeze
|
|
300
|
+
|
|
301
|
+
def field_changed?(desired, existing)
|
|
302
|
+
FIELD_DIFF_ATTRS.any? do |attr|
|
|
303
|
+
desired_field_value(desired, attr) != existing_field_value(existing, attr)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def desired_field_value(field, attr)
|
|
308
|
+
value = field.fetch(attr) { field[attr.to_s] }
|
|
309
|
+
attr == :type ? value.to_s : value
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Resolve a live field's attribute, applying Typesense defaults when the
|
|
313
|
+
# retrieved schema omits the key, so unchanged fields don't look modified.
|
|
314
|
+
def existing_field_value(field, attr)
|
|
315
|
+
type = field['type'].to_s
|
|
316
|
+
return type if attr == :type
|
|
317
|
+
return field[attr.to_s] if field.key?(attr.to_s)
|
|
318
|
+
|
|
319
|
+
case attr
|
|
320
|
+
when :facet, :optional then false
|
|
321
|
+
when :index then true
|
|
322
|
+
when :sort then DEFAULT_SORTABLE_TYPES.include?(type)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
184
325
|
end
|
|
185
326
|
|
|
186
327
|
attr_accessor :attributes
|
|
@@ -200,6 +341,15 @@ module TypesenseModel
|
|
|
200
341
|
attributes['id']
|
|
201
342
|
end
|
|
202
343
|
|
|
344
|
+
# Delete the current record from Typesense. Returns true if a document was
|
|
345
|
+
# removed, false if there was no id or nothing to delete.
|
|
346
|
+
def delete
|
|
347
|
+
return false unless id
|
|
348
|
+
|
|
349
|
+
response = self.class.delete(id)
|
|
350
|
+
!response.nil? && response != false
|
|
351
|
+
end
|
|
352
|
+
|
|
203
353
|
def method_missing(method_name, *args)
|
|
204
354
|
attribute_name = method_name.to_s
|
|
205
355
|
|
|
@@ -233,13 +383,5 @@ module TypesenseModel
|
|
|
233
383
|
def client
|
|
234
384
|
self.class.send(:client)
|
|
235
385
|
end
|
|
236
|
-
|
|
237
|
-
# Instance method to delete the current record
|
|
238
|
-
def delete
|
|
239
|
-
return false unless id
|
|
240
|
-
|
|
241
|
-
response = self.class.delete(id)
|
|
242
|
-
!response.nil?
|
|
243
|
-
end
|
|
244
386
|
end
|
|
245
387
|
end
|
|
@@ -1,24 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module TypesenseModel
|
|
2
4
|
class Configuration
|
|
3
|
-
attr_accessor :api_key, :host, :port, :protocol
|
|
5
|
+
attr_accessor :api_key, :host, :port, :protocol, :connection_timeout_seconds
|
|
4
6
|
|
|
5
7
|
def initialize
|
|
6
8
|
@api_key = nil
|
|
7
9
|
@host = 'localhost'
|
|
8
10
|
@port = 8108
|
|
9
11
|
@protocol = 'http'
|
|
12
|
+
@connection_timeout_seconds = 5
|
|
13
|
+
@client_mutex = Mutex.new
|
|
10
14
|
end
|
|
11
15
|
|
|
12
16
|
def client
|
|
13
|
-
@client
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
return @client if @client
|
|
18
|
+
|
|
19
|
+
@client_mutex.synchronize do
|
|
20
|
+
@client ||= Typesense::Client.new(
|
|
21
|
+
api_key: api_key,
|
|
22
|
+
nodes: [{
|
|
23
|
+
host: host,
|
|
24
|
+
port: port,
|
|
25
|
+
protocol: protocol
|
|
26
|
+
}],
|
|
27
|
+
connection_timeout_seconds: connection_timeout_seconds
|
|
28
|
+
)
|
|
29
|
+
end
|
|
22
30
|
end
|
|
23
31
|
end
|
|
24
|
-
end
|
|
32
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module TypesenseModel
|
|
2
4
|
class Schema
|
|
3
5
|
attr_reader :fields, :collection_name, :default_sorting_field
|
|
@@ -29,11 +31,5 @@ module TypesenseModel
|
|
|
29
31
|
default_sorting_field: @default_sorting_field
|
|
30
32
|
}.compact
|
|
31
33
|
end
|
|
32
|
-
|
|
33
|
-
private
|
|
34
|
-
|
|
35
|
-
def default_sorting_field
|
|
36
|
-
@fields.find { |f| f[:name] == 'id' }&.dig(:name)
|
|
37
|
-
end
|
|
38
34
|
end
|
|
39
35
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module TypesenseModel
|
|
2
4
|
class Search
|
|
3
5
|
def initialize(model_class, query, options = {})
|
|
@@ -25,12 +27,7 @@ module TypesenseModel
|
|
|
25
27
|
private
|
|
26
28
|
|
|
27
29
|
def default_queryable_fields
|
|
28
|
-
@model_class.
|
|
29
|
-
.select { |f| f[:index] }
|
|
30
|
-
.select { |f| f[:type] == 'string' }
|
|
31
|
-
.reject { |f| f[:name] == 'id' }
|
|
32
|
-
.map { |f| f[:name] }
|
|
33
|
-
.join(',')
|
|
30
|
+
@model_class.default_query_by
|
|
34
31
|
end
|
|
35
32
|
end
|
|
36
33
|
|
|
@@ -60,6 +57,33 @@ module TypesenseModel
|
|
|
60
57
|
@raw_response['hits'] || []
|
|
61
58
|
end
|
|
62
59
|
|
|
60
|
+
# Each hit paired with its search metadata: the model record, the per-field
|
|
61
|
+
# highlight snippets, and the relevance score Typesense computed.
|
|
62
|
+
#
|
|
63
|
+
# @return [Array<Hash>] { record:, highlights:, highlight:, text_match: }
|
|
64
|
+
def hits_with_meta
|
|
65
|
+
hits.map do |hit|
|
|
66
|
+
{
|
|
67
|
+
record: @model_class.new(hit['document']),
|
|
68
|
+
highlights: hit['highlights'] || [],
|
|
69
|
+
highlight: hit['highlight'] || {},
|
|
70
|
+
text_match: hit['text_match']
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Grouped results, populated only when the search used `group_by`.
|
|
76
|
+
#
|
|
77
|
+
# @return [Array<Hash>] { group_key:, hits: [records] }
|
|
78
|
+
def grouped_hits
|
|
79
|
+
(@raw_response['grouped_hits'] || []).map do |group|
|
|
80
|
+
{
|
|
81
|
+
group_key: group['group_key'],
|
|
82
|
+
hits: (group['hits'] || []).map { |h| @model_class.new(h['document']) }
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
63
87
|
def size
|
|
64
88
|
total_hits
|
|
65
89
|
end
|
|
@@ -68,13 +92,13 @@ module TypesenseModel
|
|
|
68
92
|
@raw_response['found'] || 0
|
|
69
93
|
end
|
|
70
94
|
# PAGY COMPATIBILITY
|
|
71
|
-
def count(
|
|
95
|
+
def count(*)
|
|
72
96
|
total_hits
|
|
73
97
|
end
|
|
74
|
-
def offset(
|
|
98
|
+
def offset(*)
|
|
75
99
|
self
|
|
76
100
|
end
|
|
77
|
-
def limit(
|
|
101
|
+
def limit(*)
|
|
78
102
|
self
|
|
79
103
|
end
|
|
80
104
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded only when ActiveJob is available (see typesense_model.rb). Performs the
|
|
4
|
+
# Typesense sync/remove off the request cycle when a model opts into
|
|
5
|
+
# `uses_typesense(async: true)`.
|
|
6
|
+
module TypesenseModel
|
|
7
|
+
class SyncJob < ActiveJob::Base
|
|
8
|
+
def perform(class_name, id, action)
|
|
9
|
+
klass = class_name.constantize
|
|
10
|
+
|
|
11
|
+
case action.to_s
|
|
12
|
+
when "remove"
|
|
13
|
+
proxy = ActiveRecordExtension::TypesenseProxy.for(klass)
|
|
14
|
+
proxy.delete(id)
|
|
15
|
+
else
|
|
16
|
+
record = klass.respond_to?(:find_by) ? klass.find_by(id: id) : nil
|
|
17
|
+
record&.sync_to_typesense_now
|
|
18
|
+
end
|
|
19
|
+
rescue Typesense::Error => e
|
|
20
|
+
TypesenseModel.logger.error("Async Typesense #{action} failed for #{class_name}##{id}: #{e.message}")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/typesense_model.rb
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
1
4
|
require "typesense"
|
|
5
|
+
require "active_support/core_ext/string/inflections"
|
|
2
6
|
require "typesense_model/version"
|
|
3
7
|
require "typesense_model/base"
|
|
4
8
|
require "typesense_model/search"
|
|
@@ -11,15 +15,39 @@ module TypesenseModel
|
|
|
11
15
|
|
|
12
16
|
class << self
|
|
13
17
|
attr_accessor :configuration
|
|
18
|
+
attr_writer :logger
|
|
14
19
|
end
|
|
15
20
|
|
|
16
21
|
def self.configure
|
|
17
22
|
self.configuration ||= Configuration.new
|
|
18
23
|
yield(configuration) if block_given?
|
|
19
24
|
end
|
|
25
|
+
|
|
26
|
+
# Logger used for non-fatal failures (e.g. background sync errors). Defaults to
|
|
27
|
+
# Rails.logger when available, otherwise a STDERR logger. Assign your own with
|
|
28
|
+
# TypesenseModel.logger = ...
|
|
29
|
+
def self.logger
|
|
30
|
+
@logger ||= if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
31
|
+
Rails.logger
|
|
32
|
+
else
|
|
33
|
+
Logger.new($stderr)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
20
36
|
end
|
|
21
37
|
|
|
22
|
-
# Auto-include into ActiveRecord
|
|
23
|
-
if
|
|
38
|
+
# Auto-include into ActiveRecord. Prefer the Rails load hook so this works
|
|
39
|
+
# regardless of gem load order; fall back to a direct include if ActiveRecord
|
|
40
|
+
# is already loaded (e.g. non-Rails usage).
|
|
41
|
+
if defined?(ActiveSupport)
|
|
42
|
+
ActiveSupport.on_load(:active_record) do
|
|
43
|
+
include TypesenseModel::ActiveRecordExtension
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Define the async sync job only once ActiveJob is loaded, so we never depend
|
|
47
|
+
# on it at load time (it's optional — only needed for `async: true`).
|
|
48
|
+
ActiveSupport.on_load(:active_job) do
|
|
49
|
+
require "typesense_model/sync_job"
|
|
50
|
+
end
|
|
51
|
+
elsif defined?(ActiveRecord::Base)
|
|
24
52
|
ActiveRecord::Base.include(TypesenseModel::ActiveRecordExtension)
|
|
25
53
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: typesense_model
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Emanuel Comsa
|
|
@@ -29,14 +29,14 @@ dependencies:
|
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '
|
|
32
|
+
version: '6.0'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '
|
|
39
|
+
version: '6.0'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: rake
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -65,6 +65,20 @@ dependencies:
|
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '3.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: activejob
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '6.0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '6.0'
|
|
68
82
|
description: A Ruby gem that provides an ActiveModel-like interface for working with
|
|
69
83
|
Typesense search engine
|
|
70
84
|
email:
|
|
@@ -73,6 +87,7 @@ executables: []
|
|
|
73
87
|
extensions: []
|
|
74
88
|
extra_rdoc_files: []
|
|
75
89
|
files:
|
|
90
|
+
- CHANGELOG.md
|
|
76
91
|
- MIT-LICENSE
|
|
77
92
|
- README.md
|
|
78
93
|
- lib/typesense_model.rb
|
|
@@ -81,14 +96,15 @@ files:
|
|
|
81
96
|
- lib/typesense_model/configuration.rb
|
|
82
97
|
- lib/typesense_model/schema.rb
|
|
83
98
|
- lib/typesense_model/search.rb
|
|
99
|
+
- lib/typesense_model/sync_job.rb
|
|
84
100
|
- lib/typesense_model/version.rb
|
|
85
101
|
homepage: https://github.com/rubydevro/typesense_model
|
|
86
102
|
licenses:
|
|
87
103
|
- MIT
|
|
88
104
|
metadata:
|
|
89
|
-
homepage_uri: https://www.rubydev.ro
|
|
90
105
|
source_code_uri: https://github.com/rubydevro/typesense_model
|
|
91
|
-
changelog_uri: https://github.com/rubydevro/typesense_model/blob/
|
|
106
|
+
changelog_uri: https://github.com/rubydevro/typesense_model/blob/master/CHANGELOG.md
|
|
107
|
+
rubygems_mfa_required: 'true'
|
|
92
108
|
rdoc_options: []
|
|
93
109
|
require_paths:
|
|
94
110
|
- lib
|
|
@@ -96,14 +112,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
96
112
|
requirements:
|
|
97
113
|
- - ">="
|
|
98
114
|
- !ruby/object:Gem::Version
|
|
99
|
-
version:
|
|
115
|
+
version: 3.1.0
|
|
100
116
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
101
117
|
requirements:
|
|
102
118
|
- - ">="
|
|
103
119
|
- !ruby/object:Gem::Version
|
|
104
120
|
version: '0'
|
|
105
121
|
requirements: []
|
|
106
|
-
rubygems_version:
|
|
122
|
+
rubygems_version: 4.0.10
|
|
107
123
|
specification_version: 4
|
|
108
124
|
summary: ActiveModel-like interface for Typesense
|
|
109
125
|
test_files: []
|