activerecord-clickhouse-adapter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,464 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+ require "securerandom"
6
+ require "strscan"
7
+
8
+ module ActiveRecord
9
+ module ConnectionAdapters
10
+ module ClickHouse
11
+ module DatabaseStatements
12
+ READ_QUERY = ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp(
13
+ :select, :show, :describe, :desc, :exists, :explain, :check, :with
14
+ )
15
+ private_constant :READ_QUERY
16
+
17
+ def write_query?(sql) # :nodoc:
18
+ !READ_QUERY.match?(sql)
19
+ rescue ArgumentError # non-UTF8 SQL, mirror the built-in adapters
20
+ !READ_QUERY.match?(sql.b)
21
+ end
22
+
23
+ EXPLAIN_VARIANTS = {
24
+ plan: "EXPLAIN", pipeline: "EXPLAIN PIPELINE",
25
+ estimate: "EXPLAIN ESTIMATE", indexes: "EXPLAIN indexes = 1"
26
+ }.freeze
27
+ private_constant :EXPLAIN_VARIANTS
28
+
29
+ # Scopes extra ClickHouse settings to every request made inside the block —
30
+ # the write-path counterpart of a relation's in-SQL SETTINGS clause. Validated
31
+ # here, before with_raw_connection wraps errors into StatementInvalid.
32
+ def with_request_settings(settings, &block)
33
+ settings.each_key do |name|
34
+ unless /\A[a-zA-Z_][a-zA-Z0-9_]*\z/.match?(name.to_s)
35
+ raise ArgumentError, "invalid ClickHouse setting name: #{name.inspect}"
36
+ end
37
+ end
38
+ with_raw_connection { |raw_connection| raw_connection.with_request_settings(settings, &block) }
39
+ end
40
+
41
+ # Bulk ingestion without materializing the batch: rows (any Enumerable of
42
+ # Hashes, lazy included) stream to the server as one chunked
43
+ # JSONCompactEachRow INSERT. Returns the server-reported written row count.
44
+ def insert_stream(table_name, rows, columns: nil)
45
+ column_names = columns || stream_column_names(rows)
46
+ sql = insert_stream_sql(table_name, column_names)
47
+ lines = Enumerator.new do |yielder|
48
+ rows.each { |row| yielder << encoded_stream_row(row, column_names) }
49
+ end
50
+
51
+ stream_lines(sql, "#{table_name} Stream Insert", lines)
52
+ end
53
+
54
+ def explain(arel, binds = [], options = []) # :nodoc:
55
+ sql = "#{build_explain_clause(options)} #{to_sql(arel, binds)}"
56
+ result = select_all(sql, "EXPLAIN", binds)
57
+ ([result.columns.join("\t")] + result.rows.map { |row| row.join("\t") }).join("\n")
58
+ end
59
+
60
+ def build_explain_clause(options = [])
61
+ variant = options.first || :plan
62
+ EXPLAIN_VARIANTS.fetch(variant.to_sym) do
63
+ raise ArgumentError, "unknown EXPLAIN variant #{variant.inspect}; use #{EXPLAIN_VARIANTS.keys.inspect}"
64
+ end
65
+ end
66
+
67
+ # No autoincrement and no INSERT ... RETURNING: primary keys are generated
68
+ # client-side before the INSERT (the Oracle-adapter prefetch seam), but only
69
+ # for tables whose sorting key is a single generatable column — Rails'
70
+ # prefetch path cannot handle composite primary keys.
71
+ def prefetch_primary_key?(table_name = nil)
72
+ !table_name.nil? && !generatable_primary_key(table_name).nil?
73
+ end
74
+
75
+ # The "sequence" is the pk column itself; encode table.column so
76
+ # next_sequence_value can check it against the sorting key. Composite keys
77
+ # have no single column to generate.
78
+ def default_sequence_name(table_name, column_name)
79
+ return nil if column_name.is_a?(Array)
80
+
81
+ "#{table_name}.#{column_name}"
82
+ end
83
+
84
+ # Sequence names are usually the "table.column" this adapter encodes, but models
85
+ # may override sequence_name with a free-form label (Oracle legacy); those get
86
+ # integer ids on trust — the prefetch gate already vetted the table. nil means a
87
+ # composite primary key, which Rails' prefetch path cannot populate.
88
+ def next_sequence_value(sequence_name)
89
+ raise_ungeneratable_primary_key(sequence_name) if sequence_name.nil?
90
+
91
+ table_name, column_name = sequence_name.to_s.split(".", 2)
92
+ return generate_time_ordered_id unless column_name
93
+
94
+ generatable_column, sql_type = generatable_primary_key(table_name)
95
+ raise_ungeneratable_primary_key(sequence_name) unless generatable_column == column_name
96
+
97
+ sql_type == "UUID" ? generate_uuid_v7 : generate_time_ordered_id
98
+ end
99
+
100
+ # Without RETURNING the server hands nothing back after an insert, whatever a
101
+ # column's default expression says; only the prefetched pk is knowable.
102
+ def return_value_after_insert?(_column) = false
103
+
104
+ # No INSERT ... RETURNING in ClickHouse: the prefetched client-side id is the
105
+ # only post-insert-knowable value, so surface it aligned to the requested
106
+ # returning columns for _create_record's write-back. The signature is Rails'
107
+ # DatabaseStatements#insert contract.
108
+ def insert(arel, name = nil, pk = nil, id_value = nil, sequence_name = nil, binds = [], returning: nil) # rubocop:disable Metrics/ParameterLists
109
+ inserted_id = super(arel, name, pk, id_value, sequence_name, binds, returning: nil)
110
+ return inserted_id if returning.nil?
111
+
112
+ returning.map { |column| column.to_s == pk.to_s ? inserted_id : nil }
113
+ end
114
+
115
+ # Mutations report no affected-row count (X-ClickHouse-Summary stays all zeros —
116
+ # probed 2026-07-13), so matching rows are counted just before mutating. Rows
117
+ # touched by concurrent writes between the two statements are not reflected.
118
+ def update(arel, name = nil, binds = [])
119
+ matched_rows = mutation_target_count(arel, name)
120
+ result = super
121
+ matched_rows || result
122
+ end
123
+
124
+ def delete(arel, name = nil, binds = [])
125
+ matched_rows = mutation_target_count(arel, name)
126
+ result = super
127
+ matched_rows || result
128
+ end
129
+
130
+ # The abstract default (CURRENT_TIMESTAMP) is not a ClickHouse identifier.
131
+ # Rails stamps date (created_on) and datetime (created_at) attributes with this
132
+ # one expression, and only a plain DateTime coerces into both Date32 and
133
+ # DateTime64 in a VALUES section — now64(6) raises TYPE_MISMATCH on dates, so
134
+ # bulk-insert timestamps carry second precision (probed 2026-07-13, PLAN.md §2).
135
+ HIGH_PRECISION_CURRENT_TIMESTAMP = Arel.sql("now()").freeze
136
+ private_constant :HIGH_PRECISION_CURRENT_TIMESTAMP
137
+
138
+ def high_precision_current_timestamp
139
+ HIGH_PRECISION_CURRENT_TIMESTAMP
140
+ end
141
+
142
+ # Rails nests a SavepointTransaction inside any dirty transaction (e.g. the
143
+ # retry in create_or_find_by) even though supports_savepoints? is false. Like
144
+ # begin/commit/rollback (PLAN.md §5 decision 4), the savepoint verbs are honest
145
+ # no-ops — nothing transactional exists to restore.
146
+ def create_savepoint(_name = nil); end
147
+ def exec_rollback_to_savepoint(_name = nil); end
148
+ def release_savepoint(_name = nil); end
149
+
150
+ # The abstract version wraps bare DELETEs in a transaction; ClickHouse has
151
+ # neither, so fixtures load as TRUNCATE + batched INSERTs.
152
+ def insert_fixtures_set(fixture_set, tables_to_delete = [])
153
+ statements = tables_to_delete.map { |table| build_truncate_statement(table) }
154
+ statements += fixture_set.filter_map do |table_name, fixtures|
155
+ build_fixture_sql(fixtures, table_name) unless fixtures.empty?
156
+ end
157
+ statements.each { |statement| execute(statement, "Fixtures Load") }
158
+ end
159
+
160
+ private
161
+
162
+ # nil for raw-SQL mutations; callers then fall back to the server's report of
163
+ # zero. ORDER alone never changes how many rows match, and a LIMIT caps the
164
+ # count through a subquery — SELECT count() FROM (matching rows LIMIT n).
165
+ def mutation_target_count(arel, name)
166
+ ast = arel.respond_to?(:ast) ? arel.ast : arel
167
+ return nil unless countable_mutation?(ast)
168
+
169
+ select_value(mutation_target_count_select(ast), "#{name} Count").to_i
170
+ end
171
+
172
+ def mutation_target_count_select(ast)
173
+ matching = Arel::SelectManager.new(ast.relation)
174
+ ast.wheres.each { |where| matching.where(where) }
175
+ ast.limit.nil? ? matching.project(Arel.star.count) : capped_count_select(matching, ast.limit)
176
+ end
177
+
178
+ def capped_count_select(matching, limit)
179
+ capped = matching.project(Arel.sql("1")).take(limit.expr).as("mutation_target")
180
+ Arel::SelectManager.new(capped).project(Arel.star.count)
181
+ end
182
+
183
+ def countable_mutation?(ast)
184
+ ast.is_a?(Arel::Nodes::UpdateStatement) || ast.is_a?(Arel::Nodes::DeleteStatement)
185
+ end
186
+
187
+ GENERATABLE_ID_TYPES = /\A(?:U?Int(?:64|128|256)|UUID)\z/
188
+ private_constant :GENERATABLE_ID_TYPES
189
+
190
+ SORTING_KEY_COLUMN_SQL = <<~SQL.squish
191
+ SELECT columns.name AS name, columns.type AS type
192
+ FROM system.tables AS tables
193
+ INNER JOIN system.columns AS columns
194
+ ON columns.database = tables.database
195
+ AND columns.table = tables.name
196
+ AND columns.name = tables.sorting_key
197
+ WHERE tables.database = currentDatabase() AND tables.name = %s
198
+ SQL
199
+ private_constant :SORTING_KEY_COLUMN_SQL
200
+
201
+ # [column_name, sql_type] when the table's sorting key is one column typed
202
+ # widely enough to hold a generated id; nil otherwise (composite keys,
203
+ # expression keys, narrow integers, strings). Cached per connection because
204
+ # Rails re-checks prefetch_primary_key? on every create; the migration-API
205
+ # DDL methods (create/drop/rename_table) invalidate it.
206
+ def generatable_primary_key(table_name)
207
+ cache = @generatable_primary_keys ||= {}
208
+ cache.fetch(table_name.to_s) do |key|
209
+ cache[key] = fetch_generatable_primary_key(key)
210
+ end
211
+ end
212
+
213
+ def clear_generatable_primary_key_cache
214
+ @generatable_primary_keys = nil
215
+ end
216
+
217
+ def fetch_generatable_primary_key(table_name)
218
+ row = select_one(format(SORTING_KEY_COLUMN_SQL, quote(table_name)), "SCHEMA")
219
+ return nil unless row && GENERATABLE_ID_TYPES.match?(row["type"])
220
+
221
+ [row["name"], row["type"]]
222
+ end
223
+
224
+ RANDOM_ID_BITS = 22
225
+ private_constant :RANDOM_ID_BITS
226
+
227
+ # 41 bits of Unix milliseconds + 22 random bits = 63 bits: time-ordered like
228
+ # UUIDv7 and safely inside signed Int64 until the year 4707.
229
+ def generate_time_ordered_id
230
+ milliseconds = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
231
+ (milliseconds << RANDOM_ID_BITS) | SecureRandom.random_number(1 << RANDOM_ID_BITS)
232
+ end
233
+
234
+ # SecureRandom.uuid_v7 needs Ruby >= 3.3; build the same layout on 3.2:
235
+ # 48-bit millisecond timestamp, version nibble 7, variant bits 10.
236
+ def generate_uuid_v7
237
+ return SecureRandom.uuid_v7 if SecureRandom.respond_to?(:uuid_v7)
238
+
239
+ milliseconds = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
240
+ hex = format("%<ms>012x", ms: milliseconds) + SecureRandom.hex(10)
241
+ hex[12] = "7"
242
+ hex[16] = %w[8 9 a b].fetch(hex[16].to_i(16) & 0x3)
243
+ hex.insert(20, "-").insert(16, "-").insert(12, "-").insert(8, "-")
244
+ end
245
+
246
+ def raise_ungeneratable_primary_key(sequence_name)
247
+ raise ActiveRecordError, "cannot generate a primary key for #{sequence_name.inspect}: " \
248
+ "the table's sorting key must be that single column, typed " \
249
+ "at least 64 bits wide or UUID; assign ids explicitly instead"
250
+ end
251
+
252
+ def stream_lines(sql, name, lines)
253
+ log(sql, name) do |notification_payload|
254
+ with_raw_connection do |raw_connection|
255
+ result = raw_connection.execute_stream(sql, lines)
256
+ verified!
257
+ notification_payload[:clickhouse] = result.stats
258
+ result.written_rows
259
+ end
260
+ end
261
+ end
262
+
263
+ def stream_column_names(rows)
264
+ first_row = rows.first
265
+ raise ArgumentError, "insert_stream got no rows" unless first_row
266
+
267
+ first_row.keys
268
+ end
269
+
270
+ def insert_stream_sql(table_name, column_names)
271
+ quoted = column_names.map { |name| quote_column_name(name) }.join(", ")
272
+ "INSERT INTO #{quote_table_name(table_name)} (#{quoted}) FORMAT JSONCompactEachRow"
273
+ end
274
+
275
+ def encoded_stream_row(row, column_names)
276
+ JSON.generate(column_names.map { |name| encoded_stream_value(row[name]) })
277
+ end
278
+
279
+ # JSONCompactEachRow input casts strings server-side; only the types whose
280
+ # default Ruby JSON form the server misreads need explicit encoding.
281
+ def encoded_stream_value(value)
282
+ case value
283
+ when Time, DateTime then quoted_date(value)
284
+ when Date then value.to_s
285
+ when BigDecimal then value.to_s("F")
286
+ else value
287
+ end
288
+ end
289
+
290
+ TRAILING_COMMENTS = %r{(?:\s*/\*(?:[^*]|\*(?!/))*\*/)+\s*\z}
291
+ INSERT_STATEMENT = /\A\s*INSERT\b/i
292
+ private_constant :TRAILING_COMMENTS, :INSERT_STATEMENT
293
+
294
+ # ClickHouse reads everything after VALUES with the Values input format, which
295
+ # rejects trailing comments (probed 2026-07-13), so sqlcommenter tags appended
296
+ # by Rails' QueryLogs are hoisted to the front of INSERT statements.
297
+ def hoist_trailing_comments(sql)
298
+ return sql unless INSERT_STATEMENT.match?(sql)
299
+
300
+ comments = sql[TRAILING_COMMENTS]
301
+ return sql unless comments
302
+
303
+ "#{comments.strip} #{sql[0...-comments.length]}"
304
+ end
305
+
306
+ # Rails main (8.2) funnels execution through a QueryIntent object; released
307
+ # 8.1 passes the pieces positionally. One wire body serves both contracts.
308
+ if ActiveRecord::ConnectionAdapters.const_defined?(:QueryIntent)
309
+ def perform_query(raw_connection, intent)
310
+ execute_wire_query(raw_connection, intent.processed_sql, intent.binds, intent.type_casted_binds,
311
+ notification_payload: intent.notification_payload)
312
+ end
313
+ else
314
+ def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notification_payload:, batch:) # rubocop:disable Lint/UnusedMethodArgument
315
+ execute_wire_query(raw_connection, sql, binds, type_casted_binds,
316
+ notification_payload: notification_payload)
317
+ end
318
+ end
319
+
320
+ def execute_wire_query(raw_connection, sql, binds, type_casted_binds, notification_payload:)
321
+ sql, params = materialize_query_params(hoist_trailing_comments(sql), binds, type_casted_binds)
322
+ result = raw_connection.execute(sql, params: params)
323
+ verified!
324
+ if notification_payload
325
+ notification_payload[:row_count] = result.rows.size
326
+ notification_payload[:clickhouse] = result.stats
327
+ end
328
+ result
329
+ end
330
+
331
+ def cast_result(raw_result)
332
+ if raw_result.columns.empty?
333
+ ActiveRecord::Result.empty(affected_rows: raw_result.written_rows)
334
+ else
335
+ ActiveRecord::Result.new(bare_column_names(raw_result.columns), cast_rows(raw_result))
336
+ end
337
+ end
338
+
339
+ def cast_rows(raw_result)
340
+ casters = raw_result.types.map { |type_string| Types.caster_for(type_string) }
341
+ raw_result.rows.map do |row|
342
+ row.each_with_index.map { |value, index| casters.fetch(index).cast(value) }
343
+ end
344
+ end
345
+
346
+ # With 2+ JOINs the analyzer renames a qualified star's colliding columns to
347
+ # "table.column" on the wire (probed 2026-07-13, PLAN.md §2); Rails maps
348
+ # attributes by bare name, so the qualifier goes wherever stripping it leaves
349
+ # the name unambiguous.
350
+ def bare_column_names(columns)
351
+ return columns if columns.none? { |name| name.include?(".") }
352
+
353
+ stripped = columns.map { |name| name.rpartition(".").last }
354
+ collisions = stripped.tally
355
+ columns.zip(stripped).map { |original, bare| collisions[bare] == 1 ? bare : original }
356
+ end
357
+
358
+ def affected_rows(raw_result) = raw_result.written_rows
359
+
360
+ STRING_LITERAL = /'(?:\\.|''|[^'])*'/m
361
+ BACKTICK_IDENTIFIER = /`(?:\\.|``|[^`])*`/m
362
+ private_constant :STRING_LITERAL, :BACKTICK_IDENTIFIER
363
+
364
+ # Replace `?` placeholders with ClickHouse `{pN:Type}` and collect HTTP param
365
+ # values, skipping `?` inside string literals and backtick identifiers.
366
+ def materialize_query_params(sql, binds, type_casted_binds) # rubocop:disable Metrics/MethodLength
367
+ return [sql, {}] if binds.blank?
368
+
369
+ scanner = StringScanner.new(sql)
370
+ rewritten = +""
371
+ params = {}
372
+ until scanner.eos?
373
+ rewritten << if scanner.skip("?")
374
+ bind_placeholder(params, binds, type_casted_binds)
375
+ else
376
+ scan_non_placeholder_fragment(scanner)
377
+ end
378
+ end
379
+ verify_bind_count(params, binds)
380
+ [rewritten, params]
381
+ end
382
+
383
+ def verify_bind_count(params, binds)
384
+ return if params.length == binds.length
385
+
386
+ raise ArgumentError, "wrong number of binds (#{params.length} for #{binds.length})"
387
+ end
388
+
389
+ def scan_non_placeholder_fragment(scanner)
390
+ scanner.scan(STRING_LITERAL) || scanner.scan(BACKTICK_IDENTIFIER) || scanner.scan(/[^?'`]+/) || scanner.getch
391
+ end
392
+
393
+ def bind_placeholder(params, binds, type_casted_binds)
394
+ index = params.length
395
+ raise ArgumentError, "more ? placeholders than binds (#{binds.length})" if index >= binds.length
396
+
397
+ name = "p#{index}"
398
+ params[name] = format_query_param(type_casted_binds.fetch(index))
399
+ "{#{name}:#{clickhouse_bind_type(binds.fetch(index), type_casted_binds.fetch(index))}}"
400
+ end
401
+
402
+ # The server reads String params in its escaped format: raw newlines/tabs raise
403
+ # BAD_QUERY_PARAMETER and a literal backslash-n would silently become a newline.
404
+ PARAM_ESCAPES = { "\\" => "\\\\", "\n" => "\\n", "\t" => "\\t", "\r" => "\\r", "\0" => "\\0" }.freeze
405
+ PARAM_ESCAPE_PATTERN = /[\\\n\t\r\0]/
406
+ private_constant :PARAM_ESCAPES, :PARAM_ESCAPE_PATTERN
407
+
408
+ # Adapter-level type_cast already stringifies Date/Time (with subseconds) and
409
+ # BigDecimal before values reach here.
410
+ def format_query_param(value)
411
+ case value
412
+ when nil then "\\N"
413
+ when true, false then value
414
+ else
415
+ string = value.to_s
416
+ string.match?(PARAM_ESCAPE_PATTERN) ? string.gsub(PARAM_ESCAPE_PATTERN, PARAM_ESCAPES) : string
417
+ end
418
+ end
419
+
420
+ # Date/Time binds arrive as pre-formatted strings (adapter type_cast), so only the
421
+ # declared AR type can recover them; everything else is inferable from the value.
422
+ def clickhouse_bind_type(bind, casted_value)
423
+ # Nullable(Nothing) is the only param type that both carries NULL and compares
424
+ # against any column type (probed 2026-07-13, PLAN.md §2).
425
+ return "Nullable(Nothing)" if casted_value.nil?
426
+
427
+ type = bind.type if bind.respond_to?(:type)
428
+ case type
429
+ when ActiveRecord::Type::Date then "Date"
430
+ when ActiveRecord::Type::DateTime, ActiveRecord::Type::Time then "DateTime64(6)"
431
+ else inferred_bind_type(casted_value)
432
+ end
433
+ end
434
+
435
+ def inferred_bind_type(casted_value)
436
+ case casted_value
437
+ when Integer then clickhouse_integer_type(casted_value)
438
+ when Float then "Float64"
439
+ when true, false then "Bool"
440
+ when Date then "Date"
441
+ when Time, DateTime then "DateTime64(6)"
442
+ else "String"
443
+ end
444
+ end
445
+
446
+ INTEGER_TYPE_BY_RANGE = {
447
+ "Int64" => -(2**63)...(2**63),
448
+ "UInt64" => 0...(2**64),
449
+ "Int128" => -(2**127)...(2**127),
450
+ "UInt128" => 0...(2**128),
451
+ "Int256" => -(2**255)...(2**255),
452
+ "UInt256" => 0...(2**256)
453
+ }.freeze
454
+ private_constant :INTEGER_TYPE_BY_RANGE
455
+
456
+ # Sized by magnitude — a too-small type makes the server wrap the value mod 2^N.
457
+ def clickhouse_integer_type(value)
458
+ type, = INTEGER_TYPE_BY_RANGE.find { |_, range| range.cover?(value) }
459
+ type || raise(ActiveModel::RangeError, "#{value} is out of range for ClickHouse integer types")
460
+ end
461
+ end
462
+ end
463
+ end
464
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module ClickHouse
6
+ class UnknownTable < ActiveRecord::StatementInvalid; end
7
+ class UnknownDatabase < ActiveRecord::StatementInvalid; end
8
+ class MemoryLimitExceeded < ActiveRecord::StatementInvalid; end
9
+ class QueryTimeout < ActiveRecord::StatementInvalid; end
10
+ class AuthenticationError < ActiveRecord::StatementInvalid; end
11
+
12
+ module ErrorTranslation
13
+ EXCEPTION_CLASS_BY_CODE = {
14
+ 60 => UnknownTable,
15
+ 81 => UnknownDatabase,
16
+ 241 => MemoryLimitExceeded,
17
+ 159 => QueryTimeout,
18
+ 160 => QueryTimeout,
19
+ 516 => AuthenticationError
20
+ }.freeze
21
+
22
+ # Code 53 is TYPE_MISMATCH at large; only its NULL-insert shape (surfaced by
23
+ # input_format_null_as_default = 0) is a Rails not-null violation.
24
+ NULL_INSERT_MESSAGE = /Cannot insert NULL value into a column of type/
25
+
26
+ private
27
+
28
+ def translate_exception(exception, message:, sql:, binds:)
29
+ server_error = server_exception_class(exception)
30
+ return server_error.new(message, sql: sql, binds: binds) if server_error
31
+
32
+ case exception
33
+ when Errno::ECONNREFUSED, SocketError, Net::OpenTimeout, Net::ReadTimeout
34
+ ActiveRecord::ConnectionNotEstablished.new(exception)
35
+ else
36
+ super
37
+ end
38
+ end
39
+
40
+ def server_exception_class(exception)
41
+ return nil unless exception.is_a?(HTTPConnection::ExecutionError)
42
+
43
+ if exception.code == 53 && exception.message.match?(NULL_INSERT_MESSAGE)
44
+ ActiveRecord::NotNullViolation
45
+ else
46
+ EXCEPTION_CLASS_BY_CODE[exception.code]
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module ClickHouse
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ end