Class: TypesenseModel::Base

Inherits:
Object
  • Object
show all
Defined in:
lib/typesense_model/base.rb

Direct Known Subclasses

ActiveRecordExtension::TypesenseProxy

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(attributes = {}) ⇒ Base

Returns a new instance of Base.



329
330
331
# File 'lib/typesense_model/base.rb', line 329

def initialize(attributes = {})
  @attributes = attributes.transform_keys(&:to_s)
end

Dynamic Method Handling

This class handles dynamic methods through the method_missing method

#method_missing(method_name, *args) ⇒ Object



353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
# File 'lib/typesense_model/base.rb', line 353

def method_missing(method_name, *args)
  attribute_name = method_name.to_s
  
  # Handle setters (e.g., name=)
  if attribute_name.end_with?('=')
    attribute_name = attribute_name.chop # Remove the '=' from the end
    return set_attribute(attribute_name, args.first)
  end
  
  # Handle getters (e.g., name)
  if attributes.key?(attribute_name)
    return attributes[attribute_name]
  end
  
  nil
end

Class Attribute Details

._collection_nameObject

Returns the value of attribute _collection_name.



6
7
8
# File 'lib/typesense_model/base.rb', line 6

def _collection_name
  @_collection_name
end

._schema_definitionObject

Returns the value of attribute _schema_definition.



6
7
8
# File 'lib/typesense_model/base.rb', line 6

def _schema_definition
  @_schema_definition
end

Instance Attribute Details

#attributesObject

Returns the value of attribute attributes.



327
328
329
# File 'lib/typesense_model/base.rb', line 327

def attributes
  @attributes
end

Class Method Details

.collection_exists?Boolean

Check if collection exists

Returns:

  • (Boolean)


91
92
93
94
95
96
# File 'lib/typesense_model/base.rb', line 91

def collection_exists?
  client.collections[collection_name].retrieve
  true
rescue Typesense::Error::ObjectNotFound
  false
end

.collection_name(name = nil) ⇒ Object



8
9
10
11
12
13
14
# File 'lib/typesense_model/base.rb', line 8

def collection_name(name = nil)
  if name
    @_collection_name = name
  else
    @_collection_name ||= self.name.underscore.pluralize
  end
end

.collection_statsObject

Get collection stats



156
157
158
159
# File 'lib/typesense_model/base.rb', line 156

def collection_stats
  return nil unless collection_exists?
  client.collections[collection_name].stats
end

.countObject

Get number of documents in collection



162
163
164
# File 'lib/typesense_model/base.rb', line 162

def count
  collection_stats&.dig('num_documents') || 0
end

.create(attributes = {}) ⇒ Object



25
26
27
# File 'lib/typesense_model/base.rb', line 25

def create(attributes = {})
  new(attributes).save
end

.create_collection(force = false) ⇒ Object

Create the collection in Typesense



74
75
76
77
78
79
80
81
82
83
# File 'lib/typesense_model/base.rb', line 74

def create_collection(force = false)
  delete_collection if force
  return if collection_exists? 
  
  schema = schema_definition.to_hash.merge(
    name: collection_name
  )

  client.collections.create(schema)
end

.create_or_update_collectionObject

Create or update collection



145
146
147
# File 'lib/typesense_model/base.rb', line 145

def create_or_update_collection
  collection_exists? ? update_collection : create_collection
end

.default_query_byObject

