elasticsearch_record 1.0.2 → 1.1.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +4 -0
  3. data/Gemfile.lock +10 -14
  4. data/README.md +180 -27
  5. data/docs/CHANGELOG.md +36 -18
  6. data/docs/LICENSE.txt +1 -1
  7. data/elasticsearch_record.gemspec +42 -0
  8. data/lib/active_record/connection_adapters/elasticsearch/column.rb +20 -6
  9. data/lib/active_record/connection_adapters/elasticsearch/database_statements.rb +142 -125
  10. data/lib/active_record/connection_adapters/elasticsearch/quoting.rb +2 -23
  11. data/lib/active_record/connection_adapters/elasticsearch/schema_creation.rb +30 -0
  12. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/attribute_methods.rb +103 -0
  13. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/column_methods.rb +42 -0
  14. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/create_table_definition.rb +158 -0
  15. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/table_alias_definition.rb +32 -0
  16. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/table_definition.rb +132 -0
  17. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/table_mapping_definition.rb +110 -0
  18. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/table_setting_definition.rb +136 -0
  19. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/update_table_definition.rb +174 -0
  20. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions.rb +37 -0
  21. data/lib/active_record/connection_adapters/elasticsearch/schema_dumper.rb +110 -0
  22. data/lib/active_record/connection_adapters/elasticsearch/schema_statements.rb +398 -174
  23. data/lib/active_record/connection_adapters/elasticsearch/table_statements.rb +232 -0
  24. data/lib/active_record/connection_adapters/elasticsearch/type/multicast_value.rb +2 -0
  25. data/lib/active_record/connection_adapters/elasticsearch/unsupported_implementation.rb +32 -0
  26. data/lib/active_record/connection_adapters/elasticsearch_adapter.rb +112 -19
  27. data/lib/arel/collectors/elasticsearch_query.rb +0 -1
  28. data/lib/arel/visitors/elasticsearch.rb +7 -579
  29. data/lib/arel/visitors/elasticsearch_base.rb +234 -0
  30. data/lib/arel/visitors/elasticsearch_query.rb +463 -0
  31. data/lib/arel/visitors/elasticsearch_schema.rb +124 -0
  32. data/lib/elasticsearch_record/core.rb +44 -10
  33. data/lib/elasticsearch_record/errors.rb +13 -0
  34. data/lib/elasticsearch_record/gem_version.rb +6 -2
  35. data/lib/elasticsearch_record/instrumentation/log_subscriber.rb +27 -9
  36. data/lib/elasticsearch_record/model_schema.rb +5 -0
  37. data/lib/elasticsearch_record/persistence.rb +31 -26
  38. data/lib/elasticsearch_record/query.rb +56 -17
  39. data/lib/elasticsearch_record/querying.rb +17 -0
  40. data/lib/elasticsearch_record/relation/calculation_methods.rb +3 -0
  41. data/lib/elasticsearch_record/relation/core_methods.rb +57 -17
  42. data/lib/elasticsearch_record/relation/query_clause_tree.rb +38 -1
  43. data/lib/elasticsearch_record/relation/query_methods.rb +6 -0
  44. data/lib/elasticsearch_record/relation/result_methods.rb +15 -9
  45. data/lib/elasticsearch_record/result.rb +32 -5
  46. data/lib/elasticsearch_record/statement_cache.rb +2 -1
  47. data/lib/elasticsearch_record.rb +2 -2
  48. metadata +29 -11
  49. data/.ruby-version +0 -1
  50. data/lib/elasticsearch_record/schema_migration.rb +0 -30
@@ -1,590 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'arel/visitors/elasticsearch_base'
4
+ require 'arel/visitors/elasticsearch_query'
5
+ require 'arel/visitors/elasticsearch_schema'
6
+
3
7
  require 'arel/collectors/elasticsearch_query'
4
8
 
5
9
  module Arel # :nodoc: all
6
10
  module Visitors
7
11
  class Elasticsearch < Arel::Visitors::Visitor
