elasticsearch_record 1.8.2 → 3.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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +0 -0
  3. data/.yardopts +0 -0
  4. data/Gemfile +7 -0
  5. data/README.md +177 -19
  6. data/Rakefile +0 -0
  7. data/docs/CHANGELOG.md +66 -0
  8. data/docs/CODE_OF_CONDUCT.md +0 -0
  9. data/docs/LICENSE +0 -0
  10. data/elasticsearch_record.gemspec +3 -3
  11. data/lib/active_record/connection_adapters/elasticsearch/column.rb +0 -0
  12. data/lib/active_record/connection_adapters/elasticsearch/database_statements.rb +19 -17
  13. data/lib/active_record/connection_adapters/elasticsearch/quoting.rb +16 -0
  14. data/lib/active_record/connection_adapters/elasticsearch/schema_creation.rb +0 -0
  15. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/attribute_methods.rb +0 -0
  16. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/clone_table_definition.rb +0 -0
  17. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/create_table_definition.rb +6 -0
  18. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/table_alias_definition.rb +0 -0
  19. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/table_definition.rb +0 -0
  20. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/table_meta_definition.rb +0 -0
  21. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/table_setting_definition.rb +7 -5
  22. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/update_table_definition.rb +3 -1
  23. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions.rb +0 -0
  24. data/lib/active_record/connection_adapters/elasticsearch/schema_dumper.rb +24 -3
  25. data/lib/active_record/connection_adapters/elasticsearch/schema_statements.rb +39 -34
  26. data/lib/active_record/connection_adapters/elasticsearch/table_statements.rb +251 -72
  27. data/lib/active_record/connection_adapters/elasticsearch/transactions.rb +0 -0
  28. data/lib/active_record/connection_adapters/elasticsearch/type/format_string.rb +0 -0
  29. data/lib/active_record/connection_adapters/elasticsearch/type/multicast_value.rb +0 -0
  30. data/lib/active_record/connection_adapters/elasticsearch/type/nested.rb +0 -0
  31. data/lib/active_record/connection_adapters/elasticsearch/type/object.rb +0 -0
  32. data/lib/active_record/connection_adapters/elasticsearch/type/range.rb +0 -0
  33. data/lib/active_record/connection_adapters/elasticsearch/type.rb +0 -0
  34. data/lib/active_record/connection_adapters/elasticsearch/unsupported_implementation.rb +0 -0
  35. data/lib/active_record/connection_adapters/elasticsearch_adapter.rb +164 -99
  36. data/lib/arel/collectors/elasticsearch_query.rb +3 -0
  37. data/lib/arel/nodes/select_agg.rb +0 -0
  38. data/lib/arel/nodes/select_configure.rb +0 -0
  39. data/lib/arel/nodes/select_kind.rb +0 -0
  40. data/lib/arel/nodes/select_query.rb +0 -0
  41. data/lib/arel/visitors/elasticsearch.rb +0 -0
  42. data/lib/arel/visitors/elasticsearch_query.rb +51 -9
  43. data/lib/elasticsearch_record/base.rb +0 -0
  44. data/lib/elasticsearch_record/core.rb +1 -1
  45. data/lib/elasticsearch_record/errors.rb +0 -0
  46. data/lib/elasticsearch_record/extensions/relation.rb +0 -0
  47. data/lib/elasticsearch_record/gem_version.rb +3 -3
  48. data/lib/elasticsearch_record/instrumentation/controller_runtime.rb +0 -0
  49. data/lib/elasticsearch_record/instrumentation/log_subscriber.rb +11 -9
  50. data/lib/elasticsearch_record/instrumentation/railtie.rb +0 -0
  51. data/lib/elasticsearch_record/instrumentation.rb +0 -0
  52. data/lib/elasticsearch_record/internal_metadata.rb +19 -0
  53. data/lib/elasticsearch_record/model_api.rb +8 -5
  54. data/lib/elasticsearch_record/model_schema.rb +1 -1
  55. data/lib/elasticsearch_record/patches/active_record/connection_pool_patch.rb +57 -0
  56. data/lib/elasticsearch_record/patches/active_record/relation_merger_patch.rb +0 -0
  57. data/lib/elasticsearch_record/patches/arel/select_core_patch.rb +0 -0
  58. data/lib/elasticsearch_record/patches/arel/select_manager_patch.rb +0 -0
  59. data/lib/elasticsearch_record/patches/arel/select_statement_patch.rb +0 -0
  60. data/lib/elasticsearch_record/patches/arel/update_manager_patch.rb +0 -0
  61. data/lib/elasticsearch_record/patches/arel/update_statement_patch.rb +0 -0
  62. data/lib/elasticsearch_record/persistence.rb +39 -21
  63. data/lib/elasticsearch_record/query.rb +53 -33
  64. data/lib/elasticsearch_record/querying.rb +43 -21
  65. data/lib/elasticsearch_record/relation/calculation_methods.rb +38 -2
  66. data/lib/elasticsearch_record/relation/core_methods.rb +1 -1
  67. data/lib/elasticsearch_record/relation/query_clause.rb +0 -0
  68. data/lib/elasticsearch_record/relation/query_clause_tree.rb +2 -1
  69. data/lib/elasticsearch_record/relation/query_methods.rb +31 -1
  70. data/lib/elasticsearch_record/relation/result_methods.rb +47 -43
  71. data/lib/elasticsearch_record/relation/value_methods.rb +4 -4
  72. data/lib/elasticsearch_record/result.rb +144 -60
  73. data/lib/elasticsearch_record/schema_migration.rb +5 -2
  74. data/lib/elasticsearch_record/statement_cache.rb +0 -0
  75. data/lib/elasticsearch_record/tasks/elasticsearch_database_tasks.rb +0 -0
  76. data/lib/elasticsearch_record/version.rb +0 -0
  77. data/lib/elasticsearch_record.rb +17 -0
  78. metadata +22 -9
