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,1178 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), "spec_helper"))
2
+ # require "debug"
3
+ # DEBUGGER__::CONFIG.set_config(
4
+ # port: 12345,
5
+ # nonstop: false,
6
+ # )
7
+ # debugger
8
+ NEW_RAILS = Gem.loaded_specs["rails"].version >= Gem::Version.new("6.0")
9
+
10
+ require "active_record"
11
+ unless NEW_RAILS
12
+ require "active_job/test_helper"
13
+ ActiveJob::Base.queue_adapter = :test
14
+ end
15
+ require "sqlite3" unless defined?(JRUBY_VERSION)
16
+ require "logger"
17
+ require "sequel"
18
+ require "active_model_serializers"
19
+
20
+ Typesense.configuration = {
21
+ nodes: [{
22
+ host: "localhost", # For Typesense Cloud use xxx.a1.typesense.net
23
+ port: 8108, # For Typesense Cloud use 443
24
+ protocol: "http", # For Typesense Cloud use https
25
+ }],
26
+ api_key: "xyz",
27
+ connection_timeout_seconds: 2,
28
+ }
29
+
30
+ begin
31
+ FileUtils.rm("data.sqlite3")
32
+ rescue StandardError
33
+ nil
34
+ end
35
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
36
+ ActiveRecord::Base.logger.level = Logger::WARN
37
+ ActiveRecord::Base.establish_connection(
38
+ "adapter" => defined?(JRUBY_VERSION) ? "jdbcsqlite3" : "sqlite3",
39
+ "database" => "data.sqlite3",
40
+ "pool" => 5,
41
+ "timeout" => 5000,
42
+ )
43
+
44
+ ActiveRecord::Base.raise_in_transactional_callbacks = true if ActiveRecord::Base.respond_to?(:raise_in_transactional_callbacks)
45
+
46
+ SEQUEL_DB = Sequel.connect(if defined?(JRUBY_VERSION)
47
+ "jdbc:sqlite:sequel_data.sqlite3"
48
+ else
49
+ { "adapter" => "sqlite",
50
+ "database" => "sequel_data.sqlite3" }
51
+ end)
52
+
53
+ unless SEQUEL_DB.table_exists?(:sequel_books)
54
+ SEQUEL_DB.create_table(:sequel_books) do
55
+ primary_key :id
56
+ String :name
57
+ String :author
58
+ FalseClass :released
59
+ FalseClass :premium
60
+ end
61
+ end
62
+
63
+ ActiveRecord::Schema.define do
64
+ create_table :products do |t|
65
+ t.string :name
66
+ t.string :href
67
+ t.string :type
68
+ t.text :description
69
+ t.datetime :release_date
70
+ end
71
+ create_table :colors do |t|
72
+ t.string :name
73
+ t.string :short_name
74
+ t.integer :hex
75
+ end
76
+ create_table :namespaced_models do |t|
77
+ t.string :name
78
+ t.integer :another_private_value
79
+ end
80
+ create_table :uniq_users, id: false do |t|
81
+ t.string :name
82
+ end
83
+ create_table :nullable_ids do |t|
84
+ end
85
+ create_table :nested_items do |t|
86
+ t.integer :parent_id
87
+ t.boolean :hidden
88
+ end
89
+ create_table :cities do |t|
90
+ t.string :name
91
+ t.string :country
92
+ t.float :lat
93
+ t.float :lng
94
+ t.string :gl_array
95
+ end
96
+ create_table :with_slaves do |t|
97
+ end
98
+ create_table :mongo_objects do |t|
99
+ t.string :name
100
+ end
101
+ create_table :books do |t|
102
+ t.string :name
103
+ t.string :author
104
+ t.boolean :premium
105
+ t.boolean :released
106
+ end
107
+ create_table :ebooks do |t|
108
+ t.string :name
109
+ t.string :author
110
+ t.boolean :premium
111
+ t.boolean :released
112
+ end
113
+ create_table :disabled_booleans do |t|
114
+ t.string :name
115
+ end
116
+ create_table :disabled_procs do |t|
117
+ t.string :name
118
+ end
119
+ create_table :disabled_symbols do |t|
120
+ t.string :name
121
+ end
122
+ create_table :encoded_strings do |t|
123
+ end
124
+ create_table :forward_to_replicas do |t|
125
+ t.string :name
126
+ end
127
+ create_table :forward_to_replicas_twos do |t|
128
+ t.string :name
129
+ end
130
+ create_table :sub_replicas do |t|
131
+ t.string :name
132
+ end
133
+ create_table :enqueued_objects do |t|
134
+ t.string :name
135
+ end
136
+ create_table :disabled_enqueued_objects do |t|
137
+ t.string :name
138
+ end
139
+ create_table :misconfigured_blocks do |t|
140
+ t.string :name
141
+ end
142
+ if defined?(ActiveModel::Serializer)
143
+ create_table :serialized_objects do |t|
144
+ t.string :name
145
+ t.string :skip
146
+ end
147
+ end
148
+ end
149
+
150
+ class Product < ActiveRecord::Base
151
+ include Typesense
152
+
153
+ typesense auto_index: false,
154
+ if: :published?, unless: ->(o) { o.href.blank? },
155
+ index_name: safe_index_name("my_products_index") do
156
+ attribute :href, :name
157
+
158
+ multi_way_synonyms [
159
+ { "phone-synonym" => %w[galaxy samsung samsung_electronics] },
160
+ ]
161
+
162
+ one_way_synonyms [
163
+ { "smart-phone-synonym" => { "root" => "smartphone",
164
+ "synonyms" => %w[nokia samsung motorola android] } },
165
+ ]
166
+ end
167
+
168
+ def published?
169
+ release_date.blank? || release_date <= Time.now
170
+ end
171
+ end
172
+
173
+ class Camera < Product
174
+ end
175
+
176
+ class Color < ActiveRecord::Base
177
+ include Typesense
178
+ attr_accessor :not_indexed
179
+
180
+ typesense collection_name: safe_index_name("Color"), per_environment: true do
181
+ fields [
182
+ { "name" => "name", "type" => "string", "facet" => true },
183
+ { "name" => "short_name", "type" => "string", "index" => false, "optional" => true },
184
+ { "name" => "hex", "type" => "int32" },
185
+ ]
186
+
187
+ default_sorting_field "hex"
188
+
189
+ # we're using all attributes of the Color class + the _tag "extra" attribute
190
+ end
191
+
192
+ def hex_changed?
193
+ false
194
+ end
195
+
196
+ def will_save_change_to_short_name?
197
+ false
198
+ end
199
+ end
200
+
201
+ class DisabledBoolean < ActiveRecord::Base
202
+ include Typesense
203
+
204
+ typesense disable_indexing: true, index_name: safe_index_name("DisabledBoolean") do
205
+ end
206
+ end
207
+
208
+ class DisabledProc < ActiveRecord::Base
209
+ include Typesense
210
+
211
+ typesense disable_indexing: proc { true }, index_name: safe_index_name("DisabledProc") do
212
+ end
213
+ end
214
+
215
+ class DisabledSymbol < ActiveRecord::Base
216
+ include Typesense
217
+
218
+ typesense disable_indexing: :truth, index_name: safe_index_name("DisabledSymbol") do
219
+ end
220
+
221
+ def self.truth
222
+ true
223
+ end
224
+ end
225
+
226
+ module Namespaced
227
+ def self.table_name_prefix
228
+ "namespaced_"
229
+ end
230
+ end
231
+
232
+ class Namespaced::Model < ActiveRecord::Base
233
+ include Typesense
234
+
235
+ typesense index_name: safe_index_name(typesense_collection_name({})) do
236
+ attribute :customAttr do
237
+ 40 + another_private_value
238
+ end
239
+ attribute :myid do
240
+ id
241
+ end
242
+ end
243
+ end
244
+
245
+ class UniqUser < ActiveRecord::Base
246
+ include Typesense
247
+
248
+ typesense index_name: safe_index_name("UniqUser"), per_environment: true, id: :name do
249
+ end
250
+ end
251
+
252
+ class NullableId < ActiveRecord::Base
253
+ include Typesense
254
+
255
+ typesense index_name: safe_index_name("NullableId"), per_environment: true, id: :custom_id,
256
+ if: :never do
257
+ end
258
+
259
+ def custom_id
260
+ nil
261
+ end
262
+
263
+ def never
264
+ false
265
+ end
266
+ end
267
+
268
+ class NestedItem < ActiveRecord::Base
269
+ has_many :children, class_name: "NestedItem", foreign_key: "parent_id"
270
+
271
+ include Typesense
272
+
273
+ typesense index_name: safe_index_name("NestedItem"), per_environment: true, unless: :hidden do
274
+ attribute :nb_children
275
+ end
276
+
277
+ def nb_children
278
+ children.count
279
+ end
280
+ end
281
+
282
+ class City < ActiveRecord::Base
283
+ include Typesense
284
+
285
+ serialize :gl_array
286
+
287
+ def location
288
+ lat.present? && lng.present? ? [lat, lng] : gl_array
289
+ end
290
+
291
+ typesense index_name: safe_index_name("City"), per_environment: true do
292
+ add_attribute :a_null_lat, :a_lng, :location
293
+
294
+ predefined_fields [{ "name" => "location", "type" => "geopoint" }]
295
+ end
296
+
297
+ def a_null_lat
298
+ nil
299
+ end
300
+
301
+ def a_lng
302
+ 1.2345678
303
+ end
304
+ end
305
+
306
+ class SequelBook < Sequel::Model(SEQUEL_DB)
307
+ plugin :active_model
308
+
309
+ include Typesense
310
+
311
+ typesense index_name: safe_index_name("SequelBook"), per_environment: true, sanitize: true do
312
+ add_attribute :test
313
+ add_attribute :test2
314
+ end
315
+
316
+ def after_create
317
+ SequelBook.new
318
+ end
319
+
320
+ def test
321
+ "test"
322
+ end
323
+
324
+ def test2
325
+ "test2"
326
+ end
327
+
328
+ private
329
+
330
+ def public?
331
+ released && !premium
332
+ end
333
+ end
334
+
335
+ describe "SequelBook" do
336
+ before(:all) do
337
+ SequelBook.clear_index!
338
+ rescue StandardError
339
+ ArgumentError
340
+ end
341
+
342
+ it "should index the book" do
343
+ @steve_jobs = SequelBook.create name: "Steve Jobs", author: "Walter Isaacson", premium: true,
344
+ released: true
345
+ results = SequelBook.search("steve", "name")
346
+ expect(results.size).to eq(1)
347
+ expect(results[0].id).to eq(@steve_jobs.id)
348
+ end
349
+
350
+ it "should not override after hooks" do
351
+ expect(SequelBook).to receive(:new).twice.and_call_original
352
+ SequelBook.create name: "Steve Jobs", author: "Walter Isaacson", premium: true, released: true
353
+ end
354
+ end
355
+
356
+ class MongoObject < ActiveRecord::Base
357
+ include Typesense
358
+
359
+ typesense index_name: safe_index_name("MongoObject") do
360
+ end
361
+
362
+ def self.reindex!
363
+ raise NameError, "never reached"
364
+ end
365
+
366
+ def index!
367
+ raise NameError, "never reached"
368
+ end
369
+ end
370
+
371
+ class Book < ActiveRecord::Base
372
+ include Typesense
373
+
374
+ typesense index_name: safe_index_name("SecuredBook"), per_environment: true, sanitize: true do
375
+ end
376
+
377
+ private
378
+
379
+ def public?
380
+ released && !premium
381
+ end
382
+ end
383
+
384
+ class Ebook < ActiveRecord::Base
385
+ include Typesense
386
+ attr_accessor :current_time, :published_at
387
+
388
+ typesense index_name: safe_index_name("eBooks") do
389
+ end
390
+
391
+ def typesense_dirty?
392
+ return true if published_at.nil? || current_time.nil?
393
+
394
+ # Consider dirty if published date is in the past
395
+ # This doesn't make so much business sense but it's easy to test.
396
+ published_at < current_time
397
+ end
398
+ end
399
+
400
+ class EncodedString < ActiveRecord::Base
401
+ include Typesense
402
+
403
+ typesense force_utf8_encoding: true, index_name: safe_index_name("EncodedString") do
404
+ attribute :value do
405
+ "\xC2\xA0\xE2\x80\xA2\xC2\xA0".force_encoding("ascii-8bit")
406
+ end
407
+ end
408
+ end
409
+
410
+ class SubReplicas < ActiveRecord::Base
411
+ include Typesense
412
+
413
+ typesense force_utf8_encoding: true, index_name: safe_index_name("SubReplicas") do
414
+ end
415
+ end
416
+
417
+ class EnqueuedObject < ActiveRecord::Base
418
+ include Typesense
419
+
420
+ include GlobalID::Identification
421
+
422
+ def id
423
+ read_attribute(:id)
424
+ end
425
+
426
+ def self.find(_id)
427
+ EnqueuedObject.first
428
+ end
429
+
430
+ typesense enqueue: proc { |record| raise "enqueued #{record.id}" },
431
+ index_name: safe_index_name("EnqueuedObject") do
432
+ attributes ["name"]
433
+ end
434
+ end
435
+
436
+ class DisabledEnqueuedObject < ActiveRecord::Base
437
+ include Typesense
438
+
439
+ typesense(enqueue: proc { |_record| raise "enqueued" },
440
+ index_name: safe_index_name("EnqueuedObject"),
441
+ disable_indexing: true) do
442
+ attributes ["name"]
443
+ end
444
+ end
445
+
446
+ class MisconfiguredBlock < ActiveRecord::Base
447
+ include Typesense
448
+ end
449
+
450
+ if defined?(ActiveModel::Serializer)
451
+ class SerializedObjectSerializer < ActiveModel::Serializer
452
+ attributes :name
453
+ end
454
+
455
+ class SerializedObject < ActiveRecord::Base
456
+ include Typesense
457
+
458
+ typesense index_name: safe_index_name("SerializedObject") do
459
+ use_serializer SerializedObjectSerializer
460
+ end
461
+ end
462
+ end
463
+
464
+ if defined?(ActiveModel::Serializer)
465
+ describe "SerializedObject" do
466
+ before(:all) do
467
+ SerializedObject.clear_index!
468
+ rescue StandardError
469
+ ArgumentError
470
+ end
471
+
472
+ it "should push the name but not the other attribute" do
473
+ o = SerializedObject.new name: "test", skip: "skip me"
474
+ attributes = SerializedObject.typesense_settings.get_attributes(o)
475
+ expect(attributes).to eq({ name: "test" })
476
+ end
477
+ end
478
+ end
479
+
480
+ describe "Encoding" do
481
+ before(:all) do
482
+ EncodedString.clear_index!
483
+ rescue StandardError
484
+ ArgumentError
485
+ end
486
+
487
+ if Object.const_defined?(:RUBY_VERSION) && RUBY_VERSION.to_f > 1.8
488
+ it "should convert to utf-8" do
489
+ EncodedString.create!
490
+ results = EncodedString.raw_search("", "value")
491
+ expect(results["hits"].size).to eq(1)
492
+ expect(results["hits"].first["document"]["value"]).to eq("\xC2\xA0\xE2\x80\xA2\xC2\xA0".force_encoding("utf-8"))
493
+ end
494
+ end
495
+ end
496
+
497
+ describe "Settings" do
498
+ it "should detect settings changes" do
499
+ expect(Color.send(:typesense_settings_changed?, nil, {})).to eq(true)
500
+
501
+ expect(Color.send(:typesense_settings_changed?, {}, { "searchableAttributes" => ["name"] })).to eq(true)
502
+
503
+ expect(Color.send(:typesense_settings_changed?,
504
+ { "searchableAttributes" => ["name"] },
505
+ { "searchableAttributes" => %w[name hex] })).to eq(true)
506
+
507
+ expect(Color.send(:typesense_settings_changed?,
508
+ { "searchableAttributes" => ["name"] },
509
+ { "customRanking" => ["asc(hex)"] })).to eq(true)
510
+ end
511
+
512
+ it "should not detect settings changes" do
513
+ expect(Color.send(:typesense_settings_changed?, {}, {})).to eq(false)
514
+
515
+ expect(Color.send(:typesense_settings_changed?,
516
+ { "searchableAttributes" => ["name"] },
517
+ { searchableAttributes: ["name"] })).to eq(false)
518
+
519
+ expect(Color.send(:typesense_settings_changed?,
520
+ { "searchableAttributes" => ["name"], "customRanking" => ["asc(hex)"] },
521
+ { "customRanking" => ["asc(hex)"] })).to eq(false)
522
+ end
523
+ end
524
+
525
+ describe "Change detection" do
526
+ it "should detect attribute changes" do
527
+ color = Color.new name: "dark-blue", short_name: "blue", hex: 123
528
+
529
+ expect(Color.typesense_must_reindex?(color)).to eq(true)
530
+ color.save
531
+ expect(Color.typesense_must_reindex?(color)).to eq(false)
532
+
533
+ color.hex = 123_456
534
+ expect(Color.typesense_must_reindex?(color)).to eq(false)
535
+
536
+ color.not_indexed = "strstr"
537
+ expect(Color.typesense_must_reindex?(color)).to eq(false)
538
+
539
+ color.name = "red"
540
+ expect(Color.typesense_must_reindex?(color)).to eq(true)
541
+ end
542
+ it "should detect attribute changes even in a transaction" do
543
+ color = Color.new name: "dark-blue", short_name: "blue", hex: 123
544
+ color.save
545
+
546
+ expect(color.instance_variable_get("@typesense_must_reindex")).to be_nil
547
+ Color.transaction do
548
+ color.name = "red"
549
+ color.save
550
+ color.not_indexed = "strstr"
551
+ color.save
552
+ expect(color.instance_variable_get("@typesense_must_reindex")).to eq(true)
553
+ end
554
+ expect(color.instance_variable_get("@typesense_must_reindex")).to be_nil
555
+
556
+ color.delete
557
+ end
558
+
559
+ it "should detect change with typesense_dirty? method" do
560
+ ebook = Ebook.new name: "My life", author: "Myself", premium: false, released: true
561
+
562
+ # Check initial state - should need reindexing due to typesense_dirty? method
563
+ expect(Ebook.typesense_must_reindex?(ebook)).to eq(true)
564
+
565
+ # Set current time and published time where published_at < current_time
566
+ ebook.current_time = 10
567
+ ebook.published_at = 8
568
+ expect(Ebook.typesense_must_reindex?(ebook)).to eq(true)
569
+
570
+ # Change published_at to be after current_time
571
+ ebook.published_at = 12
572
+ expect(Ebook.typesense_must_reindex?(ebook)).to eq(false)
573
+ end
574
+
575
+ it "should know if the _changed? method is user-defined",
576
+ skip: Object.const_defined?(:RUBY_VERSION) && RUBY_VERSION.to_f < 1.9 do
577
+ color = Color.new name: "dark-blue", short_name: "blue", hex: 123
578
+
579
+ expect do
580
+ Color.send(:automatic_changed_method?, color, :something_that_doesnt_exist)
581
+ end.to raise_error(ArgumentError)
582
+
583
+ expect(Color.send(:automatic_changed_method?, color, :name_changed?)).to eq(true)
584
+ expect(Color.send(:automatic_changed_method?, color, :hex_changed?)).to eq(false)
585
+ expect(Color.send(:automatic_changed_method?, color, :will_save_change_to_short_name?)).to eq(false)
586
+
587
+ if Color.send(:automatic_changed_method_deprecated?)
588
+ expect(Color.send(:automatic_changed_method?, color, :will_save_change_to_name?)).to eq(true)
589
+ expect(Color.send(:automatic_changed_method?, color, :will_save_change_to_hex?)).to eq(true)
590
+ end
591
+ end
592
+ end
593
+
594
+ describe "Namespaced::Model" do
595
+ before(:all) do
596
+ Namespaced::Model.clear_index!
597
+ rescue StandardError
598
+ ArgumentError
599
+ end
600
+
601
+ it "should have an index name without :: hierarchy" do
602
+ expect(Namespaced::Model.index_name.end_with?("Namespaced_Model")).to eq(true)
603
+ end
604
+
605
+ it "should use the block to determine attribute's value" do
606
+ m = Namespaced::Model.new(another_private_value: 2)
607
+ attributes = Namespaced::Model.typesense_settings.get_attributes(m)
608
+ expect(attributes["customAttr"]).to eq(42)
609
+ expect(attributes["myid"]).to eq(m.id)
610
+ end
611
+
612
+ it "should always update when there is no custom _changed? function" do
613
+ m = Namespaced::Model.new(another_private_value: 2)
614
+ m.save
615
+ results = Namespaced::Model.search("*", "", { "filter_by" => "customAttr:42" })
616
+ expect(results.size).to eq(1)
617
+ expect(results[0].id).to eq(m.id)
618
+
619
+ m.another_private_value = 5
620
+ m.save
621
+
622
+ results = Namespaced::Model.search("*", "", { "filter_by" => "customAttr:42" })
623
+ expect(results.size).to eq(0)
624
+
625
+ results = Namespaced::Model.search("*", "", { "filter_by" => "customAttr:45" })
626
+ expect(results.size).to eq(1)
627
+ expect(results[0].id).to eq(m.id)
628
+ end
629
+ end
630
+
631
+ describe "UniqUsers" do
632
+ before(:all) do
633
+ UniqUser.clear_index!
634
+ rescue StandardError
635
+ ArgumentError
636
+ end
637
+
638
+ it "should not use the id field" do
639
+ UniqUser.create name: "fooBar"
640
+ results = UniqUser.search("foo", "name")
641
+ expect(results.size).to eq(1)
642
+ end
643
+ end
644
+
645
+ describe "NestedItem" do
646
+ before(:all) do
647
+ NestedItem.clear_index!
648
+ rescue StandardError
649
+ ArgumentError
650
+ end
651
+
652
+ it "should fetch attributes unscoped" do
653
+ @i1 = NestedItem.create hidden: false
654
+ @i2 = NestedItem.create hidden: true
655
+
656
+ @i1.children << NestedItem.create(hidden: true) << NestedItem.create(hidden: true)
657
+ NestedItem.where(id: [@i1.id, @i2.id]).reindex!(Typesense::IndexSettings::DEFAULT_BATCH_SIZE)
658
+
659
+ result = NestedItem.retrieve_document(@i1.id)
660
+ expect(result["nb_children"]).to eq(2)
661
+
662
+ result = NestedItem.raw_search("*", "")
663
+ expect(result["found"]).to eq(1)
664
+
665
+ if @i2.respond_to? :update_attributes
666
+ @i2.update_attributes hidden: false
667
+ else
668
+ @i2.update hidden: false
669
+ end
670
+
671
+ result = NestedItem.raw_search("*", "")
672
+ expect(result["found"]).to eq(2)
673
+ end
674
+ end
675
+
676
+ describe "Colors" do
677
+ before(:all) do
678
+ Color.clear_index!
679
+ end
680
+
681
+ it "should detect predefined_fields" do
682
+ color = Color.create name: "dark-blue", hex: 123
683
+ expect(color.short_name).to be_nil
684
+ end
685
+
686
+ it "should auto index" do
687
+ @blue = Color.create!(name: "blue", short_name: "b", hex: 0xFF0000)
688
+ results = Color.search("blue", "name")
689
+ expect(results.size).to eq(1)
690
+ expect(results).to include(@blue)
691
+ end
692
+
693
+ it "should facet as well" do
694
+ results = Color.search("*", "", { "facet_by" => "name" })
695
+
696
+ expect(results.raw_answer).not_to be_nil
697
+ expect(results.raw_answer["facet_counts"]).not_to be_nil
698
+ expect(results.raw_answer["facet_counts"].size).to eq(1)
699
+ expect(results.raw_answer["facet_counts"][0]["counts"][0]["count"]).to eq(1)
700
+ end
701
+
702
+ it "should be raw searchable" do
703
+ results = Color.raw_search("blue", "name")
704
+ expect(results["hits"].size).to eq(1)
705
+ expect(results["found"]).to eq(1)
706
+ end
707
+
708
+ it "should not auto index if scoped" do
709
+ Color.without_auto_index do
710
+ Color.create!(name: "blue", short_name: "b", hex: 0xFF0000)
711
+ end
712
+ expect(Color.search("blue", "name").size).to eq(1)
713
+ Color.reindex!(Typesense::IndexSettings::DEFAULT_BATCH_SIZE)
714
+ expect(Color.search("blue", "name").size).to eq(2)
715
+ end
716
+
717
+ it "should not be searchable with non-indexed fields" do
718
+ @blue = Color.create!(name: "blue", short_name: "x", hex: 0xFF0000)
719
+ expect { Color.search("x", "short_name") }.to raise_error(Typesense::Error)
720
+ # expect(results.size).to eq(0)
721
+ end
722
+
723
+ it "should rank with default_sorting_field hex" do
724
+ @blue = Color.create!(name: "red", short_name: "r3", hex: 3)
725
+ @blue2 = Color.create!(name: "red", short_name: "r1", hex: 1)
726
+ @blue3 = Color.create!(name: "red", short_name: "r2", hex: 2)
727
+ results = Color.search("red", "name")
728
+ expect(results.size).to eq(3)
729
+ expect(results[0].hex).to eq(3)
730
+ expect(results[1].hex).to eq(2)
731
+ expect(results[2].hex).to eq(1)
732
+ end
733
+
734
+ it "should update the index if the attribute changed" do
735
+ @purple = Color.create!(name: "purple", short_name: "p", hex: 123)
736
+ expect(Color.search("purple", "name").size).to eq(1)
737
+ expect(Color.search("pink", "name").size).to eq(0)
738
+ @purple.name = "pink"
739
+ @purple.save
740
+ expect(Color.search("purple", "name").size).to eq(0)
741
+ expect(Color.search("pink", "name").size).to eq(1)
742
+ end
743
+
744
+ it "should use the specified scope" do
745
+ Color.clear_index!
746
+ Color.where(name: "red").reindex!(Typesense::IndexSettings::DEFAULT_BATCH_SIZE)
747
+ expect(Color.search("*", "").size).to eq(3)
748
+ Color.clear_index!
749
+ Color.where(id: Color.first.id).reindex!(Typesense::IndexSettings::DEFAULT_BATCH_SIZE)
750
+ expect(Color.search("*", "").size).to eq(1)
751
+ end
752
+
753
+ it "should have a Rails env-based index name" do
754
+ expect(Color.index_name).to eq(safe_index_name("Color") + "_#{Rails.env}")
755
+ end
756
+
757
+ it "should include the _highlightResult and _snippetResults" do
758
+ @green = Color.create!(name: "green", short_name: "gre", hex: 0x00FF00)
759
+ results = Color.search("green", "name", { "highlight_fields" => ["short_name"] })
760
+ expect(results.size).to eq(1)
761
+ expect(results[0].highlight_result).to_not be_nil
762
+ expect(results[0].snippet_result).to_not be_nil
763
+ end
764
+
765
+ it "should index an array of objects" do
766
+ json = Color.raw_search("*", "")
767
+ Color.index_objects Color.limit(1)
768
+ expect(json["found"]).to eq(Color.raw_search("*", "")["found"])
769
+ end
770
+
771
+ it "should not index non-saved object" do
772
+ expect { Color.new(name: "purple").index!(true) }.to raise_error(ArgumentError)
773
+ expect { Color.new(name: "purple").remove_from_index!(true) }.to raise_error(ArgumentError)
774
+ end
775
+
776
+ it "should reindex with a temporary index name based on custom index name & per_environment" do
777
+ Color.reindex
778
+ end
779
+ it "should process objects async" do
780
+ Color.clear_index!
781
+
782
+ colors = []
783
+ Color.without_auto_index do
784
+ colors = [
785
+ Color.create!(name: "red", short_name: "r", hex: 0xFF0000),
786
+ Color.create!(name: "green", short_name: "g", hex: 0x00FF00),
787
+ Color.create!(name: "blue", short_name: "b", hex: 0x0000FF),
788
+ ]
789
+ end
790
+
791
+ expect(Typesense::ImportJob).to receive(:perform).exactly(1).times.and_call_original do |jsonl, collection_name, batch_size|
792
+ documents = jsonl.split("\n").map { |d| JSON.parse(d) }
793
+ expect(documents.length).to eq(3)
794
+ expect(documents.map { |d| d["name"] }).to contain_exactly("red", "green", "blue")
795
+ end
796
+
797
+ Color.typesense_index_objects_async(colors)
798
+
799
+ # Allow time for indexing to complete
800
+ sleep(1)
801
+
802
+ results = Color.search("*", "")
803
+ expect(results.size).to eq(3)
804
+ expect(results.map(&:name)).to contain_exactly("red", "green", "blue")
805
+ expect(results.map(&:hex)).to contain_exactly(0xFF0000, 0x00FF00, 0x0000FF)
806
+ end
807
+ end
808
+
809
+ describe "An imaginary store" do
810
+ before(:all) do
811
+ begin
812
+ Product.clear_index!
813
+ rescue StandardError
814
+ ArgumentError
815
+ end
816
+ # Google products
817
+ @blackberry = Product.create!(name: "blackberry", href: "google")
818
+ @nokia = Product.create!(name: "nokia", href: "google")
819
+
820
+ # Amazon products
821
+ @android = Product.create!(name: "android", href: "amazon")
822
+ @samsung = Product.create!(name: "samsung", href: "amazon")
823
+ @motorola = Product.create!(name: "motorola", href: "amazon",
824
+ description: "Not sure about features since I've never owned one.")
825
+
826
+ # Ebay products
827
+ @palmpre = Product.create!(name: "palmpre", href: "ebay")
828
+ @palm_pixi_plus = Product.create!(name: "palm pixi plus", href: "ebay")
829
+ @lg_vortex = Product.create!(name: "lg vortex", href: "ebay")
830
+ @t_mobile = Product.create!(name: "t mobile", href: "ebay")
831
+
832
+ # Yahoo products
833
+ @htc = Product.create!(name: "htc", href: "yahoo")
834
+ @htc_evo = Product.create!(name: "htc evo", href: "yahoo")
835
+ @ericson = Product.create!(name: "ericson", href: "yahoo")
836
+
837
+ # Apple products
838
+ @iphone = Product.create!(name: "iphone", href: "apple",
839
+ description: "Puts even more features at your fingertips")
840
+
841
+ # Unindexed products
842
+ @sekrit = Product.create!(name: "super sekrit", href: "amazon", release_date: Time.now + 1.day)
843
+ @no_href = Product.create!(name: "super sekrit too; missing href")
844
+
845
+ # Subproducts
846
+ @camera = Camera.create!(name: "canon eos rebel t3", href: "canon")
847
+
848
+ 100.times do
849
+ Product.create!(name: "crapoola", href: "crappy")
850
+ end
851
+
852
+ @products_in_database = Product.all
853
+
854
+ Product.reindex(Typesense::IndexSettings::DEFAULT_BATCH_SIZE)
855
+ sleep 5
856
+ end
857
+
858
+ describe "pagination" do
859
+ it "should display total results correctly" do
860
+ results = Product.search("crapoola", "name", { "per_page" => Typesense::IndexSettings::DEFAULT_BATCH_SIZE })
861
+ expect(results.length).to eq(Product.where(name: "crapoola").count)
862
+ end
863
+ end
864
+
865
+ describe "basic searching" do
866
+ it "should find the iphone" do
867
+ results = Product.search("iphone", "name")
868
+ expect(results.size).to eq(1)
869
+ expect(results).to include(@iphone)
870
+ end
871
+
872
+ it "should search case insensitively" do
873
+ results = Product.search("IPHONE", "name")
874
+ expect(results.size).to eq(1)
875
+ expect(results).to include(@iphone)
876
+ end
877
+
878
+ it "should find all amazon products" do
879
+ results = Product.search("amazon", "href")
880
+ expect(results.size).to eq(3)
881
+ expect(results).to include(@android, @samsung, @motorola)
882
+ end
883
+
884
+ it 'should find all "palm" phones with wildcard word search' do
885
+ results = Product.search("pal", "name")
886
+
887
+ expect(results).to include(@palmpre, @palm_pixi_plus)
888
+ end
889
+
890
+ it "should search multiple words from the same field" do
891
+ results = Product.search("palm pixi plus", "name")
892
+ expect(results).to include(@palm_pixi_plus)
893
+ end
894
+
895
+ it "should narrow the results by searching across multiple fields" do
896
+ results = Product.search("apple iphone", "href,name")
897
+ expect(results.size).to eq(1)
898
+ expect(results).to include(@iphone)
899
+ end
900
+
901
+ it "should not search on non-indexed fields" do
902
+ expect { Product.search("features", "description") }.to raise_error(Typesense::Error)
903
+ end
904
+
905
+ it "should delete the associated record" do
906
+ @iphone.destroy
907
+ results = Product.search("iphone", "name")
908
+ expect(results.size).to eq(0)
909
+ end
910
+
911
+ it "should not throw an exception if a search result isn't found locally" do
912
+ Product.without_auto_index { @palmpre.destroy }
913
+ expect { Product.search("pal", "name").to_json }.to_not raise_error
914
+ end
915
+
916
+ it "should return the other results if those are still available locally" do
917
+ Product.without_auto_index { @palmpre.destroy }
918
+ results = Product.search("pal", "name")
919
+ expect(results).to include(@palm_pixi_plus)
920
+ end
921
+
922
+ it "should not duplicate an already indexed record" do
923
+ expect(Product.search("nokia", "name").size).to eq(1)
924
+ @nokia.index!
925
+ expect(Product.search("nokia", "name").size).to eq(1)
926
+ @nokia.index!
927
+ @nokia.index!
928
+ expect(Product.search("nokia", "name").size).to eq(1)
929
+ end
930
+
931
+ it "should not duplicate while reindexing" do
932
+ n = Product.search("*", "", { "per_page" => Typesense::IndexSettings::DEFAULT_BATCH_SIZE }).length
933
+ Product.reindex!(Typesense::IndexSettings::DEFAULT_BATCH_SIZE)
934
+ expect(Product.search("*", "", { "per_page" => Typesense::IndexSettings::DEFAULT_BATCH_SIZE }).size).to eq(n)
935
+ Product.reindex!(Typesense::IndexSettings::DEFAULT_BATCH_SIZE)
936
+ Product.reindex!(Typesense::IndexSettings::DEFAULT_BATCH_SIZE)
937
+ expect(Product.search("*", "", { "per_page" => Typesense::IndexSettings::DEFAULT_BATCH_SIZE }).size).to eq(n)
938
+ end
939
+
940
+ it "should not return products that are not indexable" do
941
+ @sekrit.index!
942
+ @no_href.index!
943
+ results = Product.search("sekrit", "name")
944
+ expect(results.size).to eq(0)
945
+ end
946
+
947
+ it "should include items belong to subclasses" do
948
+ @camera.index!
949
+ results = Product.search("eos rebel", "name")
950
+ expect(results).to include(@camera)
951
+ end
952
+
953
+ it "should delete a not-anymore-indexable product" do
954
+ results = Product.search("sekrit", "name")
955
+ expect(results.size).to eq(0)
956
+
957
+ @sekrit.release_date = Time.now - 1.day
958
+ @sekrit.save!
959
+ @sekrit.index!
960
+ results = Product.search("sekrit", "name")
961
+ expect(results.size).to eq(1)
962
+
963
+ @sekrit.release_date = Time.now + 1.day
964
+ @sekrit.save!
965
+ @sekrit.index!
966
+ results = Product.search("sekrit", "name")
967
+ expect(results.size).to eq(0)
968
+ end
969
+
970
+ it "should delete not-anymore-indexable product while reindexing" do
971
+ n = Product.search("*", "", { "per_page" => Typesense::IndexSettings::DEFAULT_BATCH_SIZE }).size
972
+ Product.where(release_date: nil).first.update_attribute :release_date, Time.now + 1.day
973
+ Product.reindex!(Typesense::IndexSettings::DEFAULT_BATCH_SIZE)
974
+ expect(Product.search("*", "",
975
+ { "per_page" => Typesense::IndexSettings::DEFAULT_BATCH_SIZE }).size).to eq(n - 1)
976
+ end
977
+
978
+ it "should find using multi-way synonyms" do
979
+ expect(Product.search("galaxy", "name").size).to eq(Product.search("samsung", "name").size)
980
+ end
981
+
982
+ it "should find using one-way synonyms" do
983
+ expect(Product.search("smartphone", "name").size).to eq(4)
984
+ end
985
+ end
986
+ end
987
+
988
+ describe "Cities" do
989
+ before(:all) do
990
+ City.clear_index!
991
+ rescue StandardError
992
+ ArgumentError
993
+ end
994
+
995
+ it "should index geo" do
996
+ sf = City.create name: "San Francisco", country: "USA", lat: 37.75, lng: -122.68
997
+ mv = City.create name: "Mountain View", country: 'No man\'s land', lat: 37.38, lng: -122.08
998
+ sf_and_mv = City.create name: "San Francisco & Mountain View", country: "Hybrid", gl_array: [37.75, -122.08]
999
+ results = City.search("*", "", { "filter_by" => "location:(37.33, -121.89,50 km)" })
1000
+ expect(results.size).to eq(2)
1001
+ expect(results).to include(mv, sf_and_mv)
1002
+
1003
+ results = City.search("*", "", { "filter_by" => "location:(37.33, -121.89, 500 km)" })
1004
+ expect(results.size).to eq(3)
1005
+ expect(results).to include(mv)
1006
+ expect(results).to include(sf)
1007
+ expect(results).to include(sf_and_mv)
1008
+ end
1009
+ end
1010
+
1011
+ describe "MongoObject" do
1012
+ it "should not have method conflicts" do
1013
+ expect { MongoObject.reindex! }.to raise_error(NameError)
1014
+ expect { MongoObject.new.index! }.to raise_error(NameError)
1015
+ MongoObject.typesense_reindex!
1016
+ MongoObject.create(name: "mongo").typesense_index!
1017
+ end
1018
+ end
1019
+
1020
+ describe "Book" do
1021
+ before(:all) do
1022
+ Book.clear_index!
1023
+ rescue StandardError
1024
+ ArgumentError
1025
+ end
1026
+
1027
+ it "should sanitize attributes" do
1028
+ @hack = Book.create! name: '"><img src=x onerror=alert(1)> hack0r',
1029
+ author: '<script type="text/javascript">alert(1)</script>', premium: true, released: true
1030
+ b = Book.raw_search("hack", "name")
1031
+ expect(b["hits"].length).to eq(1)
1032
+ begin
1033
+ expect(b["hits"][0]["document"]["name"]).to eq('"&gt; hack0r')
1034
+ expect(b["hits"][0]["document"]["author"]).to eq("")
1035
+ expect(b["hits"][0]["highlights"][0]["snippet"]).to eq('"&gt; <mark>hack</mark>0r')
1036
+ rescue StandardError
1037
+ # rails 4.2's sanitizer
1038
+ begin
1039
+ expect(b["hits"][0]["document"]["name"]).to eq("&quot;&gt; hack0r")
1040
+ expect(b["hits"][0]["document"]["author"]).to eq("")
1041
+ expect(b["hits"][0]["highlights"][0]["snippet"]).to eq("&quot;&gt; <mark>hack0r</mark>")
1042
+ rescue StandardError
1043
+ # jruby
1044
+ expect(b["hits"][0]["document"]["name"]).to eq('"&gt; hack0r')
1045
+ expect(b["hits"][0]["document"]["author"]).to eq("")
1046
+ expect(b["hits"][0]["highlights"][0]["snippet"]).to eq('"&gt; <mark>hack0r</mark>')
1047
+ end
1048
+ end
1049
+ end
1050
+ end
1051
+
1052
+ describe "Kaminari" do
1053
+ before(:all) do
1054
+ require "kaminari"
1055
+ Typesense.configuration = {
1056
+ nodes: [{
1057
+ host: "localhost", # For Typesense Cloud use xxx.a1.typesense.net
1058
+ port: 8108, # For Typesense Cloud use 443
1059
+ protocol: "http", # For Typesense Cloud use https
1060
+ }],
1061
+ api_key: "xyz",
1062
+ connection_timeout_seconds: 2,
1063
+ pagination_backend: :kaminari,
1064
+ }
1065
+ end
1066
+
1067
+ it "should paginate" do
1068
+ pagination = City.search("*", "")
1069
+ expect(pagination.total_count).to eq(City.raw_search("*", "")["found"])
1070
+ p1 = City.search("*", "", { page: 1, per_page: 1 })
1071
+ expect(p1.size).to eq(1)
1072
+ expect(p1[0]).to eq(pagination[0])
1073
+ expect(p1.total_count).to eq(City.raw_search("*", "")["found"])
1074
+ p2 = City.search("*", "", { page: 2, per_page: 1 })
1075
+ expect(p2.size).to eq(1)
1076
+ expect(p2[0]).to eq(pagination[1])
1077
+ expect(p2.total_count).to eq(City.raw_search("*", "")["found"])
1078
+ end
1079
+ end
1080
+
1081
+ describe "Will_paginate" do
1082
+ before(:all) do
1083
+ require "will_paginate"
1084
+ Typesense.configuration = {
1085
+ nodes: [{
1086
+ host: "localhost", # For Typesense Cloud use xxx.a1.typesense.net
1087
+ port: 8108, # For Typesense Cloud use 443
1088
+ protocol: "http", # For Typesense Cloud use https
1089
+ }],
1090
+ api_key: "xyz",
1091
+ connection_timeout_seconds: 2,
1092
+ pagination_backend: :will_paginate,
1093
+ }
1094
+ end
1095
+
1096
+ it "should paginate" do
1097
+ p1 = City.search("*", "", { "per_page" => 2 })
1098
+
1099
+ expect(p1.length).to eq(2)
1100
+ expect(p1.per_page).to eq(2)
1101
+ expect(p1.total_entries).to eq(City.raw_search("*", "")["found"])
1102
+ end
1103
+ end
1104
+
1105
+ describe "Disabled" do
1106
+ before(:all) do
1107
+ begin
1108
+ DisabledBoolean.clear_index!
1109
+ rescue StandardError
1110
+ ArgumentError
1111
+ end
1112
+ begin
1113
+ DisabledProc.clear_index!
1114
+ rescue StandardError
1115
+ ArgumentError
1116
+ end
1117
+ begin
1118
+ DisabledSymbol.clear_index!
1119
+ rescue StandardError
1120
+ ArgumentError
1121
+ end
1122
+ end
1123
+
1124
+ it "should disable the indexing using a boolean" do
1125
+ DisabledBoolean.create name: "foo"
1126
+ expect(DisabledBoolean.search("*", "").size).to eq(0)
1127
+ end
1128
+
1129
+ it "should disable the indexing using a proc" do
1130
+ DisabledProc.create name: "foo"
1131
+ expect(DisabledProc.search("*", "").size).to eq(0)
1132
+ end
1133
+
1134
+ it "should disable the indexing using a symbol" do
1135
+ DisabledSymbol.create name: "foo"
1136
+ expect(DisabledSymbol.search("*", "").size).to eq(0)
1137
+ end
1138
+ end
1139
+
1140
+ describe "NullableId" do
1141
+ before(:all) do
1142
+ end
1143
+ it "should not delete a null objectID" do
1144
+ NullableId.create!
1145
+ end
1146
+ end
1147
+
1148
+ describe "EnqueuedObject" do
1149
+ it "should enqueue a job" do
1150
+ expect do
1151
+ EnqueuedObject.create! name: "test"
1152
+ end.to raise_error("enqueued 1")
1153
+ end
1154
+
1155
+ it "should not enqueue a job inside no index block" do
1156
+ expect do
1157
+ EnqueuedObject.without_auto_index do
1158
+ EnqueuedObject.create! name: "test"
1159
+ end
1160
+ end.not_to raise_error
1161
+ end
1162
+ end
1163
+
1164
+ describe "DisabledEnqueuedObject" do
1165
+ it "should not try to enqueue a job" do
1166
+ expect do
1167
+ DisabledEnqueuedObject.create! name: "test"
1168
+ end.not_to raise_error
1169
+ end
1170
+ end
1171
+
1172
+ describe "Misconfigured Block" do
1173
+ it "should force the typesense block" do
1174
+ expect do
1175
+ MisconfiguredBlock.reindex
1176
+ end.to raise_error(ArgumentError)
1177
+ end
1178
+ end