typesense-rails 1.0.0.rc1

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.
@@ -0,0 +1,996 @@
1
+ require "typesense"
2
+ require "typesense/version"
3
+ require "typesense/utilities"
4
+ require "rails/all"
5
+
6
+ if defined? Rails
7
+ begin
8
+ require "typesense/railtie"
9
+ rescue LoadError
10
+ end
11
+ end
12
+
13
+ begin
14
+ require "active_job"
15
+ rescue LoadError
16
+ # no queue support, fine
17
+ end
18
+
19
+ require "logger"
20
+ Rails.logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT))
21
+ Rails.logger.level = Logger::INFO
22
+
23
+ module Typesense
24
+ class NotConfigured < StandardError; end
25
+ class BadConfiguration < StandardError; end
26
+ class NoBlockGiven < StandardError; end
27
+
28
+ autoload :Config, "typesense/config"
29
+ extend Config
30
+
31
+ autoload :Pagination, "typesense/pagination"
32
+
33
+ class << self
34
+ attr_reader :included_in
35
+
36
+ def included(klass)
37
+ @included_in ||= []
38
+ @included_in << klass
39
+ @included_in.uniq!
40
+
41
+ klass.class_eval do
42
+ extend ClassMethods
43
+ include InstanceMethods
44
+ end
45
+ end
46
+ end
47
+
48
+ class IndexSettings
49
+ DEFAULT_BATCH_SIZE = 250
50
+
51
+ OPTIONS = [
52
+ :multi_way_synonyms,
53
+ :one_way_synonyms,
54
+ :predefined_fields,
55
+ :fields,
56
+ :default_sorting_field,
57
+ :symbols_to_index,
58
+ :token_separators,
59
+ :enable_nested_fields,
60
+ :metadata,
61
+ ]
62
+ OPTIONS.each do |k|
63
+ define_method k do |v|
64
+ instance_variable_set("@#{k}", v)
65
+ end
66
+ end
67
+
68
+ def initialize(options, &block)
69
+ @options = options
70
+ instance_exec(&block) if block_given?
71
+ end
72
+
73
+ def use_serializer(serializer)
74
+ @serializer = serializer
75
+ end
76
+
77
+ def attribute(*names, &block)
78
+ raise ArgumentError.new("Cannot pass multiple attribute names if block given") if block_given? and names.length > 1
79
+ @attributes ||= {}
80
+ names.flatten.each do |name|
81
+ @attributes[name.to_s] = block_given? ? Proc.new { |o| o.instance_eval(&block) } : Proc.new { |o| o.send(name) }
82
+ end
83
+ end
84
+
85
+ alias :attributes :attribute
86
+
87
+ def add_attribute(*names, &block)
88
+ raise ArgumentError.new("Cannot pass multiple attribute names if block given") if block_given? and names.length > 1
89
+ @additional_attributes ||= {}
90
+ names.each do |name|
91
+ @additional_attributes[name.to_s] = block_given? ? Proc.new { |o| o.instance_eval(&block) } : Proc.new { |o| o.send(name) }
92
+ end
93
+ end
94
+
95
+ alias :add_attributes :add_attribute
96
+
97
+ def mongoid?(object)
98
+ defined?(::Mongoid::Document) && object.class.include?(::Mongoid::Document)
99
+ end
100
+
101
+ def sequel?(object)
102
+ defined?(::Sequel) && object.class < ::Sequel::Model
103
+ end
104
+
105
+ def active_record?(object)
106
+ !mongoid?(object) && !sequel?(object)
107
+ end
108
+
109
+ def get_default_attributes(object)
110
+ if mongoid?(object)
111
+ # work-around mongoid 2.4's unscoped method, not accepting a block
112
+ object.attributes
113
+ elsif sequel?(object)
114
+ object.to_hash
115
+ else
116
+ object.class.unscoped do
117
+ object.attributes
118
+ end
119
+ end
120
+ end
121
+
122
+ def get_attribute_names(object)
123
+ get_attributes(object).keys
124
+ end
125
+
126
+ def attributes_to_hash(attributes, object)
127
+ if attributes
128
+ Hash[attributes.map { |name, value| [name.to_s, value.call(object)] }]
129
+ else
130
+ {}
131
+ end
132
+ end
133
+
134
+ def get_attributes(object)
135
+ # If a serializer is set, we ignore attributes
136
+ # everything should be done via the serializer
137
+ if not @serializer.nil?
138
+ attributes = @serializer.new(object).attributes
139
+ elsif @attributes.nil? || @attributes.length.zero?
140
+ attributes = get_default_attributes(object)
141
+ # no `attribute ...` have been configured, use the default attributes of the model
142
+ elsif active_record?(object)
143
+ # at least 1 `attribute ...` has been configured, therefore use ONLY the one configured
144
+ object.class.unscoped do
145
+ attributes = attributes_to_hash(@attributes, object)
146
+ end
147
+ else
148
+ attributes = attributes_to_hash(@attributes, object)
149
+ end
150
+
151
+ attributes.merge!(attributes_to_hash(@additional_attributes, object)) if @additional_attributes
152
+
153
+ if @options[:sanitize]
154
+ sanitizer = begin
155
+ ::HTML::FullSanitizer.new
156
+ rescue NameError
157
+ # from rails 4.2
158
+ ::Rails::Html::FullSanitizer.new
159
+ end
160
+ attributes = sanitize_attributes(attributes, sanitizer)
161
+ end
162
+
163
+ attributes = encode_attributes(attributes) if @options[:force_utf8_encoding] && Object.const_defined?(:RUBY_VERSION) && RUBY_VERSION.to_f > 1.8
164
+
165
+ attributes
166
+ end
167
+
168
+ def sanitize_attributes(v, sanitizer)
169
+ case v
170
+ when String
171
+ sanitizer.sanitize(v)
172
+ when Hash
173
+ v.each { |key, value| v[key] = sanitize_attributes(value, sanitizer) }
174
+ when Array
175
+ v.map { |x| sanitize_attributes(x, sanitizer) }
176
+ else
177
+ v
178
+ end
179
+ end
180
+
181
+ def encode_attributes(v)
182
+ case v
183
+ when String
184
+ v.dup.force_encoding("utf-8")
185
+ when Hash
186
+ v.each { |key, value| v[key] = encode_attributes(value) }
187
+ when Array
188
+ v.map { |x| encode_attributes(x) }
189
+ else
190
+ v
191
+ end
192
+ end
193
+
194
+ def get_setting(name)
195
+ instance_variable_get("@#{name}")
196
+ end
197
+ end
198
+
199
+ # Default queueing system
200
+ if defined?(::ActiveJob::Base)
201
+ # lazy load the ActiveJob class to ensure the
202
+ # queue is initialized before using it
203
+ autoload :TypesenseJob, "typesense/typesense_job"
204
+ autoload :ImportJob, "typesense/import_job"
205
+ end
206
+
207
+ # these are the class methods added when Typesense is included
208
+ module ClassMethods
209
+ def self.extended(base)
210
+ class << base
211
+ alias_method :without_auto_index, :typesense_without_auto_index unless method_defined? :without_auto_index
212
+ alias_method :reindex!, :typesense_reindex! unless method_defined? :reindex!
213
+ alias_method :reindex, :typesense_reindex unless method_defined? :reindex
214
+ alias_method :index_objects, :typesense_index_objects unless method_defined? :index_objects
215
+ alias_method :index!, :typesense_index! unless method_defined? :index!
216
+ alias_method :remove_from_index!, :typesense_remove_from_index! unless method_defined? :remove_from_index!
217
+ alias_method :clear_index!, :typesense_clear_index! unless method_defined? :clear_index!
218
+ alias_method :search, :typesense_search unless method_defined? :search
219
+ alias_method :raw_search, :typesense_raw_search unless method_defined? :raw_search
220
+ alias_method :index, :typesense_index unless method_defined? :index
221
+ alias_method :index_name, :typesense_collection_name unless method_defined? :index_name
222
+ alias_method :collection_name, :typesense_collection_name unless method_defined? :collection_name
223
+ alias_method :must_reindex?, :typesense_must_reindex? unless method_defined? :must_reindex?
224
+ alias_method :create_collection, :typesense_create_collection unless method_defined? :create_collection
225
+ alias_method :upsert_alias, :typesense_upsert_alias unless method_defined? :upsert_alias
226
+ alias_method :get_collection, :typesense_get_collection unless method_defined? :get_collection
227
+ alias_method :num_documents, :typesense_num_documents unless method_defined? :num_documents
228
+ alias_method :get_alias, :typesense_get_alias unless method_defined? :get_alias
229
+ alias_method :upsert_document, :typesense_upsert_document unless method_defined? :upsert_document
230
+ alias_method :import_documents, :typesense_import_documents unless method_defined? :import_documents
231
+ alias_method :retrieve_document, :typesense_retrieve_document unless method_defined? :retrieve_document
232
+ alias_method :delete_document, :typesense_delete_document unless method_defined? :delete_document
233
+ alias_method :delete_collection, :typesense_delete_collection unless method_defined? :delete_collection
234
+ alias_method :delete_by_query, :typesense_delete_by_query unless method_defined? :delete_by_query
235
+ alias_method :search_collection, :typesense_search_collection unless method_defined? :search_collection
236
+ end
237
+
238
+ base.cattr_accessor :typesense_options, :typesense_settings, :typesense_client
239
+ end
240
+
241
+ def collection_name_with_timestamp(options)
242
+ "#{typesense_collection_name(options)}_#{Time.now.to_i}"
243
+ end
244
+
245
+ def typesense_create_collection(collection_name, settings = nil)
246
+ fields = settings.get_setting(:predefined_fields) || settings.get_setting(:fields)
247
+ default_sorting_field = settings.get_setting(:default_sorting_field)
248
+ multi_way_synonyms = settings.get_setting(:multi_way_synonyms)
249
+ one_way_synonyms = settings.get_setting(:one_way_synonyms)
250
+ symbols_to_index = settings.get_setting(:symbols_to_index)
251
+ token_separators = settings.get_setting(:token_separators)
252
+ enable_nested_fields = settings.get_setting(:enable_nested_fields)
253
+ metadata = settings.get_setting(:metadata)
254
+ typesense_client.collections.create(
255
+ { "name" => collection_name }
256
+ .merge(
257
+ if fields
258
+ { "fields" => fields }
259
+ else
260
+ { "fields" => [
261
+ { "name" => ".*",
262
+ "type" => "auto" },
263
+ ] }
264
+ end,
265
+ default_sorting_field ? { "default_sorting_field" => default_sorting_field } : {},
266
+ symbols_to_index ? { "symbols_to_index" => symbols_to_index } : {},
267
+ token_separators ? { "token_separators" => token_separators } : {},
268
+ enable_nested_fields ? { "enable_nested_fields" => enable_nested_fields } : {},
269
+ metadata ? { "metadata" => metadata } : {}
270
+ )
271
+ )
272
+ Rails.logger.info "Collection '#{collection_name}' created!"
273
+
274
+ typesense_multi_way_synonyms(collection_name, multi_way_synonyms) if multi_way_synonyms
275
+
276
+ typesense_one_way_synonyms(collection_name, one_way_synonyms) if one_way_synonyms
277
+ end
278
+
279
+ def typesense_upsert_alias(collection_name, alias_name)
280
+ typesense_client.aliases.upsert(alias_name, { "collection_name" => collection_name })
281
+ end
282
+
283
+ def typesense_get_collection(collection)
284
+ typesense_client.collections[collection].retrieve
285
+ rescue Typesense::Error::ObjectNotFound
286
+ nil
287
+ end
288
+
289
+ def typesense_num_documents(collection)
290
+ typesense_client.collections[collection].retrieve["num_documents"]
291
+ end
292
+
293
+ def typesense_get_alias(alias_name)
294
+ typesense_client.aliases[alias_name].retrieve
295
+ end
296
+
297
+ def typesense_upsert_document(object, collection, dirtyvalues = nil)
298
+ raise ArgumentError, "Object is required" unless object
299
+
300
+ typesense_client.collections[collection].documents.upsert(object, dirty_values: dirtyvalues) if dirtyvalues
301
+ typesense_client.collections[collection].documents.upsert(object)
302
+ end
303
+
304
+ def typesense_import_documents(jsonl_object, action, collection, batch_size: nil)
305
+ raise ArgumentError, "JSONL object is required" unless jsonl_object
306
+ import_options = { action: action }
307
+ import_options[:batch_size] = batch_size if batch_size
308
+
309
+ typesense_client.collections[collection].documents.import(jsonl_object, import_options)
310
+ end
311
+
312
+ def typesense_retrieve_document(object_id, collection = nil)
313
+ if collection
314
+ typesense_client.collections[collection].documents[object_id].retrieve
315
+ else
316
+ collection_obj = typesense_ensure_init
317
+ typesense_client.collections[collection_obj[:alias_name]].documents[object_id].retrieve
318
+ end
319
+ end
320
+
321
+ def typesense_delete_document(object_id, collection)
322
+ typesense_client.collections[collection].documents[object_id].delete
323
+ end
324
+
325
+ def typesense_delete_by_query(collection, query)
326
+ typesense_client.collections[collection].documents.delete(filter_by: query)
327
+ end
328
+
329
+ def typesense_delete_collection(collection)
330
+ typesense_client.collections[collection].delete
331
+ end
332
+
333
+ def typesense_search_collection(search_parameters, collection)
334
+ typesense_client.collections[collection].documents.search(search_parameters)
335
+ end
336
+
337
+ def typesense_multi_way_synonyms(collection, synonyms)
338
+ synonyms.each do |synonym_hash|
339
+ synonym_hash.each do |synonym_name, synonym|
340
+ typesense_client.collections[collection].synonyms.upsert(
341
+ synonym_name,
342
+ { "synonyms" => synonym }
343
+ )
344
+ end
345
+ end
346
+ end
347
+
348
+ def typesense_one_way_synonyms(collection, synonyms)
349
+ synonyms.each do |synonym_hash|
350
+ synonym_hash.each do |synonym_name, synonym|
351
+ typesense_client.collections[collection].synonyms.upsert(
352
+ synonym_name,
353
+ synonym
354
+ )
355
+ end
356
+ end
357
+ end
358
+
359
+ def typesense(options = {}, &block)
360
+ self.typesense_settings = IndexSettings.new(options, &block)
361
+ self.typesense_options = { type: typesense_full_const_get(model_name.to_s) }.merge(options) # :per_page => typesense_settings.get_setting(:hitsPerPage) || 10, :page => 1
362
+ self.typesense_client ||= Typesense.client
363
+ attr_accessor :highlight_result, :snippet_result
364
+
365
+ if options[:enqueue]
366
+ proc = if options[:enqueue] == true
367
+ proc do |record, remove|
368
+ typesenseJob.perform_later(record, remove ? "typesense_remove_from_index!" : "typesense_index!")
369
+ end
370
+ elsif options[:enqueue].respond_to?(:call)
371
+ options[:enqueue]
372
+ elsif options[:enqueue].is_a?(Symbol)
373
+ proc { |record, remove| send(options[:enqueue], record, remove) }
374
+ else
375
+ raise ArgumentError, "Invalid `enqueue` option: #{options[:enqueue]}"
376
+ end
377
+ typesense_options[:enqueue] = proc do |record, remove|
378
+ proc.call(record, remove) unless typesense_without_auto_index_scope
379
+ end
380
+ end
381
+ unless options[:auto_index] == false
382
+ if defined?(::Sequel) && self < Sequel::Model
383
+ class_eval do
384
+ copy_after_validation = instance_method(:after_validation)
385
+ copy_before_save = instance_method(:before_save)
386
+
387
+ define_method(:after_validation) do |*args|
388
+ super(*args)
389
+ copy_after_validation.bind(self).call
390
+ typesense_mark_must_reindex
391
+ end
392
+
393
+ define_method(:before_save) do |*args|
394
+ copy_before_save.bind(self).call
395
+ typesense_mark_for_auto_indexing
396
+ super(*args)
397
+ end
398
+
399
+ sequel_version = Gem::Version.new(Sequel.version)
400
+ if sequel_version >= Gem::Version.new("4.0.0") && sequel_version < Gem::Version.new("5.0.0")
401
+ copy_after_commit = instance_method(:after_commit)
402
+ define_method(:after_commit) do |*args|
403
+ super(*args)
404
+ copy_after_commit.bind(self).call
405
+ typesense_perform_index_tasks
406
+ end
407
+ else
408
+ copy_after_save = instance_method(:after_save)
409
+ define_method(:after_save) do |*args|
410
+ super(*args)
411
+ copy_after_save.bind(self).call
412
+ db.after_commit do
413
+ typesense_perform_index_tasks
414
+ end
415
+ end
416
+ end
417
+ end
418
+ else
419
+ after_validation :typesense_mark_must_reindex if respond_to?(:after_validation)
420
+ before_save :typesense_mark_for_auto_indexing if respond_to?(:before_save)
421
+ if respond_to?(:after_commit)
422
+ after_commit :typesense_perform_index_tasks
423
+ elsif respond_to?(:after_save)
424
+ after_save :typesense_perform_index_tasks
425
+ end
426
+ end
427
+ end
428
+ unless options[:auto_remove] == false
429
+ if defined?(::Sequel) && self < Sequel::Model
430
+ class_eval do
431
+ copy_after_destroy = instance_method(:after_destroy)
432
+
433
+ define_method(:after_destroy) do |*args|
434
+ copy_after_destroy.bind(self).call
435
+ typesense_enqueue_remove_from_index!
436
+ super(*args)
437
+ end
438
+ end
439
+ elsif respond_to?(:after_destroy)
440
+ after_destroy { |searchable| searchable.typesense_enqueue_remove_from_index! }
441
+ end
442
+ end
443
+ end
444
+
445
+ def typesense_without_auto_index
446
+ self.typesense_without_auto_index_scope = true
447
+ begin
448
+ yield
449
+ ensure
450
+ self.typesense_without_auto_index_scope = false
451
+ end
452
+ end
453
+
454
+ def typesense_without_auto_index_scope=(value)
455
+ Thread.current["typesense_without_auto_index_scope_for_#{model_name}"] = value
456
+ end
457
+
458
+ def typesense_without_auto_index_scope
459
+ Thread.current["typesense_without_auto_index_scope_for_#{model_name}"]
460
+ end
461
+
462
+ def typesense_reindex!(batch_size = Typesense::IndexSettings::DEFAULT_BATCH_SIZE)
463
+ # typesense_reindex!: Reindexes all objects in database(does not remove deleted objects from the collection)
464
+ return if typesense_without_auto_index_scope
465
+
466
+ api_response = nil
467
+
468
+ typesense_configurations.each do |options, settings|
469
+ next if typesense_indexing_disabled?(options)
470
+
471
+ collection_obj = typesense_ensure_init(options, settings)
472
+
473
+ typesense_find_in_batches(batch_size) do |group|
474
+ if typesense_conditional_index?(options)
475
+ # delete non-indexable objects
476
+ ids = group.reject { |o| typesense_indexable?(o, options) }.map { |o| typesense_object_id_of(o, options) }
477
+ delete_by_query(collection_obj[:alias_name], "id: [#{ids.reject(&:blank?).join(",")}]")
478
+
479
+ group = group.select { |o| typesense_indexable?(o, options) }
480
+ end
481
+ documents = group.map do |o|
482
+ attributes = settings.get_attributes(o)
483
+ attributes = attributes.to_hash unless attributes.instance_of?(Hash)
484
+ # convert to JSON object
485
+ attributes.merge!("id" => typesense_object_id_of(o, options)).to_json
486
+ end
487
+
488
+ jsonl_object = documents.join("\n")
489
+ api_response = import_documents(jsonl_object, "upsert", collection_obj[:alias_name])
490
+ end
491
+ end
492
+ api_response
493
+ end
494
+
495
+ def typesense_reindex(batch_size = Typesense::IndexSettings::DEFAULT_BATCH_SIZE)
496
+ # typesense_reindex: Reindexes whole database using alias(removes deleted objects from collection)
497
+ return if typesense_without_auto_index_scope
498
+
499
+ typesense_configurations.each do |options, settings|
500
+ next if typesense_indexing_disabled?(options)
501
+
502
+ begin
503
+ master_index = typesense_ensure_init(options, settings, false)
504
+ delete_collection(master_index[:alias_name])
505
+ rescue ArgumentError
506
+ @typesense_indexes[settings] = { collection_name: "", alias_name: typesense_collection_name(options) }
507
+ master_index = @typesense_indexes[settings]
508
+ end
509
+
510
+ # init temporary index
511
+ src_index_name = collection_name_with_timestamp(options)
512
+ tmp_options = options.merge({ index_name: src_index_name })
513
+ tmp_options.delete(:per_environment) # already included in the temporary index_name
514
+ tmp_settings = settings.dup
515
+
516
+ create_collection(src_index_name, settings)
517
+
518
+ typesense_find_in_batches(batch_size) do |group|
519
+ if typesense_conditional_index?(options)
520
+ # select only indexable objects
521
+ group = group.select { |o| typesense_indexable?(o, tmp_options) }
522
+ end
523
+ documents = group.map do |o|
524
+ tmp_settings.get_attributes(o).merge!("id" => typesense_object_id_of(o, tmp_options)).to_json
525
+ end
526
+ jsonl_object = documents.join("\n")
527
+ import_documents(jsonl_object, "upsert", src_index_name)
528
+ end
529
+
530
+ upsert_alias(src_index_name, master_index[:alias_name])
531
+ master_index[:collection_name] = src_index_name
532
+ end
533
+ nil
534
+ end
535
+
536
+ def typesense_index_objects_async(objects, batch_size = Typesense::IndexSettings::DEFAULT_BATCH_SIZE)
537
+ typesense_configurations.each do |options, settings|
538
+ next if typesense_indexing_disabled?(options)
539
+ collection_obj = typesense_ensure_init(options, settings)
540
+ documents = objects.map do |o|
541
+ settings.get_attributes(o).merge!("id" => typesense_object_id_of(o, options)).to_json
542
+ end
543
+ jsonl_object = documents.join("\n")
544
+ ImportJob.perform(jsonl_object, collection_obj[:alias_name], batch_size)
545
+ Rails.logger.info "#{objects.length} objects enqueued for import into #{collection_obj[:collection_name]}"
546
+ end
547
+ nil
548
+ end
549
+
550
+ def typesense_index_objects(objects, batch_size = Typesense::IndexSettings::DEFAULT_BATCH_SIZE)
551
+ typesense_configurations.each do |options, settings|
552
+ next if typesense_indexing_disabled?(options)
553
+
554
+ collection_obj = typesense_ensure_init(options, settings)
555
+ documents = objects.map do |o|
556
+ settings.get_attributes(o).merge!("id" => typesense_object_id_of(o, options)).to_json
557
+ end
558
+ jsonl_object = documents.join("\n")
559
+ import_documents(jsonl_object, "upsert", collection_obj[:alias_name], batch_size: batch_size)
560
+ Rails.logger.info "#{objects.length} objects upserted into #{collection_obj[:collection_name]}!"
561
+ end
562
+ nil
563
+ end
564
+
565
+ def typesense_index!(object)
566
+ # typesense_index!: Creates a document for the object and retrieves it.
567
+ return if typesense_without_auto_index_scope
568
+
569
+ api_response = nil
570
+
571
+ typesense_configurations.each do |options, settings|
572
+ next if typesense_indexing_disabled?(options)
573
+
574
+ object_id = typesense_object_id_of(object, options)
575
+ collection_obj = typesense_ensure_init(options, settings)
576
+
577
+ if typesense_indexable?(object, options)
578
+ raise ArgumentError, "Cannot index a record with a blank objectID" if object_id.blank?
579
+
580
+ object = settings.get_attributes(object).merge!("id" => object_id)
581
+
582
+ if options[:dirty_values]
583
+ api_response = upsert_document(object, collection_obj[:alias_name], options[:dirty_values])
584
+ else
585
+ api_response = upsert_document(object, collection_obj[:alias_name])
586
+ end
587
+ elsif typesense_conditional_index?(options) && !object_id.blank?
588
+ begin
589
+ api_response = delete_document(object_id, collection_obj[:collection_name])
590
+ rescue Typesense::Error::ObjectNotFound => e
591
+ Rails.logger.error "Object not found in index: #{e.message}"
592
+ end
593
+ end
594
+ end
595
+
596
+ api_response
597
+ end
598
+
599
+ def typesense_remove_from_index!(object)
600
+ # typesense_remove_from_index: Removes specified object from the collection of given model.
601
+ return if typesense_without_auto_index_scope
602
+
603
+ object_id = typesense_object_id_of(object)
604
+ raise ArgumentError, "Cannot index a record with a blank objectID" if object_id.blank?
605
+
606
+ typesense_configurations.each do |options, settings|
607
+ next if typesense_indexing_disabled?(options)
608
+
609
+ collection_obj = typesense_ensure_init(options, settings, false)
610
+
611
+ begin
612
+ delete_document(object_id, collection_obj[:alias_name])
613
+ rescue Typesense::Error::ObjectNotFound => e
614
+ Rails.logger.error "Object #{object_id} could not be removed from #{collection_obj[:collection_name]} collection! Use reindex to update the collection."
615
+ end
616
+ Rails.logger.info "Removed document with object id '#{object_id}' from #{collection_obj[:collection_name]}"
617
+ end
618
+ nil
619
+ end
620
+
621
+ def typesense_clear_index!
622
+ # typesense_clear_index!: Delete collection of given model.
623
+ typesense_configurations.each do |options, settings|
624
+ next if typesense_indexing_disabled?(options)
625
+
626
+ collection_obj = typesense_ensure_init(options, settings, false)
627
+
628
+ delete_collection(collection_obj[:alias_name])
629
+ Rails.logger.info "Deleted #{collection_obj[:alias_name]} collection!"
630
+ @typesense_indexes[settings] = nil
631
+ end
632
+ nil
633
+ end
634
+
635
+ def typesense_raw_search(q, query_by, params = {})
636
+ # typesense_raw_search: JSON output of search.
637
+ params[:page] = params[:page] ? params[:page].to_i : 1
638
+ collection_obj = typesense_index # index_name)
639
+ search_collection(params.merge({ q: q, query_by: query_by }), collection_obj[:alias_name])
640
+ end
641
+
642
+ module AdditionalMethods
643
+ def self.extended(base)
644
+ class << base
645
+ alias_method :raw_answer, :typesense_raw_answer unless method_defined? :raw_answer
646
+ alias_method :facets, :typesense_facets unless method_defined? :facets
647
+ end
648
+ end
649
+
650
+ def typesense_raw_answer
651
+ @typesense_json
652
+ end
653
+
654
+ def typesense_facets
655
+ @typesense_json["facet_counts"]
656
+ end
657
+
658
+ private
659
+
660
+ def typesense_init_raw_answer(json)
661
+ @typesense_json = json
662
+ end
663
+ end
664
+
665
+ def typesense_search(q, query_by, params = {})
666
+ # typsense_search: Searches and returns matching objects from the database.
667
+
668
+ json = typesense_raw_search(q, query_by, params)
669
+ hit_ids = json["hits"].map { |hit| hit["document"]["id"] }
670
+
671
+ condition_key = if defined?(::Mongoid::Document) && include?(::Mongoid::Document)
672
+ typesense_object_id_method.in
673
+ else
674
+ typesense_object_id_method
675
+ end
676
+
677
+ results_by_id = typesense_options[:type].where(condition_key => hit_ids).index_by do |hit|
678
+ typesense_object_id_of(hit)
679
+ end
680
+
681
+ results = json["hits"].map do |hit|
682
+ o = results_by_id[hit["document"]["id"].to_s]
683
+ next unless o
684
+
685
+ o.highlight_result = hit["highlights"]
686
+ o.snippet_result = hit["highlights"].map do |highlight|
687
+ highlight["snippet"]
688
+ end
689
+ o
690
+ end.compact
691
+
692
+ total_hits = json["found"]
693
+ res = Typesense::Pagination.create(results, total_hits,
694
+ typesense_options.merge({ page: json["page"].to_i, per_page: json["request_params"]["per_page"] }))
695
+ res.extend(AdditionalMethods)
696
+ res.send(:typesense_init_raw_answer, json)
697
+ res
698
+ end
699
+
700
+ def typesense_index(name = nil)
701
+ # typesense_index: Creates collection and its alias.
702
+ if name
703
+ typesense_configurations.each do |o, s|
704
+ return typesense_ensure_init(o, s) if o[:collection_name].to_s == name.to_s || o[:index_name].to_s == name.to_s
705
+ end
706
+ raise ArgumentError, "Invalid index/replica name: #{name}"
707
+ end
708
+ typesense_ensure_init
709
+ end
710
+
711
+ def typesense_collection_name(options = nil)
712
+ options ||= typesense_options
713
+ name = options[:collection_name] || options[:index_name] || model_name.to_s.gsub("::", "_")
714
+ name = "#{name}_#{Rails.env}" if options[:per_environment]
715
+ name
716
+ end
717
+
718
+ def typesense_must_reindex?(object)
719
+ # use +typesense_dirty?+ method if implemented
720
+ return object.send(:typesense_dirty?) if object.respond_to?(:typesense_dirty?)
721
+
722
+ # Loop over each index to see if a attribute used in records has changed
723
+ typesense_configurations.each do |options, settings|
724
+ next if typesense_indexing_disabled?(options)
725
+ return true if typesense_object_id_changed?(object, options)
726
+
727
+ settings.get_attribute_names(object).each do |k|
728
+ return true if typesense_attribute_changed?(object, k)
729
+ # return true if !object.respond_to?(changed_method) || object.send(changed_method)
730
+ end
731
+ [options[:if], options[:unless]].each do |condition|
732
+ case condition
733
+ when nil
734
+ when String, Symbol
735
+ return true if typesense_attribute_changed?(object, condition)
736
+ else
737
+ # if the :if, :unless condition is a anything else,
738
+ # we have no idea whether we should reindex or not
739
+ # let's always reindex then
740
+ return true
741
+ end
742
+ end
743
+ end
744
+ # By default, we don't reindex
745
+ false
746
+ end
747
+
748
+ protected
749
+
750
+ def typesense_ensure_init(options = nil, settings = nil, create = true)
751
+ raise ArgumentError, "No `typesense` block found in your model." if typesense_settings.nil?
752
+
753
+ @typesense_indexes ||= {}
754
+
755
+ options ||= typesense_options
756
+ settings ||= typesense_settings
757
+
758
+ return @typesense_indexes[settings] if @typesense_indexes[settings] && get_collection(@typesense_indexes[settings][:alias_name])
759
+
760
+ alias_name = typesense_collection_name(options)
761
+ collection = get_collection(alias_name)
762
+
763
+ if collection
764
+ collection_name = collection["name"]
765
+ else
766
+ collection_name = self.collection_name_with_timestamp(options)
767
+ raise ArgumentError, "#{collection_name} is not found in your model." unless create
768
+
769
+ create_collection(collection_name, settings)
770
+ upsert_alias(collection_name, alias_name)
771
+ end
772
+ @typesense_indexes[settings] = { collection_name: collection_name, alias_name: alias_name }
773
+
774
+ @typesense_indexes[settings]
775
+ end
776
+
777
+ private
778
+
779
+ def typesense_configurations
780
+ raise ArgumentError, "No `typesense` block found in your model." if typesense_settings.nil?
781
+
782
+ if @configurations.nil?
783
+ @configurations = {}
784
+ @configurations[typesense_options] = typesense_settings
785
+ end
786
+ @configurations
787
+ end
788
+
789
+ def typesense_object_id_method(options = nil)
790
+ options ||= typesense_options
791
+ options[:id] || options[:object_id] || :id
792
+ end
793
+
794
+ def typesense_object_id_of(o, options = nil)
795
+ o.send(typesense_object_id_method(options)).to_s
796
+ end
797
+
798
+ def typesense_object_id_changed?(o, options = nil)
799
+ changed = typesense_attribute_changed?(o, typesense_object_id_method(options))
800
+ changed.nil? ? false : changed
801
+ end
802
+
803
+ def typesense_settings_changed?(prev, current)
804
+ return true if prev.nil?
805
+
806
+ current.each do |k, v|
807
+ prev_v = prev[k.to_s]
808
+ if v.is_a?(Array) && prev_v.is_a?(Array)
809
+ # compare array of strings, avoiding symbols VS strings comparison
810
+ return true if v.map(&:to_s) != prev_v.map(&:to_s)
811
+ elsif prev_v != v
812
+ return true
813
+ end
814
+ end
815
+ false
816
+ end
817
+
818
+ def typesense_full_const_get(name)
819
+ list = name.split("::")
820
+ list.shift if list.first.blank?
821
+ obj = Object.const_defined?(:RUBY_VERSION) && RUBY_VERSION.to_f < 1.9 ? Object : self
822
+ list.each do |x|
823
+ # This is required because const_get tries to look for constants in the
824
+ # ancestor chain, but we only want constants that are HERE
825
+ obj = obj.const_defined?(x) ? obj.const_get(x) : obj.const_missing(x)
826
+ end
827
+ obj
828
+ end
829
+
830
+ def typesense_conditional_index?(options = nil)
831
+ options ||= typesense_options
832
+ options[:if].present? || options[:unless].present?
833
+ end
834
+
835
+ def typesense_indexable?(object, options = nil)
836
+ options ||= typesense_options
837
+ if_passes = options[:if].blank? || typesense_constraint_passes?(object, options[:if])
838
+ unless_passes = options[:unless].blank? || !typesense_constraint_passes?(object, options[:unless])
839
+ if_passes && unless_passes
840
+ end
841
+
842
+ def typesense_constraint_passes?(object, constraint)
843
+ case constraint
844
+ when Symbol
845
+ object.send(constraint)
846
+ when String
847
+ object.send(constraint.to_sym)
848
+ when Enumerable
849
+ # All constraints must pass
850
+ constraint.all? { |inner_constraint| typesense_constraint_passes?(object, inner_constraint) }
851
+ else
852
+ raise ArgumentError, "Unknown constraint type: #{constraint} (#{constraint.class})" unless constraint.respond_to?(:call)
853
+
854
+ constraint.call(object)
855
+ end
856
+ end
857
+
858
+ def typesense_indexing_disabled?(options = nil)
859
+ options ||= typesense_options
860
+ constraint = options[:disable_indexing] || options["disable_indexing"]
861
+ case constraint
862
+ when nil
863
+ return false
864
+ when true, false
865
+ return constraint
866
+ when String, Symbol
867
+ return send(constraint)
868
+ else
869
+ return constraint.call if constraint.respond_to?(:call) # Proc
870
+ end
871
+ raise ArgumentError, "Unknown constraint type: #{constraint} (#{constraint.class})"
872
+ end
873
+
874
+ def typesense_find_in_batches(batch_size, &block)
875
+ if (defined?(::ActiveRecord) && ancestors.include?(::ActiveRecord::Base)) || respond_to?(:find_in_batches)
876
+ find_in_batches(batch_size: batch_size, &block)
877
+ elsif defined?(::Sequel) && self < Sequel::Model
878
+ dataset.extension(:pagination).each_page(batch_size, &block)
879
+ else
880
+ # don't worry, mongoid has its own underlying cursor/streaming mechanism
881
+ items = []
882
+ all.each do |item|
883
+ items << item
884
+ if items.length % batch_size.zero?
885
+ yield items
886
+ items = []
887
+ end
888
+ end
889
+ yield items unless items.empty?
890
+ end
891
+ end
892
+
893
+ def typesense_attribute_changed?(object, attr_name)
894
+ # if one of two method is implemented, we return its result
895
+ # true/false means whether it has changed or not
896
+ # +#{attr_name}_changed?+ always defined for automatic attributes but deprecated after Rails 5.2
897
+ # +will_save_change_to_#{attr_name}?+ should be use instead for Rails 5.2+, also defined for automatic attributes.
898
+ # If none of the method are defined, it's a dynamic attribute
899
+
900
+ method_name = "#{attr_name}_changed?"
901
+ if object.respond_to?(method_name)
902
+ # If +#{attr_name}_changed?+ respond we want to see if the method is user defined or if it's automatically
903
+ # defined by Rails.
904
+ # If it's user-defined, we call it.
905
+ # If it's automatic we check ActiveRecord version to see if this method is deprecated
906
+ # and try to call +will_save_change_to_#{attr_name}?+ instead.
907
+ # See: https://github.com/typesense/typesense-rails/pull/338
908
+ # This feature is not compatible with Ruby 1.8
909
+ # In this case, we always call #{attr_name}_changed?
910
+ return object.send(method_name) if Object.const_defined?(:RUBY_VERSION) && RUBY_VERSION.to_f < 1.9
911
+ return object.send(method_name) unless automatic_changed_method?(object, method_name) && automatic_changed_method_deprecated?
912
+ end
913
+
914
+ return object.send("will_save_change_to_#{attr_name}?") if object.respond_to?("will_save_change_to_#{attr_name}?")
915
+
916
+ # We don't know if the attribute has changed, so conservatively assume it has
917
+ true
918
+ end
919
+
920
+ def automatic_changed_method?(object, method_name)
921
+ unless object.respond_to?(method_name)
922
+ raise ArgumentError,
923
+ "Method #{method_name} doesn't exist on #{object.class.name}"
924
+ end
925
+
926
+ file = object.method(method_name).source_location[0]
927
+ file.end_with?("active_model/attribute_methods.rb")
928
+ end
929
+
930
+ def automatic_changed_method_deprecated?
931
+ (defined?(::ActiveRecord) && ActiveRecord::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 1) ||
932
+ (defined?(::ActiveRecord) && ActiveRecord::VERSION::MAJOR > 5)
933
+ end
934
+ end
935
+
936
+ # these are the instance methods included
937
+ module InstanceMethods
938
+ def self.included(base)
939
+ base.instance_eval do
940
+ alias_method :index!, :typesense_index! unless method_defined? :index!
941
+ alias_method :remove_from_index!, :typesense_remove_from_index! unless method_defined? :remove_from_index!
942
+ end
943
+ end
944
+
945
+ def typesense_index!
946
+ self.class.typesense_index!(self)
947
+ end
948
+
949
+ def typesense_remove_from_index!
950
+ self.class.typesense_remove_from_index!(self)
951
+ end
952
+
953
+ def typesense_enqueue_remove_from_index!
954
+ if typesense_options[:enqueue]
955
+ typesense_options[:enqueue].call(self, true) unless self.class.send(:typesense_indexing_disabled?,
956
+ typesense_options)
957
+ else
958
+ typesense_remove_from_index!
959
+ end
960
+ end
961
+
962
+ def typesense_enqueue_index!
963
+ if typesense_options[:enqueue]
964
+ typesense_options[:enqueue].call(self, false) unless self.class.send(:typesense_indexing_disabled?,
965
+ typesense_options)
966
+ else
967
+ typesense_index!
968
+ end
969
+ end
970
+
971
+ private
972
+
973
+ def typesense_mark_for_auto_indexing
974
+ @typesense_auto_indexing = true
975
+ end
976
+
977
+ def typesense_mark_must_reindex
978
+ # typesense_must_reindex flag is reset after every commit as part. If we must reindex at any point in
979
+ # a stransaction, keep flag set until it is explicitly unset
980
+ @typesense_must_reindex ||= if defined?(::Sequel) && is_a?(Sequel::Model)
981
+ new? || self.class.typesense_must_reindex?(self)
982
+ else
983
+ new_record? || self.class.typesense_must_reindex?(self)
984
+ end
985
+ true
986
+ end
987
+
988
+ def typesense_perform_index_tasks
989
+ return if !@typesense_auto_indexing || @typesense_must_reindex == false
990
+
991
+ typesense_enqueue_index!
992
+ remove_instance_variable(:@typesense_auto_indexing) if instance_variable_defined?(:@typesense_auto_indexing)
993
+ remove_instance_variable(:@typesense_must_reindex) if instance_variable_defined?(:@typesense_must_reindex)
994
+ end
995
+ end
996
+ end