@@ -50,6 +50,7 @@ module ElasticsearchRecord
50
50
  end
51
51
 
52
52
  # same like +#configure!+, but on the same relation (no spawn)
53
+ # @return [self]
53
54
  def configure!(*args)
54
55
  check_if_method_has_arguments!(__callee__, args)
55
56
 
@@ -259,7 +260,7 @@ module ElasticsearchRecord
259
260
  unless _valid_unscoping_values.include?(scope)
260
261
  raise ArgumentError, "Called unscope() with invalid unscoping argument ':#{scope}'. Valid arguments are :#{_valid_unscoping_values.to_a.join(", :")}."
261
262
  end
262
- assert_mutability!
263
+ assert_modifiable!
263
264
  @values.delete(scope)
264
265
  when Hash
265
266
  scope.each do |key, target_value|
@@ -274,6 +275,27 @@ module ElasticsearchRecord
274
275
  self
275
276
  end
276
277
 
278
+ # overwrite to prevent metadata fields within the projection.
279
+ # Metadata fields (like '_id' or '_score') are NOT part of the +_source+ node, so they cannot be
280
+ # resolved through the +_source+-filter this method builds - providing them would silently create
281
+ # a filter that never matches.
282
+ # HINT: This is different to the +pluck+-method which allows to resolve meta keys directly.
283
+ # see @ Arel::Visitors::ElasticsearchQuery#visit_Selects
284
+ # @param [Array] fields
285
+ def select(*fields)
286
+ # IMPORTANT: +select+ can also be called with a block (and without any fields) - in this case
287
+ # ActiveRecord directly forwards to +super()+, so we must not interfere here.
288
+ if fields.any? && (invalid = _invalid_projection_fields(fields)).present?
289
+ raise(ActiveRecord::UnknownAttributeReference,
290
+ "Unable to select metadata attributes: #{invalid.map(&:inspect).join(", ")}. " \
291
+ "Metadata fields are not part of the '_source' node but are always returned and accessible within the record. " \
292
+ "(e.g. #{klass.name}.first.#{invalid.first})."
293
+ )
294
+ end
295
+
296
+ super
297
+ end
298
+
277
299
  private
278
300
 
279
301
  def build_where_clause(opts, _rest = [])
@@ -342,6 +364,14 @@ module ElasticsearchRecord
342
364
 
343
365
  arel
344
366
  end
367
+
368
+ # returns any provided field that is a metadata field and therefore not resolvable
369
+ # through a projection.
370
+ # @param [Array] fields
371
+ # @return [Array<String>]
372
+ def _invalid_projection_fields(fields)
373
+ ActiveRecord::ConnectionAdapters::ElasticsearchAdapter.metadata_keys & fields.flatten.select{|fld| fld.is_a?(String) || fld.is_a?(Symbol)}.map(&:to_s)
374
+ end
345
375
  end
346
376
  end
347
377
  end
@@ -66,7 +66,7 @@ module ElasticsearchRecord
66
66
  # @return [nil, String] - either returns the pit_id (no block given) or nil
67
67
  def point_in_time(keep_alive: '1m')
68
68
  # resolve a initial PIT id
