elasticsearch_record 1.8.2 → 2.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 (77) 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 +75 -9
  6. data/Rakefile +0 -0
  7. data/docs/CHANGELOG.md +46 -0
  8. data/docs/CODE_OF_CONDUCT.md +0 -0
  9. data/docs/LICENSE +0 -0
  10. data/elasticsearch_record.gemspec +1 -1
  11. data/lib/active_record/connection_adapters/elasticsearch/column.rb +0 -0
  12. data/lib/active_record/connection_adapters/elasticsearch/database_statements.rb +18 -17
  13. data/lib/active_record/connection_adapters/elasticsearch/quoting.rb +0 -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/column_methods.rb +0 -0
  18. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/create_table_definition.rb +6 -0
  19. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/table_alias_definition.rb +0 -0
  20. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/table_definition.rb +0 -0
  21. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/table_meta_definition.rb +0 -0
  22. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/table_setting_definition.rb +7 -5
  23. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/update_table_definition.rb +3 -1
  24. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions.rb +0 -0
  25. data/lib/active_record/connection_adapters/elasticsearch/schema_dumper.rb +24 -3
  26. data/lib/active_record/connection_adapters/elasticsearch/schema_statements.rb +39 -34
  27. data/lib/active_record/connection_adapters/elasticsearch/table_statements.rb +251 -72
  28. data/lib/active_record/connection_adapters/elasticsearch/transactions.rb +0 -0
  29. data/lib/active_record/connection_adapters/elasticsearch/type/format_string.rb +0 -0
  30. data/lib/active_record/connection_adapters/elasticsearch/type/multicast_value.rb +0 -0
  31. data/lib/active_record/connection_adapters/elasticsearch/type/nested.rb +0 -0
  32. data/lib/active_record/connection_adapters/elasticsearch/type/object.rb +0 -0
  33. data/lib/active_record/connection_adapters/elasticsearch/type/range.rb +0 -0
  34. data/lib/active_record/connection_adapters/elasticsearch/type.rb +0 -0
  35. data/lib/active_record/connection_adapters/elasticsearch/unsupported_implementation.rb +0 -0
  36. data/lib/active_record/connection_adapters/elasticsearch_adapter.rb +144 -87
  37. data/lib/arel/collectors/elasticsearch_query.rb +0 -0
  38. data/lib/arel/nodes/select_agg.rb +0 -0
  39. data/lib/arel/nodes/select_configure.rb +0 -0
  40. data/lib/arel/nodes/select_kind.rb +0 -0
  41. data/lib/arel/nodes/select_query.rb +0 -0
  42. data/lib/arel/visitors/elasticsearch.rb +0 -0
  43. data/lib/arel/visitors/elasticsearch_query.rb +51 -9
  44. data/lib/elasticsearch_record/base.rb +0 -0
  45. data/lib/elasticsearch_record/core.rb +0 -0
  46. data/lib/elasticsearch_record/errors.rb +0 -0
  47. data/lib/elasticsearch_record/extensions/relation.rb +0 -0
  48. data/lib/elasticsearch_record/gem_version.rb +3 -3
  49. data/lib/elasticsearch_record/instrumentation/controller_runtime.rb +0 -0
  50. data/lib/elasticsearch_record/instrumentation/log_subscriber.rb +11 -9
  51. data/lib/elasticsearch_record/instrumentation/railtie.rb +0 -0
  52. data/lib/elasticsearch_record/instrumentation.rb +0 -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/relation_merger_patch.rb +0 -0
  56. data/lib/elasticsearch_record/patches/arel/select_core_patch.rb +0 -0
  57. data/lib/elasticsearch_record/patches/arel/select_manager_patch.rb +0 -0
  58. data/lib/elasticsearch_record/patches/arel/select_statement_patch.rb +0 -0
  59. data/lib/elasticsearch_record/patches/arel/update_manager_patch.rb +0 -0
  60. data/lib/elasticsearch_record/patches/arel/update_statement_patch.rb +0 -0
  61. data/lib/elasticsearch_record/persistence.rb +35 -18
  62. data/lib/elasticsearch_record/query.rb +53 -33
  63. data/lib/elasticsearch_record/querying.rb +5 -7
  64. data/lib/elasticsearch_record/relation/calculation_methods.rb +38 -2
  65. data/lib/elasticsearch_record/relation/core_methods.rb +0 -0
  66. data/lib/elasticsearch_record/relation/query_clause.rb +0 -0
  67. data/lib/elasticsearch_record/relation/query_clause_tree.rb +0 -0
  68. data/lib/elasticsearch_record/relation/query_methods.rb +30 -0
  69. data/lib/elasticsearch_record/relation/result_methods.rb +47 -43
  70. data/lib/elasticsearch_record/relation/value_methods.rb +0 -0
  71. data/lib/elasticsearch_record/result.rb +144 -60
  72. data/lib/elasticsearch_record/schema_migration.rb +0 -0
  73. data/lib/elasticsearch_record/statement_cache.rb +0 -0
  74. data/lib/elasticsearch_record/tasks/elasticsearch_database_tasks.rb +0 -0
  75. data/lib/elasticsearch_record/version.rb +0 -0
  76. data/lib/elasticsearch_record.rb +15 -0
  77. metadata +17 -6
