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.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/LICENSE +21 -0
  4. data/README.md +251 -0
  5. data/lib/clickhouse_ruby/active_record/arel_visitor.rb +468 -0
  6. data/lib/clickhouse_ruby/active_record/connection_adapter.rb +723 -0
  7. data/lib/clickhouse_ruby/active_record/railtie.rb +192 -0
  8. data/lib/clickhouse_ruby/active_record/schema_statements.rb +693 -0
  9. data/lib/clickhouse_ruby/active_record.rb +121 -0
  10. data/lib/clickhouse_ruby/client.rb +471 -0
  11. data/lib/clickhouse_ruby/configuration.rb +145 -0
  12. data/lib/clickhouse_ruby/connection.rb +328 -0
  13. data/lib/clickhouse_ruby/connection_pool.rb +301 -0
  14. data/lib/clickhouse_ruby/errors.rb +144 -0
  15. data/lib/clickhouse_ruby/result.rb +189 -0
  16. data/lib/clickhouse_ruby/types/array.rb +183 -0
  17. data/lib/clickhouse_ruby/types/base.rb +77 -0
  18. data/lib/clickhouse_ruby/types/boolean.rb +68 -0
  19. data/lib/clickhouse_ruby/types/date_time.rb +163 -0
  20. data/lib/clickhouse_ruby/types/float.rb +115 -0
  21. data/lib/clickhouse_ruby/types/integer.rb +157 -0
  22. data/lib/clickhouse_ruby/types/low_cardinality.rb +58 -0
  23. data/lib/clickhouse_ruby/types/map.rb +249 -0
  24. data/lib/clickhouse_ruby/types/nullable.rb +73 -0
  25. data/lib/clickhouse_ruby/types/parser.rb +244 -0
  26. data/lib/clickhouse_ruby/types/registry.rb +148 -0
  27. data/lib/clickhouse_ruby/types/string.rb +83 -0
  28. data/lib/clickhouse_ruby/types/tuple.rb +206 -0
  29. data/lib/clickhouse_ruby/types/uuid.rb +84 -0
  30. data/lib/clickhouse_ruby/types.rb +69 -0
  31. data/lib/clickhouse_ruby/version.rb +5 -0
  32. data/lib/clickhouse_ruby.rb +101 -0
  33. 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