clickhouse-ruby 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 +80 -0
- data/LICENSE +21 -0
- data/README.md +251 -0
- data/lib/clickhouse_ruby/active_record/arel_visitor.rb +468 -0
- data/lib/clickhouse_ruby/active_record/connection_adapter.rb +723 -0
- data/lib/clickhouse_ruby/active_record/railtie.rb +192 -0
- data/lib/clickhouse_ruby/active_record/schema_statements.rb +693 -0
- data/lib/clickhouse_ruby/active_record.rb +121 -0
- data/lib/clickhouse_ruby/client.rb +471 -0
- data/lib/clickhouse_ruby/configuration.rb +145 -0
- data/lib/clickhouse_ruby/connection.rb +328 -0
- data/lib/clickhouse_ruby/connection_pool.rb +301 -0
- data/lib/clickhouse_ruby/errors.rb +144 -0
- data/lib/clickhouse_ruby/result.rb +189 -0
- data/lib/clickhouse_ruby/types/array.rb +183 -0
- data/lib/clickhouse_ruby/types/base.rb +77 -0
- data/lib/clickhouse_ruby/types/boolean.rb +68 -0
- data/lib/clickhouse_ruby/types/date_time.rb +163 -0
- data/lib/clickhouse_ruby/types/float.rb +115 -0
- data/lib/clickhouse_ruby/types/integer.rb +157 -0
- data/lib/clickhouse_ruby/types/low_cardinality.rb +58 -0
- data/lib/clickhouse_ruby/types/map.rb +249 -0
- data/lib/clickhouse_ruby/types/nullable.rb +73 -0
- data/lib/clickhouse_ruby/types/parser.rb +244 -0
- data/lib/clickhouse_ruby/types/registry.rb +148 -0
- data/lib/clickhouse_ruby/types/string.rb +83 -0
- data/lib/clickhouse_ruby/types/tuple.rb +206 -0
- data/lib/clickhouse_ruby/types/uuid.rb +84 -0
- data/lib/clickhouse_ruby/types.rb +69 -0
- data/lib/clickhouse_ruby/version.rb +5 -0
- data/lib/clickhouse_ruby.rb +101 -0
- metadata +150 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_record/connection_adapters/abstract_adapter'
|
|
4
|
+
require_relative 'arel_visitor'
|
|
5
|
+
require_relative 'schema_statements'
|
|
6
|
+
|
|
7
|
+
module ClickhouseRuby
|
|
8
|
+
module ActiveRecord
|
|
9
|
+
# ClickHouse database connection adapter for ActiveRecord
|
|
10
|
+
#
|
|
11
|
+
# This adapter allows Rails applications to use ClickHouse as a database
|
|
12
|
+
# backend through ActiveRecord's standard interface.
|
|
13
|
+
#
|
|
14
|
+
# @note ClickHouse has significant differences from traditional RDBMS:
|
|
15
|
+
# - No transaction support (commits are immediate)
|
|
16
|
+
# - DELETE uses ALTER TABLE ... DELETE WHERE syntax
|
|
17
|
+
# - UPDATE uses ALTER TABLE ... UPDATE ... WHERE syntax
|
|
18
|
+
# - No foreign key constraints
|
|
19
|
+
# - No savepoints
|
|
20
|
+
#
|
|
21
|
+
# @example database.yml configuration
|
|
22
|
+
# development:
|
|
23
|
+
# adapter: clickhouse
|
|
24
|
+
# host: localhost
|
|
25
|
+
# port: 8123
|
|
26
|
+
# database: analytics
|
|
27
|
+
# username: default
|
|
28
|
+
# password: ''
|
|
29
|
+
# ssl: false
|
|
30
|
+
# ssl_verify: true
|
|
31
|
+
#
|
|
32
|
+
# @example Model usage
|
|
33
|
+
# class Event < ApplicationRecord
|
|
34
|
+
# self.table_name = 'events'
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# Event.where(user_id: 123).count
|
|
38
|
+
# Event.insert_all(records)
|
|
39
|
+
# Event.where(status: 'old').delete_all # Raises on error!
|
|
40
|
+
#
|
|
41
|
+
class ConnectionAdapter < ::ActiveRecord::ConnectionAdapters::AbstractAdapter
|
|
42
|
+
ADAPTER_NAME = 'Clickhouse'
|
|
43
|
+
|
|
44
|
+
include SchemaStatements
|
|
45
|
+
|
|
46
|
+
# Native database types mapping for ClickHouse
|
|
47
|
+
# Used by migrations and schema definitions
|
|
48
|
+
NATIVE_DATABASE_TYPES = {
|
|
49
|
+
primary_key: 'UInt64',
|
|
50
|
+
string: { name: 'String' },
|
|
51
|
+
text: { name: 'String' },
|
|
52
|
+
integer: { name: 'Int32' },
|
|
53
|
+
bigint: { name: 'Int64' },
|
|
54
|
+
float: { name: 'Float32' },
|
|
55
|
+
decimal: { name: 'Decimal', precision: 10, scale: 0 },
|
|
56
|
+
datetime: { name: 'DateTime' },
|
|
57
|
+
timestamp: { name: 'DateTime64', precision: 3 },
|
|
58
|
+
time: { name: 'DateTime' },
|
|
59
|
+
date: { name: 'Date' },
|
|
60
|
+
binary: { name: 'String' },
|
|
61
|
+
boolean: { name: 'UInt8' },
|
|
62
|
+
uuid: { name: 'UUID' },
|
|
63
|
+
json: { name: 'String' }
|
|
64
|
+
}.freeze
|
|
65
|
+
|
|
66
|
+
class << self
|
|
67
|
+
# Creates a new database connection
|
|
68
|
+
# Called by ActiveRecord's connection handler
|
|
69
|
+
#
|
|
70
|
+
# @param connection [Object, nil] existing connection (unused)
|
|
71
|
+
# @param logger [Logger] Rails logger
|
|
72
|
+
# @param connection_options [Hash] unused
|
|
73
|
+
# @param config [Hash] database configuration
|
|
74
|
+
# @return [ConnectionAdapter] the adapter instance
|
|
75
|
+
def new_client(config)
|
|
76
|
+
chruby_config = build_chruby_config(config)
|
|
77
|
+
chruby_config.validate!
|
|
78
|
+
ClickhouseRuby::Client.new(chruby_config)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Build a ClickhouseRuby::Configuration from Rails database.yml config
|
|
84
|
+
#
|
|
85
|
+
# @param config [Hash] database configuration hash
|
|
86
|
+
# @return [ClickhouseRuby::Configuration] configured client
|
|
87
|
+
def build_chruby_config(config)
|
|
88
|
+
ClickhouseRuby::Configuration.new.tap do |c|
|
|
89
|
+
c.host = config[:host] || 'localhost'
|
|
90
|
+
c.port = config[:port]&.to_i || 8123
|
|
91
|
+
c.database = config[:database] || 'default'
|
|
92
|
+
c.username = config[:username]
|
|
93
|
+
c.password = config[:password]
|
|
94
|
+
c.ssl = config[:ssl]
|
|
95
|
+
# SECURITY: SSL verification enabled by default
|
|
96
|
+
# Only disable in development with explicit ssl_verify: false
|
|
97
|
+
c.ssl_verify = config.fetch(:ssl_verify, true)
|
|
98
|
+
c.ssl_ca_path = config[:ssl_ca_path]
|
|
99
|
+
c.connect_timeout = config[:connect_timeout]&.to_i || 10
|
|
100
|
+
c.read_timeout = config[:read_timeout]&.to_i || 60
|
|
101
|
+
c.write_timeout = config[:write_timeout]&.to_i || 60
|
|
102
|
+
c.pool_size = config[:pool]&.to_i || 5
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Initialize a new ConnectionAdapter
|
|
108
|
+
#
|
|
109
|
+
# @param connection [Object, nil] existing connection
|
|
110
|
+
# @param logger [Logger] Rails logger
|
|
111
|
+
# @param connection_options [Array] connection options
|
|
112
|
+
# @param config [Hash] database configuration
|
|
113
|
+
def initialize(connection, logger = nil, connection_options = nil, config = {})
|
|
114
|
+
@config = config.symbolize_keys
|
|
115
|
+
@chruby_client = nil
|
|
116
|
+
@connection_parameters = nil
|
|
117
|
+
|
|
118
|
+
super(connection, logger, config)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Returns the adapter name
|
|
122
|
+
#
|
|
123
|
+
# @return [String] 'Clickhouse'
|
|
124
|
+
def adapter_name
|
|
125
|
+
ADAPTER_NAME
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Returns native database types
|
|
129
|
+
#
|
|
130
|
+
# @return [Hash] type mapping
|
|
131
|
+
def native_database_types
|
|
132
|
+
NATIVE_DATABASE_TYPES
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# ========================================
|
|
136
|
+
# Connection Management
|
|
137
|
+
# ========================================
|
|
138
|
+
|
|
139
|
+
# Check if the connection is active
|
|
140
|
+
#
|
|
141
|
+
# @return [Boolean] true if connected and responding
|
|
142
|
+
def active?
|
|
143
|
+
return false unless @chruby_client
|
|
144
|
+
|
|
145
|
+
# Ping ClickHouse to verify connection
|
|
146
|
+
execute_internal('SELECT 1')
|
|
147
|
+
true
|
|
148
|
+
rescue ClickhouseRuby::Error
|
|
149
|
+
false
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Check if connected to the database
|
|
153
|
+
#
|
|
154
|
+
# @return [Boolean] true if we have a client instance
|
|
155
|
+
def connected?
|
|
156
|
+
!@chruby_client.nil?
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Disconnect from the database
|
|
160
|
+
#
|
|
161
|
+
# @return [void]
|
|
162
|
+
def disconnect!
|
|
163
|
+
super
|
|
164
|
+
@chruby_client&.close if @chruby_client.respond_to?(:close)
|
|
165
|
+
@chruby_client = nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Reconnect to the database
|
|
169
|
+
#
|
|
170
|
+
# @return [void]
|
|
171
|
+
def reconnect!
|
|
172
|
+
super
|
|
173
|
+
disconnect!
|
|
174
|
+
connect
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Clear the connection (called when returning connection to pool)
|
|
178
|
+
#
|
|
179
|
+
# @return [void]
|
|
180
|
+
def reset!
|
|
181
|
+
reconnect!
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Establish connection to ClickHouse
|
|
185
|
+
#
|
|
186
|
+
# @return [void]
|
|
187
|
+
def connect
|
|
188
|
+
@chruby_client = self.class.new_client(@config)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# ========================================
|
|
192
|
+
# ClickHouse Capabilities
|
|
193
|
+
# These return false because ClickHouse doesn't support these features
|
|
194
|
+
# ========================================
|
|
195
|
+
|
|
196
|
+
# ClickHouse doesn't support DDL transactions
|
|
197
|
+
#
|
|
198
|
+
# @return [Boolean] false
|
|
199
|
+
def supports_ddl_transactions?
|
|
200
|
+
false
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# ClickHouse doesn't support savepoints
|
|
204
|
+
#
|
|
205
|
+
# @return [Boolean] false
|
|
206
|
+
def supports_savepoints?
|
|
207
|
+
false
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# ClickHouse doesn't support transaction isolation levels
|
|
211
|
+
#
|
|
212
|
+
# @return [Boolean] false
|
|
213
|
+
def supports_transaction_isolation?
|
|
214
|
+
false
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# ClickHouse doesn't support INSERT RETURNING
|
|
218
|
+
#
|
|
219
|
+
# @return [Boolean] false
|
|
220
|
+
def supports_insert_returning?
|
|
221
|
+
false
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# ClickHouse doesn't support foreign keys
|
|
225
|
+
#
|
|
226
|
+
# @return [Boolean] false
|
|
227
|
+
def supports_foreign_keys?
|
|
228
|
+
false
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# ClickHouse doesn't support check constraints in the traditional sense
|
|
232
|
+
#
|
|
233
|
+
# @return [Boolean] false
|
|
234
|
+
def supports_check_constraints?
|
|
235
|
+
false
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# ClickHouse doesn't support partial indexes
|
|
239
|
+
#
|
|
240
|
+
# @return [Boolean] false
|
|
241
|
+
def supports_partial_index?
|
|
242
|
+
false
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# ClickHouse doesn't support expression indexes
|
|
246
|
+
#
|
|
247
|
+
# @return [Boolean] false
|
|
248
|
+
def supports_expression_index?
|
|
249
|
+
false
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# ClickHouse doesn't support standard views (has MATERIALIZED VIEWS)
|
|
253
|
+
#
|
|
254
|
+
# @return [Boolean] false
|
|
255
|
+
def supports_views?
|
|
256
|
+
false
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# ClickHouse supports datetime with precision (DateTime64)
|
|
260
|
+
#
|
|
261
|
+
# @return [Boolean] true
|
|
262
|
+
def supports_datetime_with_precision?
|
|
263
|
+
true
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# ClickHouse supports JSON type (as String with JSON functions)
|
|
267
|
+
#
|
|
268
|
+
# @return [Boolean] true
|
|
269
|
+
def supports_json?
|
|
270
|
+
true
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# ClickHouse doesn't support standard comments on columns
|
|
274
|
+
#
|
|
275
|
+
# @return [Boolean] false
|
|
276
|
+
def supports_comments?
|
|
277
|
+
false
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# ClickHouse doesn't support bulk alter
|
|
281
|
+
#
|
|
282
|
+
# @return [Boolean] false
|
|
283
|
+
def supports_bulk_alter?
|
|
284
|
+
false
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# ClickHouse supports EXPLAIN
|
|
288
|
+
#
|
|
289
|
+
# @return [Boolean] true
|
|
290
|
+
def supports_explain?
|
|
291
|
+
true
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# ========================================
|
|
295
|
+
# Query Execution
|
|
296
|
+
# CRITICAL: Never silently fail - always propagate errors
|
|
297
|
+
# See: clickhouse-activerecord Issue #230
|
|
298
|
+
# ========================================
|
|
299
|
+
|
|
300
|
+
# Execute a SQL query
|
|
301
|
+
# CRITICAL: This method MUST raise on errors, never silently fail
|
|
302
|
+
#
|
|
303
|
+
# @param sql [String] the SQL query
|
|
304
|
+
# @param name [String] query name for logging
|
|
305
|
+
# @return [ClickhouseRuby::Result] query result
|
|
306
|
+
# @raise [ClickhouseRuby::QueryError] on ClickHouse errors
|
|
307
|
+
def execute(sql, name = nil)
|
|
308
|
+
ensure_connected!
|
|
309
|
+
|
|
310
|
+
log(sql, name) do
|
|
311
|
+
result = execute_internal(sql)
|
|
312
|
+
# CRITICAL: Check for errors and raise them
|
|
313
|
+
# ClickHouse may return 200 OK with error in body
|
|
314
|
+
raise_if_error!(result)
|
|
315
|
+
result
|
|
316
|
+
end
|
|
317
|
+
rescue ClickhouseRuby::Error => e
|
|
318
|
+
# Re-raise ClickhouseRuby errors with the SQL context
|
|
319
|
+
raise_query_error(e, sql)
|
|
320
|
+
rescue StandardError => e
|
|
321
|
+
# Wrap unexpected errors
|
|
322
|
+
raise ClickhouseRuby::QueryError.new(
|
|
323
|
+
"Query execution failed: #{e.message}",
|
|
324
|
+
sql: sql,
|
|
325
|
+
original_error: e
|
|
326
|
+
)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Execute an INSERT statement
|
|
330
|
+
# For bulk inserts, use insert_all which is more efficient
|
|
331
|
+
#
|
|
332
|
+
# @param sql [String] the INSERT SQL
|
|
333
|
+
# @param name [String] query name for logging
|
|
334
|
+
# @param pk [String, nil] primary key column
|
|
335
|
+
# @param id_value [Object, nil] id value
|
|
336
|
+
# @param sequence_name [String, nil] sequence name (unused)
|
|
337
|
+
# @param binds [Array] bind values
|
|
338
|
+
# @return [Object] the id value
|
|
339
|
+
# @raise [ClickhouseRuby::QueryError] on ClickHouse errors
|
|
340
|
+
def exec_insert(sql, name = nil, binds = [], pk = nil, sequence_name = nil)
|
|
341
|
+
execute(sql, name)
|
|
342
|
+
# ClickHouse doesn't return inserted IDs
|
|
343
|
+
# Return nil as we can't get the last insert ID
|
|
344
|
+
nil
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Execute a DELETE statement
|
|
348
|
+
# CRITICAL: This method MUST raise on errors (Issue #230)
|
|
349
|
+
#
|
|
350
|
+
# ClickHouse DELETE syntax: ALTER TABLE table DELETE WHERE condition
|
|
351
|
+
# This method handles the conversion automatically via Arel visitor
|
|
352
|
+
#
|
|
353
|
+
# @param sql [String] the DELETE SQL (converted to ALTER TABLE ... DELETE)
|
|
354
|
+
# @param name [String] query name for logging
|
|
355
|
+
# @param binds [Array] bind values
|
|
356
|
+
# @return [Integer] number of affected rows (estimated, ClickHouse doesn't return exact count)
|
|
357
|
+
# @raise [ClickhouseRuby::QueryError] on ClickHouse errors - NEVER silently fails
|
|
358
|
+
def exec_delete(sql, name = nil, binds = [])
|
|
359
|
+
ensure_connected!
|
|
360
|
+
|
|
361
|
+
# The Arel visitor should have already converted this to
|
|
362
|
+
# ALTER TABLE ... DELETE WHERE syntax
|
|
363
|
+
# But if it's standard DELETE, convert it here
|
|
364
|
+
clickhouse_sql = convert_delete_to_alter(sql)
|
|
365
|
+
|
|
366
|
+
log(clickhouse_sql, name || 'DELETE') do
|
|
367
|
+
result = execute_internal(clickhouse_sql)
|
|
368
|
+
# CRITICAL: Raise on any error
|
|
369
|
+
raise_if_error!(result)
|
|
370
|
+
|
|
371
|
+
# ClickHouse doesn't return affected row count for mutations
|
|
372
|
+
# Return 0 as a safe default, but the operation succeeded
|
|
373
|
+
0
|
|
374
|
+
end
|
|
375
|
+
rescue ClickhouseRuby::Error => e
|
|
376
|
+
# CRITICAL: Always propagate errors, never silently fail
|
|
377
|
+
raise_query_error(e, sql)
|
|
378
|
+
rescue StandardError => e
|
|
379
|
+
raise ClickhouseRuby::QueryError.new(
|
|
380
|
+
"DELETE failed: #{e.message}",
|
|
381
|
+
sql: sql,
|
|
382
|
+
original_error: e
|
|
383
|
+
)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Execute an UPDATE statement
|
|
387
|
+
# CRITICAL: This method MUST raise on errors
|
|
388
|
+
#
|
|
389
|
+
# ClickHouse UPDATE syntax: ALTER TABLE table UPDATE col = val WHERE condition
|
|
390
|
+
# This method handles the conversion automatically via Arel visitor
|
|
391
|
+
#
|
|
392
|
+
# @param sql [String] the UPDATE SQL (converted to ALTER TABLE ... UPDATE)
|
|
393
|
+
# @param name [String] query name for logging
|
|
394
|
+
# @param binds [Array] bind values
|
|
395
|
+
# @return [Integer] number of affected rows (estimated)
|
|
396
|
+
# @raise [ClickhouseRuby::QueryError] on ClickHouse errors
|
|
397
|
+
def exec_update(sql, name = nil, binds = [])
|
|
398
|
+
ensure_connected!
|
|
399
|
+
|
|
400
|
+
# The Arel visitor should have already converted this to
|
|
401
|
+
# ALTER TABLE ... UPDATE ... WHERE syntax
|
|
402
|
+
clickhouse_sql = convert_update_to_alter(sql)
|
|
403
|
+
|
|
404
|
+
log(clickhouse_sql, name || 'UPDATE') do
|
|
405
|
+
result = execute_internal(clickhouse_sql)
|
|
406
|
+
raise_if_error!(result)
|
|
407
|
+
|
|
408
|
+
# ClickHouse doesn't return affected row count for mutations
|
|
409
|
+
0
|
|
410
|
+
end
|
|
411
|
+
rescue ClickhouseRuby::Error => e
|
|
412
|
+
raise_query_error(e, sql)
|
|
413
|
+
rescue StandardError => e
|
|
414
|
+
raise ClickhouseRuby::QueryError.new(
|
|
415
|
+
"UPDATE failed: #{e.message}",
|
|
416
|
+
sql: sql,
|
|
417
|
+
original_error: e
|
|
418
|
+
)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Execute a raw query, returning results
|
|
422
|
+
#
|
|
423
|
+
# @param sql [String] the SQL query
|
|
424
|
+
# @param name [String] query name for logging
|
|
425
|
+
# @param binds [Array] bind values
|
|
426
|
+
# @param prepare [Boolean] whether to prepare (ignored, ClickHouse doesn't support)
|
|
427
|
+
# @return [ClickhouseRuby::Result] query result
|
|
428
|
+
def exec_query(sql, name = 'SQL', binds = [], prepare: false)
|
|
429
|
+
execute(sql, name)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# ========================================
|
|
433
|
+
# Transaction Methods (ClickHouse has limited support)
|
|
434
|
+
# ========================================
|
|
435
|
+
|
|
436
|
+
# Begin a transaction (no-op for ClickHouse)
|
|
437
|
+
# ClickHouse doesn't support multi-statement transactions
|
|
438
|
+
#
|
|
439
|
+
# @return [void]
|
|
440
|
+
def begin_db_transaction
|
|
441
|
+
# No-op: ClickHouse doesn't support transactions
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Commit a transaction (no-op for ClickHouse)
|
|
445
|
+
# All statements are auto-committed in ClickHouse
|
|
446
|
+
#
|
|
447
|
+
# @return [void]
|
|
448
|
+
def commit_db_transaction
|
|
449
|
+
# No-op: ClickHouse doesn't support transactions
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Rollback a transaction (no-op for ClickHouse)
|
|
453
|
+
# ClickHouse doesn't support rollback
|
|
454
|
+
#
|
|
455
|
+
# @return [void]
|
|
456
|
+
def exec_rollback_db_transaction
|
|
457
|
+
# No-op: ClickHouse doesn't support transactions
|
|
458
|
+
# Log a warning since rollback was requested but cannot be performed
|
|
459
|
+
@logger&.warn('ClickHouse does not support transaction rollback')
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# ========================================
|
|
463
|
+
# Quoting
|
|
464
|
+
# ========================================
|
|
465
|
+
|
|
466
|
+
# Quote a column name for ClickHouse
|
|
467
|
+
# ClickHouse uses backticks or double quotes for identifiers
|
|
468
|
+
#
|
|
469
|
+
# @param name [String, Symbol] the column name
|
|
470
|
+
# @return [String] the quoted column name
|
|
471
|
+
def quote_column_name(name)
|
|
472
|
+
"`#{name.to_s.gsub('`', '``')}`"
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Quote a table name for ClickHouse
|
|
476
|
+
#
|
|
477
|
+
# @param name [String, Symbol] the table name
|
|
478
|
+
# @return [String] the quoted table name
|
|
479
|
+
def quote_table_name(name)
|
|
480
|
+
"`#{name.to_s.gsub('`', '``')}`"
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Quote a string value for ClickHouse
|
|
484
|
+
#
|
|
485
|
+
# @param string [String] the string to quote
|
|
486
|
+
# @return [String] the quoted string
|
|
487
|
+
def quote_string(string)
|
|
488
|
+
string.gsub("\\", "\\\\\\\\").gsub("'", "\\\\'")
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# ========================================
|
|
492
|
+
# Arel Visitor
|
|
493
|
+
# ========================================
|
|
494
|
+
|
|
495
|
+
# Returns the Arel visitor for ClickHouse SQL generation
|
|
496
|
+
#
|
|
497
|
+
# @return [ArelVisitor] the visitor instance
|
|
498
|
+
def arel_visitor
|
|
499
|
+
@arel_visitor ||= ArelVisitor.new(self)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# ========================================
|
|
503
|
+
# Type Mapping
|
|
504
|
+
# ========================================
|
|
505
|
+
|
|
506
|
+
# Initialize the type map with ClickHouse types
|
|
507
|
+
#
|
|
508
|
+
# @param m [ActiveRecord::Type::TypeMap] the type map to populate
|
|
509
|
+
# @return [void]
|
|
510
|
+
def initialize_type_map(m = type_map)
|
|
511
|
+
# Register standard types
|
|
512
|
+
register_class_with_limit m, %r{^String}i, ::ActiveRecord::Type::String
|
|
513
|
+
register_class_with_limit m, %r{^FixedString}i, ::ActiveRecord::Type::String
|
|
514
|
+
|
|
515
|
+
# Integer types
|
|
516
|
+
m.register_type %r{^Int8}i, ::ActiveRecord::Type::Integer.new(limit: 1)
|
|
517
|
+
m.register_type %r{^Int16}i, ::ActiveRecord::Type::Integer.new(limit: 2)
|
|
518
|
+
m.register_type %r{^Int32}i, ::ActiveRecord::Type::Integer.new(limit: 4)
|
|
519
|
+
m.register_type %r{^Int64}i, ::ActiveRecord::Type::Integer.new(limit: 8)
|
|
520
|
+
m.register_type %r{^UInt8}i, ::ActiveRecord::Type::Integer.new(limit: 1)
|
|
521
|
+
m.register_type %r{^UInt16}i, ::ActiveRecord::Type::Integer.new(limit: 2)
|
|
522
|
+
m.register_type %r{^UInt32}i, ::ActiveRecord::Type::Integer.new(limit: 4)
|
|
523
|
+
m.register_type %r{^UInt64}i, ::ActiveRecord::Type::Integer.new(limit: 8)
|
|
524
|
+
|
|
525
|
+
# Float types
|
|
526
|
+
m.register_type %r{^Float32}i, ::ActiveRecord::Type::Float.new
|
|
527
|
+
m.register_type %r{^Float64}i, ::ActiveRecord::Type::Float.new
|
|
528
|
+
|
|
529
|
+
# Decimal types
|
|
530
|
+
m.register_type %r{^Decimal}i, ::ActiveRecord::Type::Decimal.new
|
|
531
|
+
|
|
532
|
+
# Date/Time types
|
|
533
|
+
m.register_type %r{^Date$}i, ::ActiveRecord::Type::Date.new
|
|
534
|
+
m.register_type %r{^DateTime}i, ::ActiveRecord::Type::DateTime.new
|
|
535
|
+
m.register_type %r{^DateTime64}i, ::ActiveRecord::Type::DateTime.new
|
|
536
|
+
|
|
537
|
+
# Boolean (UInt8 with 0/1)
|
|
538
|
+
m.register_type %r{^Bool}i, ::ActiveRecord::Type::Boolean.new
|
|
539
|
+
|
|
540
|
+
# UUID
|
|
541
|
+
m.register_type %r{^UUID}i, ::ActiveRecord::Type::String.new
|
|
542
|
+
|
|
543
|
+
# Nullable wrapper - extract inner type
|
|
544
|
+
m.register_type %r{^Nullable\((.+)\)}i do |sql_type|
|
|
545
|
+
inner_type = sql_type.match(%r{^Nullable\((.+)\)}i)[1]
|
|
546
|
+
lookup_cast_type(inner_type)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Array types
|
|
550
|
+
m.register_type %r{^Array\(}i, ::ActiveRecord::Type::String.new
|
|
551
|
+
|
|
552
|
+
# Map types
|
|
553
|
+
m.register_type %r{^Map\(}i, ::ActiveRecord::Type::String.new
|
|
554
|
+
|
|
555
|
+
# Tuple types
|
|
556
|
+
m.register_type %r{^Tuple\(}i, ::ActiveRecord::Type::String.new
|
|
557
|
+
|
|
558
|
+
# Enum types (treated as strings)
|
|
559
|
+
m.register_type %r{^Enum}i, ::ActiveRecord::Type::String.new
|
|
560
|
+
|
|
561
|
+
# LowCardinality wrapper
|
|
562
|
+
m.register_type %r{^LowCardinality\((.+)\)}i do |sql_type|
|
|
563
|
+
inner_type = sql_type.match(%r{^LowCardinality\((.+)\)}i)[1]
|
|
564
|
+
lookup_cast_type(inner_type)
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
private
|
|
569
|
+
|
|
570
|
+
# Ensure we have an active connection
|
|
571
|
+
#
|
|
572
|
+
# @raise [ClickhouseRuby::ConnectionNotEstablished] if not connected
|
|
573
|
+
def ensure_connected!
|
|
574
|
+
connect unless connected?
|
|
575
|
+
|
|
576
|
+
unless @chruby_client
|
|
577
|
+
raise ClickhouseRuby::ConnectionNotEstablished,
|
|
578
|
+
'No connection to ClickHouse. Call connect first.'
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# Execute SQL through the ClickhouseRuby client
|
|
583
|
+
#
|
|
584
|
+
# @param sql [String] the SQL to execute
|
|
585
|
+
# @return [ClickhouseRuby::Result] the result
|
|
586
|
+
def execute_internal(sql)
|
|
587
|
+
@chruby_client.execute(sql)
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# Check if result contains an error and raise it
|
|
591
|
+
#
|
|
592
|
+
# @param result [ClickhouseRuby::Result] the result to check
|
|
593
|
+
# @raise [ClickhouseRuby::QueryError] if result contains an error
|
|
594
|
+
def raise_if_error!(result)
|
|
595
|
+
# ClickhouseRuby::Result should raise errors, but double-check
|
|
596
|
+
return unless result.respond_to?(:error?) && result.error?
|
|
597
|
+
|
|
598
|
+
raise ClickhouseRuby::QueryError.new(
|
|
599
|
+
result.error_message,
|
|
600
|
+
code: result.error_code,
|
|
601
|
+
http_status: result.http_status
|
|
602
|
+
)
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# Raise a query error with SQL context
|
|
606
|
+
#
|
|
607
|
+
# @param error [ClickhouseRuby::Error] the original error
|
|
608
|
+
# @param sql [String] the SQL that caused the error
|
|
609
|
+
# @raise [ClickhouseRuby::QueryError] always
|
|
610
|
+
def raise_query_error(error, sql)
|
|
611
|
+
if error.is_a?(ClickhouseRuby::QueryError)
|
|
612
|
+
# Re-raise with SQL if not already set
|
|
613
|
+
if error.sql.nil?
|
|
614
|
+
raise ClickhouseRuby::QueryError.new(
|
|
615
|
+
error.message,
|
|
616
|
+
code: error.code,
|
|
617
|
+
http_status: error.http_status,
|
|
618
|
+
sql: sql,
|
|
619
|
+
original_error: error.original_error
|
|
620
|
+
)
|
|
621
|
+
else
|
|
622
|
+
raise error
|
|
623
|
+
end
|
|
624
|
+
else
|
|
625
|
+
raise ClickhouseRuby::QueryError.new(
|
|
626
|
+
error.message,
|
|
627
|
+
sql: sql,
|
|
628
|
+
original_error: error
|
|
629
|
+
)
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# Convert standard DELETE to ClickHouse ALTER TABLE DELETE
|
|
634
|
+
#
|
|
635
|
+
# Standard: DELETE FROM table WHERE condition
|
|
636
|
+
# ClickHouse: ALTER TABLE table DELETE WHERE condition
|
|
637
|
+
#
|
|
638
|
+
# @param sql [String] the DELETE SQL
|
|
639
|
+
# @return [String] the converted SQL
|
|
640
|
+
def convert_delete_to_alter(sql)
|
|
641
|
+
# Check if already in ALTER TABLE format
|
|
642
|
+
return sql if sql.strip.match?(/^ALTER\s+TABLE/i)
|
|
643
|
+
|
|
644
|
+
# Parse standard DELETE
|
|
645
|
+
if (match = sql.strip.match(/^DELETE\s+FROM\s+(\S+)(?:\s+WHERE\s+(.+))?$/im))
|
|
646
|
+
table = match[1]
|
|
647
|
+
where_clause = match[2]
|
|
648
|
+
|
|
649
|
+
if where_clause
|
|
650
|
+
"ALTER TABLE #{table} DELETE WHERE #{where_clause}"
|
|
651
|
+
else
|
|
652
|
+
# DELETE without WHERE - delete all rows
|
|
653
|
+
"ALTER TABLE #{table} DELETE WHERE 1=1"
|
|
654
|
+
end
|
|
655
|
+
else
|
|
656
|
+
# Return as-is if we can't parse it
|
|
657
|
+
sql
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# Convert standard UPDATE to ClickHouse ALTER TABLE UPDATE
|
|
662
|
+
#
|
|
663
|
+
# Standard: UPDATE table SET col = val WHERE condition
|
|
664
|
+
# ClickHouse: ALTER TABLE table UPDATE col = val WHERE condition
|
|
665
|
+
#
|
|
666
|
+
# @param sql [String] the UPDATE SQL
|
|
667
|
+
# @return [String] the converted SQL
|
|
668
|
+
def convert_update_to_alter(sql)
|
|
669
|
+
# Check if already in ALTER TABLE format
|
|
670
|
+
return sql if sql.strip.match?(/^ALTER\s+TABLE/i)
|
|
671
|
+
|
|
672
|
+
# Parse standard UPDATE
|
|
673
|
+
if (match = sql.strip.match(/^UPDATE\s+(\S+)\s+SET\s+(.+?)\s+WHERE\s+(.+)$/im))
|
|
674
|
+
table = match[1]
|
|
675
|
+
set_clause = match[2]
|
|
676
|
+
where_clause = match[3]
|
|
677
|
+
|
|
678
|
+
"ALTER TABLE #{table} UPDATE #{set_clause} WHERE #{where_clause}"
|
|
679
|
+
elsif (match = sql.strip.match(/^UPDATE\s+(\S+)\s+SET\s+(.+)$/im))
|
|
680
|
+
# UPDATE without WHERE
|
|
681
|
+
table = match[1]
|
|
682
|
+
set_clause = match[2]
|
|
683
|
+
|
|
684
|
+
"ALTER TABLE #{table} UPDATE #{set_clause} WHERE 1=1"
|
|
685
|
+
else
|
|
686
|
+
# Return as-is if we can't parse it
|
|
687
|
+
sql
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Register a type class with limit support
|
|
692
|
+
#
|
|
693
|
+
# @param mapping [TypeMap] the type map
|
|
694
|
+
# @param pattern [Regexp] the pattern to match
|
|
695
|
+
# @param klass [Class] the type class
|
|
696
|
+
def register_class_with_limit(mapping, pattern, klass)
|
|
697
|
+
mapping.register_type(pattern) do |sql_type|
|
|
698
|
+
limit = extract_limit(sql_type)
|
|
699
|
+
klass.new(limit: limit)
|
|
700
|
+
end
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# Extract limit from a type string (e.g., FixedString(100))
|
|
704
|
+
#
|
|
705
|
+
# @param sql_type [String] the SQL type
|
|
706
|
+
# @return [Integer, nil] the limit or nil
|
|
707
|
+
def extract_limit(sql_type)
|
|
708
|
+
if (match = sql_type.match(/\((\d+)\)/))
|
|
709
|
+
match[1].to_i
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Register the adapter with ActiveRecord
|
|
717
|
+
if defined?(::ActiveRecord::ConnectionAdapters)
|
|
718
|
+
::ActiveRecord::ConnectionAdapters.register(
|
|
719
|
+
'clickhouse',
|
|
720
|
+
'ClickhouseRuby::ActiveRecord::ConnectionAdapter',
|
|
721
|
+
'chruby/active_record/connection_adapter'
|
|
722
|
+
)
|
|
723
|
+
end
|