@@ -15,12 +15,12 @@ module ElasticsearchRecord
15
15
  _insert_with_auto_increment(values) do |arguments|
16
16
  # build new query
17
17
  query = ElasticsearchRecord::Query.new(
18
- index: table_name,
19
- type: ElasticsearchRecord::Query::TYPE_CREATE,
18
+ index: table_name,
19
+ type: ElasticsearchRecord::Query::TYPE_CREATE,
20
20
  # IMPORTANT: always exclude possible provided +_id+ field
21
- body: values.except('_id'),
21
+ body: values.except('_id'),
22
22
  arguments: arguments,
23
- refresh: true)
23
+ refresh: true)
24
24
 
25
25
  # execute query and return inserted id
26
26
  connection.insert(query, "#{self} Create", returning: returning)
@@ -35,11 +35,11 @@ module ElasticsearchRecord
35
35
 
36
36
  # build new query
37
37
  query = ElasticsearchRecord::Query.new(
38
- index: table_name,
39
- type: ElasticsearchRecord::Query::TYPE_UPDATE,
40
- body: { doc: values },
38
+ index: table_name,
39
+ type: ElasticsearchRecord::Query::TYPE_UPDATE,
40
+ body: { doc: values },
41
41
  arguments: { id: constraints[self.primary_key] },
42
- refresh: true)
42
+ refresh: true)
43
43
 
44
44
  # execute query and return total updates
45
45
  connection.update(query, "#{self} Update")
@@ -50,10 +50,10 @@ module ElasticsearchRecord
50
50
  def _delete_record(constraints)
51
51
  # build new query
52
52
  query = ElasticsearchRecord::Query.new(
53
- index: table_name,
54
- type: ElasticsearchRecord::Query::TYPE_DELETE,
53
+ index: table_name,
54
+ type: ElasticsearchRecord::Query::TYPE_DELETE,
55
55
  arguments: { id: constraints[self.primary_key] },
56
- refresh: true)
56
+ refresh: true)
57
57
 
58
58
  # execute query and return total deletes
59
59
  connection.delete(query, "#{self} Delete")
@@ -66,7 +66,7 @@ module ElasticsearchRecord
66
66
  # check, if the primary_key's value is provided.
67
67
  # so, no need to resolve a +auto_increment+ value, but provide the id directly
68
68
  if (id = values[self.primary_key]).present?
69
- yield({id: id})
69
+ yield({ id: id })
70
70
  elsif auto_increment?
71
71
  # future increments: uuid (+uuidv6 ?), hex, radix(2-36), integer
72
72
  # allocated through: primary_key_type
@@ -75,17 +75,34 @@ module ElasticsearchRecord
75
75
  # try to resolve the current-auto-increment value from the tables meta
