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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0396e88e835a9892f58864d4598ec27ece27f4fc749b31df76639d4c71fbd34d'
4
- data.tar.gz: 81028cc019492513b25c0628cbbd960693698fc6ba46add32e2f35f36c440d2b
3
+ metadata.gz: 5ca8300fbc0ea8d083208fe48333fae67c3bba9eadfd97e6041caf9c0717d361
4
+ data.tar.gz: e0dfdcdd67c114ebbf7fd036a9d28751c50a78c429ccacb877705b3b41fe0863
5
5
  SHA512:
6
- metadata.gz: 7a056ab185ce24fa77e3419d9ec3bf3584dedafffd7a268fc0602fb785b8efea3c352e44d5831f013d0533535235246fa2d371c0a5548e8abee5372660931fbd
7
- data.tar.gz: 82a7412801f57a83b001e6f4f8ff3a6d1e0ee40ecbd225424baa149d2968f60d80b66cbeb6be0a5715eee4a1ad66440c097617df64966e0c69e6f0ce11ac3506
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
- Rails.logger.error "Failed to sync #{self.class.name}##{id} to Typesense: #{e.message}" if defined?(Rails)
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
- Rails.logger.error "Failed to remove #{self.class.name}##{id} from Typesense: #{e.message}" if defined?(Rails)
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
- # Simple adapter that maps an AR model class into a TypesenseModel::Base-like class
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
- @ar_class = ar_class
106
- collection_name(ar_class.respond_to?(:typesense_collection_name) ? ar_class.typesense_collection_name : ar_class.name.underscore.pluralize)
149
+ @proxies ||= {}
150
+ return @proxies[ar_class.name] if @proxies.key?(ar_class.name)
107
151
 
108
- if ar_class.respond_to?(:typesense_schema) && ar_class.typesense_schema
109
- @_schema_definition = ar_class.typesense_schema
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
@@ -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
- # Typesense only allows updating `fields` (and `metadata`).
68
- # Do not send `name` or `default_sorting_field` on update.
69
- # Exclude the implicit `id` field from updates (Typesense does not allow altering it)
70
- updated_fields = (schema_definition.to_hash[:fields] || []).reject { |f| f[:name] == 'id' || f['name'] == 'id' }
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
- update_payload = { fields: updated_fields }.compact
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
- client.collections[collection_name].update(update_payload)
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
- if results[:errors].is_a?(Array) && results[:errors].any?
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 ||= Typesense::Client.new(
14
- api_key: api_key,
15
- nodes: [{
16
- host: host,
17
- port: port,
18
- protocol: protocol
19
- }],
20
- connection_timeout_seconds: 5
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.schema_definition.fields
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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module TypesenseModel
2
- VERSION = "0.2.0"
4
+ VERSION = "0.2.1"
3
5
  end
@@ -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 if available
23
- if defined?(ActiveRecord::Base)
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.0
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: '5.0'
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: '5.0'
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/main/CHANGELOG.md
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: 2.6.0
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: 3.6.7
122
+ rubygems_version: 4.0.10
107
123
  specification_version: 4
108
124
  summary: ActiveModel-like interface for Typesense
109
125
  test_files: []