8
- class UnsupportedVisitError < StandardError
9
- def initialize(dispatch_method)
10
- super "Unsupported dispatch method: #{dispatch_method}. Construct an Arel node instead."
11
- end
12
- end
13
-
14
- attr_accessor :collector
15
-
16
- def initialize(connection)
17
- super()
18
- @connection = connection
19
-
20
- # required for nested assignment.
21
- # see +#assign+ method
22
- @nested = false
23
- @nested_args = []
24
- end
25
-
26
- def compile(node, collector = Arel::Collectors::ElasticsearchQuery.new)
27
- # we don't need to forward the collector each time - we just set it and always access it, when we need.
28
- self.collector = collector
29
-
30
- # so we just visit the first node without any additionally provided collector ...
31
- accept(node)
32
-
33
- # ... and return the final result
34
- self.collector.value
35
- end
36
-
37
- private
38
-
39
- # auto prevent visits on missing nodes
40
- def method_missing(method, *args, &block)
41
- raise(UnsupportedVisitError, method.to_s) if method.to_s[0..4] == 'visit'
42
-
43
- super
44
- end
45
-
46
- # collects and returns provided object 'visit' result.
47
- # returns an array of results if a array was provided.
48
- # does not validate if the object is present...
49
- # @param [Object] obj
50
- # @param [Symbol] method (default: :visit)
51
- # @return [Object,nil]
52
- def collect(obj, method = :visit)
53
- if obj.is_a?(Array)
54
- obj.map { |o| self.__send__(method, o) }
55
- elsif obj.present?
56
- self.__send__(method, obj)
57
- else
58
- nil
59
- end
60
- end
61
-
62
- # resolves the provided object 'visit' result.
63
- # check if the object is present.
64
- # does not return any values
65
- # @param [Object] obj
66
- # @param [Symbol] method (default: :visit)
67
- # @return [nil]
68
- def resolve(obj, method = :visit)
69
- return unless obj.present?
70
-
71
- objects = obj.is_a?(Array) ? obj : [obj]
72
- objects.each do |obj|
73
- self.__send__(method, obj)
74
- end
75
-
76
- nil
77
- end
78
-
79
- # assign provided args on the collector.
80
- # The TOP-Level assignment must be a (key, value) while sub-assignments will be collected by provided block.
81
- # Sub-assignments will never claim on the query but 'merged' to the TOP-assignment.
82
- #
83
- # assign(:query, {}) do
84
- # #... do some stuff ...
85
- # assign(:bool, {}) do
86
- # assign(:x,99)
87
- # assign({y: 45})
88
- # end
89
- # end
90
- # #> query: {bool: {x: 99, y: 45}}
91
- def assign(*args)
92
- # resolve possible TOP-LEVEL assignment
93
- key, value = args
94
-
95
- # if a block was provided we want to collect the nested assignments
96
- if block_given?
97
- raise ArgumentError, "Unsupported assignment value for provided block (#{key}). Provide any Object as value!" if value.nil?
98
-
99
- # set nested state to tell all nested assignments to not claim it's values
100
- old_nested, @nested = @nested, true
101
- old_nested_args, @nested_args = @nested_args, []
102
-
103
- # call block, but don't interact with its return.
104
- # nested args are separately stored
105
- yield
106
-
107
- # restore nested state
108
- @nested = old_nested
109
-
110
- # assign nested args
111
- @nested_args.each do |nested_args|
112
- # ARRAY assignment
113
- case value
114
- when Array
115
- if nested_args[0].is_a?(Array)
116
- nested_args[0].each do |nested_arg|
117
- value << nested_arg
118
- end
119
- else
120
- value << nested_args[0]
121
- end
122
- when Hash
123
- if nested_args[0].is_a?(Hash)
124
- value.merge!(nested_args[0])
125
- elsif value[nested_args[0]].is_a?(Hash) && nested_args[1].is_a?(Hash)
126
- value[nested_args[0]] = value[nested_args[0]].merge(nested_args[1])
127
- elsif value[nested_args[0]].is_a?(Array) && nested_args[1].is_a?(Array)
128
- value[nested_args[0]] += nested_args[1]
129
- elsif nested_args[1].nil?
130
- value.delete(nested_args[0])
131
- else
132
- value[nested_args[0]] = nested_args[1]
133
- end
134
- when String
135
- if nested_args[0].is_a?(Array)
136
- value = value + nested_args[0].map(&:to_s).join
137
- else
138
- value = value + nested_args[0].to_s
139
- end
140
- else
141
- value = nested_args[0] unless nested_args.blank?
142
- end
143
- end
144
-
145
- # clear nested args
146
- @nested_args = old_nested_args
147
- end
148
-
149
- # for nested assignments we only want the assignable args - no +claim+ on the query!
150
- if @nested
151
- @nested_args << args
152
- return
153
- end
154
-
155
- raise ArgumentError, "Unsupported assign key: '#{key}' for provided block. Provide a Symbol as key!" unless key.is_a?(Symbol)
156
-
157
- claim(:assign, key, value)
158
- end
159
-
160
- # creates and sends a new claim to the collector.
161
- # @param [Symbol] action - claim action (:index, :type, :status, :argument, :body, :assign)
162
- # @param [Array] args - either <key>,<value> or <Hash{<key> => <value>, ...}> or <Array>
163
- def claim(action, *args)
164
- self.collector << [action, args]
165
- end
166
-
167
- ######################
168
- # CORE VISITS (CRUD) #
169
- ######################
170
-
171
- # SELECT // SEARCH
172
- def visit_Arel_Nodes_SelectStatement(o)
173
- # prepare query
174
- claim(:type, ::ElasticsearchRecord::Query::TYPE_SEARCH)
175
-
176
- resolve(o.cores) # visit_Arel_Nodes_SelectCore
177
-
178
- resolve(o.orders) # visit_Sort
179
- resolve(o.limit) # visit_Arel_Nodes_Limit
180
- resolve(o.offset) # visit_Arel_Nodes_Offset
181
-
182
- # configure is able to overwrite everything in the query
183
- resolve(o.configure)
184
- end
185
-
186
- # UPDATE
187
- def visit_Arel_Nodes_UpdateStatement(o)
188
- # switch between updating a single Record or multiple by query
189
- if o.relation.is_a?(::Arel::Table)
190
- raise NotImplementedError, "if you've made it this far, something went wrong ..."
191
- end
192
-
193
- # prepare query
194
- claim(:type, ::ElasticsearchRecord::Query::TYPE_UPDATE_BY_QUERY)
195
-
196
- # sets the index
197
- resolve(o.relation)
198
-
199
- # updating multiple entries need a script
200
- assign(:script, {}) do
201
- assign(:inline, "") do
202
- updates = collect(o.values)
203
- assign(updates.join('; ')) if updates
204
- end
205
- end
206
-
207
- # sets the search query
208
- resolve(o, :visit_Query)
209
-
210
- resolve(o.orders) # visit_Sort
211
-
212
- assign(:max_docs, collect(o.limit.expr)) if o.limit.present?
213
- resolve(o.offset)
214
-
215
- # configure is able to overwrite everything
216
- resolve(o.configure)
217
- end
218
-
219
- # DELETE
220
- def visit_Arel_Nodes_DeleteStatement(o)
221
- # switch between updating a single Record or multiple by query
222
- if o.relation.is_a?(::Arel::Table)
223
- raise NotImplementedError, "if you've made it this far, something went wrong ..."
224
- end
225
-
226
- # prepare query
227
- claim(:type, ::ElasticsearchRecord::Query::TYPE_DELETE_BY_QUERY)
228
-
229
- # sets the index
230
- resolve(o.relation)
231
-
232
- # sets the search query
233
- resolve(o, :visit_Query)
234
-
235
- resolve(o.orders) # visit_Sort
236
-
237
- assign(:max_docs, collect(o.limit.expr)) if o.limit.present?
238
- resolve(o.offset)
239
-
240
- # configure is able to overwrite everything
241
- resolve(o.configure)
242
- end
243
-
244
- # INSERT
245
- def visit_Arel_Nodes_InsertStatement(o)
246
- # switch between updating a single Record or multiple by query
247
- if o.relation.is_a?(::Arel::Table)
248
- raise NotImplementedError, "if you've made it this far, something went wrong ..."
249
- end
250
-
251
- raise NotImplementedError
252
- end
253
-
254
- ##############################
255
- # SUBSTRUCTURE VISITS (CRUD) #
256
- ##############################
257
-
258
- def visit_Arel_Nodes_SelectCore(o)
259
- # sets the index
260
- resolve(o.source)
261
-
262
- # IMPORTANT: Since Elasticsearch does not store nil-values in the +_source+ / +doc+ it will NOT return
263
- # empty / nil columns - instead the nil columns do not exist!!!
264
- # This is a big mess, because those missing columns are +not+ editable or savable in any way after we initialize the record...
265
- # To prevent NOT-accessible attributes, we need to provide the "full-column-definition" to the query.
266
- resource_klass = o.source.left.instance_variable_get(:@klass)
267
- claim(:columns, resource_klass.source_column_names) if resource_klass.respond_to?(:source_column_names)
268
-
269
- # sets the query
270
- resolve(o, :visit_Query) if o.queries.present? || o.wheres.present?
271
-
272
- # sets the aggs
273
- resolve(o, :visit_Aggs) if o.aggs.present?
274
-
275
- # sets the selects
276
- resolve(o, :visit_Selects) if o.projections.present?
277
- end
278
-
279
- # CUSTOM node by elasticsearch_record
280
- def visit_Query(o)
281
- # in some cases we don't have a kind, but where conditions.
282
- # in this case we force the kind as +:bool+.
283
- kind = :bool if o.wheres.present? && o.kind.blank?
284
-
285
- # resolve kind, if not already set
286
- kind ||= o.kind.present? ? visit(o.kind.expr) : nil
287
-
288
- # check for existing kind - we cannot create a node if we don't have any kind
289
- return unless kind
290
-
291
- assign(:query, {}) do
292
- # this creates a kind node and creates nested queries
293
- # e.g. :bool => { ... }
294
- assign(kind, {}) do
295
- # each query has a type (e.g.: :filter) and one or multiple statements.
296
- # this is handled within the +visit_Arel_Nodes_SelectQuery+ method
297
- o.queries.each do |query|
298
- resolve(query) # visit_Arel_Nodes_SelectQuery
299
-
300
- # assign additional opts on the type level
301
- assign(query.opts) if query.opts.present?
302
- end
303
-
304
- # collect the where from predicate builds
305
- # should call:
306
- # - visit_Arel_Nodes_Equality
307
- # - visit_Arel_Nodes_NotEqual
308
- # - visit_Arel_Nodes_HomogeneousIn'
309
- resolve(o.wheres) if o.wheres.present?
310
-
311
- # annotations
312
- resolve(o.comment) if o.respond_to?(:comment)
313
- end
314
- end
315
- end
316
-
317
- # CUSTOM node by elasticsearch_record
318
- def visit_Aggs(o)
319
- assign(:aggs, {}) do
320
- o.aggs.each do |agg|
321
- resolve(agg)
322
-
323
- # we assign the opts on the top agg level
324
- assign(agg.opts) if agg.opts.present?
325
- end
326
- end
327
- end
328
-
329
- # CUSTOM node by elasticsearch_record
330
- def visit_Selects(o)
331
- fields = collect(o.projections)
332
-
333
- case fields[0]
334
- when '*'
335
- # force return all fields
336
- # assign(:_source, true)
337
- when ::ActiveRecord::FinderMethods::ONE_AS_ONE
338
- # force return NO fields
339
- assign(:_source, false)
340
- else
341
- assign(:_source, fields)
342
- # also overwrite the columns in the query
343
- claim(:columns, fields)
344
- end
345
- end
346
-
347
- # CUSTOM node by elasticsearch_record
348
- def visit_Arel_Nodes_SelectKind(o)
349
- visit(o.expr)
350
- end
351
-
352
- # CUSTOM node by elasticsearch_record
353
- def visit_Arel_Nodes_SelectConfigure(o)
354
- attrs = visit(o.expr)
355
-
356
- # we need to assign each key - value independently since +nil+ values will be treated as +delete+
357
- attrs.each do |key, value|
358
- assign(key, value)
359
- end if attrs.present?
360
- end
361
-
362
- # CUSTOM node by elasticsearch_record
363
- def visit_Arel_Nodes_SelectQuery(o)
364
- # this creates a query select node (includes key, value(s) and additional opts)
365
- # e.g.
366
- # :filter => [ ... ]
367
- # :must => [ ... ]
368
-
369
- # the query value must always be a array, since it might be extended by where clause.
370
- # assign(:filter, []) ...
371
- assign(visit(o.left), []) do
372
- # assign(terms: ...)
373
- assign(visit(o.right))
374
- end
375
- end
376
-
377
- # CUSTOM node by elasticsearch_record
378
- def visit_Arel_Nodes_SelectAgg(o)
379
- assign(visit(o.left) => visit(o.right))
380
- end
381
-
382
- # used to write new data to columns
383
- def visit_Arel_Nodes_Assignment(o)
384
- value = visit(o.right)
385
-
386
- value_assign = if o.right.value_before_type_cast.is_a?(Symbol)
387
- "ctx._source.#{value}"
388
- else
389
- quote(value)
390
- end
391
-
392
- "ctx._source.#{visit(o.left)} = #{value_assign}"
393
- end
394
-
395
- def visit_Arel_Nodes_Comment(o)
396
- assign(:_name, o.values.join(' - '))
397
- end
398
-
399
- # directly assigns the offset to the current scope
400
- def visit_Arel_Nodes_Offset(o)
401
- assign(:from, visit(o.expr))
402
- end
403
-
404
- # directly assigns the size to the current scope
405
- def visit_Arel_Nodes_Limit(o)
406
- assign(:size, visit(o.expr))
407
- end
408
-
409
- def visit_Sort(o)
410
- assign(:sort, {}) do
411
- key = visit(o.expr)
412
- dir = visit(o.direction)
413
-
414
- # we support a special key: __rand__ to create a simple random method ...
415
- if key == '__rand__'
416
- assign({
417
- "_script" => {
418
- "script" => "Math.random()",
419
- "type" => "number",
420
- "order" => dir
421
- }
422
- })
423
- else
424
- assign(key => dir)
425
- end
426
- end
427
- end
428
-
429
- alias :visit_Arel_Nodes_Ascending :visit_Sort
430
- alias :visit_Arel_Nodes_Descending :visit_Sort
431
-
432
-
433
- # DIRECT ASSIGNMENT
434
- def visit_Arel_Nodes_Equality(o)
435
- right = visit(o.right)
436
-
437
- return failed! if unboundable?(right)
438
-
439
- key = visit(o.left)
440
-
441
- if right.nil?
442
- # transforms nil to exists
443
- assign(:must_not, [{ exists: { field: key } }])
444
- else
445
- assign(:filter, [{ term: { key => right } }])
446
- end
447
- end
448
-
449
- # DIRECT ASSIGNMENT
450
- def visit_Arel_Nodes_NotEqual(o)
451
- right = visit(o.right)
452
-
453
- return failed! if unboundable?(right)
454
-
455
- key = visit(o.left)
456
-
457
- if right.nil?
458
- # transforms nil to exists
459
- assign(:filter, [{ exists: { field: key } }])
460
- else
461
- assign(:must_not, [{ term: { key => right } }])
462
- end
463
- end
464
-
465
- # DIRECT FAIL
466
- def visit_Arel_Nodes_Grouping(o)
467
- # grouping is NOT supported and will force to fail the query
468
- failed!
469
- end
470
-
471
- # DIRECT ASSIGNMENT
472
- def visit_Arel_Nodes_HomogeneousIn(o)
473
- self.collector.preparable = false
474
-
475
- values = o.casted_values
476
-
477
- # IMPORTANT: For SQL defaults (see @ Arel::Collectors::SubstituteBinds) a value
478
- # will +not+ directly assigned (see @ Arel::Visitors::ToSql#visit_Arel_Nodes_HomogeneousIn).
479
- # instead it will be send as bind and then re-delegated to the SQL collector.
480
- #
481
- # This only works for linear SQL-queries and not nested Hashes
482
- # (otherwise we have to collect those binds, and replace them afterwards).
483
- #
484
- # Here, we'll directly assign the "real" _(casted)_ values but also provide a additional bind.
485
- # This will be ignored by the ElasticsearchQuery collector, but supports statement caches on the other side
486
- # (see @ ActiveRecord::StatementCache::PartialQueryCollector)
487
- self.collector.add_binds(values, o.proc_for_binds)
488
-
489
- if o.type == :in
490
- assign(:filter, [{ terms: { o.column_name => o.casted_values } }])
491
- else
492
- assign(:must_not, [{ terms: { o.column_name => o.casted_values } }])
493
- end
494
- end
495
-
496
- def visit_Arel_Nodes_And(o)
497
- collect(o.children)
498
- end
499
-
500
- def visit_Arel_Nodes_JoinSource(o)
501
- sources = []
502
- sources << visit(o.left) if o.left
503
- sources += collect(o.right) if o.right
504
-
505
- claim(:index, sources.join(', '))
506
- end
507
-
508
- def visit_Arel_Table(o)
509
- raise ActiveRecord::StatementInvalid, "table alias are not supported (#{o.table_alias})" if o.table_alias
510
-
511
- o.name
512
- end
513
-
514
- def visit_Struct_Raw(o)
515
- o
516
- end
517
-
518
- alias :visit_Symbol :visit_Struct_Raw
519
- alias :visit_Hash :visit_Struct_Raw
520
- alias :visit_NilClass :visit_Struct_Raw
521
- alias :visit_String :visit_Struct_Raw
522
- alias :visit_Arel_Nodes_SqlLiteral :visit_Struct_Raw
523
-
524
- def visit_Struct_Value(o)
525
- o.value
526
- end
527
-
528
- alias :visit_Integer :visit_Struct_Value
529
- alias :visit_ActiveModel_Attribute_WithCastValue :visit_Struct_Value
530
-
531
- def visit_Struct_Attribute(o)
532
- o.name
533
- end
534
-
535
- alias :visit_Arel_Attributes_Attribute :visit_Struct_Attribute
536
- alias :visit_Arel_Nodes_UnqualifiedColumn :visit_Struct_Attribute
537
- alias :visit_ActiveModel_Attribute_FromUser :visit_Struct_Attribute
538
-
539
- def visit_Struct_BindValue(o)
540
- # IMPORTANT: For SQL defaults (see @ Arel::Collectors::SubstituteBinds) a value
541
- # will +not+ directly assigned (see @ Arel::Visitors::ToSql#visit_Arel_Nodes_HomogeneousIn).
542
- # instead it will be send as bind and then re-delegated to the SQL collector.
543
- #
544
- # This only works for linear SQL-queries and not nested Hashes
545
- # (otherwise we have to collect those binds, and replace them afterwards).
546
- #
547
- # Here, we'll directly assign the "real" _(casted)_ values but also provide a additional bind.
548
- # This will be ignored by the ElasticsearchQuery collector, but supports statement caches on the other side
549
- # (see @ ActiveRecord::StatementCache::PartialQueryCollector)
550
- self.collector.add_bind(o)
551
-
552
- o.value
553
- end
554
-
555
-
556
- alias :visit_ActiveModel_Attribute :visit_Struct_BindValue
557
- alias :visit_ActiveRecord_Relation_QueryAttribute :visit_Struct_BindValue
558
-
559
- ##############
560
- # DATA TYPES #
561
- ##############
562
-
563
- def visit_Array(o)
564
- collect(o)
565
- end
566
-
567
- alias :visit_Set :visit_Array
568
-
569
- ###########
570
- # HELPERS #
571
- ###########
572
-
573
- def unboundable?(value)
574
- value.respond_to?(:unboundable?) && value.unboundable?
575
- end
576
-
577
- def quote(value)
578
- return value if Arel::Nodes::SqlLiteral === value
579
- @connection.quote value
580
- end
581
-
582
- # assigns a failed status to the current query
583
- def failed!
584
- claim(:status, ElasticsearchRecord::Query::STATUS_FAILED)
12
+ include ElasticsearchBase
13
+ include ElasticsearchQuery
14
+ include ElasticsearchSchema
585
15
 
586
- nil
587
- end
588
16
  end
589
17
  end
590
18
  end