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
@@ -39,7 +39,7 @@ module ElasticsearchRecord
39
39
  end
40
40
 
41
41
  # delegated dangerous methods with confirm parameter (created with exclamation mark)
42
- # a exception will be raised, if +confirm:true+ is missing.
42
+ # an exception will be raised, if +confirm:true+ is missing.
43
43
  #
44
44
  # @example
45
45
  # drop!(confirm: true)
@@ -183,12 +183,15 @@ module ElasticsearchRecord
183
183
  # @param [Boolean] close
184
184
  # @return [String] backup_name
185
185
 
186
- # @!method restore!(from:, timeout: nil, open: true, drop_backup: false)
186
+ # @!method restore!(from:, timeout: nil, unblock: true, drop_backup: false)
187
187
  # Shortcut for restore_table
188
+ # PLEASE NOTE: the restore runs through a +clone+, so the restored table inherits the
189
+ # 'write'-block of its source - +unblock+ releases it again.
188
190
  # @param [String] from
189
191
  # @param [String (frozen)] timeout
190
- # @param [Boolean] open
191
- # @return [Boolean] acknowledged status
192
+ # @param [Boolean] unblock - releases the inherited 'write'-block (default: true)
193
+ # @param [Boolean] drop_backup
194
+ # @return [nil]
192
195
 
193
196
  # @!method reindex!(target_name, **options)
194
197
  # Shortcut for reindex_table
@@ -267,7 +270,7 @@ module ElasticsearchRecord
267
270
  def bulk(data, operation = :index, refresh: true, **options)
268
271
  data = [data] unless data.is_a?(Array)
269
272
 
270
- _connection.api(:core, :bulk, {
273
+ _connection.api(:bulk, {
271
274
  index: _index_name,
272
275
  body: case operation
273
276
  when :update
@@ -45,7 +45,7 @@ module ElasticsearchRecord
45
45
  # so this is a array of real document (+_source+) attributes of the index.
46
46
  # @return [Array<String>]
47
47
  def source_column_names
48
- @source_column_names ||= columns.reject(&:virtual?).map(&:name) - ActiveRecord::ConnectionAdapters::ElasticsearchAdapter.base_structure_keys
48
+ @source_column_names ||= columns.reject(&:virtual?).map(&:name) - ActiveRecord::ConnectionAdapters::ElasticsearchAdapter.metadata_keys
49
49
  end
50
50
 
51
51
  # returns an array with columns names, that are searchable (also includes nested fields & properties )
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record/connection_adapters/abstract/connection_pool'
4
+
5
+ module ElasticsearchRecord
6
+ module Patches
7
+ module ActiveRecord
8
+ # Since rails 7.2 the migration plumbing is resolved through the connection POOL
9
+ # (+ConnectionPool#migration_context+ builds from +#migrations_paths+, +#schema_migration+ &
10
+ # +#internal_metadata+) instead of the connection - so the adapters own implementations are
11
+ # no longer consulted. Without this patch a Elasticsearch pool would migrate with the generic
12
+ # +ActiveRecord::SchemaMigration+ (which resolves the index name through +ActiveRecord::Base+
13
+ # and only ever sees the first ten migrations - Elasticsearch default search size), enable the
14
+ # internal metadata index (the adapter cannot serve it) and fall back to the default
15
+ # 'db/migrate' path.
16
+ #
17
+ # The patch simply routes those factories back to the adapter class, which keeps the
18
+ # Elasticsearch-specific implementations in one place.
19
+ module ConnectionPoolPatch
20
+ def self.included(base)
21
+ base.send(:prepend, PrependMethods)
22
+ end
23
+
24
+ module PrependMethods
25
+ def migrations_paths
26
+ return super unless elasticsearch_pool?
27
+
28
+ db_config.migrations_paths || ['db/migrate_elasticsearch']
29
+ end
30
+
31
+ def schema_migration
32
+ return super unless elasticsearch_pool?
33
+
34
+ ElasticsearchRecord::SchemaMigration.new(self)
35
+ end
36
+
37
+ def internal_metadata
38
+ return super unless elasticsearch_pool?
39
+
40
+ ElasticsearchRecord::InternalMetadata.new(self)
41
+ end
42
+
43
+ private
44
+
45
+ # returns true, if the pools config resolves to the Elasticsearch adapter
46
+ # @return [Boolean]
47
+ def elasticsearch_pool?
48
+ db_config.adapter_class <= ::ActiveRecord::ConnectionAdapters::ElasticsearchAdapter
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ # include once only!
57
+ ::ActiveRecord::ConnectionAdapters::ConnectionPool.include(ElasticsearchRecord::Patches::ActiveRecord::ConnectionPoolPatch) unless ::ActiveRecord::ConnectionAdapters::ConnectionPool.included_modules.include?(ElasticsearchRecord::Patches::ActiveRecord::ConnectionPoolPatch)
@@ -5,22 +5,23 @@ module ElasticsearchRecord
5
5
  module ClassMethods
6
6
  # insert a new record into the Elasticsearch index
7
7
  # NOTICE: We don't want to mess up with the Arel-builder - so we send new data directly to the API
8
+ # @param [ActiveRecord::ConnectionAdapters::ElasticsearchAdapter] connection
8
9
  # @param [ActiveModel::Attribute] values
9
10
  # @return [Object] id
10
- def _insert_record(values, returning)
11
+ def _insert_record(connection, values, returning)
11
12
  # values is not a "key=>values"-Hash, but a +ActiveModel::Attribute+ - so the casted values gets resolved here
12
13
  values = values.transform_values(&:value)
13
14
 
14
15
  # resolve & update a auto_increment value, if configured
15
- _insert_with_auto_increment(values) do |arguments|
16
+ _insert_with_auto_increment(connection, values) do |arguments|
16
17
  # build new query
17
18
  query = ElasticsearchRecord::Query.new(
18
- index: table_name,
19
- type: ElasticsearchRecord::Query::TYPE_CREATE,
19
+ index: table_name,
20
+ type: ElasticsearchRecord::Query::TYPE_CREATE,
20
21
  # IMPORTANT: always exclude possible provided +_id+ field
21
- body: values.except('_id'),
22
+ body: values.except('_id'),
22
23
  arguments: arguments,
23
- refresh: true)
24
+ refresh: true)
24
25
 
25
26
  # execute query and return inserted id
26
27
  connection.insert(query, "#{self} Create", returning: returning)
@@ -35,11 +36,11 @@ module ElasticsearchRecord
35
36
 
36
37
  # build new query
37
38
  query = ElasticsearchRecord::Query.new(
38
- index: table_name,
39
- type: ElasticsearchRecord::Query::TYPE_UPDATE,
40
- body: { doc: values },
39
+ index: table_name,
40
+ type: ElasticsearchRecord::Query::TYPE_UPDATE,
41
+ body: { doc: values },
41
42
  arguments: { id: constraints[self.primary_key] },
42
- refresh: true)
43
+ refresh: true)
43
44
 
44
45
  # execute query and return total updates
45
46
  connection.update(query, "#{self} Update")
@@ -50,10 +51,10 @@ module ElasticsearchRecord
50
51
  def _delete_record(constraints)
51
52
  # build new query
52
53
  query = ElasticsearchRecord::Query.new(
53
- index: table_name,
54
- type: ElasticsearchRecord::Query::TYPE_DELETE,
54
+ index: table_name,
55
+ type: ElasticsearchRecord::Query::TYPE_DELETE,
55
56
  arguments: { id: constraints[self.primary_key] },
56
- refresh: true)
57
+ refresh: true)
57
58
 
58
59
  # execute query and return total deletes
59
60
  connection.delete(query, "#{self} Delete")
@@ -62,11 +63,11 @@ module ElasticsearchRecord
62
63
  private
63
64
 
64
65
  # Resolves the +auto_increment+ status from the tables +_meta+ attributes.
65
- def _insert_with_auto_increment(values)
66
+ def _insert_with_auto_increment(connection, values)
66
67
  # check, if the primary_key's value is provided.
67
68
  # so, no need to resolve a +auto_increment+ value, but provide the id directly
68
69
  if (id = values[self.primary_key]).present?
69
- yield({id: id})
70
+ yield({ id: id })
70
71
  elsif auto_increment?
71
72
  # future increments: uuid (+uuidv6 ?), hex, radix(2-36), integer
72
73
  # allocated through: primary_key_type
@@ -75,17 +76,34 @@ module ElasticsearchRecord
75
76
  # try to resolve the current-auto-increment value from the tables meta
76
77
  connection.table_metas(self.table_name).dig('auto_increment').to_i + 1,
77
78
  # for secure reasons: also resolve the current maximum value for the primary key
78
- self.unscoped.all.maximum(self.primary_key).to_i + 1
79
+ self.unscoped.maximum(self.primary_key).to_i + 1
79
80
  ]
