activerecord-clickhouse-adapter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,722 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module ClickHouse
6
+ module SchemaStatements
7
+ NON_TABLE_ENGINES = %w[View MaterializedView LiveView].freeze
8
+
9
+ # ClickHouse has no autoincrement; tables default to no id column and the
10
+ # sorting key acts as the primary key.
11
+ def create_table(table_name, id: false, **options, &block)
12
+ clear_generatable_primary_key_cache
13
+ options = internal_table_options(table_name, options)
14
+ # With id: false Rails' own primary_key: kwarg is inert, so the DSL reuses the
15
+ # ClickHouse clause name; renamed here because super would swallow it. With an
16
+ # explicit id column the Rails meaning (pk column name) wins untouched.
17
+ options[:primary_key_clause] = options.delete(:primary_key) if id == false && options.key?(:primary_key)
18
+ super
19
+ end
20
+
21
+ def drop_table(*, **)
22
+ clear_generatable_primary_key_cache
23
+ super
24
+ end
25
+
26
+ # HABTM join tables have an obvious sorting key: the two reference columns —
27
+ # unless they are nullable (sorting keys reject Nullable columns, PLAN.md §2).
28
+ def create_join_table(first_table, second_table, **options)
29
+ options[:order] ||= join_table_sorting_key(first_table, second_table, options)
30
+ super
31
+ end
32
+
33
+ def rename_table(table_name, new_name, **)
34
+ clear_generatable_primary_key_cache
35
+ validate_table_length!(new_name.to_s)
36
+ execute("RENAME TABLE #{quote_table_name(table_name)} TO #{quote_table_name(new_name)}#{on_cluster_clause}")
37
+ rename_table_indexes(table_name, new_name)
38
+ end
39
+
40
+ def remove_column(table_name, column_name, type = nil, **options)
41
+ return if options[:if_exists] == true && !column_exists?(table_name, column_name)
42
+
43
+ # The DROP mutation refuses to break a skip index (UNKNOWN_IDENTIFIER,
44
+ # probed 2026-07-14); Rails semantics drop dependent indexes with the column.
45
+ indexes(table_name).each do |index|
46
+ remove_index(table_name, name: index.name) if index.columns.include?(column_name.to_s)
47
+ end
48
+ execute(<<~SQL.squish)
49
+ ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
50
+ #{remove_column_for_alter(table_name, column_name, type, **options)}
51
+ SQL
52
+ end
53
+
54
+ # The server rewrites skip-index expressions to the new column name itself
55
+ # (probed 2026-07-14); Rails' shared helper then renames auto-named indexes.
56
+ def rename_column(table_name, column_name, new_column_name)
57
+ execute(<<~SQL.squish)
58
+ ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
59
+ RENAME COLUMN #{quote_column_name(column_name)} TO #{quote_column_name(new_column_name)}
60
+ SQL
61
+ rename_column_indexes(table_name, column_name, new_column_name)
62
+ end
63
+
64
+ # No RENAME INDEX in ClickHouse (probed 2026-07-14) — drop and re-add. New
65
+ # parts index immediately; existing parts after MATERIALIZE INDEX, same
66
+ # contract as add_index.
67
+ def rename_index(table_name, old_name, new_name)
68
+ validate_index_length!(table_name, new_name.to_s)
69
+ index = indexes(table_name).find { |candidate| candidate.name == old_name.to_s }
70
+ raise ArgumentError, "no such index #{old_name} in #{table_name}" unless index
71
+
72
+ remove_index(table_name, name: old_name)
73
+ add_index(table_name, index.columns, name: new_name, using: index.using, granularity: index.granularity)
74
+ end
75
+
76
+ # MODIFY COLUMN takes the full new definition; existing rows are cast in a
77
+ # mutation, so incompatible narrowing surfaces as a server error. Like Rails,
78
+ # the new definition fully replaces the old: an omitted default clears an
79
+ # existing one (the server keeps it through a bare type change, probed 2026-07-14).
80
+ def change_column(table_name, column_name, type, **options)
81
+ sql_type = changed_column_sql_type(type, options)
82
+ new_default = options.key?(:default) && !options[:default].nil?
83
+ default_clause =
84
+ if new_default
85
+ " DEFAULT #{quote(options[:default])}"
86
+ elsif !options[:null]
87
+ narrowing_placeholder_default(table_name, column_name, sql_type)
88
+ end
89
+ execute(<<~SQL.squish)
90
+ ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
91
+ MODIFY COLUMN #{quote_column_name(column_name)} #{sql_type}#{default_clause}
92
+ SQL
93
+ change_column_default(table_name, column_name, nil) unless new_default
94
+ end
95
+
96
+ # Dry-run seams (Rails 7.1+): describe the change without executing it.
97
+ def build_change_column_definition(table_name, column_name, type, **)
98
+ definition = create_table_definition(table_name)
99
+ ChangeColumnDefinition.new(definition.new_column_definition(column_name, type, **), column_name)
100
+ end
101
+
102
+ def build_change_column_default_definition(table_name, column_name, default_or_changes)
103
+ column = column_for(table_name, column_name)
104
+ return unless column
105
+
106
+ ChangeColumnDefaultDefinition.new(column, extract_new_default_value(default_or_changes))
107
+ end
108
+
109
+ # Narrowing to non-Nullable would silently rewrite stored NULLs to the type
110
+ # default (26.6+) or fail mid-mutation (25.8, code 349), so the Rails backfill
111
+ # default runs first as a synchronous mutation and is required when NULLs exist.
112
+ def change_column_null(table_name, column_name, null, default = nil)
113
+ validate_change_column_null_argument!(null)
114
+
115
+ column = columns(table_name).find { |candidate| candidate.name == column_name.to_s }
116
+ raise ArgumentError, "no such column #{column_name} in #{table_name}" unless column
117
+
118
+ inner_type = column.sql_type.sub(/\ANullable\((.*)\)\z/m, '\1')
119
+ return widen_column_to_nullable(table_name, column_name, inner_type) if null
120
+
121
+ if default.nil?
122
+ assert_no_stored_nulls(table_name, column_name)
123
+ else
124
+ backfill_nulls(table_name, column_name, default)
125
+ end
126
+ narrow_column(table_name, column_name, inner_type, column)
127
+ end
128
+
129
+ # The insert-trigger half of the OLAP ingest-raw/read-aggregated idiom. A TO
130
+ # target is required: inner-storage views hide data in an implicit table and
131
+ # POPULATE misses concurrent inserts, so neither is supported.
132
+ def create_materialized_view(view_name, to: nil, as: nil)
133
+ raise ArgumentError, "create_materialized_view requires to: (a target table)" if to.nil?
134
+ raise ArgumentError, "create_materialized_view requires as: (a SELECT)" if as.nil?
135
+
136
+ execute(<<~SQL.squish)
137
+ CREATE MATERIALIZED VIEW #{quote_table_name(view_name)}
138
+ TO #{quote_table_name(to)} AS #{as}
139
+ SQL
140
+ end
141
+
142
+ def drop_materialized_view(view_name, if_exists: false)
143
+ execute("DROP VIEW #{"IF EXISTS " if if_exists}#{quote_table_name(view_name)}")
144
+ end
145
+
146
+ # Projections are per-part alternate physical layouts (sort orders or
147
+ # pre-aggregations) the optimizer picks automatically; materialize_projection
148
+ # backfills parts written before the projection existed (async mutation).
149
+ def add_projection(table_name, projection_name, select: "*", order: nil, group: nil)
150
+ body = ["SELECT #{select}"]
151
+ body << "GROUP BY #{group}" if group
152
+ body << "ORDER BY #{order}" if order
153
+ execute(<<~SQL.squish)
154
+ ALTER TABLE #{quote_table_name(table_name)}
155
+ ADD PROJECTION #{quote_column_name(projection_name)} (#{body.join(" ")})
156
+ SQL
157
+ end
158
+
159
+ def drop_projection(table_name, projection_name, if_exists: false)
160
+ execute(<<~SQL.squish)
161
+ ALTER TABLE #{quote_table_name(table_name)}
162
+ DROP PROJECTION #{"IF EXISTS " if if_exists}#{quote_column_name(projection_name)}
163
+ SQL
164
+ end
165
+
166
+ def materialize_projection(table_name, projection_name)
167
+ execute(<<~SQL.squish)
168
+ ALTER TABLE #{quote_table_name(table_name)}
169
+ MATERIALIZE PROJECTION #{quote_column_name(projection_name)}
170
+ SQL
171
+ end
172
+
173
+ # Dictionaries replace star-schema dimension JOINs with in-memory lookups.
174
+ # Columns are inferred from the source table, and the SOURCE clause carries the
175
+ # adapter's credentials — the dictionary's own loader authenticates separately
176
+ # and would otherwise connect as `default` (probed 2026-07-14).
177
+ def create_dictionary(name, source:, primary_key:, layout: :flat, lifetime: 300, database: nil)
178
+ execute(<<~SQL.squish)
179
+ CREATE DICTIONARY #{quote_table_name(name)} (#{dictionary_columns(source, database)})
180
+ PRIMARY KEY #{quote_column_name(primary_key)}
181
+ SOURCE(CLICKHOUSE(#{dictionary_source(source, database)}))
182
+ LAYOUT(#{dictionary_layout(layout)})
183
+ LIFETIME(#{dictionary_lifetime(lifetime)})
184
+ SQL
185
+ end
186
+
187
+ def drop_dictionary(name, if_exists: false)
188
+ execute("DROP DICTIONARY #{"IF EXISTS " if if_exists}#{quote_table_name(name)}")
189
+ end
190
+
191
+ def dictionaries
192
+ select_values(<<~SQL.squish, "SCHEMA")
193
+ SELECT name FROM system.dictionaries WHERE database = currentDatabase() ORDER BY name
194
+ SQL
195
+ end
196
+
197
+ def reload_dictionary(name)
198
+ execute("SYSTEM RELOAD DICTIONARY #{quote_table_name(name)}")
199
+ end
200
+
201
+ # Partition lifecycle: the OLAP replacement for bulk deletes and archival. All
202
+ # verbs take the partition_id string (see #partitions) — the ID form is a plain
203
+ # quoted literal, so arbitrary expressions never reach the ALTER.
204
+ def partitions(table_name)
205
+ select_values(<<~SQL.squish, "SCHEMA")
206
+ SELECT DISTINCT partition_id FROM system.parts
207
+ WHERE database = currentDatabase() AND table = #{quote(table_name.to_s)} AND active
208
+ ORDER BY partition_id
209
+ SQL
210
+ end
211
+
212
+ def detach_partition(table_name, partition_id)
213
+ alter_partition(table_name, "DETACH", partition_id)
214
+ end
215
+
216
+ def attach_partition(table_name, partition_id)
217
+ alter_partition(table_name, "ATTACH", partition_id)
218
+ end
219
+
220
+ def drop_partition(table_name, partition_id)
221
+ alter_partition(table_name, "DROP", partition_id)
222
+ end
223
+
224
+ # Hard-links the partition into shadow/<name> as an instant local backup.
225
+ def freeze_partition(table_name, partition_id, name: nil)
226
+ alter_partition(table_name, "FREEZE", partition_id, suffix: name && " WITH NAME #{quote(name)}")
227
+ end
228
+
229
+ # Forces an unscheduled merge; FINAL merges down to one part per partition —
230
+ # the maintenance verb that makes ReplacingMergeTree deduplication actually
231
+ # happen instead of eventually.
232
+ def optimize_table(table_name, final: true)
233
+ execute("OPTIMIZE TABLE #{quote_table_name(table_name)}#{" FINAL" if final}")
234
+ end
235
+
236
+ # MODIFY COLUMN accepts DEFAULT without restating the type; REMOVE DEFAULT
237
+ # drops it (probed 2026-07-13) but errors when none exists (code 36, probed
238
+ # 2026-07-14) — Rails treats clearing an absent default as a no-op.
239
+ def change_column_default(table_name, column_name, default_or_changes)
240
+ default = extract_new_default_value(default_or_changes)
241
+ alteration = default_alteration_clause(table_name, column_name, default)
242
+ return unless alteration
243
+
244
+ execute(<<~SQL.squish)
245
+ ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
246
+ MODIFY COLUMN #{quote_column_name(column_name)} #{alteration}
247
+ SQL
248
+ end
249
+
250
+ def tables
251
+ select_values(data_source_sql(type: "BASE TABLE"), "SCHEMA")
252
+ end
253
+
254
+ def views
255
+ select_values(data_source_sql(type: "VIEW"), "SCHEMA")
256
+ end
257
+
258
+ def table_exists?(table_name)
259
+ tables.include?(table_name.to_s)
260
+ end
261
+
262
+ def view_exists?(view_name)
263
+ views.include?(view_name.to_s)
264
+ end
265
+
266
+ def data_sources
267
+ select_values(data_source_sql, "SCHEMA")
268
+ end
269
+
270
+ def data_source_exists?(name)
271
+ data_sources.include?(name.to_s)
272
+ end
273
+
274
+ # ClickHouse PRIMARY KEY is an index prefix, not a uniqueness guarantee, so no
275
+ # column is safe to expose as an Active Record primary key.
276
+ def primary_keys(_table_name)
277
+ []
278
+ end
279
+
280
+ def indexes(table_name)
281
+ skipping_indices_sql = <<~SQL.squish
282
+ SELECT name, expr, type_full, granularity FROM system.data_skipping_indices
283
+ WHERE database = currentDatabase() AND table = #{quote(table_name.to_s)}
284
+ SQL
285
+
286
+ select_all(skipping_indices_sql, "SCHEMA").map do |row|
287
+ ClickHouse::IndexDefinition.new(
288
+ table_name.to_s, row["name"],
289
+ # The server stores the expression bare ("a, b", probed 2026-07-14);
290
+ # Rails' index helpers expect a column-name array.
291
+ columns: row["expr"].split(", "), using: row["type_full"], granularity: row["granularity"]
292
+ )
293
+ end
294
+ end
295
+
296
+ def valid_table_definition_options
297
+ super + %i[engine order partition ttl settings primary_key_clause sample]
298
+ end
299
+
300
+ def valid_column_definition_options
301
+ super + %i[low_cardinality codec materialized alias]
302
+ end
303
+
304
+ # Rails' schema_migrations/ar_internal_metadata bookkeeping arrives via fixed
305
+ # create_table calls; give those tables an append-safe ReplacingMergeTree shape.
306
+ def internal_string_options_for_primary_key
307
+ {}
308
+ end
309
+
310
+ def type_to_sql(type, limit: nil, precision: nil, scale: nil, **)
311
+ case type.to_s
312
+ when "integer" then integer_to_sql(limit)
313
+ when "bigint" then "Int64"
314
+ when "string", "text" then "String"
315
+ when "float" then "Float64"
316
+ when "decimal", "numeric" then "Decimal(#{precision || 38}, #{scale || 10})"
317
+ # Rails' shared tests pass mysql-style parenthesized precision ("datetime(6)");
318
+ # the naked default matches Rails' microsecond convention, not CH's ms.
319
+ when /\A(?:datetime|timestamp)(?:\((\d+)\))?\z/
320
+ "DateTime64(#{Regexp.last_match(1) || precision || 6}, 'UTC')"
321
+ when "date" then "Date32"
322
+ when "boolean" then "Bool"
323
+ when "uuid" then "UUID"
324
+ when "json" then "JSON"
325
+ else
326
+ clickhouse_type_verbatim(type)
327
+ end
328
+ end
329
+
330
+ # Everything system.tables knows about the table beyond columns, in the shape
331
+ # our create_table DSL accepts — this is what the schema dumper emits.
332
+ def table_options(table_name)
333
+ row = select_one(<<~SQL.squish, "SCHEMA")
334
+ SELECT engine_full, sorting_key, partition_key, primary_key, sampling_key
335
+ FROM system.tables
336
+ WHERE database = currentDatabase() AND name = #{quote(table_name.to_s)}
337
+ SQL
338
+ row ? dumpable_table_options(row) : {}
339
+ end
340
+
341
+ def change_column_comment(table_name, column_name, comment_or_changes)
342
+ comment = extract_new_comment_value(comment_or_changes)
343
+ execute(<<~SQL.squish)
344
+ ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
345
+ COMMENT COLUMN #{quote_column_name(column_name)} #{quote(comment.to_s)}
346
+ SQL
347
+ end
348
+
349
+ def change_table_comment(table_name, comment_or_changes)
350
+ comment = extract_new_comment_value(comment_or_changes)
351
+ execute(<<~SQL.squish)
352
+ ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
353
+ MODIFY COMMENT #{quote(comment.to_s)}
354
+ SQL
355
+ end
356
+
357
+ def table_comment(table_name)
358
+ comment = select_value(<<~SQL.squish, "SCHEMA")
359
+ SELECT comment FROM system.tables
360
+ WHERE database = currentDatabase() AND name = #{quote(table_name.to_s)}
361
+ SQL
362
+ comment.presence
363
+ end
364
+
365
+ # Data-skipping indexes on existing tables; new parts index immediately,
366
+ # existing parts only after MATERIALIZE INDEX (not issued here).
367
+ def add_index(table_name, column_name, name: nil, if_not_exists: false, internal: false, **options)
368
+ # The full abstract option list is accepted so cross-database migrations
369
+ # port verbatim; only using:/granularity: affect the DDL. unique: is
370
+ # unenforceable — ClickHouse has no unique indexes, so
371
+ # index_exists?(unique: true) stays false.
372
+ options.assert_valid_keys(valid_index_options)
373
+ index_name = (name || index_name(table_name, column_name)).to_s
374
+ validate_index_length!(table_name, index_name, internal)
375
+
376
+ # bloom_filter serves equality lookups on any scalar type, so vanilla Rails
377
+ # add_index calls port without edits; specialized types stay a using: away.
378
+ execute(<<~SQL.squish)
379
+ ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
380
+ ADD INDEX #{"IF NOT EXISTS " if if_not_exists}#{quote_column_name(index_name)}
381
+ #{index_expression(column_name)} TYPE #{options.fetch(:using, "bloom_filter")}
382
+ GRANULARITY #{options.fetch(:granularity, 1)}
383
+ SQL
384
+ end
385
+
386
+ def remove_index(table_name, column_name = nil, **options)
387
+ return if options[:if_exists] && !index_exists?(table_name, column_name, **options)
388
+
389
+ # Rails' resolver matches by columns, not derived name, so a custom-named
390
+ # index is found by its columns and a name-shaped string is refused.
391
+ name = index_name_for_remove(table_name, column_name, options.except(:if_exists))
392
+
393
+ execute(<<~SQL.squish)
394
+ ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
395
+ DROP INDEX #{quote_column_name(name)}
396
+ SQL
397
+ end
398
+
399
+ private
400
+
401
+ def valid_index_options
402
+ super + [:granularity]
403
+ end
404
+
405
+ # Multi-column indexes need one tuple expression; a bare list is a syntax error.
406
+ def index_expression(column_name)
407
+ quoted = Array(column_name).map { |part| quote_column_name(part) }
408
+ quoted.length == 1 ? quoted.first : "(#{quoted.join(", ")})"
409
+ end
410
+
411
+ def join_table_sorting_key(first_table, second_table, options)
412
+ return "tuple()" if options.dig(:column_options, :null)
413
+
414
+ references = [first_table, second_table].map { |table| "#{table.to_s.singularize}_id" }.sort
415
+ "(#{references.join(", ")})"
416
+ end
417
+
418
+ def changed_column_sql_type(type, options)
419
+ sql_type = type_to_sql(type, **options.slice(:limit, :precision, :scale))
420
+ sql_type = "Nullable(#{sql_type})" if options[:null]
421
+ sql_type = "LowCardinality(#{sql_type})" if options[:low_cardinality]
422
+ sql_type
423
+ end
424
+
425
+ # 26.6+ refuses Nullable(T) -> T without an in-statement DEFAULT (§2), so the
426
+ # type's own default rides along when change_column narrows; the trailing
427
+ # change_column_default(nil) removes it again. The stored-NULLs guard keeps
428
+ # that DEFAULT from silently rewriting data during the conversion.
429
+ def narrowing_placeholder_default(table_name, column_name, sql_type)
430
+ column = column_for(table_name, column_name)
431
+ return "" unless column&.null
432
+
433
+ assert_no_stored_nulls(table_name, column_name)
434
+ " DEFAULT defaultValueOfTypeName(#{quote(sql_type)})"
435
+ end
436
+
437
+ # REMOVE DEFAULT errors when none exists (code 36, probed 2026-07-14);
438
+ # Rails treats clearing an absent default as a no-op, hence nil.
439
+ def default_alteration_clause(table_name, column_name, default)
440
+ if default.nil?
441
+ column = columns(table_name).find { |candidate| candidate.name == column_name.to_s }
442
+ return nil if column && column.default.nil? && column.default_function.nil?
443
+
444
+ "REMOVE DEFAULT"
445
+ else
446
+ "DEFAULT #{default.respond_to?(:call) ? default.call : quote(default)}"
447
+ end
448
+ end
449
+
450
+ # A bare MODIFY keeps the existing default (probed 2026-07-14) — deliberately
451
+ # not change_column, whose replace-the-definition semantics would clear it.
452
+ def widen_column_to_nullable(table_name, column_name, inner_type)
453
+ execute(<<~SQL.squish)
454
+ ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
455
+ MODIFY COLUMN #{quote_column_name(column_name)} Nullable(#{inner_type})
456
+ SQL
457
+ end
458
+
459
+ # ClickHouse 26.6+ refuses Nullable(T) -> T without a DEFAULT clause in the
460
+ # MODIFY COLUMN itself (BAD_ARGUMENTS, probed 2026-07-14). When the column has
461
+ # no real default, the type's own default rides along as a placeholder and is
462
+ # removed right after, restoring the pre-26.6 shape.
463
+ def narrow_column(table_name, column_name, inner_type, column)
464
+ default_expression =
465
+ column.default_function || (column.default.nil? ? nil : quote(column.default))
466
+ placeholder = default_expression.nil?
467
+ default_expression ||= "defaultValueOfTypeName(#{quote(inner_type)})"
468
+ execute(<<~SQL.squish)
469
+ ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
470
+ MODIFY COLUMN #{quote_column_name(column_name)} #{inner_type} DEFAULT #{default_expression}
471
+ SQL
472
+ change_column_default(table_name, column_name, nil) if placeholder
473
+ end
474
+
475
+ # The narrowing MODIFY's DEFAULT clause would rewrite stored NULLs silently;
476
+ # Rails semantics say a narrow over NULLs without a backfill default is an error.
477
+ # 25.8 serves a stale .null subcolumn for parts written before the column went
478
+ # Nullable (probed 2026-07-14), so the count must read the real values.
479
+ def assert_no_stored_nulls(table_name, column_name)
480
+ nulls = select_value(<<~SQL.squish)
481
+ SELECT count() FROM #{quote_table_name(table_name)}
482
+ WHERE #{quote_column_name(column_name)} IS NULL
483
+ SETTINGS optimize_functions_to_subcolumns = 0
484
+ SQL
485
+ return if nulls.to_i.zero?
486
+
487
+ raise ActiveRecordError, "cannot make #{table_name}.#{column_name} non-nullable: " \
488
+ "#{nulls} stored NULLs; pass a default to backfill them"
489
+ end
490
+
491
+ def backfill_nulls(table_name, column_name, default)
492
+ with_request_settings(mutations_sync: 1) do
493
+ execute(<<~SQL.squish)
494
+ ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
495
+ UPDATE #{quote_column_name(column_name)} = #{quote(default)}
496
+ WHERE #{quote_column_name(column_name)} IS NULL
497
+ SQL
498
+ end
499
+ end
500
+
501
+ def drop_table_sql(...)
502
+ "#{super}#{on_cluster_clause}"
503
+ end
504
+
505
+ def dictionary_columns(source, database)
506
+ rows = select_all(<<~SQL.squish, "SCHEMA").to_a
507
+ SELECT name, type FROM system.columns
508
+ WHERE database = #{database ? quote(database.to_s) : "currentDatabase()"}
509
+ AND table = #{quote(source.to_s)}
510
+ ORDER BY position
511
+ SQL
512
+ raise ArgumentError, "dictionary source table #{source} has no columns" if rows.empty?
513
+
514
+ rows.map { |row| "#{quote_column_name(row["name"])} #{row["type"]}" }.join(", ")
515
+ end
516
+
517
+ def dictionary_source(source, database)
518
+ clauses = ["TABLE #{quote(source.to_s)}", "DB #{quote((database || @config[:database]).to_s)}"]
519
+ clauses << "USER #{quote(@config[:username].to_s)}" if @config[:username]
520
+ clauses << "PASSWORD #{quote(@config[:password].to_s)}" if @config[:password]
521
+ clauses.join(" ")
522
+ end
523
+
524
+ def dictionary_layout(layout)
525
+ raise ArgumentError, "unknown dictionary layout #{layout.inspect}" unless /\A[a-z_]+\z/.match?(layout.to_s)
526
+
527
+ "#{layout.to_s.upcase}()"
528
+ end
529
+
530
+ def dictionary_lifetime(lifetime)
531
+ range = lifetime.is_a?(Range) ? lifetime : (0..lifetime)
532
+ "MIN #{Integer(range.begin)} MAX #{Integer(range.end)}"
533
+ end
534
+
535
+ def alter_partition(table_name, verb, partition_id, suffix: nil)
536
+ execute(<<~SQL.squish)
537
+ ALTER TABLE #{quote_table_name(table_name)}
538
+ #{verb} PARTITION ID #{quote(partition_id.to_s)}#{suffix}
539
+ SQL
540
+ end
541
+
542
+ # DSL types like t.column :tags, "Array(String)" pass through verbatim once they
543
+ # parse as a ClickHouse type; the server validates the family at DDL time.
544
+ def clickhouse_type_verbatim(type)
545
+ string = type.to_s
546
+ raise ArgumentError, "unsupported column type for ClickHouse: #{type.inspect}" unless string.match?(/\A[A-Z]/)
547
+
548
+ TypeParser.parse(string)
549
+ string
550
+ rescue TypeParser::Error
551
+ raise ArgumentError, "unsupported column type for ClickHouse: #{type.inspect}"
552
+ end
553
+
554
+ # engine_full is "Engine(args) [PARTITION BY ...] [PRIMARY KEY ...] [ORDER BY ...]
555
+ # [SAMPLE BY ...] [TTL ...] [SETTINGS ...]" — split on the clause keywords.
556
+ ENGINE_FULL_CLAUSES = /\s+(PARTITION BY|PRIMARY KEY|ORDER BY|SAMPLE BY|TTL|SETTINGS)\s+/
557
+ private_constant :ENGINE_FULL_CLAUSES
558
+
559
+ def parse_engine_full(engine_full)
560
+ engine, *clause_pairs = engine_full.to_s.split(ENGINE_FULL_CLAUSES)
561
+ clauses = clause_pairs.each_slice(2).to_h { |keyword, expression| [keyword, expression] }
562
+ { engine: engine.presence, ttl: clauses["TTL"], settings: clauses["SETTINGS"] }
563
+ end
564
+
565
+ def dumpable_table_options(row)
566
+ clauses = parse_engine_full(row["engine_full"])
567
+ {
568
+ engine: clauses[:engine],
569
+ partition: row["partition_key"].presence,
570
+ primary_key: dumpable_primary_key(row),
571
+ order: format_sorting_key(row["sorting_key"]),
572
+ sample: row["sampling_key"].presence,
573
+ ttl: clauses[:ttl],
574
+ settings: dumpable_settings(clauses[:settings])
575
+ }.compact
576
+ end
577
+
578
+ # The primary key defaults to the whole sorting key; only a narrower one is a
579
+ # real PRIMARY KEY clause worth dumping.
580
+ def dumpable_primary_key(row)
581
+ format_sorting_key(row["primary_key"]) if row["primary_key"] != row["sorting_key"]
582
+ end
583
+
584
+ def format_sorting_key(sorting_key)
585
+ return nil if sorting_key.blank?
586
+
587
+ sorting_key.include?(",") ? "(#{sorting_key})" : sorting_key
588
+ end
589
+
590
+ # index_granularity = 8192 is the server default — dumping it is pure noise.
591
+ def dumpable_settings(settings_clause)
592
+ return nil if settings_clause.blank?
593
+
594
+ settings = settings_clause.split(", ").to_h do |assignment|
595
+ key, value = assignment.split(" = ", 2)
596
+ [key.to_sym, value.match?(/\A-?\d+\z/) ? Integer(value) : value]
597
+ end
598
+ settings.delete(:index_granularity) if settings[:index_granularity] == 8192
599
+ settings.presence
600
+ end
601
+
602
+ def internal_table_options(table_name, options)
603
+ case table_name.to_s
604
+ when ActiveRecord::Base.schema_migrations_table_name
605
+ { engine: "ReplacingMergeTree", order: "version" }.merge(options)
606
+ # No version column: Rails updates metadata entries via ALTER UPDATE, and
607
+ # mutations cannot touch a ReplacingMergeTree version column (code 420,
608
+ # probed 2026-07-14). Versionless keeps last-insert-wins for the create path.
609
+ when ActiveRecord::Base.internal_metadata_table_name
610
+ { engine: "ReplacingMergeTree", order: "key" }.merge(options)
611
+ else
612
+ options
613
+ end
614
+ end
615
+
616
+ def integer_to_sql(limit)
617
+ case limit
618
+ when nil, 3, 4 then "Int32"
619
+ when 1 then "Int8"
620
+ when 2 then "Int16"
621
+ when 5..8 then "Int64"
622
+ when 9..16 then "Int128"
623
+ when 17..32 then "Int256"
624
+ else raise ArgumentError, "no ClickHouse integer type has byte size #{limit}"
625
+ end
626
+ end
627
+
628
+ def data_source_sql(name = nil, type: nil)
629
+ conditions = ["database = currentDatabase()"]
630
+ conditions << "name = #{quote(name.to_s)}" if name
631
+ case type
632
+ when "BASE TABLE" then conditions << "engine NOT IN (#{quoted_non_table_engines}, 'Dictionary')"
633
+ when "VIEW" then conditions << "engine IN (#{quoted_non_table_engines})"
634
+ end
635
+ "SELECT name FROM system.tables WHERE #{conditions.join(" AND ")} ORDER BY name"
636
+ end
637
+
638
+ def quoted_non_table_engines
639
+ NON_TABLE_ENGINES.map { |engine| quote(engine) }.join(", ")
640
+ end
641
+
642
+ def column_definitions(table_name)
643
+ select_all(<<~SQL.squish, "SCHEMA").to_a
644
+ SELECT name, type, default_kind, default_expression, comment, compression_codec
645
+ FROM system.columns
646
+ WHERE database = currentDatabase() AND table = #{quote(table_name.to_s)}
647
+ ORDER BY position
648
+ SQL
649
+ end
650
+
651
+ def new_column_from_field(_table_name, field, _definitions)
652
+ sql_type = field["type"]
653
+ cast_type = Types.active_record_cast_type(sql_type)
654
+ default_value, default_function = extract_default(field)
655
+
656
+ Column.new(
657
+ field["name"],
658
+ cast_type,
659
+ default_value,
660
+ fetch_type_metadata(sql_type, cast_type),
661
+ sql_type.start_with?("Nullable("),
662
+ default_function,
663
+ comment: field["comment"].presence,
664
+ **clickhouse_column_extras(field)
665
+ )
666
+ end
667
+
668
+ def clickhouse_column_extras(field)
669
+ {
670
+ codec: field["compression_codec"].delete_prefix("CODEC(").delete_suffix(")").presence,
671
+ computed_kind: field["default_kind"].presence_in(%w[MATERIALIZED ALIAS])&.downcase,
672
+ computed_expression: field["default_expression"].presence
673
+ }
674
+ end
675
+
676
+ def extract_default(field)
677
+ return [nil, nil] unless field["default_kind"] == "DEFAULT"
678
+
679
+ expression = field["default_expression"]
680
+ case expression
681
+ when /\A'(.*)'\z/m then [unescape_string_literal(Regexp.last_match(1)), nil]
682
+ when /\A-?\d+(?:\.\d+)?\z/, "true", "false" then [expression, nil]
683
+ else [nil, expression]
684
+ end
685
+ end
686
+
687
+ # The server renders control characters as escape sequences in
688
+ # default_expression ('foo\nbar' arrives as backslash-n, probed 2026-07-14).
689
+ STRING_LITERAL_ESCAPES = {
690
+ "0" => "\0", "a" => "\a", "b" => "\b", "f" => "\f",
691
+ "n" => "\n", "r" => "\r", "t" => "\t", "v" => "\v"
692
+ }.freeze
693
+ private_constant :STRING_LITERAL_ESCAPES
694
+
695
+ def unescape_string_literal(contents)
696
+ contents.gsub(/\\(.)|''/) do
697
+ escaped = Regexp.last_match(1)
698
+ escaped ? STRING_LITERAL_ESCAPES.fetch(escaped, escaped) : "'"
699
+ end
700
+ end
701
+
702
+ def fetch_type_metadata(sql_type, cast_type = Types.active_record_cast_type(sql_type))
703
+ SqlTypeMetadata.new(
704
+ sql_type: sql_type,
705
+ type: cast_type.type,
706
+ limit: cast_type.limit,
707
+ precision: cast_type.precision,
708
+ scale: cast_type.scale
709
+ )
710
+ end
711
+
712
+ def schema_creation
713
+ SchemaCreation.new(self)
714
+ end
715
+
716
+ def create_table_definition(name, **)
717
+ TableDefinition.new(self, name, **)
718
+ end
719
+ end
720
+ end
721
+ end
722
+ end