searchkick 0.9.1 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|