69
- initial_pit_id = klass.connection.api(:core, :open_point_in_time, { index: klass.table_name, keep_alive: keep_alive }, "#{klass} Open Pit").dig('id')
69
+ initial_pit_id = klass.connection.api(:open_point_in_time, { index: klass.table_name, keep_alive: keep_alive }, "#{klass} Open Pit").dig('id')
70
70
 
71
71
  return initial_pit_id unless block_given?
72
72
 
@@ -74,7 +74,7 @@ module ElasticsearchRecord
74
74
  yield initial_pit_id
75
75
 
76
76
  # close PIT
77
- klass.connection.api(:core, :close_point_in_time, { body: { id: initial_pit_id } }, "#{klass} Close Pit")
77
+ klass.connection.api(:close_point_in_time, { body: { id: initial_pit_id } }, "#{klass} Close Pit")
78
78
 
79
79
  # return nil if everything was ok
80
80
  nil
@@ -90,13 +90,12 @@ module ElasticsearchRecord
90
90
  #
91
91
  # @param [String] keep_alive - how long to keep alive (for each single request) - default: '1m'
92
92
  # @param [Integer] batch_size - how many results per query (default: 1000 - this means at least 10 queries before reaching the +max_result_window+)
93
- # @param [Boolean] ids_only - resolve ids only from results
94
93
  # @return [Integer, Array] either returns the results-array (no block provided) or the total amount of results
95
- def pit_results(keep_alive: '1m', batch_size: 1000, ids_only: false)
96
- raise(ArgumentError, "Batch size cannot be above the 'max_result_window' (#{klass.max_result_window}) !") if batch_size > klass.max_result_window
94
+ def pit_results(keep_alive: '1m', batch_size: 1000)
95
+ raise(ArgumentError, "Batch size cannot be above the 'max_result_window' (#{batch_size} > #{klass.max_result_window}) !") if batch_size > klass.max_result_window
97
96
 
98
97
  # check if limit or offset values where provided
99
- results_limit = limit_value ? limit_value : Float::INFINITY
98
+ results_limit = limit_value ? limit_value : Float::INFINITY
100
99
  results_offset = offset_value ? offset_value : 0
101
100
 
102
101
  # search_after requires a order - we resolve a order either from provided value or by default ...
@@ -107,9 +106,6 @@ module ElasticsearchRecord
107
106
  # see @ https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html
108
107
  relation.order!(_shard_doc: :asc) if relation.order_values.empty? && klass.connection.access_shard_doc?
109
108
 
110
- # resolve ids only
111
- relation.reselect!('_id') if ids_only
112
-
113
109
  # clear limit & offset
114
110
  relation.offset!(nil).limit!(nil)
115
111
 
@@ -117,47 +113,47 @@ module ElasticsearchRecord
117
113
  relation.configure!(:__query__, { index: nil })
118
114
 
119
115
  # we store the results in this array
120
- results = []
116
+ results = []
121
117
  results_total = 0
122
118
 
123
119
  # resolve a new pit and auto-close after we finished
124
120
  point_in_time(keep_alive: keep_alive) do |pit_id|
121
+ # set the initial pit hash, used to configure the ES query
125
122
  current_pit_hash = { pit: { id: pit_id, keep_alive: keep_alive } }
126
123
 
127
124
  # resolve new data until we got all we need
128
125
  loop do
129
126
  # change pit settings & limit (spawn is required, since a +resolve+ will make the relation immutable)
130
- current_response = relation.spawn.configure!(current_pit_hash).limit!(batch_size).resolve('Pit Results').response
127
+ # @type [ElasticsearchRecord::Result]
128
+ current_result = relation.spawn.configure!(current_pit_hash).limit!(batch_size).resolve('Pit Results')
131
129
 
132
- # resolve only data from hits->hits[{_source}]
133
- current_results = if ids_only
134
- current_response['hits']['hits'].map { |result| result['_id'] }
135
- else
136
- current_response['hits']['hits'].map { |result| result['_source'].merge('_id' => result['_id']) }
137
- end
130
+ # resolve all results, depending on the existing query (select, ...)
131
+ current_results = current_result.to_ary
138
132
 
133
+ # temporary store the absolute length - used for pagination or stop
139
134
  current_results_length = current_results.length
140
135
 
141
136
  # check if we reached the required offset
142
137
  if results_offset < current_results_length
143
138
  # check for parts
144
- # (maybe a offset 6300 was provided but the batch size is 1000 - so we need to skip a part ...)
139
+ # (maybe an offset of 6300 was provided but the batch size is 1000 - so we need to skip a part ...)
145
140
  results_from = results_offset > 0 ? results_offset : 0
