tiny-quick-gem 0.0.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.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/searchkick-6.1.2/CHANGELOG.md +919 -0
  3. data/searchkick-6.1.2/LICENSE.txt +22 -0
  4. data/searchkick-6.1.2/README.md +2348 -0
  5. data/searchkick-6.1.2/lib/searchkick/bulk_reindex_job.rb +19 -0
  6. data/searchkick-6.1.2/lib/searchkick/controller_runtime.rb +40 -0
  7. data/searchkick-6.1.2/lib/searchkick/hash_wrapper.rb +41 -0
  8. data/searchkick-6.1.2/lib/searchkick/index.rb +480 -0
  9. data/searchkick-6.1.2/lib/searchkick/index_cache.rb +30 -0
  10. data/searchkick-6.1.2/lib/searchkick/index_options.rb +652 -0
  11. data/searchkick-6.1.2/lib/searchkick/indexer.rb +43 -0
  12. data/searchkick-6.1.2/lib/searchkick/log_subscriber.rb +57 -0
  13. data/searchkick-6.1.2/lib/searchkick/middleware.rb +19 -0
  14. data/searchkick-6.1.2/lib/searchkick/model.rb +120 -0
  15. data/searchkick-6.1.2/lib/searchkick/multi_search.rb +46 -0
  16. data/searchkick-6.1.2/lib/searchkick/process_batch_job.rb +20 -0
  17. data/searchkick-6.1.2/lib/searchkick/process_queue_job.rb +33 -0
  18. data/searchkick-6.1.2/lib/searchkick/query.rb +1372 -0
  19. data/searchkick-6.1.2/lib/searchkick/railtie.rb +7 -0
  20. data/searchkick-6.1.2/lib/searchkick/record_data.rb +147 -0
  21. data/searchkick-6.1.2/lib/searchkick/record_indexer.rb +174 -0
  22. data/searchkick-6.1.2/lib/searchkick/reindex_queue.rb +57 -0
  23. data/searchkick-6.1.2/lib/searchkick/reindex_v2_job.rb +17 -0
  24. data/searchkick-6.1.2/lib/searchkick/relation.rb +728 -0
  25. data/searchkick-6.1.2/lib/searchkick/relation_indexer.rb +184 -0
  26. data/searchkick-6.1.2/lib/searchkick/reranking.rb +28 -0
  27. data/searchkick-6.1.2/lib/searchkick/results.rb +359 -0
  28. data/searchkick-6.1.2/lib/searchkick/script.rb +11 -0
  29. data/searchkick-6.1.2/lib/searchkick/version.rb +3 -0
  30. data/searchkick-6.1.2/lib/searchkick/where.rb +11 -0
  31. data/searchkick-6.1.2/lib/searchkick.rb +388 -0
  32. data/searchkick-6.1.2/lib/tasks/searchkick.rake +37 -0
  33. data/tiny-quick-gem.gemspec +12 -0
  34. metadata +73 -0