Comma-separated list of indexed string fields (excluding id) used as the default ‘query_by` when a search doesn’t specify one.



64
65
66
67
68
69
70
71
# File 'lib/typesense_model/base.rb', line 64

def default_query_by
  return '' unless schema_definition

  schema_definition.fields
    .select { |f| f[:index] && f[:type] == 'string' && f[:name] != 'id' }
    .map { |f| f[:name] }
    .join(',')
end

.define_schema(&block) ⇒ Object



16
17
18
19
# File 'lib/typesense_model/base.rb', line 16

def define_schema(&block)
  @_schema_definition = Schema.new
  @_schema_definition.instance_eval(&block)
end

.delete(id) ⇒ Object

Delete a record by ID



227
228
229
230
231
232
233
# File 'lib/typesense_model/base.rb', line 227

def delete(id)
  client.collections[collection_name]
    .documents[id]
    .delete
rescue Typesense::Error::ObjectNotFound
  false
end

.delete_by(filter_by) ⇒ Object

Delete multiple records by query. Returns the Typesense response (e.g. { “num_deleted” => N }); when the collection is missing, returns { “num_deleted” => 0 } for symmetry with the singular #delete.



238
239
240
241
242
243
244
# File 'lib/typesense_model/base.rb', line 238

def delete_by(filter_by)
  client.collections[collection_name]
    .documents
    .delete({ filter_by: filter_by })
rescue Typesense::Error::ObjectNotFound
  { "num_deleted" => 0 }
end

.delete_collectionObject

Delete the collection from Typesense



86
87
88
# File 'lib/typesense_model/base.rb', line 86

def delete_collection
  client.collections[collection_name].delete if collection_exists?
end

.delete_override(id) ⇒ Object



272
273
274
275
276
# File 'lib/typesense_model/base.rb', line 272

def delete_override(id)
  client.collections[collection_name].overrides[id].delete
rescue Typesense::Error::ObjectNotFound
  false
end

.delete_synonym(id) ⇒ Object



256
257
258
259
260
# File 'lib/typesense_model/base.rb', line 256

def delete_synonym(id)
  client.collections[collection_name].synonyms[id].delete
rescue Typesense::Error::ObjectNotFound
  false
end

.find(id) ⇒ Object



29
30
31
32
33
34
# File 'lib/typesense_model/base.rb', line 29

def find(id)
  response = client.collections[collection_name].documents[id].retrieve
  new(response)
rescue Typesense::Error::ObjectNotFound
  nil
end

.import(documents, options = {}) ⇒ Hash

Import multiple records

Returns:

  • (Hash)

    { success: Integer, failed: Integer }



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/typesense_model/base.rb', line 168

def import(documents, options = {})
  sanitized_documents = Array(documents).map { |doc| sanitize_document(doc) }

  response = client.collections[collection_name]
    .documents
    .import(sanitized_documents, options)

  # Typesense returns one result hash per document. Guard against an
  # unexpected non-array shape (e.g. an error payload) so we never blow up
  # in the tally below.
  unless response.is_a?(Array)
    return { success: 0, failed: sanitized_documents.size,
             errors: [{ code: nil, error: "Unexpected import response: #{response.inspect}", document: nil }] }
  end

  results = response.each_with_object({ success: 0, failed: 0, errors: [] }) do |result, counts|
    if result['success']
      counts[:success] += 1
    else
      counts[:failed] += 1
      counts[:errors] << {
        code: result['code'],
        error: result['error'],
        document: result['document']
      }
    end
  end

  results
end

.import_from_model(model_class, batch_size, transform_method = :as_json, preloads = nil, import_options = {}) ⇒ Hash

Import records from an ActiveRecord model

Parameters:

  • model_class (Class)

    The ActiveRecord model class to import from

  • batch_size (Integer)

    Number of records to fetch per batch

  • transform_method (Symbol, Proc) (defaults to: :as_json)

    Method or Proc to transform records

  • preloads (Array, Symbol, Hash, nil) (defaults to: nil)

    Associations to preload to avoid N+1

  • import_options (Hash) (defaults to: {})

    Options to pass to the import method

Returns:

  • (Hash)

    { success: Integer, failed: Integer }



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/typesense_model/base.rb', line 206

def import_from_model(model_class, batch_size, transform_method = :as_json, preloads = nil, import_options = {})
  total_results = { success: 0, failed: 0, errors: [] }

  transformer = transform_method.is_a?(Proc) ? transform_method : ->(record) { record.send(transform_method) }

  relation = model_class.all
  relation = relation.preload(preloads) if preloads

  relation.find_in_batches(batch_size: batch_size) do |batch|
    documents = batch.map(&transformer)
    results = import(documents, import_options)

    total_results[:success] += results[:success]
    total_results[:failed] += results[:failed]
    total_results[:errors].concat(results[:errors]) if results[:errors].is_a?(Array)
  end

  total_results
end

.multi_search(searches, common_params = {}) ⇒ Array<SearchResults>

Perform several searches in a single request.

Parameters:

  • searches (Array<Hash>)

    each a set of Typesense search params; ‘collection` defaults to this model’s collection, and ‘q`/`query_by` fall back the same way as #search (pass `query_by` explicitly when targeting a different collection).

  • common_params (Hash) (defaults to: {})

    params applied to every search.

Returns:

  • (Array<SearchResults>)

    one result set per search, in order.



48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/typesense_model/base.rb', line 48

def multi_search(searches, common_params = {})
  payload = Array(searches).map do |params|
    params = params.transform_keys(&:to_sym)
    {
      collection: params[:collection] || collection_name,
      q: params[:q] || '*',
      query_by: params[:query_by] || default_query_by
    }.merge(params.except(:collection, :q, :query_by))
  end

  response = client.multi_search.perform({ searches: payload }, common_params)
  (response['results'] || []).map { |result| SearchResults.new(result, self) }
end

.overridesObject



268
269
270
# File 'lib/typesense_model/base.rb', line 268

def overrides
  client.collections[collection_name].overrides.retrieve
end

.retrieve_collectionObject

Retrieve collection details



150
151
152
153
# File 'lib/typesense_model/base.rb', line 150

def retrieve_collection
  return nil unless collection_exists?
  client.collections[collection_name].retrieve
end

.schema_definitionObject



21
22
23
# File 'lib/typesense_model/base.rb', line 21

def schema_definition
  @_schema_definition
end

.search(query, options = {}) ⇒ Object



36
37
38
# File 'lib/typesense_model/base.rb', line 36

def search(query, options = {})
  Search.new(self, query, options).execute
end

.synonymsObject



252
253
254
# File 'lib/typesense_model/base.rb', line 252

def synonyms
  client.collections[collection_name].synonyms.retrieve
end

.update_collectionObject

Update the collection schema in Typesense to match the model schema.

Typesense only accepts a ‘fields` diff on update: a field can be added, or dropped (`drop: true`), and a change is expressed as a drop followed by a re-add. Re-sending an existing, unchanged field raises an error, so we diff the desired schema against the live collection and send only the additions, modifications, and removals. The implicit `id` field cannot be altered and is always skipped.



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/typesense_model/base.rb', line 106