146
- results_to = (results_total + current_results_length - results_from) > results_limit ? results_limit - results_total + results_from - 1 : -1
141
+ results_to = (results_total + current_results_length - results_from) > results_limit ? results_limit - results_total + results_from - 1 : -1
147
142
 
148
- ranged_results = current_results[results_from..results_to]
143
+ # reduce the *current_results* by calculated +from..to+ range
144
+ current_results = current_results[results_from..results_to] if results_from != 0 || results_to != -1
149
145
 
150
146
  if block_given?
151
- yield ranged_results
147
+ yield current_results
152
148
  else
153
- results += ranged_results
149
+ results += current_results
154
150
  end
155
151
 
156
152
  # add to total
157
- results_total += ranged_results.length
153
+ results_total += current_results.length
158
154
  end
159
155
 
160
- # -------- BREAK conditions --------
156
+ # -- BREAK conditions --------------------------------------------------------------------------------------
161
157
 
162
158
  # we reached our maximum value
163
159
  break if results_total >= results_limit
@@ -166,22 +162,24 @@ module ElasticsearchRecord
166
162
  break if current_results_length < batch_size
167
163
 
168
164
  # additional security - prevents infinite loops
169
- raise(::ActiveRecord::StatementInvalid, "'pit_results' aborted due an infinite loop error (invalid or missing order)") if current_pit_hash[:search_after] == current_response['hits']['hits'][-1]['sort'] && current_pit_hash[:pit][:id] == current_response['pit_id']
165
+ if current_pit_hash[:search_after] == current_result.response['hits']['hits'][-1]['sort'] && current_pit_hash[:pit][:id] == current_result.response['pit_id']
166
+ raise(::ActiveRecord::StatementInvalid, "'pit_results' aborted due an infinite loop error (invalid or missing order)")
167
+ end
170
168
 
171
- # -------- NEXT LOOP changes --------
169
+ # -- NEXT LOOP changes -------------------------------------------------------------------------------------
172
170
 
173
171
  # reduce the offset
174
172
  results_offset -= current_results_length
175
173
 
176
174
  # assign new pit
177
- current_pit_hash = { search_after: current_response['hits']['hits'][-1]['sort'], pit: { id: current_response['pit_id'], keep_alive: keep_alive } }
175
+ current_pit_hash = { search_after: current_result.response['hits']['hits'][-1]['sort'], pit: { id: current_result.response['pit_id'], keep_alive: keep_alive } }
178
176
 
179
- # we need to justify the +batch_size+ if the query will reach over the limit
180
- batch_size = results_limit - results_total if results_offset < batch_size && (results_total + batch_size) > results_limit
177
+ # we need to justify the +batch_size+ if the query reaches over the limit
178
+ batch_size = results_limit - results_total if results_offset < batch_size && (results_total + batch_size) > results_limit
181
179
  end
182
180
  end
183
181
 
184
- # return results array or total value
182
+ # returns either to total number of +pit+ results or an array of all collected results
185
183
  if block_given?
186
184
  results_total
187
185
  else
@@ -193,17 +191,19 @@ module ElasticsearchRecord
193
191
 
194
192
  # executes a delete query in a +point_in_time+ scope.
195
193
  # this will provide the possibility to delete more than the +max_result_window+ (default: 10000) docs in a batched process.
196
- # @param [String] keep_alive
197
- # @param [Integer] batch_size
198
- # @param [Boolean] refresh index after delete finished (default: true)
194
+ # @param [String] keep_alive - defines the keep alive time per +pit+ (not in total) - should be relative to *batch_size*
195
+ # @param [Integer] batch_size - the size of entries to delete per +pit+
196
+ # @param [Boolean] refresh - auto-refresh index after delete finished (default: true)
199
197
  # @return [Integer] total amount of deleted docs
200
- def pit_delete(keep_alive: '1m', batch_size: 1000, refresh: true)
201
- delete_count = select('_id').pit_results(keep_alive: keep_alive, batch_size: batch_size, ids_only: true) do |ids|
198
+ def pit_delete(keep_alive: '1m', batch_size: 1_000, refresh: true)
199
+ # spawns a new query with disabled results (so only ids will be resolved)
200
+ delete_count = spawn.meta_only!.pit_results(keep_alive: keep_alive, batch_size: batch_size) do |results|
202
201
  # skip empty results
203
- next unless ids.any?
202
+ next unless results.any?
204
203
 
