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,693 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickhouseRuby
4
+ module ActiveRecord
5
+ # Schema introspection and manipulation methods for ClickHouse
6
+ #
7
+ # Provides methods to query and modify database schema through
8
+ # ClickHouse's system tables (system.tables, system.columns, etc.)
9
+ #
10
+ # @note ClickHouse schema operations differ significantly from traditional RDBMS:
11
+ # - Tables require ENGINE specification (MergeTree, etc.)
12
+ # - No SERIAL/AUTO_INCREMENT (use generateUUIDv4() or application-side IDs)
13
+ # - No ALTER TABLE ADD COLUMN migrations (use ALTER TABLE ADD COLUMN)
14
+ # - Indexes are defined at table creation time
15
+ #
16
+ module SchemaStatements
17
+ # Returns list of tables in the current database
18
+ #
19
+ # @return [Array<String>] list of table names
20
+ def tables
21
+ sql = <<~SQL
22
+ SELECT name
23
+ FROM system.tables
24
+ WHERE database = currentDatabase()
25
+ AND engine NOT IN ('View', 'MaterializedView', 'LiveView')
26
+ ORDER BY name
27
+ SQL
28
+
29
+ result = execute(sql, 'SCHEMA')
30
+ result.map { |row| row['name'] }
31
+ end
32
+
33
+ # Returns list of views in the current database
34
+ #
35
+ # @return [Array<String>] list of view names
36
+ def views
37
+ sql = <<~SQL
38
+ SELECT name
39
+ FROM system.tables
40
+ WHERE database = currentDatabase()
41
+ AND engine IN ('View', 'MaterializedView', 'LiveView')
42
+ ORDER BY name
43
+ SQL
44
+
45
+ result = execute(sql, 'SCHEMA')
46
+ result.map { |row| row['name'] }
47
+ end
48
+
49
+ # Check if a table exists
50
+ #
51
+ # @param table_name [String] the table name to check
52
+ # @return [Boolean] true if the table exists
53
+ def table_exists?(table_name)
54
+ sql = <<~SQL
55
+ SELECT 1
56
+ FROM system.tables
57
+ WHERE database = currentDatabase()
58
+ AND name = '#{quote_string(table_name.to_s)}'
59
+ LIMIT 1
60
+ SQL
61
+
62
+ result = execute(sql, 'SCHEMA')
63
+ result.any?
64
+ end
65
+
66
+ # Check if a view exists
67
+ #
68
+ # @param view_name [String] the view name to check
69
+ # @return [Boolean] true if the view exists
70
+ def view_exists?(view_name)
71
+ sql = <<~SQL
72
+ SELECT 1
73
+ FROM system.tables
74
+ WHERE database = currentDatabase()
75
+ AND name = '#{quote_string(view_name.to_s)}'
76
+ AND engine IN ('View', 'MaterializedView', 'LiveView')
77
+ LIMIT 1
78
+ SQL
79
+
80
+ result = execute(sql, 'SCHEMA')
81
+ result.any?
82
+ end
83
+
84
+ # Returns list of indexes for a table
85
+ #
86
+ # @param table_name [String] the table name
87
+ # @return [Array<Hash>] list of index information
88
+ def indexes(table_name)
89
+ sql = <<~SQL
90
+ SELECT
91
+ name,
92
+ type,
93
+ expr,
94
+ granularity
95
+ FROM system.data_skipping_indices
96
+ WHERE database = currentDatabase()
97
+ AND table = '#{quote_string(table_name.to_s)}'
98
+ ORDER BY name
99
+ SQL
100
+
101
+ result = execute(sql, 'SCHEMA')
102
+ result.map do |row|
103
+ {
104
+ name: row['name'],
105
+ type: row['type'],
106
+ expression: row['expr'],
107
+ granularity: row['granularity']
108
+ }
109
+ end
110
+ end
111
+
112
+ # Returns list of columns for a table
113
+ #
114
+ # @param table_name [String] the table name
115
+ # @return [Array<Column>] list of column objects
116
+ def columns(table_name)
117
+ sql = <<~SQL
118
+ SELECT
119
+ name,
120
+ type,
121
+ default_kind,
122
+ default_expression,
123
+ comment,
124
+ is_in_primary_key,
125
+ is_in_sorting_key,
126
+ is_in_partition_key
127
+ FROM system.columns
128
+ WHERE database = currentDatabase()
129
+ AND table = '#{quote_string(table_name.to_s)}'
130
+ ORDER BY position
131
+ SQL
132
+
133
+ result = execute(sql, 'SCHEMA')
134
+ result.map do |row|
135
+ new_column(
136
+ row['name'],
137
+ row['default_expression'],
138
+ fetch_type_metadata(row['type']),
139
+ row['type'] =~ /^Nullable/i,
140
+ row['comment']
141
+ )
142
+ end
143
+ end
144
+
145
+ # Returns the primary key columns for a table
146
+ #
147
+ # @param table_name [String] the table name
148
+ # @return [Array<String>, nil] primary key column names or nil
149
+ def primary_keys(table_name)
150
+ sql = <<~SQL
151
+ SELECT name
152
+ FROM system.columns
153
+ WHERE database = currentDatabase()
154
+ AND table = '#{quote_string(table_name.to_s)}'
155
+ AND is_in_primary_key = 1
156
+ ORDER BY position
157
+ SQL
158
+
159
+ result = execute(sql, 'SCHEMA')
160
+ keys = result.map { |row| row['name'] }
161
+ keys.empty? ? nil : keys
162
+ end
163
+
164
+ # Create a new table
165
+ #
166
+ # @param table_name [String] the table name
167
+ # @param options [Hash] table options
168
+ # @option options [String] :engine the table engine (default: MergeTree)
169
+ # @option options [String] :order_by ORDER BY clause for MergeTree
170
+ # @option options [String] :partition_by PARTITION BY clause
171
+ # @option options [String] :primary_key PRIMARY KEY clause
172
+ # @option options [String] :settings table SETTINGS
173
+ # @yield [TableDefinition] the table definition block
174
+ # @return [void]
175
+ # @raise [ClickhouseRuby::QueryError] on error
176
+ def create_table(table_name, **options, &block)
177
+ td = create_table_definition(table_name, **options)
178
+
179
+ if block_given?
180
+ yield td
181
+ end
182
+
183
+ sql = schema_creation.accept(td)
184
+ execute(sql, 'CREATE TABLE')
185
+ end
186
+
187
+ # Drop a table
188
+ #
189
+ # @param table_name [String] the table name
190
+ # @param options [Hash] drop options
191
+ # @option options [Boolean] :if_exists add IF EXISTS clause
192
+ # @return [void]
193
+ # @raise [ClickhouseRuby::QueryError] on error (unless if_exists: true)
194
+ def drop_table(table_name, **options)
195
+ if_exists = options.fetch(:if_exists, false)
196
+ sql = "DROP TABLE #{if_exists ? 'IF EXISTS ' : ''}#{quote_table_name(table_name)}"
197
+ execute(sql, 'DROP TABLE')
198
+ end
199
+
200
+ # Rename a table
201
+ #
202
+ # @param old_name [String] the current table name
203
+ # @param new_name [String] the new table name
204
+ # @return [void]
205
+ # @raise [ClickhouseRuby::QueryError] on error
206
+ def rename_table(old_name, new_name)
207
+ sql = "RENAME TABLE #{quote_table_name(old_name)} TO #{quote_table_name(new_name)}"
208
+ execute(sql, 'RENAME TABLE')
209
+ end
210
+
211
+ # Truncate a table (delete all data)
212
+ #
213
+ # @param table_name [String] the table name
214
+ # @param options [Hash] truncate options
215
+ # @return [void]
216
+ # @raise [ClickhouseRuby::QueryError] on error
217
+ def truncate_table(table_name, **options)
218
+ sql = "TRUNCATE TABLE #{quote_table_name(table_name)}"
219
+ execute(sql, 'TRUNCATE TABLE')
220
+ end
221
+
222
+ # Add a column to a table
223
+ #
224
+ # @param table_name [String] the table name
225
+ # @param column_name [String] the column name
226
+ # @param type [Symbol, String] the column type
227
+ # @param options [Hash] column options
228
+ # @option options [String] :after add column after this column
229
+ # @option options [Object] :default default value
230
+ # @option options [Boolean] :null whether column is nullable
231
+ # @return [void]
232
+ # @raise [ClickhouseRuby::QueryError] on error
233
+ def add_column(table_name, column_name, type, **options)
234
+ sql_type = type_to_sql(type, **options)
235
+
236
+ # Handle nullable
237
+ if options[:null] != false && !sql_type.match?(/^Nullable/i)
238
+ sql_type = "Nullable(#{sql_type})"
239
+ end
240
+
241
+ sql = "ALTER TABLE #{quote_table_name(table_name)} ADD COLUMN #{quote_column_name(column_name)} #{sql_type}"
242
+
243
+ # Add AFTER clause if specified
244
+ if options[:after]
245
+ sql += " AFTER #{quote_column_name(options[:after])}"
246
+ end
247
+
248
+ # Add DEFAULT if specified
249
+ if options.key?(:default)
250
+ sql += " DEFAULT #{quote(options[:default])}"
251
+ end
252
+
253
+ execute(sql, 'ADD COLUMN')
254
+ end
255
+
256
+ # Remove a column from a table
257
+ #
258
+ # @param table_name [String] the table name
259
+ # @param column_name [String] the column name
260
+ # @param options [Hash] options (unused)
261
+ # @return [void]
262
+ # @raise [ClickhouseRuby::QueryError] on error
263
+ def remove_column(table_name, column_name, _type = nil, **options)
264
+ sql = "ALTER TABLE #{quote_table_name(table_name)} DROP COLUMN #{quote_column_name(column_name)}"
265
+ execute(sql, 'DROP COLUMN')
266
+ end
267
+
268
+ # Rename a column
269
+ #
270
+ # @param table_name [String] the table name
271
+ # @param old_name [String] the current column name
272
+ # @param new_name [String] the new column name
273
+ # @return [void]
274
+ # @raise [ClickhouseRuby::QueryError] on error
275
+ def rename_column(table_name, old_name, new_name)
276
+ sql = "ALTER TABLE #{quote_table_name(table_name)} RENAME COLUMN #{quote_column_name(old_name)} TO #{quote_column_name(new_name)}"
277
+ execute(sql, 'RENAME COLUMN')
278
+ end
279
+
280
+ # Change a column's type
281
+ #
282
+ # @param table_name [String] the table name
283
+ # @param column_name [String] the column name
284
+ # @param type [Symbol, String] the new column type
285
+ # @param options [Hash] column options
286
+ # @return [void]
287
+ # @raise [ClickhouseRuby::QueryError] on error
288
+ def change_column(table_name, column_name, type, **options)
289
+ sql_type = type_to_sql(type, **options)
290
+
291
+ # Handle nullable
292
+ if options[:null] != false && !sql_type.match?(/^Nullable/i)
293
+ sql_type = "Nullable(#{sql_type})"
294
+ end
295
+
296
+ sql = "ALTER TABLE #{quote_table_name(table_name)} MODIFY COLUMN #{quote_column_name(column_name)} #{sql_type}"
297
+
298
+ # Add DEFAULT if specified
299
+ if options.key?(:default)
300
+ sql += " DEFAULT #{quote(options[:default])}"
301
+ end
302
+
303
+ execute(sql, 'MODIFY COLUMN')
304
+ end
305
+
306
+ # Add an index to a table
307
+ # ClickHouse uses data skipping indexes (minmax, set, bloom_filter, etc.)
308
+ #
309
+ # @param table_name [String] the table name
310
+ # @param column_name [String, Array<String>] the column(s) to index
311
+ # @param options [Hash] index options
312
+ # @option options [String] :name the index name
313
+ # @option options [String] :type the index type (minmax, set, bloom_filter, etc.)
314
+ # @option options [Integer] :granularity the index granularity
315
+ # @return [void]
316
+ # @raise [ClickhouseRuby::QueryError] on error
317
+ def add_index(table_name, column_name, **options)
318
+ columns = Array(column_name).map { |c| quote_column_name(c) }.join(', ')
319
+ index_name = options[:name] || "idx_#{Array(column_name).join('_')}"
320
+ index_type = options[:type] || 'minmax'
321
+ granularity = options[:granularity] || 1
322
+
323
+ sql = "ALTER TABLE #{quote_table_name(table_name)} ADD INDEX #{quote_column_name(index_name)} (#{columns}) TYPE #{index_type} GRANULARITY #{granularity}"
324
+ execute(sql, 'ADD INDEX')
325
+ end
326
+
327
+ # Remove an index from a table
328
+ #
329
+ # @param table_name [String] the table name
330
+ # @param options_or_column [Hash, String, Symbol] index name or options with :name
331
+ # @return [void]
332
+ # @raise [ClickhouseRuby::QueryError] on error
333
+ def remove_index(table_name, options_or_column = nil, **options)
334
+ index_name = if options_or_column.is_a?(Hash)
335
+ options_or_column[:name]
336
+ elsif options[:name]
337
+ options[:name]
338
+ else
339
+ "idx_#{Array(options_or_column).join('_')}"
340
+ end
341
+
342
+ sql = "ALTER TABLE #{quote_table_name(table_name)} DROP INDEX #{quote_column_name(index_name)}"
343
+ execute(sql, 'DROP INDEX')
344
+ end
345
+
346
+ # Check if an index exists
347
+ #
348
+ # @param table_name [String] the table name
349
+ # @param index_name [String] the index name
350
+ # @return [Boolean] true if the index exists
351
+ def index_exists?(table_name, index_name)
352
+ sql = <<~SQL
353
+ SELECT 1
354
+ FROM system.data_skipping_indices
355
+ WHERE database = currentDatabase()
356
+ AND table = '#{quote_string(table_name.to_s)}'
357
+ AND name = '#{quote_string(index_name.to_s)}'
358
+ LIMIT 1
359
+ SQL
360
+
361
+ result = execute(sql, 'SCHEMA')
362
+ result.any?
363
+ end
364
+
365
+ # Check if a column exists
366
+ #
367
+ # @param table_name [String] the table name
368
+ # @param column_name [String] the column name
369
+ # @return [Boolean] true if the column exists
370
+ def column_exists?(table_name, column_name, type = nil, **options)
371
+ sql = <<~SQL
372
+ SELECT type
373
+ FROM system.columns
374
+ WHERE database = currentDatabase()
375
+ AND table = '#{quote_string(table_name.to_s)}'
376
+ AND name = '#{quote_string(column_name.to_s)}'
377
+ LIMIT 1
378
+ SQL
379
+
380
+ result = execute(sql, 'SCHEMA')
381
+ return false if result.empty?
382
+
383
+ if type
384
+ # Check if type matches
385
+ column_type = result.first['type']
386
+ expected_type = type_to_sql(type, **options)
387
+ column_type.downcase.include?(expected_type.downcase)
388
+ else
389
+ true
390
+ end
391
+ end
392
+
393
+ # Get the current database name
394
+ #
395
+ # @return [String] the database name
396
+ def current_database
397
+ result = execute('SELECT currentDatabase() AS db', 'SCHEMA')
398
+ result.first['db']
399
+ end
400
+
401
+ # List all databases
402
+ #
403
+ # @return [Array<String>] list of database names
404
+ def databases
405
+ result = execute('SELECT name FROM system.databases ORDER BY name', 'SCHEMA')
406
+ result.map { |row| row['name'] }
407
+ end
408
+
409
+ # Create a database
410
+ #
411
+ # @param database_name [String] the database name
412
+ # @param options [Hash] database options
413
+ # @option options [Boolean] :if_not_exists add IF NOT EXISTS clause
414
+ # @return [void]
415
+ # @raise [ClickhouseRuby::QueryError] on error
416
+ def create_database(database_name, **options)
417
+ if_not_exists = options.fetch(:if_not_exists, false)
418
+ sql = "CREATE DATABASE #{if_not_exists ? 'IF NOT EXISTS ' : ''}`#{database_name}`"
419
+ execute(sql, 'CREATE DATABASE')
420
+ end
421
+
422
+ # Drop a database
423
+ #
424
+ # @param database_name [String] the database name
425
+ # @param options [Hash] drop options
426
+ # @option options [Boolean] :if_exists add IF EXISTS clause
427
+ # @return [void]
428
+ # @raise [ClickhouseRuby::QueryError] on error
429
+ def drop_database(database_name, **options)
430
+ if_exists = options.fetch(:if_exists, false)
431
+ sql = "DROP DATABASE #{if_exists ? 'IF EXISTS ' : ''}`#{database_name}`"
432
+ execute(sql, 'DROP DATABASE')
433
+ end
434
+
435
+ private
436
+
437
+ # Convert a Rails type to ClickHouse SQL type
438
+ #
439
+ # @param type [Symbol, String] the Rails type
440
+ # @param options [Hash] type options
441
+ # @return [String] the ClickHouse SQL type
442
+ def type_to_sql(type, **options)
443
+ type = type.to_sym if type.respond_to?(:to_sym)
444
+
445
+ case type
446
+ when :primary_key
447
+ 'UInt64'
448
+ when :string, :text
449
+ if options[:limit]
450
+ "FixedString(#{options[:limit]})"
451
+ else
452
+ 'String'
453
+ end
454
+ when :integer
455
+ case options[:limit]
456
+ when 1 then 'Int8'
457
+ when 2 then 'Int16'
458
+ when 3, 4 then 'Int32'
459
+ when 5, 6, 7, 8 then 'Int64'
460
+ else 'Int32'
461
+ end
462
+ when :bigint
463
+ 'Int64'
464
+ when :float
465
+ options[:limit] == 8 ? 'Float64' : 'Float32'
466
+ when :decimal
467
+ precision = options[:precision] || 10
468
+ scale = options[:scale] || 0
469
+ "Decimal(#{precision}, #{scale})"
470
+ when :datetime
471
+ if options[:precision]
472
+ "DateTime64(#{options[:precision]})"
473
+ else
474
+ 'DateTime'
475
+ end
476
+ when :timestamp
477
+ "DateTime64(#{options[:precision] || 3})"
478
+ when :time
479
+ 'DateTime'
480
+ when :date
481
+ 'Date'
482
+ when :binary
483
+ 'String'
484
+ when :boolean
485
+ 'UInt8'
486
+ when :uuid
487
+ 'UUID'
488
+ when :json
489
+ 'String'
490
+ else
491
+ # Return as-is if it's a ClickHouse type
492
+ type.to_s
493
+ end
494
+ end
495
+
496
+ # Create a new column object
497
+ #
498
+ # @param name [String] column name
499
+ # @param default [Object] default value
500
+ # @param sql_type_metadata [Object] type metadata
501
+ # @param null [Boolean] nullable
502
+ # @param comment [String] column comment
503
+ # @return [ActiveRecord::ConnectionAdapters::Column]
504
+ def new_column(name, default, sql_type_metadata, null, comment = nil)
505
+ ::ActiveRecord::ConnectionAdapters::Column.new(
506
+ name,
507
+ default,
508
+ sql_type_metadata,
509
+ null,
510
+ comment: comment
511
+ )
512
+ end
513
+
514
+ # Fetch type metadata for a column type
515
+ #
516
+ # @param sql_type [String] the SQL type string
517
+ # @return [ActiveRecord::ConnectionAdapters::SqlTypeMetadata]
518
+ def fetch_type_metadata(sql_type)
519
+ cast_type = lookup_cast_type(sql_type)
520
+ ::ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new(
521
+ sql_type: sql_type,
522
+ type: cast_type.type,
523
+ limit: cast_type.limit,
524
+ precision: cast_type.precision,
525
+ scale: cast_type.scale
526
+ )
527
+ end
528
+
529
+ # Look up the cast type for a SQL type
530
+ #
531
+ # @param sql_type [String] the SQL type string
532
+ # @return [ActiveRecord::Type::Value] the type object
533
+ def lookup_cast_type(sql_type)
534
+ type_map.lookup(sql_type)
535
+ rescue KeyError
536
+ # Fall back to string if type not found
537
+ ::ActiveRecord::Type::String.new
538
+ end
539
+
540
+ # Create a table definition object
541
+ #
542
+ # @param table_name [String] the table name
543
+ # @param options [Hash] table options
544
+ # @return [TableDefinition]
545
+ def create_table_definition(table_name, **options)
546
+ TableDefinition.new(self, table_name, **options)
547
+ end
548
+
549
+ # Get the schema creation object
550
+ #
551
+ # @return [SchemaCreation]
552
+ def schema_creation
553
+ SchemaCreation.new(self)
554
+ end
555
+ end
556
+
557
+ # Table definition for ClickHouse CREATE TABLE statements
558
+ class TableDefinition
559
+ attr_reader :name, :columns, :options
560
+
561
+ def initialize(adapter, name, **options)
562
+ @adapter = adapter
563
+ @name = name
564
+ @columns = []
565
+ @options = options
566
+ end
567
+
568
+ # Add a column to the table definition
569
+ #
570
+ # @param name [String, Symbol] the column name
571
+ # @param type [Symbol, String] the column type
572
+ # @param options [Hash] column options
573
+ # @return [self]
574
+ def column(name, type, **options)
575
+ @columns << { name: name.to_s, type: type, options: options }
576
+ self
577
+ end
578
+
579
+ # Shorthand methods for common types
580
+ %i[string text integer bigint float decimal datetime timestamp date binary boolean uuid].each do |type|
581
+ define_method(type) do |name, **options|
582
+ column(name, type, **options)
583
+ end
584
+ end
585
+
586
+ # Primary key column (UInt64 for ClickHouse)
587
+ def primary_key(name, type = :primary_key, **options)
588
+ column(name, type, **options)
589
+ end
590
+
591
+ # Timestamps (created_at, updated_at)
592
+ def timestamps(**options)
593
+ column(:created_at, :datetime, **options)
594
+ column(:updated_at, :datetime, **options)
595
+ end
596
+ end
597
+
598
+ # Schema creation for ClickHouse DDL statements
599
+ class SchemaCreation
600
+ def initialize(adapter)
601
+ @adapter = adapter
602
+ end
603
+
604
+ # Generate CREATE TABLE SQL from a TableDefinition
605
+ #
606
+ # @param table_definition [TableDefinition] the table definition
607
+ # @return [String] the CREATE TABLE SQL
608
+ def accept(table_definition)
609
+ columns_sql = table_definition.columns.map do |col|
610
+ column_sql(col)
611
+ end.join(",\n ")
612
+
613
+ engine = table_definition.options[:engine] || 'MergeTree'
614
+ order_by = table_definition.options[:order_by]
615
+ partition_by = table_definition.options[:partition_by]
616
+ primary_key = table_definition.options[:primary_key]
617
+ settings = table_definition.options[:settings]
618
+
619
+ sql = "CREATE TABLE #{@adapter.quote_table_name(table_definition.name)} (\n #{columns_sql}\n)"
620
+ sql += "\nENGINE = #{engine}"
621
+ sql += "\nORDER BY (#{order_by})" if order_by
622
+ sql += "\nPARTITION BY #{partition_by}" if partition_by
623
+ sql += "\nPRIMARY KEY (#{primary_key})" if primary_key
624
+ sql += "\nSETTINGS #{settings}" if settings
625
+
626
+ sql
627
+ end
628
+
629
+ private
630
+
631
+ def column_sql(col)
632
+ type = type_to_sql(col[:type], **col[:options])
633
+
634
+ # Handle nullable
635
+ if col[:options][:null] != false && !type.match?(/^Nullable/i) && !col[:type].to_s.match?(/primary_key/)
636
+ type = "Nullable(#{type})"
637
+ end
638
+
639
+ sql = "#{@adapter.quote_column_name(col[:name])} #{type}"
640
+
641
+ # Add DEFAULT if specified
642
+ if col[:options].key?(:default)
643
+ sql += " DEFAULT #{@adapter.quote(col[:options][:default])}"
644
+ end
645
+
646
+ sql
647
+ end
648
+
649
+ def type_to_sql(type, **options)
650
+ type = type.to_sym if type.respond_to?(:to_sym)
651
+
652
+ case type
653
+ when :primary_key
654
+ 'UInt64'
655
+ when :string, :text
656
+ options[:limit] ? "FixedString(#{options[:limit]})" : 'String'
657
+ when :integer
658
+ case options[:limit]
659
+ when 1 then 'Int8'
660
+ when 2 then 'Int16'
661
+ when 3, 4 then 'Int32'
662
+ when 5, 6, 7, 8 then 'Int64'
663
+ else 'Int32'
664
+ end
665
+ when :bigint
666
+ 'Int64'
667
+ when :float
668
+ options[:limit] == 8 ? 'Float64' : 'Float32'
669
+ when :decimal
670
+ "Decimal(#{options[:precision] || 10}, #{options[:scale] || 0})"
671
+ when :datetime
672
+ options[:precision] ? "DateTime64(#{options[:precision]})" : 'DateTime'
673
+ when :timestamp
674
+ "DateTime64(#{options[:precision] || 3})"
675
+ when :time
676
+ 'DateTime'
677
+ when :date
678
+ 'Date'
679
+ when :binary
680
+ 'String'
681
+ when :boolean
682
+ 'UInt8'
683
+ when :uuid
684
+ 'UUID'
685
+ when :json
686
+ 'String'
687
+ else
688
+ type.to_s
689
+ end
690
+ end
691
+ end
692
+ end
693
+ end