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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +313 -0
- data/lib/active_record/connection_adapters/clickhouse/database_statements.rb +464 -0
- data/lib/active_record/connection_adapters/clickhouse/error_translation.rb +52 -0
- data/lib/active_record/connection_adapters/clickhouse/gem_version.rb +9 -0
- data/lib/active_record/connection_adapters/clickhouse/http_connection.rb +233 -0
- data/lib/active_record/connection_adapters/clickhouse/querying.rb +417 -0
- data/lib/active_record/connection_adapters/clickhouse/quoting.rb +92 -0
- data/lib/active_record/connection_adapters/clickhouse/row_binary.rb +190 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_definitions.rb +146 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_dumper.rb +201 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +722 -0
- data/lib/active_record/connection_adapters/clickhouse/type_parser.rb +207 -0
- data/lib/active_record/connection_adapters/clickhouse/types.rb +238 -0
- data/lib/active_record/connection_adapters/clickhouse_adapter.rb +126 -0
- data/lib/active_record/tasks/clickhouse_database_tasks.rb +91 -0
- data/lib/activerecord-clickhouse-adapter.rb +21 -0
- data/lib/arel/visitors/clickhouse.rb +172 -0
- metadata +82 -0
|
@@ -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
|