@@ -0,0 +1,652 @@
1
+ module Searchkick
2
+ class IndexOptions
3
+ attr_reader :options
4
+
5
+ def initialize(index)
6
+ @options = index.options
7
+ end
8
+
9
+ def index_options
10
+ # mortal symbols are garbage collected in Ruby 2.2+
11
+ custom_settings = (options[:settings] || {}).deep_symbolize_keys
12
+ custom_mappings = (options[:mappings] || {}).deep_symbolize_keys
13
+
14
+ if options[:mappings] && !options[:merge_mappings]
15
+ settings = custom_settings
16
+ mappings = custom_mappings
17
+ else
18
+ settings = generate_settings.deep_symbolize_keys.deep_merge(custom_settings)
19
+ mappings = generate_mappings.deep_symbolize_keys.deep_merge(custom_mappings)
20
+ end
21
+
22
+ set_deep_paging(settings) if options[:deep_paging] || options[:max_result_window]
23
+
24
+ {
25
+ settings: settings,
26
+ mappings: mappings
27
+ }
28
+ end
29
+
30
+ def generate_settings
31
+ language = options[:language]
32
+ language = language.call if language.respond_to?(:call)
33
+
34
+ settings = {
35
+ analysis: {
36
+ analyzer: {
37
+ searchkick_keyword: {
38
+ type: "custom",
39
+ tokenizer: "keyword",
40
+ filter: ["lowercase"] + (options[:stem_conversions] ? ["searchkick_stemmer"] : [])
41
+ },
42
+ default_analyzer => {
43
+ type: "custom",
44
+ # character filters -> tokenizer -> token filters
45
+ # https://www.elastic.co/guide/en/elasticsearch/guide/current/analysis-intro.html
46
+ char_filter: ["ampersand"],
47
+ tokenizer: "standard",
48
+ # synonym should come last, after stemming and shingle
49
+ # shingle must come before searchkick_stemmer
50
+ filter: ["lowercase", "asciifolding", "searchkick_index_shingle", "searchkick_stemmer"]
51
+ },
52
+ searchkick_search: {
53
+ type: "custom",
54
+ char_filter: ["ampersand"],
55
+ tokenizer: "standard",
56
+ filter: ["lowercase", "asciifolding", "searchkick_search_shingle", "searchkick_stemmer"]
57
+ },
58
+ searchkick_search2: {
59
+ type: "custom",
60
+ char_filter: ["ampersand"],
61
+ tokenizer: "standard",
62
+ filter: ["lowercase", "asciifolding", "searchkick_stemmer"]
63
+ },
64
+ # https://github.com/leschenko/elasticsearch_autocomplete/blob/master/lib/elasticsearch_autocomplete/analyzers.rb
65
+ searchkick_autocomplete_search: {
66
+ type: "custom",
67
+ tokenizer: "keyword",
68
+ filter: ["lowercase", "asciifolding"]
69
+ },
70
+ searchkick_word_search: {
71
+ type: "custom",
72
+ tokenizer: "standard",
73
+ filter: ["lowercase", "asciifolding"]
74
+ },
75
+ searchkick_suggest_index: {
76
+ type: "custom",
77
+ tokenizer: "standard",
78
+ filter: ["lowercase", "asciifolding", "searchkick_suggest_shingle"]
79
+ },
80
+ searchkick_text_start_index: {
81
+ type: "custom",
82
+ tokenizer: "keyword",
83
+ filter: ["lowercase", "asciifolding", "searchkick_edge_ngram"]
84
+ },
85
+ searchkick_text_middle_index: {
86
+ type: "custom",
87
+ tokenizer: "keyword",
88
+ filter: ["lowercase", "asciifolding", "searchkick_ngram"]
89
+ },
90
+ searchkick_text_end_index: {
91
+ type: "custom",
92
+ tokenizer: "keyword",
93
+ filter: ["lowercase", "asciifolding", "reverse", "searchkick_edge_ngram", "reverse"]
94
+ },
95
+ searchkick_word_start_index: {
96
+ type: "custom",
97
+ tokenizer: "standard",
98
+ filter: ["lowercase", "asciifolding", "searchkick_edge_ngram"]
99
+ },
100
+ searchkick_word_middle_index: {
101
+ type: "custom",
102
+ tokenizer: "standard",
103
+ filter: ["lowercase", "asciifolding", "searchkick_ngram"]
104
+ },
105
+ searchkick_word_end_index: {
106
+ type: "custom",
107
+ tokenizer: "standard",
108
+ filter: ["lowercase", "asciifolding", "reverse", "searchkick_edge_ngram", "reverse"]
109
+ }
110
+ },
111
+ filter: {
112
+ searchkick_index_shingle: {
113
+ type: "shingle",
114
+ token_separator: ""
115
+ },
116
+ # lucky find https://web.archiveorange.com/archive/v/AAfXfQ17f57FcRINsof7
117
+ searchkick_search_shingle: {
118
+ type: "shingle",
119
+ token_separator: "",
120
+ output_unigrams: false,
121
+ output_unigrams_if_no_shingles: true
122
+ },
123
+ searchkick_suggest_shingle: {
124
+ type: "shingle",
125
+ max_shingle_size: 5
126
+ },
127
+ searchkick_edge_ngram: {
128
+ type: "edge_ngram",
129
+ min_gram: 1,
130
+ max_gram: 50
131
+ },
132
+ searchkick_ngram: {
133
+ type: "ngram",
134
+ min_gram: 1,
135
+ max_gram: 50
136
+ },
137
+ searchkick_stemmer: {
138
+ # use stemmer if language is lowercase, snowball otherwise
139
+ type: language == language.to_s.downcase ? "stemmer" : "snowball",
140
+ language: language || "English"
141
+ }
142
+ },
143
+ char_filter: {
144
+ # https://www.elastic.co/guide/en/elasticsearch/guide/current/custom-analyzers.html
145
+ # &_to_and
146
+ ampersand: {
147
+ type: "mapping",
148
+ mappings: ["&=> and "]
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ raise ArgumentError, "Can't pass both language and stemmer" if options[:stemmer] && language
155
+ update_language(settings, language)
156
+ update_stemming(settings)
157
+
158
+ if Searchkick.env == "test"
159
+ settings[:number_of_shards] = 1
160
+ settings[:number_of_replicas] = 0
161
+ end
162
+
163
+ if options[:similarity]
164
+ settings[:similarity] = {default: {type: options[:similarity]}}
165
+ end
166
+
167
+ settings[:index] = {
168
+ max_ngram_diff: 49,
169
+ max_shingle_diff: 4
170
+ }
171
+
172
+ if options[:knn]
173
+ unless Searchkick.knn_support?
174
+ if Searchkick.opensearch?
175
+ raise Error, "knn requires OpenSearch 2.4+"
176
+ else
177
+ raise Error, "knn requires Elasticsearch 8.6+"
178
+ end
179
+ end
180
+
181
+ if Searchkick.opensearch? && options[:knn].any? { |_, v| !v[:distance].nil? }
182
+ # only enable if doing approximate search
183
+ settings[:index][:knn] = true
184
+ end
185
+ end
186
+
187
+ add_synonyms(settings)
188
+ add_search_synonyms(settings)
189
+
190
+ if options[:special_characters] == false
191
+ settings[:analysis][:analyzer].each_value do |analyzer_settings|
192
+ analyzer_settings[:filter].reject! { |f| f == "asciifolding" }
193
+ end
194
+ end
195
+
196
+ if options[:case_sensitive]
197
+ settings[:analysis][:analyzer].each do |_, analyzer|
198
+ analyzer[:filter].delete("lowercase")
199
+ end
200
+ end
201
+
202
+ settings
203
+ end
204
+
205
+ def update_language(settings, language)
206
+ case language
207
+ when "chinese"
208
+ settings[:analysis][:analyzer].merge!(
209
+ default_analyzer => {
210
+ type: "ik_smart"
211
+ },
212
+ searchkick_search: {
213
+ type: "ik_smart"
214
+ },
215
+ searchkick_search2: {
216
+ type: "ik_max_word"
217
+ }
218
+ )
219
+ when "chinese2", "smartcn"
220
+ settings[:analysis][:analyzer].merge!(
221
+ default_analyzer => {
222
+ type: "smartcn"
223
+ },
224
+ searchkick_search: {
225
+ type: "smartcn"
226
+ },
227
+ searchkick_search2: {
228
+ type: "smartcn"
229
+ }
230
+ )
231
+ when "japanese", "japanese2"
232
+ analyzer = {
233
+ type: "custom",
234
+ tokenizer: "kuromoji_tokenizer",
235
+ filter: [
236
+ "kuromoji_baseform",
237
+ "kuromoji_part_of_speech",
238
+ "cjk_width",
239
+ "ja_stop",
240
+ "searchkick_stemmer",
241
+ "lowercase"
242
+ ]
243
+ }
244
+ settings[:analysis][:analyzer].merge!(
245
+ default_analyzer => analyzer.deep_dup,
246
+ searchkick_search: analyzer.deep_dup,
247
+ searchkick_search2: analyzer.deep_dup
248
+ )
249
+ settings[:analysis][:filter][:searchkick_stemmer] = {
250
+ type: "kuromoji_stemmer"
251
+ }
252
+ when "korean"
253
+ settings[:analysis][:analyzer].merge!(
254
+ default_analyzer => {
255
+ type: "openkoreantext-analyzer"
256
+ },
257
+ searchkick_search: {
258
+ type: "openkoreantext-analyzer"
259
+ },
260
+ searchkick_search2: {
261
+ type: "openkoreantext-analyzer"
262
+ }
263
+ )
264
+ when "korean2"
265
+ settings[:analysis][:analyzer].merge!(
266
+ default_analyzer => {
267
+ type: "nori"
268
+ },
269
+ searchkick_search: {
270
+ type: "nori"
271
+ },
272
+ searchkick_search2: {
273
+ type: "nori"
274
+ }
275
+ )
276
+ when "vietnamese"
277
+ settings[:analysis][:analyzer].merge!(
278
+ default_analyzer => {
279
+ type: "vi_analyzer"
280
+ },
281
+ searchkick_search: {
282
+ type: "vi_analyzer"
283
+ },
284
+ searchkick_search2: {
285
+ type: "vi_analyzer"
286
+ }
287
+ )
288
+ when "polish", "ukrainian"
289
+ settings[:analysis][:analyzer].merge!(
290
+ default_analyzer => {
291
+ type: language
292
+ },
293
+ searchkick_search: {
294
+ type: language
295
+ },
296
+ searchkick_search2: {
297
+ type: language
298
+ }
299
+ )
300
+ end
301
+ end
302
+
303
+ def update_stemming(settings)
304
+ if options[:stemmer]
305
+ stemmer = options[:stemmer]
306
+ # could also support snowball and stemmer
307
+ case stemmer[:type]
308
+ when "hunspell"
309
+ # supports all token filter options
310
+ settings[:analysis][:filter][:searchkick_stemmer] = stemmer
311
+ else
312
+ raise ArgumentError, "Unknown stemmer: #{stemmer[:type]}"
313
+ end
314
+ end
315
+
316
+ stem = options[:stem]
317
+
318
+ # language analyzer used
319
+ stem = false if settings[:analysis][:analyzer][default_analyzer][:type] != "custom"
320
+
321
+ if stem == false
322
+ settings[:analysis][:filter].delete(:searchkick_stemmer)
323
+ settings[:analysis][:analyzer].each do |_, analyzer|
324
+ analyzer[:filter].delete("searchkick_stemmer") if analyzer[:filter]
325
+ end
326
+ end
327
+
328
+ if options[:stemmer_override]
329
+ stemmer_override = {
330
+ type: "stemmer_override"
331
+ }
332
+ if options[:stemmer_override].is_a?(String)
333
+ stemmer_override[:rules_path] = options[:stemmer_override]
334
+ else
335
+ stemmer_override[:rules] = options[:stemmer_override]
336
+ end
337
+ settings[:analysis][:filter][:searchkick_stemmer_override] = stemmer_override
338
+
339
+ settings[:analysis][:analyzer].each do |_, analyzer|
340
+ stemmer_index = analyzer[:filter].index("searchkick_stemmer") if analyzer[:filter]
341
+ analyzer[:filter].insert(stemmer_index, "searchkick_stemmer_override") if stemmer_index
342
+ end
343
+ end
344
+
345
+ if options[:stem_exclusion]
346
+ settings[:analysis][:filter][:searchkick_stem_exclusion] = {
347
+ type: "keyword_marker",
348
+ keywords: options[:stem_exclusion]
349
+ }
350
+
351
+ settings[:analysis][:analyzer].each do |_, analyzer|
352
+ stemmer_index = analyzer[:filter].index("searchkick_stemmer") if analyzer[:filter]
353
+ analyzer[:filter].insert(stemmer_index, "searchkick_stem_exclusion") if stemmer_index
354
+ end
355
+ end
356
+ end
357
+
358
+ def generate_mappings
359
+ mapping = {}
360
+
361
+ keyword_mapping = {type: "keyword"}
362
+ keyword_mapping[:ignore_above] = options[:ignore_above] || 30000
363
+
364
+ # conversions
365
+ Array(options[:conversions]).each do |conversions_field|
366
+ mapping[conversions_field] = {
367
+ type: "nested",
368
+ properties: {
369
+ query: {type: default_type, analyzer: "searchkick_keyword"},
370
+ count: {type: "integer"}
371
+ }
372
+ }
373
+ end
374
+
375
+ Array(options[:conversions_v2]).each do |conversions_field|
376
+ mapping[conversions_field] = {
377
+ type: "rank_features"
378
+ }
379
+ end
380
+
381
+ if (Array(options[:conversions_v2]).map(&:to_s) & Array(options[:conversions]).map(&:to_s)).any?
382
+ raise ArgumentError, "Must have separate conversions fields"
383
+ end
384
+
385
+ mapping_options =
386
+ [:suggest, :word, :text_start, :text_middle, :text_end, :word_start, :word_middle, :word_end, :highlight, :searchable, :filterable]
387
+ .to_h { |type| [type, (options[type] || []).map(&:to_s)] }
388
+
389
+ word = options[:word] != false && (!options[:match] || options[:match] == :word)
390
+
391
+ mapping_options[:searchable].delete("_all")
392
+
393
+ analyzed_field_options = {type: default_type, index: true, analyzer: default_analyzer.to_s}
394
+
395
+ mapping_options.values.flatten.uniq.each do |field|
396
+ fields = {}
397
+
398
+ if options.key?(:filterable) && !mapping_options[:filterable].include?(field)
399
+ fields[field] = {type: default_type, index: false}
400
+ else
401
+ fields[field] = keyword_mapping
402
+ end
403
+
404
+ if !options[:searchable] || mapping_options[:searchable].include?(field)
405
+ if word
406
+ fields[:analyzed] = analyzed_field_options
407
+
408
+ if mapping_options[:highlight].include?(field)
409
+ fields[:analyzed][:term_vector] = "with_positions_offsets"
410
+ end
411
+ end
412
+
413
+ mapping_options.except(:highlight, :searchable, :filterable, :word).each do |type, f|
414
+ if options[:match] == type || f.include?(field)
415
+ fields[type] = {type: default_type, index: true, analyzer: "searchkick_#{type}_index"}
416
+ end
417
+ end
418
+ end
419
+
420
+ mapping[field] = fields[field].merge(fields: fields.except(field))
421
+ end
422
+
423
+ (options[:locations] || []).map(&:to_s).each do |field|
424
+ mapping[field] = {
425
+ type: "geo_point"
426
+ }
427
+ end
428
+
429
+ options[:geo_shape] = options[:geo_shape].product([{}]).to_h if options[:geo_shape].is_a?(Array)
430
+ (options[:geo_shape] || {}).each do |field, shape_options|
431
+ mapping[field] = shape_options.merge(type: "geo_shape")
432
+ end
433
+
434
+ (options[:knn] || []).each do |field, knn_options|
435
+ distance = knn_options[:distance]
436
+ quantization = knn_options[:quantization]
437
+
438
+ if Searchkick.opensearch?
439
+ if distance.nil?
440
+ # avoid server crash if method not specified
441
+ raise ArgumentError, "Must specify a distance for OpenSearch"
442
+ end
443
+
444
+ vector_options = {
445
+ type: "knn_vector",
446
+ dimension: knn_options[:dimensions]
447
+ }
448
+
449
+ if !distance.nil?
450
+ space_type =
451
+ case distance
452
+ when "cosine"
453
+ "cosinesimil"
454
+ when "euclidean"
455
+ "l2"
456
+ when "inner_product"
457
+ "innerproduct"
458
+ else
459
+ raise ArgumentError, "Unknown distance: #{distance}"
460
+ end
461
+
462
+ if !quantization.nil?
463
+ raise ArgumentError, "Quantization not supported yet for OpenSearch"
464
+ end
465
+
466
+ vector_options[:method] = {
467
+ name: "hnsw",
468
+ space_type: space_type,
469
+ engine: "lucene",
470
+ parameters: knn_options.slice(:m, :ef_construction)
471
+ }
472
+ end
473
+
474
+ mapping[field.to_s] = vector_options
475
+ else
476
+ vector_options = {
477
+ type: "dense_vector",
478
+ dims: knn_options[:dimensions],
479
+ index: !distance.nil?
480
+ }
481
+
482
+ if !distance.nil?
483
+ vector_options[:similarity] =
484
+ case distance
485
+ when "cosine"
486
+ "cosine"
487
+ when "euclidean"
488
+ "l2_norm"
489
+ when "inner_product"
490
+ "max_inner_product"
491
+ else
492
+ raise ArgumentError, "Unknown distance: #{distance}"
493
+ end
494
+
495
+ type =
496
+ case quantization
497
+ when "int8", "int4", "bbq"
498
+ "#{quantization}_hnsw"
499
+ when nil
500
+ "hnsw"
501
+ else
502
+ raise ArgumentError, "Unknown quantization: #{quantization}"
503
+ end
504
+
505
+ vector_index_options = knn_options.slice(:m, :ef_construction)
506
+ vector_options[:index_options] = {type: type}.merge(vector_index_options)
507
+ end
508
+
509
+ mapping[field.to_s] = vector_options
510
+ end
511
+ end
512
+
513
+ if options[:inheritance]
514
+ mapping[:type] = keyword_mapping
515
+ end
516
+
517
+ routing = {}
518
+ if options[:routing]
519
+ routing = {required: true}
520
+ unless options[:routing] == true
521
+ routing[:path] = options[:routing].to_s
522
+ end
523
+ end
524
+
525
+ dynamic_fields = {
526
+ # analyzed field must be the default field for include_in_all
527
+ # https://www.elastic.co/guide/reference/mapping/multi-field-type/
528
+ # however, we can include the not_analyzed field in _all
529
+ # and the _all index analyzer will take care of it
530
+ "{name}" => keyword_mapping
531
+ }
532
+
533
+ if options.key?(:filterable)
534
+ dynamic_fields["{name}"] = {type: default_type, index: false}
535
+ end
536
+
537
+ unless options[:searchable]
538
+ if options[:match] && options[:match] != :word
539
+ dynamic_fields[options[:match]] = {type: default_type, index: true, analyzer: "searchkick_#{options[:match]}_index"}
540
+ end
541
+
542
+ if word
543
+ dynamic_fields[:analyzed] = analyzed_field_options
544
+ end
545
+ end
546
+
547
+ # https://www.elastic.co/guide/reference/mapping/multi-field-type/
548
+ multi_field = dynamic_fields["{name}"].merge(fields: dynamic_fields.except("{name}"))
549
+
550
+ mappings = {
551
+ properties: mapping,
552
+ _routing: routing,
553
+ # https://gist.github.com/kimchy/2898285
554
+ dynamic_templates: [
555
+ {
556
+ string_template: {
557
+ match: "*",
558
+ match_mapping_type: "string",
559
+ mapping: multi_field
560
+ }
561
+ }
562
+ ]
563
+ }
564
+
565
+ mappings
566
+ end
567
+
568
+ def add_synonyms(settings)
569
+ synonyms = options[:synonyms] || []
570
+ synonyms = synonyms.call if synonyms.respond_to?(:call)
571
+ if synonyms.any?
572
+ settings[:analysis][:filter][:searchkick_synonym] = {
573
+ type: "synonym",
574
+ # only remove a single space from synonyms so three-word synonyms will fail noisily instead of silently
575
+ synonyms: synonyms.select { |s| s.size > 1 }.map { |s| s.is_a?(Array) ? s.map { |s2| s2.sub(/\s+/, "") }.join(",") : s }.map(&:downcase)
576
+ }
577
+ # choosing a place for the synonym filter when stemming is not easy
578
+ # https://groups.google.com/forum/#!topic/elasticsearch/p7qcQlgHdB8
579
+ # TODO use a snowball stemmer on synonyms when creating the token filter
580
+
581
+ # https://discuss.elastic.co/t/synonym-multi-words-search/10964
582
+ # I find the following approach effective if you are doing multi-word synonyms (synonym phrases):
583
+ # - Only apply the synonym expansion at index time
584
+ # - Don't have the synonym filter applied search
585
+ # - Use directional synonyms where appropriate. You want to make sure that you're not injecting terms that are too general.
586
+ settings[:analysis][:analyzer][default_analyzer][:filter].insert(2, "searchkick_synonym")
587
+
588
+ %w(word_start word_middle word_end).each do |type|
589
+ settings[:analysis][:analyzer]["searchkick_#{type}_index".to_sym][:filter].insert(2, "searchkick_synonym")
590
+ end
591
+ end
592
+ end
593
+
594
+ def add_search_synonyms(settings)
595
+ search_synonyms = options[:search_synonyms] || []
596
+ search_synonyms = search_synonyms.call if search_synonyms.respond_to?(:call)
597
+ if search_synonyms.is_a?(String) || search_synonyms.any?
598
+ if search_synonyms.is_a?(String)
599
+ synonym_graph = {
600
+ type: "synonym_graph",
601
+ synonyms_path: search_synonyms,
602
+ updateable: true
603
+ }
604
+ else
605
+ synonym_graph = {
606
+ type: "synonym_graph",
607
+ # TODO confirm this is correct
608
+ synonyms: search_synonyms.select { |s| s.size > 1 }.map { |s| s.is_a?(Array) ? s.join(",") : s }.map(&:downcase)
609
+ }
610
+ end
611
+ settings[:analysis][:filter][:searchkick_synonym_graph] = synonym_graph
612
+
613
+ if ["japanese", "japanese2"].include?(options[:language])
614
+ [:searchkick_search, :searchkick_search2].each do |analyzer|
615
+ settings[:analysis][:analyzer][analyzer][:filter].insert(4, "searchkick_synonym_graph")
616
+ end
617
+ else
618
+ [:searchkick_search2, :searchkick_word_search].each do |analyzer|
619
+ unless settings[:analysis][:analyzer][analyzer].key?(:filter)
620
+ raise Error, "Search synonyms are not supported yet for language"
621
+ end
622
+
623
+ settings[:analysis][:analyzer][analyzer][:filter].insert(2, "searchkick_synonym_graph")
624
+ end
625
+ end
626
+ end
627
+ end
628
+
629
+ def set_deep_paging(settings)
630
+ if !settings.dig(:index, :max_result_window) && !settings[:"index.max_result_window"]
631
+ settings[:index] ||= {}
632
+ settings[:index][:max_result_window] = options[:max_result_window] || 1_000_000_000
633
+ end
634
+ end
635
+
636
+ def index_type
637
+ @index_type ||= begin
638
+ index_type = options[:_type]
639
+ index_type = index_type.call if index_type.respond_to?(:call)
640
+ index_type
641
+ end
642
+ end
643
+
644
+ def default_type
645
+ "text"
646
+ end
647
+
648
+ def default_analyzer
649
+ :searchkick_index
650
+ end
651
+ end
652
+ end
@@ -0,0 +1,43 @@
1
+ # thread-local (technically fiber-local) indexer
2
+ # used to aggregate bulk callbacks across models
3
+ module Searchkick
4
+ class Indexer
5
+ attr_reader :queued_items
6
+
7
+ def initialize
8
+ @queued_items = []
9
+ end
10
+
11
+ def queue(items)
12
+ @queued_items.concat(items)
13
+ perform unless Searchkick.callbacks_value == :bulk
14
+ end
15
+
16
+ def perform
17
+ items = @queued_items
18
+ @queued_items = []
19
+
20
+ return if items.empty?
21
+
22
+ response = Searchkick.client.bulk(body: items)
23
+ if response["errors"]
24
+ # note: delete does not set error when item not found
25
+ first_with_error = response["items"].map do |item|
26
+ (item["index"] || item["delete"] || item["update"])
27
+ end.find.with_index { |item, i| item["error"] && !ignore_missing?(items[i], item["error"]) }
28
+ if first_with_error
29
+ raise ImportError, "#{first_with_error["error"]} on item with id '#{first_with_error["_id"]}'"
30
+ end
31
+ end
32
+
33
+ # maybe return response in future
34
+ nil
35
+ end
36
+
37
+ private
38
+
39
+ def ignore_missing?(item, error)
40
+ error["type"] == "document_missing_exception" && item.instance_variable_defined?(:@ignore_missing)
41
+ end
42
+ end
43
+ end