searchkick 4.6.3 → 5.5.2

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.
@@ -9,7 +9,7 @@ module Searchkick
9
9
  attr_accessor :body
10
10
 
11
11
  def_delegators :execute, :map, :each, :any?, :empty?, :size, :length, :slice, :[], :to_ary,
12
- :records, :results, :suggestions, :each_with_hit, :with_details, :aggregations, :aggs,
12
+ :results, :suggestions, :each_with_hit, :with_details, :aggregations, :aggs,
13
13
  :took, :error, :model_name, :entry_name, :total_count, :total_entries,
14
14
  :current_page, :per_page, :limit_value, :padding, :total_pages, :num_pages,
15
15
  :offset_value, :offset, :previous_page, :prev_page, :next_page, :first_page?, :last_page?,
@@ -18,8 +18,8 @@ module Searchkick
18
18
 
19
19
  def initialize(klass, term = "*", **options)
20
20
  unknown_keywords = options.keys - [:aggs, :block, :body, :body_options, :boost,
21
- :boost_by, :boost_by_distance, :boost_by_recency, :boost_where, :conversions, :conversions_term, :debug, :emoji, :exclude, :execute, :explain,
22
- :fields, :highlight, :includes, :index_name, :indices_boost, :limit, :load,
21
+ :boost_by, :boost_by_distance, :boost_by_recency, :boost_where, :conversions, :conversions_term, :debug, :emoji, :exclude, :explain,
22
+ :fields, :highlight, :includes, :index_name, :indices_boost, :knn, :limit, :load,
23
23
  :match, :misspellings, :models, :model_includes, :offset, :operator, :order, :padding, :page, :per_page, :profile,
24
24
  :request_params, :routing, :scope_results, :scroll, :select, :similar, :smart_aggs, :suggest, :total_entries, :track, :type, :where]
25
25
  raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?
@@ -148,9 +148,6 @@ module Searchkick
148
148
  }
149
149
 
150
150
  if options[:debug]
151
- # can remove when minimum Ruby version is 2.5
152
- require "pp"
153
-
154
151
  puts "Searchkick Version: #{Searchkick::VERSION}"
155
152
  puts "Elasticsearch Version: #{Searchkick.server_version}"
156
153
  puts
@@ -190,11 +187,11 @@ module Searchkick
190
187
  end
191
188
 
192
189
  # set execute for multi search
193
- @execute = Searchkick::Results.new(searchkick_klass, response, opts)
190
+ @execute = Results.new(searchkick_klass, response, opts)
194
191
  end
195
192
 
196
193
  def retry_misspellings?(response)
197
- @misspellings_below && Searchkick::Results.new(searchkick_klass, response).total_count < @misspellings_below
194
+ @misspellings_below && response["error"].nil? && Results.new(searchkick_klass, response).total_count < @misspellings_below
198
195
  end
199
196
 
200
197
  private
@@ -202,7 +199,11 @@ module Searchkick
202
199
  def handle_error(e)
203
200
  status_code = e.message[1..3].to_i
204
201
  if status_code == 404
205
- raise MissingIndexError, "Index missing - run #{reindex_command}"
202
+ if e.message.include?("No search context found for id")
203
+ raise MissingIndexError, "No search context found for id"
204
+ else
205
+ raise MissingIndexError, "Index missing - run #{reindex_command}"
206
+ end
206
207
  elsif status_code == 500 && (
207
208
  e.message.include?("IllegalArgumentException[minimumSimilarity >= 1]") ||
208
209
  e.message.include?("No query registered for [multi_match]") ||
@@ -210,15 +211,15 @@ module Searchkick
210
211
  e.message.include?("No query registered for [function_score]")
211
212
  )
212
213
 
213
- raise UnsupportedVersionError, "This version of Searchkick requires Elasticsearch 5 or greater"
214
+ raise UnsupportedVersionError
214
215
  elsif status_code == 400
215
216
  if (
216
217
  e.message.include?("bool query does not support [filter]") ||
217
218
  e.message.include?("[bool] filter does not support [filter]")
218
219
  )
219
220
 
220
- raise UnsupportedVersionError, "This version of Searchkick requires Elasticsearch 5 or greater"
221
- elsif e.message =~ /analyzer \[searchkick_.+\] not found/
221
+ raise UnsupportedVersionError
222
+ elsif e.message.match?(/analyzer \[searchkick_.+\] not found/)
222
223
  raise InvalidQueryError, "Bad mapping - run #{reindex_command}"
223
224
  else
224
225
  raise InvalidQueryError, e.message
@@ -233,7 +234,14 @@ module Searchkick
233
234
  end
234
235
 
235
236
  def execute_search
236
- Searchkick.client.search(params)
237
+ name = searchkick_klass ? "#{searchkick_klass.name} Search" : "Search"
238
+ event = {
239
+ name: name,
240
+ query: params
241
+ }
242
+ ActiveSupport::Notifications.instrument("search.searchkick", event) do
243
+ Searchkick.client.search(params)
244
+ end
237
245
  end
238
246
 
239
247
  def prepare
@@ -247,9 +255,16 @@ module Searchkick
247
255
  default_limit = searchkick_options[:deep_paging] ? 1_000_000_000 : 10_000
248
256
  per_page = (options[:limit] || options[:per_page] || default_limit).to_i
249
257
  padding = [options[:padding].to_i, 0].max
250
- offset = options[:offset] || (page - 1) * per_page + padding
258
+ offset = (options[:offset] || (page - 1) * per_page + padding).to_i
251
259
  scroll = options[:scroll]
252
260
 
261
+ max_result_window = searchkick_options[:max_result_window]
262
+ original_per_page = per_page
263
+ if max_result_window
264
+ offset = max_result_window if offset > max_result_window
265
+ per_page = max_result_window - offset if offset + per_page > max_result_window
266
+ end
267
+
253
268
  # model and eager loading
254
269
  load = options[:load].nil? ? true : options[:load]
255
270
 
@@ -268,9 +283,10 @@ module Searchkick
268
283
  should = []
269
284
 
270
285
  if options[:similar]