205
- # delete all IDs, but do not refresh index, yet
206
- klass.connection.api(:core, :bulk, { index: klass.table_name, body: ids.map { |id| { delete: { _id: id } } }, refresh: false }, "#{klass} Pit Delete")
204
+ # delete all IDs through +API+
205
+ # does not refresh index at this point (this is done below, if not disabled)
206
+ klass.connection.api(:bulk, { index: klass.table_name, body: results.map { |result| { delete: { _id: result['_id'] } } }, refresh: false }, "#{klass} Pit Delete")
207
207
  end
208
208
 
209
209
  # refresh index
@@ -249,24 +249,28 @@ module ElasticsearchRecord
249
249
  end
250
250
 
251
251
  # sets query as "hits"-only query (drops the aggs from the query)
252
+ # @return [self]
252
253
  def hits_only!
253
254
  configure!({ aggs: nil })
254
-
255
- self
256
255
  end
257
256
 
258
257
  # sets query as "aggs"-only query (drops the size & sort options - so no hits will return)
258
+ # @return [self]
259
259
  def aggs_only!
260
260
  configure!({ size: 0, from: nil, sort: nil, _source: false })
261
-
262
- self
263
261
  end
264
262
 
265
263
  # sets query as "total"-only query (drops the size, sort & aggs options - so no hits & aggs will be returned)
264
+ # @return [self]
266
265
  def total_only!
267
266
  configure!({ size: 0, from: nil, aggs: nil, sort: nil, _source: false })
267
+ end
268
268
 
269
- self
269
+ # sets query as "meta"-only query (drops aggs and source).
270
+ # This is used to prevent resolving documents from the index and only returns "meta" information (like _id, _score, _type, ...)
271
+ # @return [self]
272
+ def meta_only!
273
+ select(::ElasticsearchRecord::Query::COLUMNS_NONE).configure!({ aggs: nil, _source: false })
270
274
  end
271
275
  end
272
276
  end
@@ -8,7 +8,7 @@ module ElasticsearchRecord
8
8
 
9
9
  def kind_value=(value)
10
10
  # checks if records are already loaded - in this case we cannot mutate the query anymore
11
- assert_mutability!
11
+ assert_modifiable!
12
12
 
13
13
  @values[:kind] = value.to_sym
14
14
  end
@@ -18,7 +18,7 @@ module ElasticsearchRecord
18
18
  end
19
19
 
20
20
  def configure_value=(value)
21
- assert_mutability!
21
+ assert_modifiable!
22
22
 
23
23
  @values[:configure] = value
24
24
  end
@@ -28,7 +28,7 @@ module ElasticsearchRecord
28
28
  end
29
29
 
30
30
  def query_clause=(value)
31
- assert_mutability!
31
+ assert_modifiable!
32
32
 
33
33
  @values[:query] = value
34
34
  end
@@ -38,7 +38,7 @@ module ElasticsearchRecord
38
38
  end
39
39
 
40
40
  def aggs_clause=(value)
41
- assert_mutability!
41
+ assert_modifiable!
42
42
 
43
43
  @values[:aggs] = value
44
44
  end
@@ -7,8 +7,8 @@ module ElasticsearchRecord
7
7
  include Enumerable
8
8
 
9
9
  # creates an empty response
10
- # @return [ElasticsearchRecord::Result (frozen)]
11
- def self.empty(async: false) # :nodoc:
10
+ # @return [ElasticsearchRecord::Result (frozen), ActiveRecord::FutureResult::Complete (frozen)]
11
+ def self.empty(async: false)
12
12
  if async
13
13
  EMPTY_ASYNC
14
14
  else
@@ -23,7 +23,7 @@ module ElasticsearchRecord
23
23
  # @param [Array] columns
24
24
  # @param [Hash] column_types
25
25
  def initialize(response, columns = [], column_types = {})
26
- # contains either the response or creates a empty hash (if nil)
26
+ # contains either the response or creates an empty hash (if nil)
27
27
  @response = response.presence || {}
28
28
 
29
29
  # used to build computed_results
@@ -39,37 +39,69 @@ module ElasticsearchRecord
39
39
  response['took']
40
40
  end
41
41
 
42
+ # returns the response result string
43
+ # @return [String]
44
+ def result
45
+ response['result'] || ''
46
+ end
47
+
42
48
  # returns the response total value.
43
49
  # either chops the +total+ value directly from response, from hits or aggregations.
44
50
  # @return [Integer]
45
51
  def total
46
- # chop total only
47
- @total ||= _chop_total
52
+ # chop total from response and not from the generated data
53
+ @total ||= _total
48
54
  end
49
55
 
