searchkick 0.9.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.travis.yml +10 -9
- data/CHANGELOG.md +14 -0
- data/README.md +190 -14
- data/lib/searchkick.rb +1 -0
- data/lib/searchkick/index.rb +8 -8
- data/lib/searchkick/logging.rb +4 -2
- data/lib/searchkick/model.rb +13 -10
- data/lib/searchkick/query.rb +107 -39
- data/lib/searchkick/reindex_job.rb +0 -2
- data/lib/searchkick/reindex_v2_job.rb +0 -1
- data/lib/searchkick/results.rb +18 -1
- data/lib/searchkick/tasks.rb +0 -2
- data/lib/searchkick/version.rb +1 -1
- data/test/aggs_test.rb +102 -0
- data/test/autocomplete_test.rb +1 -3
- data/test/boost_test.rb +1 -3
- data/{ci → test/ci}/before_install.sh +4 -3
- data/test/facets_test.rb +4 -6
- data/{gemfiles → test/gemfiles}/activerecord31.gemfile +1 -1
- data/{gemfiles → test/gemfiles}/activerecord32.gemfile +1 -1
- data/{gemfiles → test/gemfiles}/activerecord40.gemfile +1 -1
- data/{gemfiles → test/gemfiles}/activerecord41.gemfile +1 -1
- data/{gemfiles → test/gemfiles}/mongoid2.gemfile +1 -1
- data/{gemfiles → test/gemfiles}/mongoid3.gemfile +1 -1
- data/{gemfiles → test/gemfiles}/mongoid4.gemfile +1 -1
- data/test/gemfiles/mongoid5.gemfile +7 -0
- data/{gemfiles → test/gemfiles}/nobrainer.gemfile +1 -1
- data/test/highlight_test.rb +2 -4
- data/test/index_test.rb +20 -9
- data/test/inheritance_test.rb +1 -3
- data/test/match_test.rb +8 -7
- data/test/model_test.rb +2 -4
- data/test/query_test.rb +1 -3
- data/test/records_test.rb +0 -2
- data/test/reindex_job_test.rb +1 -3
- data/test/reindex_v2_job_test.rb +1 -3
- data/test/routing_test.rb +4 -3
- data/test/should_index_test.rb +1 -3
- data/test/similar_test.rb +1 -3
- data/test/sql_test.rb +7 -9
- data/test/suggest_test.rb +1 -3
- data/test/synonyms_test.rb +1 -3
- data/test/test_helper.rb +23 -10
- metadata +24 -11
data/lib/searchkick/query.rb
CHANGED
@@ -92,24 +92,37 @@ module Searchkick
|
|
92
92
|
shared_options = {
|
93
93
|
query: term,
|
94
94
|
operator: operator,
|
95
|
-
boost: factor
|
95
|
+
boost: 10 * factor
|
96
96
|
}
|
97
97
|
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
shared_options.merge(fuzziness: edit_distance, prefix_length: prefix_length, max_expansions: 3, analyzer: "searchkick_search").merge(transpositions),
|
106
|
-
shared_options.merge(fuzziness: edit_distance, prefix_length: prefix_length, max_expansions: 3, analyzer: "searchkick_search2").merge(transpositions)
|
107
|
-
]
|
98
|
+
misspellings =
|
99
|
+
if options.key?(:misspellings)
|
100
|
+
options[:misspellings]
|
101
|
+
elsif options.key?(:mispellings)
|
102
|
+
options[:mispellings] # why not?
|
103
|
+
else
|
104
|
+
true
|
108
105
|
end
|
106
|
+
|
107
|
+
if misspellings != false
|
108
|
+
edit_distance = (misspellings.is_a?(Hash) && (misspellings[:edit_distance] || misspellings[:distance])) || 1
|
109
|
+
transpositions =
|
110
|
+
if misspellings.is_a?(Hash) && misspellings.key?(:transpositions)
|
111
|
+
{fuzzy_transpositions: misspellings[:transpositions]}
|
112
|
+
elsif below14?
|
113
|
+
{}
|
114
|
+
else
|
115
|
+
{fuzzy_transpositions: true}
|
116
|
+
end
|
117
|
+
prefix_length = (misspellings.is_a?(Hash) && misspellings[:prefix_length]) || 0
|
118
|
+
max_expansions = (misspellings.is_a?(Hash) && misspellings[:max_expansions]) || 3
|
119
|
+
end
|
120
|
+
|
121
|
+
if field == "_all" || field.end_with?(".analyzed")
|
109
122
|
shared_options[:cutoff_frequency] = 0.001 unless operator == "and" || misspellings == false
|
110
123
|
qs.concat [
|
111
|
-
shared_options.merge(
|
112
|
-
shared_options.merge(
|
124
|
+
shared_options.merge(analyzer: "searchkick_search"),
|
125
|
+
shared_options.merge(analyzer: "searchkick_search2")
|
113
126
|
]
|
114
127
|
elsif field.end_with?(".exact")
|
115
128
|
f = field.split(".")[0..-2].join(".")
|
@@ -119,6 +132,10 @@ module Searchkick
|
|
119
132
|
qs << shared_options.merge(analyzer: analyzer)
|
120
133
|
end
|
121
134
|
|
135
|
+
if misspellings != false
|
136
|
+
qs.concat qs.map { |q| q.except(:cutoff_frequency).merge(fuzziness: edit_distance, prefix_length: prefix_length, max_expansions: max_expansions, boost: factor).merge(transpositions) }
|
137
|
+
end
|
138
|
+
|
122
139
|
queries.concat(qs.map { |q| {match: {field => q}} })
|
123
140
|
end
|
124
141
|
|
@@ -135,7 +152,7 @@ module Searchkick
|
|
135
152
|
if below12?
|
136
153
|
{script_score: {script: "doc['count'].value"}}
|
137
154
|
else
|
138
|
-
{field_value_factor: {field: "count"}}
|
155
|
+
{field_value_factor: {field: "#{conversions_field}.count"}}
|
139
156
|
end
|
140
157
|
|
141
158
|
payload = {
|
@@ -144,13 +161,13 @@ module Searchkick
|
|
144
161
|
should: {
|
145
162
|
nested: {
|
146
163
|
path: conversions_field,
|
147
|
-
score_mode: "
|
164
|
+
score_mode: "sum",
|
148
165
|
query: {
|
149
166
|
function_score: {
|
150
167
|
boost_mode: "replace",
|
151
168
|
query: {
|
152
169
|
match: {
|
153
|
-
query
|
170
|
+
"#{conversions_field}.query" => term
|
154
171
|
}
|
155
172
|
}
|
156
173
|
}.merge(script_score)
|
@@ -170,11 +187,9 @@ module Searchkick
|
|
170
187
|
if boost_by.is_a?(Array)
|
171
188
|
boost_by = Hash[boost_by.map { |f| [f, {factor: 1}] }]
|
172
189
|
elsif boost_by.is_a?(Hash)
|
173
|
-
multiply_by, boost_by = boost_by.partition { |
|
174
|
-
end
|
175
|
-
if options[:boost]
|
176
|
-
boost_by[options[:boost]] = {factor: 1}
|
190
|
+
multiply_by, boost_by = boost_by.partition { |_, v| v[:boost_mode] == "multiply" }.map { |i| Hash[i] }
|
177
191
|
end
|
192
|
+
boost_by[options[:boost]] = {factor: 1} if options[:boost]
|
178
193
|
|
179
194
|
custom_filters.concat boost_filters(boost_by, log: true)
|
180
195
|
multiply_filters.concat boost_filters(multiply_by || {})
|
@@ -189,12 +204,10 @@ module Searchkick
|
|
189
204
|
boost_where.each do |field, value|
|
190
205
|
if value.is_a?(Array) && value.first.is_a?(Hash)
|
191
206
|
value.each do |value_factor|
|
192
|
-
|
193
|
-
custom_filters << custom_filter(field, value, factor)
|
207
|
+
custom_filters << custom_filter(field, value_factor[:value], value_factor[:factor])
|
194
208
|
end
|
195
209
|
elsif value.is_a?(Hash)
|
196
|
-
|
197
|
-
custom_filters << custom_filter(field, value, factor)
|
210
|
+
custom_filters << custom_filter(field, value[:value], value[:factor])
|
198
211
|
else
|
199
212
|
factor = 1000
|
200
213
|
custom_filters << custom_filter(field, value, factor)
|
@@ -207,7 +220,7 @@ module Searchkick
|
|
207
220
|
if !boost_by_distance[:field] || !boost_by_distance[:origin]
|
208
221
|
raise ArgumentError, "boost_by_distance requires :field and :origin"
|
209
222
|
end
|
210
|
-
function_params = boost_by_distance.select { |k,
|
223
|
+
function_params = boost_by_distance.select { |k, _| [:origin, :scale, :offset, :decay].include?(k) }
|
211
224
|
function_params[:origin] = function_params[:origin].reverse
|
212
225
|
custom_filters << {
|
213
226
|
boost_by_distance[:function] => {
|
@@ -253,7 +266,7 @@ module Searchkick
|
|
253
266
|
# filters
|
254
267
|
filters = where_filters(options[:where])
|
255
268
|
if filters.any?
|
256
|
-
if options[:facets]
|
269
|
+
if options[:facets] || options[:aggs]
|
257
270
|
payload[:filter] = {
|
258
271
|
and: filters
|
259
272
|
}
|
@@ -273,9 +286,7 @@ module Searchkick
|
|
273
286
|
# facets
|
274
287
|
if options[:facets]
|
275
288
|
facets = options[:facets] || {}
|
276
|
-
if facets.is_a?(Array) # convert to more advanced syntax
|
277
|
-
facets = Hash[facets.map { |f| [f, {}] }]
|
278
|
-
end
|
289
|
+
facets = Hash[facets.map { |f| [f, {}] }] if facets.is_a?(Array) # convert to more advanced syntax
|
279
290
|
|
280
291
|
payload[:facets] = {}
|
281
292
|
facets.each do |field, facet_options|
|
@@ -311,7 +322,7 @@ module Searchkick
|
|
311
322
|
# offset is not possible
|
312
323
|
# http://elasticsearch-users.115913.n3.nabble.com/Is-pagination-possible-in-termsStatsFacet-td3422943.html
|
313
324
|
|
314
|
-
facet_options.deep_merge!(where: options
|
325
|
+
facet_options.deep_merge!(where: options.fetch(:where, {}).reject { |k| k == field }) if options[:smart_facets] == true
|
315
326
|
facet_filters = where_filters(facet_options[:where])
|
316
327
|
if facet_filters.any?
|
317
328
|
payload[:facets][field][:facet_filter] = {
|
@@ -323,6 +334,57 @@ module Searchkick
|
|
323
334
|
end
|
324
335
|
end
|
325
336
|
|
337
|
+
# aggregations
|
338
|
+
if options[:aggs]
|
339
|
+
aggs = options[:aggs]
|
340
|
+
payload[:aggs] = {}
|
341
|
+
|
342
|
+
aggs = aggs.map { |f| [f, {}] }.to_h if aggs.is_a?(Array) # convert to more advanced syntax
|
343
|
+
|
344
|
+
aggs.each do |field, agg_options|
|
345
|
+
size = agg_options[:limit] ? agg_options[:limit] : 100_000
|
346
|
+
|
347
|
+
if agg_options[:ranges]
|
348
|
+
payload[:aggs][field] = {
|
349
|
+
range: {
|
350
|
+
field: agg_options[:field] || field,
|
351
|
+
ranges: agg_options[:ranges]
|
352
|
+
}
|
353
|
+
}
|
354
|
+
elsif agg_options[:date_ranges]
|
355
|
+
payload[:aggs][field] = {
|
356
|
+
date_range: {
|
357
|
+
field: agg_options[:field] || field,
|
358
|
+
ranges: agg_options[:date_ranges]
|
359
|
+
}
|
360
|
+
}
|
361
|
+
else
|
362
|
+
payload[:aggs][field] = {
|
363
|
+
terms: {
|
364
|
+
field: agg_options[:field] || field,
|
365
|
+
size: size
|
366
|
+
}
|
367
|
+
}
|
368
|
+
end
|
369
|
+
|
370
|
+
where = {}
|
371
|
+
where = (options[:where] || {}).reject { |k| k == field } unless options[:smart_aggs] == false
|
372
|
+
agg_filters = where_filters(where.merge(agg_options[:where] || {}))
|
373
|
+
if agg_filters.any?
|
374
|
+
payload[:aggs][field] = {
|
375
|
+
filter: {
|
376
|
+
bool: {
|
377
|
+
must: agg_filters
|
378
|
+
}
|
379
|
+
},
|
380
|
+
aggs: {
|
381
|
+
field => payload[:aggs][field]
|
382
|
+
}
|
383
|
+
}
|
384
|
+
end
|
385
|
+
end
|
386
|
+
end
|
387
|
+
|
326
388
|
# suggestions
|
327
389
|
if options[:suggest]
|
328
390
|
suggest_fields = (searchkick_options[:suggest] || []).map(&:to_s)
|
@@ -356,6 +418,10 @@ module Searchkick
|
|
356
418
|
payload[:highlight][:post_tags] = [tag.to_s.gsub(/\A</, "</")]
|
357
419
|
end
|
358
420
|
|
421
|
+
if (fragment_size = options[:highlight][:fragment_size])
|
422
|
+
payload[:highlight][:fragment_size] = fragment_size
|
423
|
+
end
|
424
|
+
|
359
425
|
highlight_fields = options[:highlight][:fields]
|
360
426
|
if highlight_fields
|
361
427
|
payload[:highlight][:fields] = {}
|
@@ -380,9 +446,7 @@ module Searchkick
|
|
380
446
|
end
|
381
447
|
|
382
448
|
# routing
|
383
|
-
if options[:routing]
|
384
|
-
@routing = options[:routing]
|
385
|
-
end
|
449
|
+
@routing = options[:routing] if options[:routing]
|
386
450
|
end
|
387
451
|
|
388
452
|
@body = payload
|
@@ -478,9 +542,7 @@ module Searchkick
|
|
478
542
|
value = {gte: value.first, (value.exclude_end? ? :lt : :lte) => value.last}
|
479
543
|
end
|
480
544
|
|
481
|
-
if value.is_a?(Array)
|
482
|
-
value = {in: value}
|
483
|
-
end
|
545
|
+
value = {in: value} if value.is_a?(Array)
|
484
546
|
|
485
547
|
if value.is_a?(Hash)
|
486
548
|
value.each do |op, op_value|
|
@@ -506,9 +568,11 @@ module Searchkick
|
|
506
568
|
when :regexp # support for regexp queries without using a regexp ruby object
|
507
569
|
filters << {regexp: {field => {value: op_value}}}
|
508
570
|
when :not # not equal
|
509
|
-
filters << {not: term_filters(field, op_value)}
|
571
|
+
filters << {not: {filter: term_filters(field, op_value)}}
|
510
572
|
when :all
|
511
|
-
|
573
|
+
op_value.each do |value|
|
574
|
+
filters << term_filters(field, value)
|
575
|
+
end
|
512
576
|
when :in
|
513
577
|
filters << term_filters(field, op_value)
|
514
578
|
else
|
@@ -560,7 +624,7 @@ module Searchkick
|
|
560
624
|
def custom_filter(field, value, factor)
|
561
625
|
{
|
562
626
|
filter: {
|
563
|
-
and: where_filters(
|
627
|
+
and: where_filters(field => value)
|
564
628
|
},
|
565
629
|
boost_factor: factor
|
566
630
|
}
|
@@ -596,6 +660,10 @@ module Searchkick
|
|
596
660
|
below_version?("1.4.0")
|
597
661
|
end
|
598
662
|
|
663
|
+
def below20?
|
664
|
+
below_version?("2.0.0")
|
665
|
+
end
|
666
|
+
|
599
667
|
def below_version?(version)
|
600
668
|
Gem::Version.new(Searchkick.server_version) < Gem::Version.new(version)
|
601
669
|
end
|
data/lib/searchkick/results.rb
CHANGED
@@ -26,7 +26,7 @@ module Searchkick
|
|
26
26
|
# results can have different types
|
27
27
|
results = {}
|
28
28
|
|
29
|
-
hits.group_by { |hit,
|
29
|
+
hits.group_by { |hit, _| hit["_type"] }.each do |type, grouped_hits|
|
30
30
|
results[type] = results_query(type.camelize.constantize, grouped_hits).to_a.index_by { |r| r.id.to_s }
|
31
31
|
end
|
32
32
|
|
@@ -75,6 +75,23 @@ module Searchkick
|
|
75
75
|
response["facets"]
|
76
76
|
end
|
77
77
|
|
78
|
+
def aggs
|
79
|
+
@aggs ||= begin
|
80
|
+
response["aggregations"].dup.each do |field, filtered_agg|
|
81
|
+
buckets = filtered_agg[field]
|
82
|
+
# move the buckets one level above into the field hash
|
83
|
+
if buckets
|
84
|
+
filtered_agg.delete(field)
|
85
|
+
filtered_agg.merge!(buckets)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def took
|
92
|
+
response["took"]
|
93
|
+
end
|
94
|
+
|
78
95
|
def model_name
|
79
96
|
klass.model_name
|
80
97
|
end
|
data/lib/searchkick/tasks.rb
CHANGED
data/lib/searchkick/version.rb
CHANGED
data/test/aggs_test.rb
ADDED
@@ -0,0 +1,102 @@
|
|
1
|
+
require_relative "test_helper"
|
2
|
+
|
3
|
+
class AggsTest < Minitest::Test
|
4
|
+
def setup
|
5
|
+
super
|
6
|
+
store [
|
7
|
+
{name: "Product Show", latitude: 37.7833, longitude: 12.4167, store_id: 1, in_stock: true, color: "blue", price: 21, created_at: 2.days.ago},
|
8
|
+
{name: "Product Hide", latitude: 29.4167, longitude: -98.5000, store_id: 2, in_stock: false, color: "green", price: 25, created_at: 2.days.from_now},
|
9
|
+
{name: "Product B", latitude: 43.9333, longitude: -122.4667, store_id: 2, in_stock: false, color: "red", price: 5},
|
10
|
+
{name: "Foo", latitude: 43.9333, longitude: 12.4667, store_id: 3, in_stock: false, color: "yellow", price: 15}
|
11
|
+
]
|
12
|
+
end
|
13
|
+
|
14
|
+
def test_basic
|
15
|
+
assert_equal ({1 => 1, 2 => 2}), store_agg(aggs: [:store_id])
|
16
|
+
end
|
17
|
+
|
18
|
+
def test_where
|
19
|
+
assert_equal ({1 => 1}), store_agg(aggs: {store_id: {where: {in_stock: true}}})
|
20
|
+
end
|
21
|
+
|
22
|
+
def test_field
|
23
|
+
assert_equal ({1 => 1, 2 => 2}), store_agg(aggs: {store_id: {}})
|
24
|
+
assert_equal ({1 => 1, 2 => 2}), store_agg(aggs: {store_id: {field: "store_id"}})
|
25
|
+
assert_equal ({1 => 1, 2 => 2}), store_agg({aggs: {store_id_new: {field: "store_id"}}}, "store_id_new")
|
26
|
+
end
|
27
|
+
|
28
|
+
def test_limit
|
29
|
+
agg = Product.search("Product", aggs: {store_id: {limit: 1}}).aggs["store_id"]
|
30
|
+
assert_equal 1, agg["buckets"].size
|
31
|
+
# assert_equal 3, agg["doc_count"]
|
32
|
+
assert_equal(1, agg["sum_other_doc_count"]) if Gem::Version.new(Searchkick.server_version) >= Gem::Version.new("1.4.0")
|
33
|
+
end
|
34
|
+
|
35
|
+
def test_ranges
|
36
|
+
price_ranges = [{to: 10}, {from: 10, to: 20}, {from: 20}]
|
37
|
+
agg = Product.search("Product", aggs: {price: {ranges: price_ranges}}).aggs["price"]
|
38
|
+
|
39
|
+
assert_equal 3, agg["buckets"].size
|
40
|
+
assert_equal 10.0, agg["buckets"][0]["to"]
|
41
|
+
assert_equal 20.0, agg["buckets"][2]["from"]
|
42
|
+
assert_equal 1, agg["buckets"][0]["doc_count"]
|
43
|
+
assert_equal 0, agg["buckets"][1]["doc_count"]
|
44
|
+
assert_equal 2, agg["buckets"][2]["doc_count"]
|
45
|
+
end
|
46
|
+
|
47
|
+
def test_date_ranges
|
48
|
+
ranges = [{to: 1.day.ago}, {from: 1.day.ago, to: 1.day.from_now}, {from: 1.day.from_now}]
|
49
|
+
agg = Product.search("Product", aggs: {created_at: {date_ranges: ranges}}).aggs["created_at"]
|
50
|
+
|
51
|
+
assert_equal 1, agg["buckets"][0]["doc_count"]
|
52
|
+
assert_equal 1, agg["buckets"][1]["doc_count"]
|
53
|
+
assert_equal 1, agg["buckets"][2]["doc_count"]
|
54
|
+
end
|
55
|
+
|
56
|
+
def test_query_where
|
57
|
+
assert_equal ({1 => 1}), store_agg(where: {in_stock: true}, aggs: [:store_id])
|
58
|
+
end
|
59
|
+
|
60
|
+
def test_two_wheres
|
61
|
+
assert_equal ({2 => 1}), store_agg(where: {color: "red"}, aggs: {store_id: {where: {in_stock: false}}})
|
62
|
+
end
|
63
|
+
|
64
|
+
def test_where_override
|
65
|
+
assert_equal ({}), store_agg(where: {color: "red"}, aggs: {store_id: {where: {in_stock: false, color: "blue"}}})
|
66
|
+
assert_equal ({2 => 1}), store_agg(where: {color: "blue"}, aggs: {store_id: {where: {in_stock: false, color: "red"}}})
|
67
|
+
end
|
68
|
+
|
69
|
+
def test_skip
|
70
|
+
assert_equal ({1 => 1, 2 => 2}), store_agg(where: {store_id: 2}, aggs: [:store_id])
|
71
|
+
end
|
72
|
+
|
73
|
+
def test_skip_complex
|
74
|
+
assert_equal ({1 => 1, 2 => 1}), store_agg(where: {store_id: 2, price: {gt: 5}}, aggs: [:store_id])
|
75
|
+
end
|
76
|
+
|
77
|
+
def test_multiple
|
78
|
+
assert_equal ({"store_id" => {1 => 1, 2 => 2}, "color" => {"blue" => 1, "green" => 1, "red" => 1}}), store_multiple_aggs(aggs: [:store_id, :color])
|
79
|
+
end
|
80
|
+
|
81
|
+
def test_smart_aggs_false
|
82
|
+
assert_equal ({2 => 2}), store_agg(where: {color: "red"}, aggs: {store_id: {where: {in_stock: false}}}, smart_aggs: false)
|
83
|
+
assert_equal ({2 => 2}), store_agg(where: {color: "blue"}, aggs: {store_id: {where: {in_stock: false}}}, smart_aggs: false)
|
84
|
+
end
|
85
|
+
|
86
|
+
protected
|
87
|
+
|
88
|
+
def buckets_as_hash(agg)
|
89
|
+
agg["buckets"].map { |v| [v["key"], v["doc_count"]] }.to_h
|
90
|
+
end
|
91
|
+
|
92
|
+
def store_agg(options, agg_key = "store_id")
|
93
|
+
buckets = Product.search("Product", options).aggs[agg_key]
|
94
|
+
buckets_as_hash(buckets)
|
95
|
+
end
|
96
|
+
|
97
|
+
def store_multiple_aggs(options)
|
98
|
+
Product.search("Product", options).aggs.map do |field, filtered_agg|
|
99
|
+
[field, buckets_as_hash(filtered_agg)]
|
100
|
+
end.to_h
|
101
|
+
end
|
102
|
+
end
|
data/test/autocomplete_test.rb
CHANGED
@@ -1,7 +1,6 @@
|
|
1
1
|
require_relative "test_helper"
|
2
2
|
|
3
|
-
class
|
4
|
-
|
3
|
+
class AutocompleteTest < Minitest::Test
|
5
4
|
def test_autocomplete
|
6
5
|
store_names ["Hummus"]
|
7
6
|
assert_search "hum", ["Hummus"], autocomplete: true
|
@@ -63,5 +62,4 @@ class TestAutocomplete < Minitest::Test
|
|
63
62
|
store_names ["hi@example.org"]
|
64
63
|
assert_search "hi@example.org", ["hi@example.org"], fields: [{name: :exact}]
|
65
64
|
end
|
66
|
-
|
67
65
|
end
|