76
76
  connection.table_metas(self.table_name).dig('auto_increment').to_i + 1,
77
77
  # for secure reasons: also resolve the current maximum value for the primary key
78
- self.unscoped.all.maximum(self.primary_key).to_i + 1
78
+ self.unscoped.maximum(self.primary_key).to_i + 1
79
79
  ]
80
80
 
81
- id = yield({ id: ids.max })
81
+ # yield and resolve the new inserted *result*
82
+ result = yield({ id: ids.max })
82
83
 
83
- if id.present?
84
- connection.change_meta(self.table_name, :auto_increment, id)
84
+ # IMPORTANT: the block returns whatever +connection.insert+ resolved, which is NOT a plain id:
85
+ # ActiveRecord always provides the primary_key as +returning+ column, so an ARRAY of the
86
+ # returning column values is resolved (see @ ActiveRecord::Persistence#_create_record).
87
+ # The +_meta+ must be updated with the PLAIN id - storing the raw result breaks the
88
+ # +.to_i+ of the NEXT insert (which then only resolves a 0 - or fails altogether).
89
+ id = case result
90
+ when Array
91
+ result.first
92
+ when Hash
93
+ result[:id] || result['id']
94
+ else
95
+ result
96
+ end
97
+
98
+ if id.present? && id.to_i > 0
99
+ # IMPORTANT: elasticsearch resolves a document +_id+ as String - the +_meta+ must keep the
100
+ # INTEGER it was created with, so the schema does not change its type behind the first insert.
101
+ connection.change_meta(self.table_name, :auto_increment, id.to_i)
85
102
  end
86
103
 
87
- # return inserted id
88
- id
104
+ # return the UNCHANGED insert result - it is zipped against the +returning+ columns
105
+ result
89
106
  else
90
107
  yield({})
91
108
  end
@@ -1,36 +1,39 @@
1
1
  module ElasticsearchRecord
2
2
  class Query
3
3
  # STATUS CONSTANTS
4
- STATUS_VALID = :valid
4
+ STATUS_VALID = :valid
5
5
  STATUS_FAILED = :failed
6
6
 
7
7
  # -- UNDEFINED TYPE ------------------------------------------------------------------------------------------------
8
8
  TYPE_UNDEFINED = :undefined
9
9
 
10
10
  # -- QUERY TYPES ---------------------------------------------------------------------------------------------------
11
- TYPE_COUNT = :count
12
- TYPE_SEARCH = :search
11
+ TYPE_COUNT = :count
12
+ TYPE_SEARCH = :search
13
13
  TYPE_MSEARCH = :msearch
14
- TYPE_SQL = :sql
15
- TYPE_ESQL = :esql
14
+ TYPE_SQL = :sql
15
+
16
+ # PLEASE NOTE: ES|QL requires Elasticsearch >= 8.11
17
+ # (the +esql+ API namespace does not exist before that)
18
+ TYPE_ESQL = :esql
16
19
 
17
20
  # -- DOCUMENT TYPES ------------------------------------------------------------------------------------------------
18
- TYPE_CREATE = :create
19
- TYPE_UPDATE = :update
21
+ TYPE_CREATE = :create
22
+ TYPE_UPDATE = :update
20
23
  TYPE_UPDATE_BY_QUERY = :update_by_query
21
- TYPE_DELETE = :delete
24
+ TYPE_DELETE = :delete
22
25
  TYPE_DELETE_BY_QUERY = :delete_by_query
23
26
 
24
27
  # -- INDEX TYPES ---------------------------------------------------------------------------------------------------
25
28
  TYPE_INDEX_CREATE = :index_create
26
- TYPE_INDEX_CLONE = :index_clone
29
+ TYPE_INDEX_CLONE = :index_clone
27
30
  # INDEX update is not implemented by Elasticsearch
28
31
  # - this is handled through individual updates of +mappings+, +settings+ & +aliases+.
29
32
  # INDEX delete is handled directly as API-call
30
33
  TYPE_INDEX_UPDATE_MAPPING = :index_update_mapping