50
- # returns the response RAW hits hash.
51
- # PLEASE NOTE: Does not return the nested hits (+response['hits']['hits']+) array!
52
- # @return [ActiveSupport::HashWithIndifferentAccess, Hash]
53
- def hits
54
- response.key?('hits') ? response['hits'].with_indifferent_access : {}
55
- end
56
-
57
- # Returns the RAW +_source+ data from each hit - aka. +rows+.
56
+ # Returns the RAW +_source+ data from each hit.
58
57
  # PLEASE NOTE: The array will only contain the RAW data from each +_source+ (meta info like '_id' or '_score' are not included)
59
58
  # @return [Array]
60
59
  def results
61
- return [] unless response['hits']
60
+ # IMPORTANT: check against missing hits without any '_source' node.
61
+ # This happens if the Elasticsearch query has the +_source:false+ flag!
62
+ if response['hits']
63
+ response['hits']['hits'].map { |doc| doc['_source'] || {} }
64
+ elsif _tabular?
65
+ # a tabular (+SQL+ / +ES|QL+) response has no '_source' node - the row values are the data
66
+ _results_from_tabular
67
+ else
68
+ []
69
+ end
70
+ end
62
71
 
63
- response['hits']['hits'].map { |result| result['_source'] }
72
+ # returns an array of all rows.
73
+ # => All result values, depending on the provided columns.
74
+ # The +rows+ is used by the ActiveRecord ConnectionAdapters and must not be removed!
75
+ # @return [Array]
76
+ def rows
77
+ # a tabular (+SQL+ / +ES|QL+) response is ALREADY positional - and it is positional to the
78
+ # response's own columns, not to the (requested) +columns+ of the query.
79
+ return _tabular_values if _tabular?
80
+
81
+ # IMPORTANT: without provided +columns+ we cannot build positional rows - mapping over an
82
+ # empty +columns+ array would return an empty array per hit and silently lose all data.
83
+ # In this case we fall back to the raw +_source+ values.
84
+ return results.map(&:values) if columns.blank?
85
+
86
+ results.map { |doc|
87
+ columns.map { |column|
88
+ doc[column]
89
+ }
90
+ }
64
91
  end
65
92
 
66
- # The +rows+ alias is used by the ActiveRecord ConnectionAdapters and must not be removed!
67
- alias_method :rows, :results
93
+ # returns the response RAW hits hash.
94
+ # PLEASE NOTE: Does not return the nested hits (+response['hits']['hits']+) array!
95
+ #
96
+ # @return [ActiveSupport::HashWithIndifferentAccess, Hash]
97
+ def hits
98
+ response['hits']&.with_indifferent_access || {}
99
+ end
68
100
 
69
101
  # returns the response RAW aggregations hash.
70
102
  # @return [ActiveSupport::HashWithIndifferentAccess, Hash]
71
103
  def aggregations
72
- response.key?('aggregations') ? response['aggregations'].with_indifferent_access : {}
104
+ response['aggregations']&.with_indifferent_access || {}
73
105
  end
74
106
 
75
107
  # returns the (nested) bucket values (and aggregated values) from the response aggregations.
@@ -84,13 +116,14 @@ module ElasticsearchRecord
84
116
  end
85
117
 
86
118
  # Returns true if this result set includes the column named +name+.
87
- # used by ActiveRecord
119
+ # used by +ActiveRecord+
88
120
  def includes_column?(name)
89
121
  @columns&.include?(name)
90
122
  end
91
123
 
92
124
  # Returns the number of elements in the response array.
93
- # Either uses the +hits+ length or the +responses+ length _(msearch)_.
125
+ # Either uses the +hits+ length, the +responses+ length _(msearch)_ or the length of the
126
+ # tabular value rows _(SQL / ES|QL)_.
94
127
  # @return [Integer]
95
128
  def length
96
129
  if response.key?('hits')
@@ -98,6 +131,9 @@ module ElasticsearchRecord
98
131
  elsif response.key?('responses')
99
132
  # used by +msearch+
100
133
  response['responses'].length
134
+ elsif _tabular?
135
+ # used by +sql+ & +esql+
136
+ _tabular_values.length
101
137
  else
102
138
  0
103
139
  end
@@ -131,51 +167,56 @@ module ElasticsearchRecord
131
167
  computed_results[idx]
132
168
  end
133
169
 
134
- # Returns the last record from the rows collection.
170
+ # Returns the last record(s) from the *computed_results* collection.
135
171
  def last(n = nil)
136
172
  n ? computed_results.last(n) : computed_results.last
137
173
  end