80
81
 
81
- id = yield({ id: ids.max })
82
+ # yield and resolve the new inserted *result*
83
+ result = yield({ id: ids.max })
82
84
 
83
- if id.present?
84
- connection.change_meta(self.table_name, :auto_increment, id)
85
+ # IMPORTANT: the block returns whatever +connection.insert+ resolved, which is NOT a plain id:
86
+ # ActiveRecord always provides the primary_key as +returning+ column, so an ARRAY of the
87
+ # returning column values is resolved (see @ ActiveRecord::Persistence#_create_record).
88
+ # The +_meta+ must be updated with the PLAIN id - storing the raw result breaks the
89
+ # +.to_i+ of the NEXT insert (which then only resolves a 0 - or fails altogether).
90
+ id = case result
91
+ when Array
92
+ result.first
93
+ when Hash
94
+ result[:id] || result['id']
95
+ else
96
+ result
97
+ end
98
+
99
+ if id.present? && id.to_i > 0
100
+ # IMPORTANT: elasticsearch resolves a document +_id+ as String - the +_meta+ must keep the
101
+ # INTEGER it was created with, so the schema does not change its type behind the first insert.
102
+ connection.change_meta(self.table_name, :auto_increment, id.to_i)
85
103
  end
86
104
 
87
- # return inserted id
88
- id
105
+ # return the UNCHANGED insert result - it is zipped against the +returning+ columns
106
+ result
89
107
  else
90
108
  yield({})
91
109
  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?
@@ -42,13 +42,14 @@ module ElasticsearchRecord
42
42
  # @param [String, Hash, ElasticsearchRecord::Query] sql
43
43
  # @param [Array] binds
44
44
  # @param [nil] preparable
45
+ # @param [Boolean] allow_retry
45
46
  # @param [Proc] block
46
- def find_by_sql(sql, binds = [], preparable: nil, &block)
47
+ def find_by_sql(sql, binds = [], preparable: nil, allow_retry: false, &block)
47
48
  query = case sql
48
49
  when String # really find by SQL
49
50
  ElasticsearchRecord::Query.new(
50
51
  type: ElasticsearchRecord::Query::TYPE_SQL,
51
- body: { query: query_or_sql },
52
+ body: { query: sql },
52
53
  # IMPORTANT: Always provide all columns
53
54
  columns: source_column_names)
54
55
  when Hash
@@ -61,7 +62,11 @@ module ElasticsearchRecord
61
62
  sql
62
63
  end
63
64
 
64
- _load_from_sql(_query_by_sql(query, binds), &block)
65
+ result = with_connection do |c|
66
+ _query_by_sql(c, query, binds, preparable: preparable, allow_retry: allow_retry)
67
+ end
68
+
69
+ _load_from_sql(result, &block)
65
70
  end
66
71
 
67
72
  # finds records by query arguments
@@ -74,7 +79,11 @@ module ElasticsearchRecord
74
79
  # IMPORTANT: Always provide all columns to prevent unknown attributes that should be nil ...
75
80
  columns: source_column_names)
76
81
 
77
- _load_from_sql(_query_by_sql(query), &block)
82
+ result = with_connection do |c|
83
+ _query_by_sql(c, query)
84
+ end
85
+
86
+ _load_from_sql(result, &block)
78
87
  end
79
88
 
80
89
  # ES|QL query API
@@ -90,14 +99,17 @@ module ElasticsearchRecord
90
99
  # IMPORTANT: Always provide all columns
91
100
  columns: source_column_names)
92
101
 
93
- _load_from_sql(_query_by_sql(query), &block)
102
+ result = with_connection do |c|
103
+ _query_by_sql(c, query)
104
+ end
105
+
106
+ _load_from_sql(result, &block)
94
107
  end
95
108
 
96
109
  # executes a +esql+ by provided *ES|SL* query
97
110
  # Does NOT instantiate records.
