elasticsearch_record 1.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 (57) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.ruby-version +1 -0
  4. data/Gemfile +6 -0
  5. data/Gemfile.lock +74 -0
  6. data/README.md +216 -0
  7. data/Rakefile +8 -0
  8. data/docs/CHANGELOG.md +44 -0
  9. data/docs/CODE_OF_CONDUCT.md +84 -0
  10. data/docs/LICENSE.txt +21 -0
  11. data/lib/active_record/connection_adapters/elasticsearch/column.rb +32 -0
  12. data/lib/active_record/connection_adapters/elasticsearch/database_statements.rb +149 -0
  13. data/lib/active_record/connection_adapters/elasticsearch/quoting.rb +38 -0
  14. data/lib/active_record/connection_adapters/elasticsearch/schema_statements.rb +134 -0
  15. data/lib/active_record/connection_adapters/elasticsearch/type/format_string.rb +28 -0
  16. data/lib/active_record/connection_adapters/elasticsearch/type/multicast_value.rb +52 -0
  17. data/lib/active_record/connection_adapters/elasticsearch/type/object.rb +44 -0
  18. data/lib/active_record/connection_adapters/elasticsearch/type/range.rb +42 -0
  19. data/lib/active_record/connection_adapters/elasticsearch/type.rb +16 -0
  20. data/lib/active_record/connection_adapters/elasticsearch_adapter.rb +197 -0
  21. data/lib/arel/collectors/elasticsearch_query.rb +112 -0
  22. data/lib/arel/nodes/select_agg.rb +22 -0
  23. data/lib/arel/nodes/select_configure.rb +9 -0
  24. data/lib/arel/nodes/select_kind.rb +9 -0
  25. data/lib/arel/nodes/select_query.rb +20 -0
  26. data/lib/arel/visitors/elasticsearch.rb +589 -0
  27. data/lib/elasticsearch_record/base.rb +14 -0
  28. data/lib/elasticsearch_record/core.rb +59 -0
  29. data/lib/elasticsearch_record/extensions/relation.rb +15 -0
  30. data/lib/elasticsearch_record/gem_version.rb +17 -0
  31. data/lib/elasticsearch_record/instrumentation/controller_runtime.rb +39 -0
  32. data/lib/elasticsearch_record/instrumentation/log_subscriber.rb +70 -0
  33. data/lib/elasticsearch_record/instrumentation/railtie.rb +16 -0
  34. data/lib/elasticsearch_record/instrumentation.rb +17 -0
  35. data/lib/elasticsearch_record/model_schema.rb +43 -0
  36. data/lib/elasticsearch_record/patches/active_record/relation_merger_patch.rb +85 -0
  37. data/lib/elasticsearch_record/patches/arel/select_core_patch.rb +64 -0
  38. data/lib/elasticsearch_record/patches/arel/select_manager_patch.rb +91 -0
  39. data/lib/elasticsearch_record/patches/arel/select_statement_patch.rb +41 -0
  40. data/lib/elasticsearch_record/patches/arel/update_manager_patch.rb +46 -0
  41. data/lib/elasticsearch_record/patches/arel/update_statement_patch.rb +60 -0
  42. data/lib/elasticsearch_record/persistence.rb +80 -0
  43. data/lib/elasticsearch_record/query.rb +129 -0
  44. data/lib/elasticsearch_record/querying.rb +90 -0
  45. data/lib/elasticsearch_record/relation/calculation_methods.rb +155 -0
  46. data/lib/elasticsearch_record/relation/core_methods.rb +64 -0
  47. data/lib/elasticsearch_record/relation/query_clause.rb +43 -0
  48. data/lib/elasticsearch_record/relation/query_clause_tree.rb +94 -0
  49. data/lib/elasticsearch_record/relation/query_methods.rb +276 -0
  50. data/lib/elasticsearch_record/relation/result_methods.rb +222 -0
  51. data/lib/elasticsearch_record/relation/value_methods.rb +54 -0
  52. data/lib/elasticsearch_record/result.rb +236 -0
  53. data/lib/elasticsearch_record/statement_cache.rb +87 -0
  54. data/lib/elasticsearch_record/version.rb +10 -0
  55. data/lib/elasticsearch_record.rb +60 -0
  56. data/sig/elasticsearch_record.rbs +4 -0
  57. metadata +175 -0
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module Elasticsearch
6
+ module Quoting # :nodoc:
7
+ # Quotes the column value to help prevent
8
+ def quote(value)
9
+ case value
10
+ # those values do not need to be quoted
11
+ when BigDecimal, Numeric, String, Symbol, nil, true, false then value
12
+ when ActiveSupport::Duration then value.to_i
13
+ when Array then value.map { |val| quote(val) }
14
+ when Hash then value.transform_values { |val| quote(val) }
15
+ else
16
+ super
17
+ end
18
+ end
19
+
20
+ def quoted_true
21
+ true
22
+ end
23
+
24
+ def unquoted_true
25
+ true
26
+ end
27
+
28
+ def quoted_false
29
+ false
30
+ end
31
+
32
+ def unquoted_false
33
+ false
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module Elasticsearch
6
+ module SchemaStatements # :nodoc:
7
+ # Returns the relation names usable to back Active Record models.
8
+ # For Elasticsearch this means all indices - which also includes system +dot+ '.' indices.
9
+ # @see ActiveRecord::ConnectionAdapters::SchemaStatements#data_sources
10
+ # @return [Array<String>]
11
+ def data_sources
12
+ api(:indices, :get_settings, { index: :_all }, 'SCHEMA').keys
13
+ end
14
+
15
+ # returns a hash of all mappings by provided index_name
16
+ # @param [String] index_name
17
+ # @return [Hash]
18
+ def mappings(index_name)
19
+ api(:indices, :get_mapping, { index: index_name }, 'SCHEMA').dig(index_name, 'mappings')
20
+ end
21
+
22
+ # returns a hash of all settings by provided index_name
23
+ # @param [String] index_name
24
+ # @return [Hash]
25
+ def settings(index_name)
26
+ api(:indices, :get_settings, { index: index_name }, 'SCHEMA').dig(index_name, 'settings','index')
27
+ end
28
+
29
+ # Returns the list of a table's column names, data types, and default values.
30
+ # @see ActiveRecord::ConnectionAdapters::SchemaStatements#columns
31
+ # @see ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter#column_definitions
32
+ # @param [String] table_name
33
+ # @return [Array<Hash>]
34
+ def column_definitions(table_name)
35
+ structure = mappings(table_name)
36
+ raise(ActiveRecord::StatementInvalid, "Could not find elasticsearch index '#{table_name}'") if structure.blank? || structure['properties'].blank?
37
+
38
+ # since the received mappings do not have the "primary" +_id+-column we manually need to add this here
39
+ # The BASE_STRUCTURE will also include some meta keys like '_score', '_type', ...
40
+ ActiveRecord::ConnectionAdapters::ElasticsearchAdapter::BASE_STRUCTURE + structure['properties'].map { |key, prop|
41
+ # mappings can have +fields+ - we also want them for 'query-conditions'
42
+ # that can be resolved through +.column_names+
43
+ fields = prop.delete('fields') || []
44
+
45
+ # we need to merge the name & possible nested fields (which are also searchable)
46
+ prop.merge('name' => key, 'fields' => fields.map { |fkey, _field| "#{key}.#{fkey}" })
47
+ }
48
+ end
49
+
50
+ # creates a new column object from provided field Hash
51
+ # @see ActiveRecord::ConnectionAdapters::SchemaStatements#columns
52
+ # @see ActiveRecord::ConnectionAdapters::MySQL::SchemaStatements#new_column_from_field
53
+ # @param [String] _table_name
54
+ # @param [Hash] field
55
+ # @return [ActiveRecord::ConnectionAdapters::Column]
56
+ def new_column_from_field(_table_name, field)
57
+ # fallback for possible empty type
58
+ field_type = field['type'].presence || (field['properties'].present? ? 'nested' : 'object')
59
+
60
+ ActiveRecord::ConnectionAdapters::Elasticsearch::Column.new(
61
+ field["name"],
62
+ field["null_value"],
63
+ fetch_type_metadata(field_type),
64
+ field['null'].nil? ? true : field['null'],
65
+ nil,
66
+ comment: field['meta'] ? field['meta'].map { |k, v| "#{k}: #{v}" }.join(' | ') : nil,
67
+ virtual: field['virtual'],
68
+ fields: field['fields']
69
+ )
70
+ end
71
+
72
+ # lookups from building the @columns_hash.
73
+ # since Elasticsearch has the "feature" to provide multicast values on any type, we need to fetch them ...
74
+ # you know, ES can return an integer or an array of integers for any column ...
75
+ # @param [ActiveRecord::ConnectionAdapters::Elasticsearch::Column] column
76
+ # @return [ActiveRecord::ConnectionAdapters::Elasticsearch::Type::MulticastValue]
77
+ def lookup_cast_type_from_column(column)
78
+ type_map.lookup(:multicast_value, super)
79
+ end
80
+
81
+ # Returns a array of tables primary keys.
82
+ # PLEASE NOTE: Elasticsearch does not have a concept of primary key.
83
+ # The only thing that uniquely identifies a document is the index together with the +_id+.
84
+ # To not break the "ConnectionAdapters" concept we simulate this through the BASE_STRUCTURE.
85
+ # We know, we can just return '_id' here ...
86
+ # @see ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter#primary_keys
87
+ # @param [String] _table_name
88
+ def primary_keys(_table_name)
89
+ ActiveRecord::ConnectionAdapters::ElasticsearchAdapter::BASE_STRUCTURE
90
+ .select { |f| f["primary"] }
91
+ .map { |f| f["name"] }
92
+ end
93
+
94
+ # Checks to see if the data source +name+ exists on the database.
95
+ #
96
+ # data_source_exists?(:ebooks)
97
+ # @see ActiveRecord::ConnectionAdapters::SchemaStatements#data_source_exists?
98
+ # @param [String, Symbol] name
99
+ # @return [Boolean]
100
+ def data_source_exists?(name)
101
+ # response returns boolean
102
+ api(:indices, :exists?, { index: name }, 'SCHEMA')
103
+ end
104
+
105
+ # Returns an array of table names defined in the database.
106
+ # For Elasticsearch this means all normal indices (no system +dot+ '.' indices)
107
+ # @see ActiveRecord::ConnectionAdapters::SchemaStatements#tables
108
+ # @return [Array<String>]
109
+ def tables
110
+ data_sources.reject { |key| key[0] == '.' }
111
+ end
112
+
113
+ # Checks to see if the table +table_name+ exists on the database.
114
+ #
115
+ # table_exists?(:developers)
116
+ #
117
+ # @see ActiveRecord::ConnectionAdapters::SchemaStatements#table_exists?
118
+ # @param [String, Symbol] table_name
119
+ # @return [Boolean]
120
+ def table_exists?(table_name)
121
+ # just reference to the data sources
122
+ data_source_exists?(table_name)
123
+ end
124
+
125
+ # returns the maximum allowed size for queries.
126
+ # The query will raise an ActiveRecord::StatementInvalid if the requested limit is above this value.
127
+ # @return [Integer]
128
+ def max_result_window
129
+ 10000
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,28 @@
1
+ module ActiveRecord
2
+ module ConnectionAdapters
3
+ module Elasticsearch
4
+ module Type # :nodoc:
5
+ class FormatString < ActiveRecord::Type::String
6
+ attr_reader :format
7
+
8
+ def initialize(**args)
9
+ @format = args.delete(:format).presence || /.*/
10
+ super
11
+ end
12
+
13
+ def type
14
+ :format_string
15
+ end
16
+
17
+ private
18
+
19
+ def cast_value(value)
20
+ return value unless ::String === value
21
+ return '' unless value.match(format)
22
+ value
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,52 @@
1
+ module ActiveRecord
2
+ module ConnectionAdapters
3
+ module Elasticsearch
4
+ module Type # :nodoc:
5
+ class MulticastValue < ActiveRecord::Type::Value
6
+
7
+ attr_reader :nested_type
8
+
9
+ def initialize(nested_type: nil, **)
10
+ @nested_type = nested_type || ActiveRecord::Type::Value.new
11
+ end
12
+
13
+ def type
14
+ nested_type.type
15
+ end
16
+
17
+ # overwrites the default deserialize behaviour
18
+ # @param [Object] value
19
+ # @return [Object,nil] deserialized object
20
+ def deserialize(value)
21
+ cast(_deserialize(value))
22
+ end
23
+
24
+ private
25
+
26
+ def _deserialize(value)
27
+ # check for the special +object+ type which is forced to be casted
28
+ return _deserialize_by_nested_type(value) if nested_type.type == :object && nested_type.forced?
29
+
30
+ if value.is_a?(Array)
31
+ value.map { |val| _deserialize_by_nested_type(val) }
32
+ elsif value.is_a?(Hash)
33
+ value.reduce({}) { |m, (key, val)|
34
+ m[key] = _deserialize_by_nested_type(val)
35
+ m
36
+ }
37
+ else
38
+ _deserialize_by_nested_type(value)
39
+ end
40
+ end
41
+
42
+ # in some cases we cannot deserialize, since the ES-type don't match well with the provided value
43
+ # but the result should be ok, as it is (e.g. 'Hash')...
44
+ # so we rescue here with just the provided value
45
+ def _deserialize_by_nested_type(value)
46
+ nested_type.deserialize(value) rescue value
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,44 @@
1
+ module ActiveRecord
2
+ module ConnectionAdapters
3
+ module Elasticsearch
4
+ module Type # :nodoc:
5
+ class Object < ActiveRecord::Type::Value
6
+ attr_reader :cast_type, :default
7
+
8
+ # creates a new object type which can be natively every value.
9
+ # Providing a +cast+ will call the value with this method (or callback)
10
+ # @param [nil, Symbol, String, Proc] cast - the cast type
11
+ # @param [nil, Object] default
12
+ # @param [Boolean] force - force value to be casted (used by the MulticastValue type) - (default: false)
13
+ def initialize(cast: nil, default: nil, force: false, **)
14
+ @cast_type = cast
15
+ @default = default
16
+ @force = force
17
+ end
18
+
19
+ def type
20
+ :object
21
+ end
22
+
23
+ def forced?
24
+ @force
25
+ end
26
+
27
+ private
28
+
29
+ # cast value by provided cast_method
30
+ def cast_value(value)
31
+ case self.cast_type
32
+ when Symbol, String
33
+ value.public_send(self.cast_type) rescue default
34
+ when Proc
35
+ self.cast_type.(value) rescue default
36
+ else
37
+ value
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,42 @@
1
+ module ActiveRecord
2
+ module ConnectionAdapters
3
+ module Elasticsearch
4
+ module Type # :nodoc:
5
+ class Range < MulticastValue
6
+
7
+ def type
8
+ "range_#{nested_type.type}".to_sym
9
+ end
10
+
11
+ private
12
+
13
+ def cast_value(value)
14
+ return (0..0) unless value.is_a?(Hash)
15
+ # check for existing gte & lte
16
+
17
+ min_value = if value['gte']
18
+ value['gte']
19
+ elsif value['gt']
20
+ value['gt'] + 1
21
+ else
22
+ nil
23
+ end
24
+
25
+ max_value = if value['lte']
26
+ value['lte']
27
+ elsif value['lt']
28
+ value['lt'] - 1
29
+ else
30
+ nil
31
+ end
32
+
33
+ return (0..0) if min_value.nil? || max_value.nil?
34
+
35
+ # build & return range
36
+ (min_value..max_value)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record/connection_adapters/elasticsearch/type/format_string'
4
+ require 'active_record/connection_adapters/elasticsearch/type/multicast_value'
5
+ require 'active_record/connection_adapters/elasticsearch/type/object'
6
+ require 'active_record/connection_adapters/elasticsearch/type/range'
7
+
8
+ module ActiveRecord
9
+ module ConnectionAdapters
10
+ module Elasticsearch
11
+ module Type # :nodoc:
12
+
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record/connection_adapters'
4
+
5
+ # new
6
+ require 'active_record/connection_adapters/elasticsearch/column'
7
+ require 'active_record/connection_adapters/elasticsearch/database_statements'
8
+ require 'active_record/connection_adapters/elasticsearch/quoting'
9
+ require 'active_record/connection_adapters/elasticsearch/schema_statements'
10
+ require 'active_record/connection_adapters/elasticsearch/type'
11
+
12
+ require 'arel/visitors/elasticsearch'
13
+ require 'arel/collectors/elasticsearch_query'
14
+
15
+ gem 'elasticsearch'
16
+ require 'elasticsearch'
17
+
18
+ module ActiveRecord # :nodoc:
19
+ module ConnectionHandling # :nodoc:
20
+ def elasticsearch_connection(config)
21
+ config = config.symbolize_keys
22
+
23
+ # move 'host' to 'hosts'
24
+ config[:hosts] = config.delete(:host) if config[:host]
25
+
26
+ # enable logging (Rails.logger)
27
+ config[:logger] = logger if config.delete(:log)
28
+
29
+ ConnectionAdapters::ElasticsearchAdapter.new(
30
+ ConnectionAdapters::ElasticsearchAdapter.new_client(config),
31
+ logger,
32
+ config
33
+ )
34
+ end
35
+ end
36
+
37
+ module ConnectionAdapters # :nodoc:
38
+ class ElasticsearchAdapter < AbstractAdapter
39
+ ADAPTER_NAME = "Elasticsearch"
40
+
41
+ # defines the Elasticsearch 'base' structure, which is always included but cannot be resolved through mappings ...
42
+ BASE_STRUCTURE = [
43
+ { 'name' => '_id', 'type' => 'string', 'null' => false, 'primary' => true },
44
+ { 'name' => '_index', 'type' => 'string', 'null' => false, 'virtual' => true },
45
+ { 'name' => '_score', 'type' => 'float', 'null' => false, 'virtual' => true },
46
+ { 'name' => '_type', 'type' => 'string', 'null' => false, 'virtual' => true }
47
+ ].freeze
48
+
49
+ include Elasticsearch::Quoting
50
+ include Elasticsearch::SchemaStatements
51
+ include Elasticsearch::DatabaseStatements
52
+
53
+ class << self
54
+ def base_structure_keys
55
+ @base_structure_keys ||= BASE_STRUCTURE.map { |struct| struct['name'] }.freeze
56
+ end
57
+
58
+ def new_client(config)
59
+ # IMPORTANT: remove +adapter+ from config - otherwise we mess up with Faraday::AdapterRegistry
60
+ client = ::Elasticsearch::Client.new(config.except(:adapter))
61
+ client.ping
62
+ client
63
+ rescue ::Elastic::Transport::Transport::ServerError => error
64
+ raise ::ActiveRecord::ConnectionNotEstablished, error.message
65
+ end
66
+
67
+ private
68
+
69
+ def initialize_type_map(m)
70
+ m.register_type 'binary', Type::Binary.new
71
+ m.register_type 'boolean', Type::Boolean.new
72
+ m.register_type 'keyword', Type::String.new
73
+
74
+ m.alias_type 'constant_keyword', 'keyword'
75
+ m.alias_type 'wildcard', 'keyword'
76
+
77
+ # maybe use integer 8 here ...
78
+ m.register_type 'long', Type::BigInteger.new
79
+ m.register_type 'integer', Type::Integer.new
80
+ m.register_type 'short', Type::Integer.new(limit: 2)
81
+ m.register_type 'byte', Type::Integer.new(limit: 1)
82
+ m.register_type 'double', Type::Float.new(limit: 8)
83
+ m.register_type 'float', Type::Float.new(limit: 4)
84
+ m.register_type 'half_float', Type::Float.new(limit: 2)
85
+ m.register_type 'scaled_float', Type::Float.new(limit: 8, scale: 8)
86
+ m.register_type 'unsigned_long', Type::UnsignedInteger.new
87
+
88
+ m.register_type 'date', Type::DateTime.new
89
+
90
+ # force a hash
91
+ m.register_type 'object', ActiveRecord::ConnectionAdapters::Elasticsearch::Type::Object.new(cast: :to_h, force: true)
92
+ m.alias_type 'flattened', "object"
93
+
94
+ # array of objects
95
+ m.register_type 'nested', ActiveRecord::ConnectionAdapters::Elasticsearch::Type::Object.new(cast: :to_h)
96
+
97
+ ip_type = ActiveRecord::ConnectionAdapters::Elasticsearch::Type::FormatString.new(format: /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/)
98
+
99
+ m.register_type 'integer_range', ActiveRecord::ConnectionAdapters::Elasticsearch::Type::Range.new(nested_type: Type::Integer.new)
100
+ m.register_type 'float_range', ActiveRecord::ConnectionAdapters::Elasticsearch::Type::Range.new(nested_type: Type::Float.new(limit: 4))
101
+ m.register_type 'long_range', ActiveRecord::ConnectionAdapters::Elasticsearch::Type::Range.new(nested_type: Type::Integer.new(limit: 8))
102
+ m.register_type 'double_range', ActiveRecord::ConnectionAdapters::Elasticsearch::Type::Range.new(nested_type: Type::Float.new(limit: 8))
103
+ m.register_type 'date_range', ActiveRecord::ConnectionAdapters::Elasticsearch::Type::Range.new(nested_type: Type::DateTime.new)
104
+ m.register_type 'ip_range', ActiveRecord::ConnectionAdapters::Elasticsearch::Type::Range.new(nested_type: ip_type)
105
+
106
+ m.register_type 'ip', ip_type
107
+ m.register_type 'version', ActiveRecord::ConnectionAdapters::Elasticsearch::Type::FormatString.new(format: /^\d+\.\d+\.\d+[\-\+A-Za-z\.]*$/)
108
+ # m.register_type 'murmur3', Murmur3.new
109
+
110
+ m.register_type 'text', Type::Text.new
111
+
112
+ # this special Type is required to parse a ES-value into the +nested_type+, array or hash.
113
+ # For arrays & hashes it tries to cast the values with the provided +nested_type+
114
+ # but falls back to provided value if cast fails.
115
+ # This type cannot be accessed through the mapping and is only called @ #lookup_cast_type_from_column
116
+ # @see ActiveRecord::ConnectionAdapters::Elasticsearch::SchemaStatements#lookup_cast_type_from_column
117
+ m.register_type :multicast_value do |_type, nested_type|
118
+ ActiveRecord::ConnectionAdapters::Elasticsearch::Type::MulticastValue.new(nested_type: nested_type)
119
+ end
120
+ end
121
+ end
122
+
123
+ # reinitialize the constant with new types
124
+ TYPE_MAP = ActiveRecord::Type::HashLookupTypeMap.new.tap { |m| initialize_type_map(m) }
125
+
126
+ def initialize(*args)
127
+ super(*args)
128
+
129
+ # prepared statements are not supported by Elasticsearch.
130
+ # documentation for mysql prepares statements @ https://dev.mysql.com/doc/refman/8.0/en/sql-prepared-statements.html
131
+ @prepared_statements = false
132
+ end
133
+
134
+ private
135
+
136
+ def type_map
137
+ TYPE_MAP
138
+ end
139
+
140
+ # catch Elasticsearch Transport-errors to be treated as +StatementInvalid+ (the original message is still readable ...)
141
+ def translate_exception(exception, message:, sql:, binds:)
142
+ case exception
143
+ when Elastic::Transport::Transport::ServerError
144
+ ::ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
145
+ else
146
+ # just forward the exception ...
147
+ exception
148
+ end
149
+ end
150
+
151
+ # provide a custom log instrumenter for elasticsearch subscribers
152
+ def log(gate, arguments, name, async: false, &block)
153
+ @instrumenter.instrument(
154
+ "query.elasticsearch_record",
155
+ gate: gate,
156
+ name: name,
157
+ arguments: gate == 'core.msearch' ? arguments.deep_dup : arguments,
158
+ async: async) do
159
+ @lock.synchronize(&block)
160
+ rescue => e
161
+ raise translate_exception_class(e, arguments, [])
162
+ end
163
+ end
164
+
165
+ # returns a new collector for the Arel visitor.
166
+ # @return [Arel::Collectors::ElasticsearchQuery]
167
+ def collector
168
+ # IMPORTANT: since prepared statements doesn't make sense for elasticsearch,
169
+ # we don't have to check for +prepared_statements+ here.
170
+ # Also, bindings are (currently) not supported.
171
+ # So, we just need a query collector...
172
+ Arel::Collectors::ElasticsearchQuery.new
173
+ end
174
+
175
+ # returns a new visitor to compile Arel into Elasticsearch Hashes (in this case we use a query object)
176
+ # @return [Arel::Visitors::Elasticsearch]
177
+ def arel_visitor
178
+ Arel::Visitors::Elasticsearch.new(self)
179
+ end
180
+
181
+ # Builds the result object.
182
+ #
183
+ # This is an internal hook to make possible connection adapters to build
184
+ # custom result objects with response-specific data.
185
+ # @return [ElasticsearchRecord::Result]
186
+ def build_result(response, columns: [], column_types: {})
187
+ ElasticsearchRecord::Result.new(response, columns, column_types)
188
+ end
189
+
190
+ # register native types
191
+ ActiveRecord::Type.register(:format_string, ActiveRecord::ConnectionAdapters::Elasticsearch::Type::FormatString, adapter: :elasticsearch)
192
+ ActiveRecord::Type.register(:multicast_value, ActiveRecord::ConnectionAdapters::Elasticsearch::Type::MulticastValue, adapter: :elasticsearch)
193
+ ActiveRecord::Type.register(:object, ActiveRecord::ConnectionAdapters::Elasticsearch::Type::Object, adapter: :elasticsearch, override: false)
194
+ ActiveRecord::Type.register(:range, ActiveRecord::ConnectionAdapters::Elasticsearch::Type::Range, adapter: :elasticsearch)
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'elasticsearch_record/query'
4
+
5
+ module Arel # :nodoc: all
6
+ module Collectors
7
+ class ElasticsearchQuery < ::ElasticsearchRecord::Query
8
+
9
+ # required for ActiveRecord
10
+ attr_accessor :preparable
11
+
12
+ def initialize
13
+ # force initialize a body as hash
14
+ super(body: {})
15
+
16
+ # @binds = []
17
+ @bind_index = 1
18
+ end
19
+
20
+ # send a proposal to this query
21
+ # @param [Symbol] action - the claim action
22
+ # @param [Array] args - args to claim
23
+ def claim(action, *args)
24
+ case action
25
+ when :index
26
+ # change the index name
27
+ @index = args[0]
28
+ when :type
29
+ # change the query type
30
+ @type = args[0]
31
+ when :status
32
+ # change the query status
33
+ @status = args[0]
34
+ when :columns
35
+ # change the query columns
36
+ @columns = args[0]
37
+ when :arguments
38
+ # change the query arguments
39
+ @arguments = args[0]
40
+ when :argument
41
+ # adds / sets any argument
42
+ if args.length == 2
43
+ @arguments[args[0]] = args[1]
44
+ else # should be a hash
45
+ @arguments.merge!(args[0])
46
+ end
47
+ when :body
48
+ # set the body var
49
+ @body = args[0]
50
+ when :assign
51
+ # calls a assign on the body
52
+ assign(*args)
53
+ else
54
+ raise "Unsupported claim action: #{action}"
55
+ end
56
+ end
57
+
58
+ def <<(claim)
59
+ self.claim(claim[0], *claim[1])
60
+ end
61
+
62
+ # used by the +Arel::Visitors::Elasticsearch#compile+ method (and default Arel visitors)
63
+ # todo: maybe return arguments with :_meta information instead of self ...
64
+ def value
65
+ self
66
+ end
67
+
68
+ # IMPORTANT: For SQL defaults (see @ Arel::Collectors::SubstituteBinds) a value
69
+ # will +not+ be directly assigned (see @ Arel::Visitors::ToSql#visit_Arel_Nodes_HomogeneousIn).
70
+ # instead it will be send as bind and then re-delegated to the SQL collector.
71
+ #
72
+ # This only works for linear SQL-queries and not nested Hashes
73
+ # (otherwise we have to collect those binds, and replace them afterwards).
74
+ #
75
+ # This will be ignored by the ElasticsearchQuery collector, but supports statement caches on the other side
76
+ # (see @ ActiveRecord::StatementCache::PartialQueryCollector)
77
+ def add_bind(bind, &block)
78
+ @bind_index += 1
79
+
80
+ self
81
+ end
82
+
83
+ # @see Arel::Collectors::ElasticsearchQuery#add_bind
84
+ def add_binds(binds, proc_for_binds = nil, &block)
85
+ @bind_index += binds.size
86
+ self
87
+ end
88
+
89
+ private
90
+
91
+ # calls a assign on the body
92
+ def assign(key, value)
93
+ # check for special provided key, to claim through an assign
94
+ if key == :__claim__
95
+ if value.is_a?(Array)
96
+ value.each do |arg|
97
+ vkey = arg.keys.first
98
+ claim(vkey, arg[vkey])
99
+ end
100
+ else
101
+ vkey = value.keys.first
102
+ claim(vkey, value[vkey])
103
+ end
104
+ elsif value.nil?
105
+ @body.delete(key)
106
+ else
107
+ @body[key] = value
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arel # :nodoc: all
4
+ module Nodes
5
+ class SelectAgg < Unary
6
+
7
+ def left
8
+ expr[0]
9
+ end
10
+
11
+ def right
12
+ return expr[1].reduce({}) { |m, data| m.merge(data) } if expr[1].is_a?(Array)
13
+
14
+ expr[1]
15
+ end
16
+
17
+ def opts
18
+ expr[2]
19
+ end
20
+ end
21
+ end
22
+ end