138
174
 
139
- # returns the response result string
140
- # @return [String]
141
- def result
142
- response['result'] || ''
143
- end
144
-
145
175
  # used by ActiveRecord
146
176
  def cancel # :nodoc:
147
177
  self
148
178
  end
149
179
 
150
- # used by ActiveRecord
180
+ # used by ActiveRecord for "pluck"
151
181
  def cast_values(type_overrides = {})
152
- # :nodoc:
182
+ # fast escape, if no hits are available
183
+ return [] unless response['hits']
184
+
185
+ # HINT: This is separated to avoid allocating a (nested) array per row
153
186
  if columns.one?
154
- # Separated to avoid allocating an array per row
187
+ # resolve the column key
155
188
  key = columns.first
156
189
 
157
- type = if type_overrides.is_a?(Array)
158
- type_overrides.first
159
- else
160
- column_type(columns.first, type_overrides)
161
- end
190
+ # resolve type from overrides or +#column_type+ method
191
+ type = type_overrides.is_a?(Array) ? type_overrides.first : column_type(key, type_overrides)
162
192
 
163
- computed_results.map do |result|
164
- type.deserialize(result[key])
193
+ # EDGE-case for metadata fields
194
+ if ActiveRecord::ConnectionAdapters::ElasticsearchAdapter.metadata_keys.include?(key)
195
+ # directly read from doc
196
+ response['hits']['hits'].map { |doc| type.deserialize(doc[key]) }
197
+ else
198
+ results.map do |result|
199
+ type.deserialize(result[key])
200
+ end
165
201
  end
166
202
  else
167
- types = if type_overrides.is_a?(Array)
168
- type_overrides
169
- else
170
- columns.map { |name| column_type(name, type_overrides) }
171
- end
203
+ # resolve types from overrides or +#column_type+ method
204
+ types = type_overrides.is_a?(Array) ? type_overrides : columns.map { |name| column_type(name, type_overrides) }
172
205
 
173
206
  size = types.size
174
207
 
175
- computed_results.map do |result|
208
+ # EDGE-case for metadata fields - they have to be resolved from the doc, so we merge them into the +_source+
209
+ rows = if (ActiveRecord::ConnectionAdapters::ElasticsearchAdapter.metadata_keys & columns).any?
210
+ response['hits']['hits'].map { |doc|
211
+ (doc['_source'] || {}).merge(doc.slice(*ActiveRecord::ConnectionAdapters::ElasticsearchAdapter.metadata_keys))
212
+ }
213
+ else
214
+ response['hits']['hits'].map { |doc| doc['_source'] || {} }
215
+ end
216
+
217
+ rows.map do |result|
176
218
  Array.new(size) { |i|
177
- key = columns[i]
178
- types[i].deserialize(result[key])
219
+ types[i].deserialize(result[columns[i]])
179
220
  }
180
221
  end
181
222
  end
@@ -185,45 +226,84 @@ module ElasticsearchRecord
185
226
 
186
227
  # used by ActiveRecord
187
228
  def column_type(name, type_overrides = {})
188
- type_overrides.fetch(name, Type.default_value)
229
+ type_overrides.fetch(name, ::ActiveRecord::Type.default_value)
189
230
  end
190
231
 
191
- # chops total value from response
232
+ # resolves total value from response
192
233
  # @return [Integer]
193
- def _chop_total
234
+ def _total
194
235
  return self.response['total'] if self.response.key?('total')
195
236
  return self.response['hits']['total']['value'] if self.response.key?('hits')
196
237
  return self.response['aggregations'].count if self.response.key?('aggregations')
197
- return self.response['_shards']['total'] if self.response.key?('_shards')
238
+ # a tabular response has no total - the transferred rows are all there is
239
+ return _tabular_values.length if _tabular?
198
240
 
199
241
  0
200
242
  end
201
243
 