98
111
  # @param [String] esql
99
- # @param [Boolean] async (default: false)
100
- def esql(esql, async: false)
112
+ def esql(esql)
101
113
  # build new query
102
114
  query = ElasticsearchRecord::Query.new(
103
115
  type: ElasticsearchRecord::Query::TYPE_ESQL,
@@ -105,15 +117,14 @@ module ElasticsearchRecord
105
117
  # IMPORTANT: Always provide all columns
106
118
  columns: source_column_names)
107
119
 
108
- connection.internal_exec_query(query, "#{name} ES|QL", async: async)
120
+ connection.exec_query(query, "#{name} ES|QL")
109
121
  end
110
122
 
111
123
 
112
124
  # executes a +msearch+ by provided *RAW* queries.
113
125
  # Does NOT instantiate records.
114
126
  # @param [Array<String>] queries
115
- # @param [Boolean] async (default: false)
116
- def msearch(queries, async: false)
127
+ def msearch(queries)
117
128
  # build new msearch query
118
129
  query = ElasticsearchRecord::Query.new(
119
130
  index: table_name,
@@ -122,20 +133,31 @@ module ElasticsearchRecord
122
133
  # IMPORTANT: Always provide all columns
123
134
  columns: source_column_names)
124
135
 
125
- connection.internal_exec_query(query, "#{name} Msearch", async: async)
136
+ connection.exec_query(query, "#{name} Msearch")
126
137
  end
127
138
 
128
- # executes a search by provided +RAW+ query - supports +Elasticsearch::DSL+ gem if loaded
139
+ # executes a search by provided +RAW+ query - supports +Elasticsearch::DSL+ gem if loaded.
140
+ #
141
+ # Without a block the trailing options-hash is used as query arguments.
142
+ # With a block the +Elasticsearch::DSL+ gem builds the query - if the gem is not
143
+ # available (or the block fails), the block is SILENTLY ignored and the
144
+ # options-hash is used instead.
129
145
  def search(*args, &block)
130
- begin
131
- # require the Elasticsearch::DSL gem, if loaded
132
- require 'elasticsearch/dsl'
133
- query = ::Elasticsearch::DSL::Search::Search.new(*args, &block).to_hash
134
- rescue LoadError
135
- query = args.extract_options!
136
- rescue
137
- query = args.extract_options!
138
- end
146
+ query = if block_given?
147
+ begin
148
+ # require the Elasticsearch::DSL gem, if loaded
149
+ require 'elasticsearch/dsl'
150
+ # PLEASE NOTE: +Search#to_hash+ returns the request BODY - it must be nested
151
+ # into the query arguments, otherwise it is sent as (invalid) URL parameters.
152
+ { body: ::Elasticsearch::DSL::Search::Search.new(*args, &block).to_hash }
153
+ rescue LoadError
154
+ args.extract_options!
155
+ rescue
156
+ args.extract_options!
157
+ end
158
+ else
159
+ args.extract_options!
160
+ end
139
161
 
140
162
  find_by_query(query)
141
163
  end
@@ -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
@@ -10,7 +10,7 @@ module ElasticsearchRecord
10
10
  # transforms the current relation into arel, compiles it to query and executes the query.
11
11
  # returns the result object.
12
12
  #
13
- # PLEASE NOTE: This makes the query +immutable+ and raises a +ActiveRecord::ImmutableRelation+
13
+ # PLEASE NOTE: This makes the query +immutable+ and raises a +ActiveRecord::UnmodifiableRelation+
14
14
  # if you try to change it's values.
15
15
  #
16
16
  # PLEASE NOTE: resolving records _(instantiate)_ is never possible after calling this method!
File without changes
@@ -109,7 +109,8 @@ module ElasticsearchRecord # :nodoc:
109
109
  left = left[key]
110
110
  right = right[key]
111
111
 
112
- or_clause = Arel::Nodes::Or.new(left, right)
112
+ # since rails 7.2 +Arel::Nodes::Or+ is a nary node and takes its children as a single Array
113
+ or_clause = Arel::Nodes::Or.new([left, right])
113
114
 
114
115
  common.predicates[key] = ElasticsearchRecord::Relation::QueryClause.new(key, [Arel::Nodes::Grouping.new(or_clause)])
115
116
  common