31
34
  TYPE_INDEX_UPDATE_SETTING = :index_update_setting
32
- TYPE_INDEX_UPDATE_ALIAS = :index_update_alias
33
- TYPE_INDEX_DELETE_ALIAS = :index_delete_alias
35
+ TYPE_INDEX_UPDATE_ALIAS = :index_update_alias
36
+ TYPE_INDEX_DELETE_ALIAS = :index_delete_alias
34
37
 
35
38
  # includes valid types only
36
39
  TYPES = [
@@ -54,22 +57,39 @@ module ElasticsearchRecord
54
57
  # acts like the SQL-query "where('1=0')"
55
58
  FAILED_BODIES = {
56
59
  TYPE_SEARCH => { size: 0, query: { bool: { filter: [{ term: { _id: '_' } }] } } },
57
- TYPE_COUNT => { query: { bool: { filter: [{ term: { _id: '_' } }] } } }
60
+ TYPE_COUNT => { query: { bool: { filter: [{ term: { _id: '_' } }] } } }
58
61
  }.freeze
59
62
 
60
63
  # defines special api gates to be used per type.
61
64
  # if no special type is defined, it simply uses +[:core,self.type]+
62
- GATES = {
63
- TYPE_SQL => [:sql, :query],
64
- TYPE_ESQL => [:esql, :query],
65
- TYPE_INDEX_CREATE => [:indices, :create],
66
- TYPE_INDEX_CLONE => [:indices, :clone],
67
- TYPE_INDEX_UPDATE_MAPPING => [:indices, :put_mapping],
68
- TYPE_INDEX_UPDATE_SETTING => [:indices, :put_settings],
69
- TYPE_INDEX_UPDATE_ALIAS => [:indices, :put_alias],
70
- TYPE_INDEX_DELETE_ALIAS => [:indices, :delete_alias],
65
+ # @return [Hash<Symbol=>String>]
66
+ GATES_MAP = {
67
+ TYPE_SQL => 'sql.query',
68
+ TYPE_ESQL => 'esql.query',
69
+ TYPE_INDEX_CREATE => 'indices.create',
70
+ TYPE_INDEX_CLONE => 'indices.clone',
71
+ TYPE_INDEX_UPDATE_MAPPING => 'indices.put_mapping',
72
+ TYPE_INDEX_UPDATE_SETTING => 'indices.put_settings',
73
+ TYPE_INDEX_UPDATE_ALIAS => 'indices.put_alias',
74
+ TYPE_INDEX_DELETE_ALIAS => 'indices.delete_alias'
71
75
  }.freeze
72
76
 
77
+ # -- PROJECTION MARKERS --------------------------------------------------------------------------------------------
78
+
79
+ # defines a projection marker that forces a query to return *no* +_source+ fields at all.
80
+ # metadata fields ('_id', '_score', ...) are not part of the +_source+ - they are always
81
+ # returned on the document level and therefore stay accessible.
82
+ #
83
+ # this is the only way to clear the columns that +visit_Arel_Nodes_SelectCore+ claims for
84
+ # every relation - a +configure+ can only reach the query-body, never the columns.
85
+ #
86
+ # HINT: only evaluated as the *first* projection - combining it with other fields
87
+ # (e.g. +select(COLUMNS_NONE, :name)+) silently discards them.
88
+ #
89
+ # see @ ElasticsearchRecord::Relation::ResultMethods#meta_only!
90
+ # see @ Arel::Visitors::ElasticsearchQuery#visit_Selects
91
+ COLUMNS_NONE = '!'
92
+
73
93
  # defines the index the query should be executed on
74
94
  # @!attribute String
75
95
  attr_reader :index
@@ -101,14 +121,14 @@ module ElasticsearchRecord
101
121
  attr_reader :columns
102
122
 
103
123
  def initialize(index: nil, type: TYPE_UNDEFINED, status: STATUS_VALID, body: nil, refresh: nil, timeout: nil, arguments: {}, columns: [])
104
- @index = index
105
- @type = type
106
- @status = status
107
- @refresh = refresh
108
- @timeout = timeout
109
- @body = body
124
+ @index = index
125
+ @type = type
126
+ @status = status
127
+ @refresh = refresh
128
+ @timeout = timeout
129
+ @body = body
110
130
  @arguments = arguments
111
- @columns = columns
131
+ @columns = columns
112
132
  end
113
133
 
114
134
  # sets the failed status for this query.
@@ -136,9 +156,9 @@ module ElasticsearchRecord
136
156
  # returns the API gate to be called to execute the query.
137
157
  # each query type needs a different endpoint.
138
158
  # @see Elasticsearch::API
139
- # @return [Array<Symbol, Symbol>] - API gate [<namespace>,<action>]
159
+ # @return [Symbol, String] - API gate "<namespace>.<action>" | <:action>
140
160
  def gate
141
- GATES[self.type].presence || [:core, self.type]
161
+ GATES_MAP[self.type].presence || self.type
142
162
  end
143
163
 
144
164
  # returns true if this is a write query
@@ -161,13 +181,13 @@ module ElasticsearchRecord
161
181
  # Also used possible PRE-defined arguments to be merged with those mentioned attributes.
162
182
  # @return [Hash]
163
183
  def query_arguments
164
- args = @arguments.deep_dup
184
+ args = @arguments.deep_dup
165
185
 
166
186
  # set index, if present
167
- args[:index] = self.index if self.index.present?
187
+ args[:index] = self.index if self.index.present?
168
188
 
169
189
  # set body, if present
170
- args[:body] = self.body if self.body.present?
190
+ args[:body] = self.body if self.body.present?
171
191
 
172
192
  # set refresh, if defined (also includes false value)
173
193
  args[:refresh] = self.refresh unless self.refresh.nil?
@@ -48,7 +48,7 @@ module ElasticsearchRecord
48
48
  when String # really find by SQL
49
49
  ElasticsearchRecord::Query.new(
50
50
  type: ElasticsearchRecord::Query::TYPE_SQL,
51
- body: { query: query_or_sql },
51
+ body: { query: sql },
52
52
  # IMPORTANT: Always provide all columns
53
53
  columns: source_column_names)
54
54
  when Hash
@@ -96,8 +96,7 @@ module ElasticsearchRecord
96
96
  # executes a +esql+ by provided *ES|SL* query
97
97
  # Does NOT instantiate records.
98
98
  # @param [String] esql
99
- # @param [Boolean] async (default: false)
100
- def esql(esql, async: false)
99
+ def esql(esql)
101
100
  # build new query
102
101
  query = ElasticsearchRecord::Query.new(
103
102
  type: ElasticsearchRecord::Query::TYPE_ESQL,
@@ -105,15 +104,14 @@ module ElasticsearchRecord
105
104
  # IMPORTANT: Always provide all columns
106
105
  columns: source_column_names)
107
106
 
108
- connection.internal_exec_query(query, "#{name} ES|QL", async: async)
107
+ connection.exec_query(query, "#{name} ES|QL")
109
108
  end
110
109
 
111
110
 
112
111
  # executes a +msearch+ by provided *RAW* queries.
113
112
  # Does NOT instantiate records.
114
113
  # @param [Array<String>] queries
115
- # @param [Boolean] async (default: false)
116
- def msearch(queries, async: false)
114
+ def msearch(queries)
117
115
  # build new msearch query
118
116
  query = ElasticsearchRecord::Query.new(
119
117
  index: table_name,
@@ -122,7 +120,7 @@ module ElasticsearchRecord
122
120
  # IMPORTANT: Always provide all columns
123
121
  columns: source_column_names)
124
122
 
125
- connection.internal_exec_query(query, "#{name} Msearch", async: async)
123
+ connection.exec_query(query, "#{name} Msearch")
126
124
  end
127
125
 
128
126
  # executes a search by provided +RAW+ query - supports +Elasticsearch::DSL+ gem if loaded
@@ -8,6 +8,12 @@ module ElasticsearchRecord
8
8
  #
9
9
  # Person.all.count(:age)
10
10
  # => returns the total count of all people whose age is present in database
11
+ #
12
+ # Person.all.limit(10).count
13
+ # => returns at most 10 - the SQL 'LIMIT n OFFSET m' semantic is applied on the resolved total
14
+ #
15
+ # @param [Symbol, String, nil] column_name
16
+ # @return [Integer, Hash]
11
17
  def count(column_name = nil)
12
18
  # fallback to default
13
19
  return super() if block_given?
@@ -36,12 +42,12 @@ module ElasticsearchRecord
36
42
  # HINT: +:__query__+ directly interacts with the query-object and sets the 'terminate_after' argument
37
43
  # see @ ElasticsearchRecord::Query#arguments & Arel::Collectors::ElasticsearchQuery#assign
38
44
  arel = spawn.unscope!(:offset, :limit, :order, :configure, :aggs).configure!(:__query__, argument: { terminate_after: limit_value }).arel
39
- klass.connection.select_count(arel, "#{klass.name} Count")
45
+ _resolve_limited_count(klass.connection.select_count(arel, "#{klass.name} Count"))
40
46
  else
41
47
  # since total will be limited to 10000 results, we need to resolve the real values by a custom query.
42
48
  # This query is called through +#select_count+.
43
49
  arel = spawn.unscope!(:offset, :limit, :order, :configure, :aggs)
44
- klass.connection.select_count(arel, "#{klass.name} Count")
50
+ _resolve_limited_count(klass.connection.select_count(arel, "#{klass.name} Count"))
45
51
  end
46
52
  end
47
53
 
@@ -129,9 +135,17 @@ module ElasticsearchRecord
129
135
  #
130
136
  # @note returns *nil* on a *NullRelation*
131
137
  #
138
+ # PLEASE NOTE: the aggregation quantifies the relationship BETWEEN fields, so it requires at
139
+ # least two of them. A single column would additionally take the 'field'-branch of
140
+ # +#calculate_aggregation+ - but the metric only accepts a 'fields' node.
141
+ #
132
142
  # @param [Array<Symbol|String>] column_names
143
+ # @raise [ArgumentError] if less than two columns were provided
133
144
  # @return [Hash,nil]
134
145
  def matrix_stats(*column_names)
146
+ # ensure minimum number of names are provided
147
+ raise(ArgumentError, "Unable to build a 'matrix_stats' aggregation with less than two columns (#{column_names.size} provided) @ #{klass.name}!") if column_names.size < 2
148
+
135
149
  calculate_aggregation(:matrix_stats, *column_names)
136
150
  end
137
151
 
@@ -316,6 +330,28 @@ module ElasticsearchRecord
316
330
  end
317
331
 
318
332
  alias_method :calculate, :calculate_aggregation
333
+
334
+ private
335
+
336
+ # applies the SQL +LIMIT n OFFSET m+ semantic onto an already resolved total.
337
+ #
338
+ # IMPORTANT: Elasticsearch always answers a count with the FULL total. The +terminate_after+
339
+ # argument (see @ +#count+) cannot provide this semantic on its own:
340
+ # - it limits the *collected* documents, but a count query collects none - so it never fires
341
+ # - and it acts PER SHARD, which would resolve +limit * shards+ on a multi-shard index
342
+ #
343
+ # Clamping the resolved total is exact either way: a per-shard early termination can only
344
+ # return a value between +min(limit, total)+ and +total+, so the minimum stays the same.
345
+ #
346
+ # @param [Integer] total - the resolved (full) total
347
+ # @return [Integer]
348
+ def _resolve_limited_count(total)
349
+ # the offset is subtracted first - it can never result in a negative count
350
+ total -= offset_value if offset_value
351
+ return 0 if total < 0
352
+
353
+ limit_value ? [total, limit_value].min : total
354
+ end
319
355
  end
320
356
  end
321
357
  end
File without changes
File without changes
@@ -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
 
@@ -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
File without changes