244
+ # true if the response is TABULAR - which is what the +sql+ & +esql+ APIs return instead of a
245
+ # (nested) 'hits' node: a flat 'columns' definition and positional value rows.
246
+ # @return [Boolean]
247
+ def _tabular?
248
+ response.key?('columns') && (response.key?('rows') || response.key?('values'))
249
+ end
250
+
251
+ # returns the column names of a tabular response.
252
+ # Both APIs describe their columns as a {'name' =>, 'type' =>} pair.
253
+ # @return [Array<String>]
254
+ def _tabular_columns
255
+ response['columns'].map { |column| column['name'] }
256
+ end
257
+
258
+ # returns the positional value rows of a tabular response.
259
+ # PLEASE NOTE: the +sql+ API names this node 'rows', the +esql+ API names it 'values'.
260
+ # @return [Array<Array>]
261
+ def _tabular_values
262
+ response['rows'] || response['values']
263
+ end
264
+
265
+ # used for +sql+ & +esql+ results
266
+ # IMPORTANT: the rows are positional to the RESPONSE columns - not to the (requested) +columns+
267
+ # of the query. A projecting query (e.g. 'SELECT name FROM ...') returns fewer columns, so
268
+ # zipping against the query's columns would shift every value.
269
+ # @return [Array]
270
+ def _results_from_tabular
271
+ # We freeze the strings to prevent them getting duped when
272
+ # used as keys in ActiveRecord::Base's @attributes hash.
273
+ keys = _tabular_columns.map(&:-@)
274
+
275
+ _tabular_values.map { |row| keys.zip(row).to_h }
276
+ end
277
+
202
278
  # used for +msearch+ results
203
279
  # @return [Array]
204
- def _results_for_responses
280
+ def _results_from_responses
205
281
  response['responses'].map { |response| self.class.new(response, self.columns, self.column_types) }
206
282
  end
207
283
 
208
284
  # used for +search+ results
209
285
  # @return [Array]
210
- def _results_for_hits
286
+ def _results_from_hits
211
287
  # PLEASE NOTE: the 'hits' response has multiple nodes: BASE nodes & the +_source+ node.
212
- # The real data is within the source node, but we also want the BASE nodes for possible score & type check
213
- base_fields = ActiveRecord::ConnectionAdapters::ElasticsearchAdapter.base_structure_keys
288
+ # The real data is within the source node, but we also want the METADATA nodes for possible score & type check
289
+ metadata_fields = ActiveRecord::ConnectionAdapters::ElasticsearchAdapter.metadata_keys
214
290
 
215
291
  # check for provided columns
216
292
  if @columns.present?
217
293
  # We freeze the strings to prevent them getting duped when
218
294
  # used as keys in ActiveRecord::Base's @attributes hash.
219
- # ALSO IMPORTANT: remove base_fields from possible provided columns
220
- columns = @columns ? (@columns - base_fields).map(&:-@) : []
295
+ # IMPORTANT: remove *metadata_fields* from possible provided columns ( &:-@ -> freeze strings )
296
+ columns = (@columns - metadata_fields).map(&:-@)
221
297
 
222
298
  # this is the hashed result array
223
299
  response['hits']['hits'].map { |doc|
224
- result = doc.slice(*base_fields)
300
+ # allocate new result hash with all known metadata keys ('_id', ...)
301
+ result = doc.slice(*metadata_fields)
302
+
303
+ # iterate through each requested column
225
304
  columns.each do |column|
226
- result[column] = doc['_source'][column]
305
+ # in case no source was provided, it prevents an exception
306
+ result[column] = doc.dig('_source', column)
227
307
  end
228
308
 
229
309
  result
@@ -234,7 +314,8 @@ module ElasticsearchRecord
234
314
 
235
315
  # this is the hashed result array
236
316
  response['hits']['hits'].map { |doc|
237
- doc.slice(*base_fields).merge(doc['_source'])
317
+ # in case no source was provided, it prevents an exception
318
+ doc.slice(*metadata_fields).merge!(doc['_source'] || {})
238
319
  }
239
320
  end
240
321
  end
@@ -270,10 +351,13 @@ module ElasticsearchRecord
270
351
  # @return [Array]
271
352
  def computed_results
272
353
  @computed_results ||= if response.key?('hits')
273
- _results_for_hits
354
+ _results_from_hits
274
355
  elsif response.key?('responses')
275
356
  # used by +msearch+
276
- _results_for_responses
357
+ _results_from_responses
358
+ elsif _tabular?
359
+ # used by +sql+ & +esql+
360
+ _results_from_tabular
277
361
  else
278
362
  []
279
363
  end
@@ -14,9 +14,12 @@ module ElasticsearchRecord
14
14
  sm = Arel::SelectManager.new(arel_table)
15
15
  sm.project(arel_table[primary_key])
16
16
  sm.order(arel_table[primary_key].asc)
17
- sm.take(connection.max_result_window(table_name))
18
17
 
19
- connection.select_values(sm, "#{self.class} Load")
18
+ @pool.with_connection do |connection|
19
+ sm.take(connection.max_result_window(table_name))
20
+
21
+ connection.select_values(sm, "#{self.class} Load")
22
+ end
20
23
  end
21
24
  end
22
25
  end
File without changes
File without changes