def update_collection
  return create_collection unless collection_exists?

  live_fields = (retrieve_collection&.dig('fields') || []).each_with_object({}) do |f, h|
    h[f['name'].to_s] = f
  end

  desired_fields = (schema_definition.to_hash[:fields] || []).reject do |f|
    (f[:name] || f['name']).to_s == 'id'
  end

  changes = []
  desired_names = []

  desired_fields.each do |field|
    name = (field[:name] || field['name']).to_s
    desired_names << name
    existing = live_fields[name]

    if existing.nil?
      changes << field
    elsif field_changed?(field, existing)
      changes << { 'name' => name, 'drop' => true }
      changes << field
    end
  end

  # Drop fields that exist in Typesense but are no longer in the schema.
  live_fields.each_key do |name|
    next if name == 'id' || name == '.*'
    changes << { 'name' => name, 'drop' => true } unless desired_names.include?(name)
  end

  return retrieve_collection if changes.empty?

  client.collections[collection_name].update(fields: changes)
end

.upsert_override(id, override) ⇒ Object

— Overrides (curation) —————————————— Thin wrappers over the Typesense overrides API for this collection.



264
265
266
# File 'lib/typesense_model/base.rb', line 264

def upsert_override(id, override)
  client.collections[collection_name].overrides.upsert(id, override)
end

.upsert_synonym(id, synonym) ⇒ Object

— Synonyms ——————————————————- Thin wrappers over the Typesense synonyms API for this collection.



248
249
250
# File 'lib/typesense_model/base.rb', line 248

def upsert_synonym(id, synonym)
  client.collections[collection_name].synonyms.upsert(id, synonym)
end

Instance Method Details

#deleteObject

Delete the current record from Typesense. Returns true if a document was removed, false if there was no id or nothing to delete.



346
347
348
349
350
351
# File 'lib/typesense_model/base.rb', line 346

def delete
  return false unless id

  response = self.class.delete(id)
  !response.nil? && response != false
end

#idObject



340
341
342
# File 'lib/typesense_model/base.rb', line 340

def id
  attributes['id']
end

#respond_to_missing?(method_name, include_private = false) ⇒ Boolean

Returns:

  • (Boolean)


370
371
372
373
374
375
# File 'lib/typesense_model/base.rb', line 370

def respond_to_missing?(method_name, include_private = false)
  attribute_name = method_name.to_s
  return true if attribute_name.end_with?('=') && attributes.key?(attribute_name.chop)
  return true if attributes.key?(attribute_name)
  super
end

#saveObject



333
334
335
336
337
338
# File 'lib/typesense_model/base.rb', line 333

def save
  response = self.class.send(:client).collections[self.class.collection_name].documents.upsert(attributes)
  
  @attributes = response.transform_keys(&:to_s)
  self
end