286
+ like = options[:similar] == true ? term : options[:similar]
271
287
  query = {
272
288
  more_like_this: {
273
- like: term,
289
+ like: like,
274
290
  min_doc_freq: 1,
275
291
  min_term_freq: 1,
276
292
  analyzer: "searchkick_search2"
@@ -358,7 +374,7 @@ module Searchkick
358
374
  field_misspellings = misspellings && (!misspellings_fields || misspellings_fields.include?(base_field(field)))
359
375
 
360
376
  if field == "_all" || field.end_with?(".analyzed")
361
- shared_options[:cutoff_frequency] = 0.001 unless operator.to_s == "and" || field_misspellings == false || (!below73? && !track_total_hits?)
377
+ shared_options[:cutoff_frequency] = 0.001 unless operator.to_s == "and" || field_misspellings == false || (!below73? && !track_total_hits?) || match_type == :match_phrase || !below80? || Searchkick.opensearch?
362
378
  qs << shared_options.merge(analyzer: "searchkick_search")
363
379
 
364
380
  # searchkick_search and searchkick_search2 are the same for some languages
@@ -372,7 +388,7 @@ module Searchkick
372
388
  exclude_field = f
373
389
  exclude_analyzer = "keyword"
374
390
  else
375
- analyzer = field =~ /\.word_(start|middle|end)\z/ ? "searchkick_word_search" : "searchkick_autocomplete_search"
391
+ analyzer = field.match?(/\.word_(start|middle|end)\z/) ? "searchkick_word_search" : "searchkick_autocomplete_search"
376
392
  qs << shared_options.merge(analyzer: analyzer)
377
393
  exclude_analyzer = analyzer
378
394
  end
@@ -383,11 +399,6 @@ module Searchkick
383
399
 
384
400
  if field.start_with?("*.")
385
401
  q2 = qs.map { |q| {multi_match: q.merge(fields: [field], type: match_type == :match_phrase ? "phrase" : "best_fields")} }
386
- if below61?
387
- q2.each do |q|
388
- q[:multi_match].delete(:fuzzy_transpositions)
389
- end
390
- end
391
402
  else
392
403
  q2 = qs.map { |q| {match_type => {field => q}} }
393
404
  end
@@ -439,7 +450,7 @@ module Searchkick
439
450
  payload = {}
440
451
 
441
452
  # type when inheritance
442
- where = (options[:where] || {}).dup
453
+ where = ensure_permitted(options[:where] || {}).dup
443
454
  if searchkick_options[:inheritance] && (options[:type] || (klass != searchkick_klass && searchkick_index))
444
455
  where[:type] = [options[:type] || klass].flatten.map { |v| searchkick_index.klass_document_type(v, true) }
445
456
  end
@@ -499,7 +510,7 @@ module Searchkick
499
510
  set_highlights(payload, fields) if options[:highlight]
500
511
 
501
512
  # timeout shortly after client times out
502
- payload[:timeout] ||= "#{Searchkick.search_timeout + 1}s"
513
+ payload[:timeout] ||= "#{((Searchkick.search_timeout + 1) * 1000).round}ms"
503
514
 
504
515
  # An empty array will cause only the _id and _type for each hit to be returned
505
516
  # https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-source-filtering.html
@@ -515,6 +526,9 @@ module Searchkick
515
526
  end
516
527
  end
517
528
 
529
+ # knn
530
+ set_knn(payload, options[:knn], per_page, offset) if options[:knn]
531
+
518
532
  # pagination
519
533
  pagination_options = options[:page] || options[:limit] || options[:per_page] || options[:offset] || options[:padding]
520
534
  if !options[:body] || pagination_options
@@ -548,7 +562,7 @@ module Searchkick
548
562
 
549
563
  @body = payload
550
564
  @page = page
551
- @per_page = per_page
565
+ @per_page = original_per_page
552
566
  @padding = padding
553
567
  @load = load
554
568
  @scroll = scroll
@@ -696,9 +710,9 @@ module Searchkick
696
710
  def set_boost_by(multiply_filters, custom_filters)
697
711
  boost_by = options[:boost_by] || {}
698
712
  if boost_by.is_a?(Array)
699
- boost_by = Hash[boost_by.map { |f| [f, {factor: 1}] }]
713
+ boost_by = boost_by.to_h { |f| [f, {factor: 1}] }
700
714
  elsif boost_by.is_a?(Hash)
701
- multiply_by, boost_by = boost_by.partition { |_, v| v.delete(:boost_mode) == "multiply" }.map { |i| Hash[i] }
715
+ multiply_by, boost_by = boost_by.partition { |_, v| v.delete(:boost_mode) == "multiply" }.map(&:to_h)
702
716
  end
703
717
  boost_by[options[:boost]] = {factor: 1} if options[:boost]
704
718
 
@@ -763,7 +777,7 @@ module Searchkick
763
777
 
764
778
  def set_highlights(payload, fields)
765
779
  payload[:highlight] = {
766
- fields: Hash[fields.map { |f| [f, {}] }],
780
+ fields: fields.to_h { |f| [f, {}] },
767
781
  fragment_size: 0
768
782
  }
769
783
 
@@ -797,7 +811,7 @@ module Searchkick
797
811
  aggs = options[:aggs]
798
812
  payload[:aggs] = {}
799
813
 
800
- aggs = Hash[aggs.map { |f| [f, {}] }] if aggs.is_a?(Array) # convert to more advanced syntax
814
+ aggs = aggs.to_h { |f| [f, {}] } if aggs.is_a?(Array) # convert to more advanced syntax
801
815
  aggs.each do |field, agg_options|
802
816
  size = agg_options[:limit] ? agg_options[:limit] : 1_000
803
817
  shared_agg_options = agg_options.except(:limit, :field, :ranges, :date_ranges, :where)
@@ -836,8 +850,9 @@ module Searchkick
836
850
  end
837
851
 
838
852
  where = {}
839
- where = (options[:where] || {}).reject { |k| k == field } unless options[:smart_aggs] == false
840
- agg_filters = where_filters(where.merge(agg_options[:where] || {}))
853
+ where = ensure_permitted(options[:where] || {}).reject { |k| k == field } unless options[:smart_aggs] == false
854
+ agg_where = ensure_permitted(agg_options[:where] || {})
855
+ agg_filters = where_filters(where.merge(agg_where))
841
856
 
842
857
  # only do one level comparison for simplicity
843
858
  filters.select! do |filter|
@@ -864,6 +879,129 @@ module Searchkick
864
879
  end
865
880
  end
866
881
 
882
+ def set_knn(payload, knn, per_page, offset)
883
+ if term != "*"
884
+ raise ArgumentError, "Use Searchkick.multi_search for hybrid search"
885
+ end
886
+
887
+ field = knn[:field]
888
+ field_options = searchkick_options.dig(:knn, field.to_sym) || searchkick_options.dig(:knn, field.to_s) || {}
889
+ vector = knn[:vector]
890
+ distance = knn[:distance] || field_options[:distance]
891
+ exact = knn[:exact]
892
+ exact = field_options[:distance].nil? || distance != field_options[:distance] if exact.nil?
893
+ k = per_page + offset
894
+ ef_search = knn[:ef_search]
895
+ filter = payload.delete(:query)
896
+
897
+ if distance.nil?
898
+ raise ArgumentError, "distance required"
899
+ elsif !exact && distance != field_options[:distance]
900
+ raise ArgumentError, "distance must match searchkick options for approximate search"
901
+ end
902
+
903
+ if Searchkick.opensearch?
904
+ if exact
905
+ # https://opensearch.org/docs/latest/search-plugins/knn/knn-score-script/#spaces
906
+ space_type =
907
+ case distance
908
+ when "cosine"
909
+ "cosinesimil"
910
+ when "euclidean"
911
+ "l2"
912
+ when "taxicab"
913
+ "l1"
914
+ when "inner_product"
915
+ "innerproduct"
916
+ when "chebyshev"
917
+ "linf"
918
+ else
919
+ raise ArgumentError, "Unknown distance: #{distance}"
920
+ end
921
+
922
+ payload[:query] = {
923
+ script_score: {
924
+ query: {
925
+ bool: {
926
+ must: [filter, {exists: {field: field}}]
927
+ }
928
+ },
929
+ script: {
930
+ source: "knn_score",
931
+ lang: "knn",
932
+ params: {
933
+ field: field,
934
+ query_value: vector,
935
+ space_type: space_type
936
+ }
937
+ },
938
+ boost: distance == "cosine" && Searchkick.server_below?("2.19.0", true) ? 0.5 : 1.0
939
+ }
940
+ }
941
+ else
942
+ if ef_search && Searchkick.server_below?("2.16.0", true)
943
+ raise Error, "ef_search requires OpenSearch 2.16+"
944
+ end
945
+
946
+ payload[:query] = {
947
+ knn: {
948
+ field.to_sym => {
949
+ vector: vector,
950
+ k: k,
951
+ filter: filter
952
+ }.merge(ef_search ? {method_parameters: {ef_search: ef_search}} : {})
953
+ }
954
+ }
955
+ end
956
+ else
957
+ if exact
958
+ # prevent incorrect distances/results with Elasticsearch 9.0.0-rc1
959
+ if !below90? && field_options[:distance] == "cosine" && distance != "cosine"
960
+ raise ArgumentError, "distance must match searchkick options"
961
+ end
962
+
963
+ # https://github.com/elastic/elasticsearch/blob/main/docs/reference/vectors/vector-functions.asciidoc
964
+ source =
965
+ case distance
966
+ when "cosine"
967
+ "(cosineSimilarity(params.query_vector, params.field) + 1.0) * 0.5"
968
+ when "euclidean"
969
+ "double l2 = l2norm(params.query_vector, params.field); 1 / (1 + l2 * l2)"
970
+ when "taxicab"
971
+ "1 / (1 + l1norm(params.query_vector, params.field))"
972
+ when "inner_product"
973
+ "double dot = dotProduct(params.query_vector, params.field); dot > 0 ? dot + 1 : 1 / (1 - dot)"
974
+ else
975
+ raise ArgumentError, "Unknown distance: #{distance}"
976
+ end
977
+
978
+ payload[:query] = {
979
+ script_score: {
980
+ query: {
981
+ bool: {
982
+ must: [filter, {exists: {field: field}}]
983
+ }
984
+ },
985
+ script: {
986
+ source: source,
987
+ params: {
988
+ field: field,
989
+ query_vector: vector
990
+ }
991
+ }
992
+ }
993
+ }
994
+ else
995
+ payload[:knn] = {
996
+ field: field,
997
+ query_vector: vector,
998
+ k: k,
999
+ filter: filter
1000
+ }.merge(ef_search ? {num_candidates: ef_search} : {})
1001
+ end
1002
+ end
1003
+ end
1004
+
867
1005
  def set_post_filters(payload, post_filters)
868
1006
  payload[:post_filter] = {
869
1007
  bool: {
@@ -873,19 +1011,17 @@ module Searchkick
873
1011
  end
874
1012
 
875
1013
  def set_order(payload)
876
- order = options[:order].is_a?(Enumerable) ? options[:order] : {options[:order] => :asc}
877
- id_field = :_id
878
- # TODO no longer map id to _id in Searchkick 5
879
- # since sorting on _id is deprecated in Elasticsearch
880
- payload[:sort] = order.is_a?(Array) ? order : Hash[order.map { |k, v| [k.to_s == "id" ? id_field : k, v] }]
1014
+ value = options[:order]
1015
+ payload[:sort] = value.is_a?(Enumerable) ? value : {value => :asc}
881
1016
  end
882
1017
 
883
- def where_filters(where)
884
- # if where.respond_to?(:permitted?) && !where.permitted?
885
- # # TODO check in more places
886
- # Searchkick.warn("Passing unpermitted parameters will raise an exception in Searchkick 5")
887
- # end
1018
+ # provides *very* basic protection from unfiltered parameters
1019
+ # this is not meant to be comprehensive and may be expanded in the future
1020
+ def ensure_permitted(obj)
1021
+ obj.to_h
1022
+ end
888
1023
 
1024
+ def where_filters(where)
889
1025
  filters = []
890
1026
  (where || {}).each do |field, value|
891
1027
  field = :_id if field.to_s == "id"
@@ -900,8 +1036,12 @@ module Searchkick
900
1036
  filters << {bool: {must_not: where_filters(value)}}
901
1037
  elsif field == :_and
902
1038
  filters << {bool: {must: value.map { |or_statement| {bool: {filter: where_filters(or_statement)}} }}}
903
- # elsif field == :_script
904
- # filters << {script: {script: {source: value, lang: "painless"}}}
1039
+ elsif field == :_script
1040
+ unless value.is_a?(Script)
1041
+ raise TypeError, "expected Searchkick::Script"
1042
+ end
1043
+
1044
+ filters << {script: {script: {source: value.source, lang: value.lang, params: value.params}}}
905
1045
  else
906
1046
  # expand ranges
907
1047
  if value.is_a?(Range)
@@ -994,20 +1134,29 @@ module Searchkick
994
1134
  when :in
995
1135
  filters << term_filters(field, op_value)
996
1136
  when :exists
1137
+ # TODO add support for false in Searchkick 6
1138
+ if op_value != true
1139
+ # TODO raise error in Searchkick 6
1140
+ Searchkick.warn("Passing a value other than true to exists is not supported")
1141
+ end
997
1142
  filters << {exists: {field: field}}
998
1143
  else
999
1144
  range_query =
1000
1145
  case op
1001
1146
  when :gt
1002
- {from: op_value, include_lower: false}
1147
+ # TODO always use gt in Searchkick 6
1148
+ below90? ? {from: op_value, include_lower: false} : {gt: op_value}
1003
1149
  when :gte
1004
- {from: op_value, include_lower: true}
1150
+ # TODO always use gte in Searchkick 6
1151
+ below90? ? {from: op_value, include_lower: true} : {gte: op_value}
1005
1152
  when :lt
1006
- {to: op_value, include_upper: false}
1153
+ # TODO always use lt in Searchkick 6
1154
+ below90? ? {to: op_value, include_upper: false} : {lt: op_value}
1007
1155
  when :lte
1008
- {to: op_value, include_upper: true}
1156
+ # TODO always use lte in Searchkick 6
1157
+ below90? ? {to: op_value, include_upper: true} : {lte: op_value}
1009
1158
  else
1010
- raise "Unknown where operator: #{op.inspect}"
1159
+ raise ArgumentError, "Unknown where operator: #{op.inspect}"
1011
1160
  end
1012
1161
  # issue 132
1013
1162
  if (existing = filters.find { |f| f[:range] && f[:range][field] })
@@ -1036,29 +1185,25 @@ module Searchkick
1036
1185
  {bool: {must_not: {exists: {field: field}}}}
1037
1186
  elsif value.is_a?(Regexp)
1038
1187
  source = value.source
1039
- unless source.start_with?("\\A") && source.end_with?("\\z")
1040
- # https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-regexp-query.html
1041
- Searchkick.warn("Regular expressions are always anchored in Elasticsearch")
1042
- end
1188
+
1189
+ # TODO handle other regexp options
1043
1190
 
1044
1191
  # TODO handle other anchor characters, like ^, $, \Z
1045
1192
  if source.start_with?("\\A")
1046
1193
  source = source[2..-1]
1047
1194
  else
1048
- # TODO uncomment in Searchkick 5
1049
- # source = ".*#{source}"
1195
+ source = ".*#{source}"
1050
1196
  end
1051
1197
 
1052
1198
  if source.end_with?("\\z")
1053
1199
  source = source[0..-3]
1054
1200
  else
1055
- # TODO uncomment in Searchkick 5
1056
- # source = "#{source}.*"
1201
+ source = "#{source}.*"
1057
1202
  end
1058
1203
 
1059
1204
  if below710?
1060
1205
  if value.casefold?
1061
- Searchkick.warn("Case-insensitive flag does not work with Elasticsearch < 7.10")
1206
+ raise ArgumentError, "Case-insensitive flag does not work with Elasticsearch < 7.10"
1062
1207
  end
1063
1208
  {regexp: {field => {value: source, flags: "NONE"}}}
1064
1209
  else
@@ -1069,9 +1214,7 @@ module Searchkick
1069
1214
  if value.as_json.is_a?(Enumerable)
1070
1215
  # query will fail, but this is better
1071
1216
  # same message as Active Record
1072
- # TODO make TypeError
1073
- # raise InvalidQueryError for backward compatibility
1074
- raise Searchkick::InvalidQueryError, "can't cast #{value.class.name}"
1217
+ raise TypeError, "can't cast #{value.class.name}"
1075
1218
  end
1076
1219
 
1077
1220
  {term: {field => {value: value}}}
@@ -1150,21 +1293,13 @@ module Searchkick
1150
1293
  end
1151
1294
 
1152
1295
  def track_total_hits?
1153
- (searchkick_options[:deep_paging] && !below70?) || body_options[:track_total_hits]
1296
+ searchkick_options[:deep_paging] || body_options[:track_total_hits]
1154
1297
  end
1155
1298
 
1156
1299
  def body_options
1157
1300
  options[:body_options] || {}
1158
1301
  end
1159
1302
 
1160
- def below61?
1161
- Searchkick.server_below?("6.1.0")
1162
- end
1163
-
1164
- def below70?
1165
- Searchkick.server_below?("7.0.0")
1166
- end
1167
-
1168
1303
  def below73?
1169
1304
  Searchkick.server_below?("7.3.0")
1170
1305
  end
@@ -1176,5 +1311,13 @@ module Searchkick
1176
1311
  def below710?
1177
1312
  Searchkick.server_below?("7.10.0")
1178
1313
  end
1314
+
1315
+ def below80?
1316
+ Searchkick.server_below?("8.0.0")
1317
+ end
1318
+
1319
+ def below90?
1320
+ Searchkick.server_below?("9.0.0")
1321
+ end
1179
1322
  end
1180
1323
  end
@@ -1,4 +1,4 @@
1
- module Searckick
1
+ module Searchkick
2
2
  class Railtie < Rails::Railtie
3
3
  rake_tasks do
4
4
  load "tasks/searchkick.rake"
@@ -25,6 +25,7 @@ module Searchkick
25
25
  {delete: record_data}
26
26
  end
27
27
 
28
+ # custom id can be useful for load: false
28
29
  def search_id
29
30
  id = record.respond_to?(:search_document_id) ? record.search_document_id : record.id
30
31
  id.is_a?(Numeric) ? id : id.to_s
@@ -39,7 +40,6 @@ module Searchkick
39
40
  _index: index.name,
40
41
  _id: search_id
41
42
  }
42
- data[:_type] = document_type if Searchkick.server_below7?
43
43
  data[:routing] = record.search_routing if record.respond_to?(:search_routing)
44
44
  data
45
45
  end