Module: ActiveRecord::ConnectionAdapters::ClickHouse::SchemaStatements

Included in:
ActiveRecord::ConnectionAdapters::ClickHouseAdapter
Defined in:
lib/active_record/connection_adapters/clickhouse/schema_statements.rb

Constant Summary collapse

NON_TABLE_ENGINES =
%w[View MaterializedView LiveView].freeze

Instance Method Summary collapse

Instance Method Details

#add_index(table_name, column_name, name: nil, if_not_exists: false, internal: false, **options) ⇒ Object

Data-skipping indexes on existing tables; new parts index immediately, existing parts only after MATERIALIZE INDEX (not issued here).



367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 367

def add_index(table_name, column_name, name: nil, if_not_exists: false, internal: false, **options)
  # The full abstract option list is accepted so cross-database migrations
  # port verbatim; only using:/granularity: affect the DDL. unique: is
  # unenforceable — ClickHouse has no unique indexes, so
  # index_exists?(unique: true) stays false.
  options.assert_valid_keys(valid_index_options)
  index_name = (name || index_name(table_name, column_name)).to_s
  validate_index_length!(table_name, index_name, internal)

  # bloom_filter serves equality lookups on any scalar type, so vanilla Rails
  # add_index calls port without edits; specialized types stay a using: away.
  execute(<<~SQL.squish)
    ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
    ADD INDEX #{"IF NOT EXISTS " if if_not_exists}#{quote_column_name(index_name)}
    #{index_expression(column_name)} TYPE #{options.fetch(:using, "bloom_filter")}
    GRANULARITY #{options.fetch(:granularity, 1)}
  SQL
end

#add_projection(table_name, projection_name, select: "*", order: nil, group: nil) ⇒ Object

Projections are per-part alternate physical layouts (sort orders or pre-aggregations) the optimizer picks automatically; materialize_projection backfills parts written before the projection existed (async mutation).



149
150
151
152
153
154
155
156
157
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 149

def add_projection(table_name, projection_name, select: "*", order: nil, group: nil)
  body = ["SELECT #{select}"]
  body << "GROUP BY #{group}" if group
  body << "ORDER BY #{order}" if order
  execute(<<~SQL.squish)
    ALTER TABLE #{quote_table_name(table_name)}
    ADD PROJECTION #{quote_column_name(projection_name)} (#{body.join(" ")})
  SQL
end

#attach_partition(table_name, partition_id) ⇒ Object



216
217
218
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 216

def attach_partition(table_name, partition_id)
  alter_partition(table_name, "ATTACH", partition_id)
end

#build_change_column_default_definition(table_name, column_name, default_or_changes) ⇒ Object



102
103
104
105
106
107
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 102

def build_change_column_default_definition(table_name, column_name, default_or_changes)
  column = column_for(table_name, column_name)
  return unless column

  ChangeColumnDefaultDefinition.new(column, extract_new_default_value(default_or_changes))
end

#build_change_column_definition(table_name, column_name, type) ⇒ Object

Dry-run seams (Rails 7.1+): describe the change without executing it.



97
98
99
100
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 97

def build_change_column_definition(table_name, column_name, type, **)
  definition = create_table_definition(table_name)
  ChangeColumnDefinition.new(definition.new_column_definition(column_name, type, **), column_name)
end

#change_column(table_name, column_name, type, **options) ⇒ Object

MODIFY COLUMN takes the full new definition; existing rows are cast in a mutation, so incompatible narrowing surfaces as a server error. Like Rails, the new definition fully replaces the old: an omitted default clears an existing one (the server keeps it through a bare type change, probed 2026-07-14).



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 80

def change_column(table_name, column_name, type, **options)
  sql_type = changed_column_sql_type(type, options)
  new_default = options.key?(:default) && !options[:default].nil?
  default_clause =
    if new_default
      " DEFAULT #{quote(options[:default])}"
    elsif !options[:null]
      narrowing_placeholder_default(table_name, column_name, sql_type)
    end
  execute(<<~SQL.squish)
    ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
    MODIFY COLUMN #{quote_column_name(column_name)} #{sql_type}#{default_clause}
  SQL
  change_column_default(table_name, column_name, nil) unless new_default
end

#change_column_comment(table_name, column_name, comment_or_changes) ⇒ Object



341
342
343
344
345
346
347
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 341

def change_column_comment(table_name, column_name, comment_or_changes)
  comment = extract_new_comment_value(comment_or_changes)
  execute(<<~SQL.squish)
    ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
    COMMENT COLUMN #{quote_column_name(column_name)} #{quote(comment.to_s)}
  SQL
end

#change_column_default(table_name, column_name, default_or_changes) ⇒ Object

MODIFY COLUMN accepts DEFAULT without restating the type; REMOVE DEFAULT drops it (probed 2026-07-13) but errors when none exists (code 36, probed 2026-07-14) — Rails treats clearing an absent default as a no-op.



239
240
241
242
243
244
245
246
247
248
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 239

def change_column_default(table_name, column_name, default_or_changes)
  default = extract_new_default_value(default_or_changes)
  alteration = default_alteration_clause(table_name, column_name, default)
  return unless alteration

  execute(<<~SQL.squish)
    ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
    MODIFY COLUMN #{quote_column_name(column_name)} #{alteration}
  SQL
end

#change_column_null(table_name, column_name, null, default = nil) ⇒ Object

Narrowing to non-Nullable would silently rewrite stored NULLs to the type default (26.6+) or fail mid-mutation (25.8, code 349), so the Rails backfill default runs first as a synchronous mutation and is required when NULLs exist.

Raises:

  • (ArgumentError)


112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 112

def change_column_null(table_name, column_name, null, default = nil)
  validate_change_column_null_argument!(null)

  column = columns(table_name).find { |candidate| candidate.name == column_name.to_s }
  raise ArgumentError, "no such column #{column_name} in #{table_name}" unless column

  inner_type = column.sql_type.sub(/\ANullable\((.*)\)\z/m, '\1')
  return widen_column_to_nullable(table_name, column_name, inner_type) if null

  if default.nil?
    assert_no_stored_nulls(table_name, column_name)
  else
    backfill_nulls(table_name, column_name, default)
  end
  narrow_column(table_name, column_name, inner_type, column)
end

#change_table_comment(table_name, comment_or_changes) ⇒ Object



349
350
351
352
353
354
355
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 349

def change_table_comment(table_name, comment_or_changes)
  comment = extract_new_comment_value(comment_or_changes)
  execute(<<~SQL.squish)
    ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
    MODIFY COMMENT #{quote(comment.to_s)}
  SQL
end

#create_dictionary(name, source:, primary_key:, layout: :flat, lifetime: 300, database: nil) ⇒ Object

Dictionaries replace star-schema dimension JOINs with in-memory lookups. Columns are inferred from the source table, and the SOURCE clause carries the adapter's credentials — the dictionary's own loader authenticates separately and would otherwise connect as default (probed 2026-07-14).



177
178
179
180
181
182
183
184
185
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 177

def create_dictionary(name, source:, primary_key:, layout: :flat, lifetime: 300, database: nil)
  execute(<<~SQL.squish)
    CREATE DICTIONARY #{quote_table_name(name)} (#{dictionary_columns(source, database)})
    PRIMARY KEY #{quote_column_name(primary_key)}
    SOURCE(CLICKHOUSE(#{dictionary_source(source, database)}))
    LAYOUT(#{dictionary_layout(layout)})
    LIFETIME(#{dictionary_lifetime(lifetime)})
  SQL
end

#create_join_table(first_table, second_table, **options) ⇒ Object

HABTM join tables have an obvious sorting key: the two reference columns — unless they are nullable (sorting keys reject Nullable columns, PLAN.md §2).



28
29
30
31
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 28

def create_join_table(first_table, second_table, **options)
  options[:order] ||= join_table_sorting_key(first_table, second_table, options)
  super
end

#create_materialized_view(view_name, to: nil, as: nil) ⇒ Object

The insert-trigger half of the OLAP ingest-raw/read-aggregated idiom. A TO target is required: inner-storage views hide data in an implicit table and POPULATE misses concurrent inserts, so neither is supported.

Raises:

  • (ArgumentError)


132
133
134
135
136
137
138
139
140
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 132

def create_materialized_view(view_name, to: nil, as: nil)
  raise ArgumentError, "create_materialized_view requires to: (a target table)" if to.nil?
  raise ArgumentError, "create_materialized_view requires as: (a SELECT)" if as.nil?

  execute(<<~SQL.squish)
    CREATE MATERIALIZED VIEW #{quote_table_name(view_name)}
    TO #{quote_table_name(to)} AS #{as}
  SQL
end

#create_table(table_name, id: false, **options, &block) ⇒ Object

ClickHouse has no autoincrement; tables default to no id column and the sorting key acts as the primary key.



11
12
13
14
15
16
17
18
19
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 11

def create_table(table_name, id: false, **options, &block)
  clear_generatable_primary_key_cache
  options = internal_table_options(table_name, options)
  # With id: false Rails' own primary_key: kwarg is inert, so the DSL reuses the
  # ClickHouse clause name; renamed here because super would swallow it. With an
  # explicit id column the Rails meaning (pk column name) wins untouched.
  options[:primary_key_clause] = options.delete(:primary_key) if id == false && options.key?(:primary_key)
  super
end

#data_source_exists?(name) ⇒ Boolean

Returns:

  • (Boolean)


270
271
272
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 270

def data_source_exists?(name)
  data_sources.include?(name.to_s)
end

#data_sourcesObject



266
267
268
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 266

def data_sources
  select_values(data_source_sql, "SCHEMA")
end

#detach_partition(table_name, partition_id) ⇒ Object



212
213
214
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 212

def detach_partition(table_name, partition_id)
  alter_partition(table_name, "DETACH", partition_id)
end

#dictionariesObject



191
192
193
194
195
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 191

def dictionaries
  select_values(<<~SQL.squish, "SCHEMA")
    SELECT name FROM system.dictionaries WHERE database = currentDatabase() ORDER BY name
  SQL
end

#drop_dictionary(name, if_exists: false) ⇒ Object



187
188
189
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 187

def drop_dictionary(name, if_exists: false)
  execute("DROP DICTIONARY #{"IF EXISTS " if if_exists}#{quote_table_name(name)}")
end

#drop_materialized_view(view_name, if_exists: false) ⇒ Object



142
143
144
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 142

def drop_materialized_view(view_name, if_exists: false)
  execute("DROP VIEW #{"IF EXISTS " if if_exists}#{quote_table_name(view_name)}")
end

#drop_partition(table_name, partition_id) ⇒ Object



220
221
222
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 220

def drop_partition(table_name, partition_id)
  alter_partition(table_name, "DROP", partition_id)
end

#drop_projection(table_name, projection_name, if_exists: false) ⇒ Object



159
160
161
162
163
164
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 159

def drop_projection(table_name, projection_name, if_exists: false)
  execute(<<~SQL.squish)
    ALTER TABLE #{quote_table_name(table_name)}
    DROP PROJECTION #{"IF EXISTS " if if_exists}#{quote_column_name(projection_name)}
  SQL
end

#drop_tableObject



21
22
23
24
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 21

def drop_table(*, **)
  clear_generatable_primary_key_cache
  super
end

#freeze_partition(table_name, partition_id, name: nil) ⇒ Object

Hard-links the partition into shadow/ as an instant local backup.



225
226
227
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 225

def freeze_partition(table_name, partition_id, name: nil)
  alter_partition(table_name, "FREEZE", partition_id, suffix: name && " WITH NAME #{quote(name)}")
end

#indexes(table_name) ⇒ Object



280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 280

def indexes(table_name)
  skipping_indices_sql = <<~SQL.squish
    SELECT name, expr, type_full, granularity FROM system.data_skipping_indices
    WHERE database = currentDatabase() AND table = #{quote(table_name.to_s)}
  SQL

  select_all(skipping_indices_sql, "SCHEMA").map do |row|
    ClickHouse::IndexDefinition.new(
      table_name.to_s, row["name"],
      # The server stores the expression bare ("a, b", probed 2026-07-14);
      # Rails' index helpers expect a column-name array.
      columns: row["expr"].split(", "), using: row["type_full"], granularity: row["granularity"]
    )
  end
end

#internal_string_options_for_primary_keyObject

Rails' schema_migrations/ar_internal_metadata bookkeeping arrives via fixed create_table calls; give those tables an append-safe ReplacingMergeTree shape.



306
307
308
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 306

def internal_string_options_for_primary_key
  {}
end

#materialize_projection(table_name, projection_name) ⇒ Object



166
167
168
169
170
171
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 166

def materialize_projection(table_name, projection_name)
  execute(<<~SQL.squish)
    ALTER TABLE #{quote_table_name(table_name)}
    MATERIALIZE PROJECTION #{quote_column_name(projection_name)}
  SQL
end

#optimize_table(table_name, final: true) ⇒ Object

Forces an unscheduled merge; FINAL merges down to one part per partition — the maintenance verb that makes ReplacingMergeTree deduplication actually happen instead of eventually.



232
233
234
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 232

def optimize_table(table_name, final: true)
  execute("OPTIMIZE TABLE #{quote_table_name(table_name)}#{" FINAL" if final}")
end

#partitions(table_name) ⇒ Object

Partition lifecycle: the OLAP replacement for bulk deletes and archival. All verbs take the partition_id string (see #partitions) — the ID form is a plain quoted literal, so arbitrary expressions never reach the ALTER.



204
205
206
207
208
209
210
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 204

def partitions(table_name)
  select_values(<<~SQL.squish, "SCHEMA")
    SELECT DISTINCT partition_id FROM system.parts
    WHERE database = currentDatabase() AND table = #{quote(table_name.to_s)} AND active
    ORDER BY partition_id
  SQL
end

#primary_keys(_table_name) ⇒ Object

ClickHouse PRIMARY KEY is an index prefix, not a uniqueness guarantee, so no column is safe to expose as an Active Record primary key.



276
277
278
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 276

def primary_keys(_table_name)
  []
end

#reload_dictionary(name) ⇒ Object



197
198
199
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 197

def reload_dictionary(name)
  execute("SYSTEM RELOAD DICTIONARY #{quote_table_name(name)}")
end

#remove_column(table_name, column_name, type = nil, **options) ⇒ Object



40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 40

def remove_column(table_name, column_name, type = nil, **options)
  return if options[:if_exists] == true && !column_exists?(table_name, column_name)

  # The DROP mutation refuses to break a skip index (UNKNOWN_IDENTIFIER,
  # probed 2026-07-14); Rails semantics drop dependent indexes with the column.
  indexes(table_name).each do |index|
    remove_index(table_name, name: index.name) if index.columns.include?(column_name.to_s)
  end
  execute(<<~SQL.squish)
    ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
    #{remove_column_for_alter(table_name, column_name, type, **options)}
  SQL
end

#remove_index(table_name, column_name = nil, **options) ⇒ Object



386
387
388
389
390
391
392
393
394
395
396
397
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 386

def remove_index(table_name, column_name = nil, **options)
  return if options[:if_exists] && !index_exists?(table_name, column_name, **options)

  # Rails' resolver matches by columns, not derived name, so a custom-named
  # index is found by its columns and a name-shaped string is refused.
  name = index_name_for_remove(table_name, column_name, options.except(:if_exists))

  execute(<<~SQL.squish)
    ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
    DROP INDEX #{quote_column_name(name)}
  SQL
end

#rename_column(table_name, column_name, new_column_name) ⇒ Object

The server rewrites skip-index expressions to the new column name itself (probed 2026-07-14); Rails' shared helper then renames auto-named indexes.



56
57
58
59
60
61
62
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 56

def rename_column(table_name, column_name, new_column_name)
  execute(<<~SQL.squish)
    ALTER TABLE #{quote_table_name(table_name)}#{on_cluster_clause}
    RENAME COLUMN #{quote_column_name(column_name)} TO #{quote_column_name(new_column_name)}
  SQL
  rename_column_indexes(table_name, column_name, new_column_name)
end

#rename_index(table_name, old_name, new_name) ⇒ Object

No RENAME INDEX in ClickHouse (probed 2026-07-14) — drop and re-add. New parts index immediately; existing parts after MATERIALIZE INDEX, same contract as add_index.

Raises:

  • (ArgumentError)


67
68
69
70
71
72
73
74
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 67

def rename_index(table_name, old_name, new_name)
  validate_index_length!(table_name, new_name.to_s)
  index = indexes(table_name).find { |candidate| candidate.name == old_name.to_s }
  raise ArgumentError, "no such index #{old_name} in #{table_name}" unless index

  remove_index(table_name, name: old_name)
  add_index(table_name, index.columns, name: new_name, using: index.using, granularity: index.granularity)
end

#rename_table(table_name, new_name) ⇒ Object



33
34
35
36
37
38
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 33

def rename_table(table_name, new_name, **)
  clear_generatable_primary_key_cache
  validate_table_length!(new_name.to_s)
  execute("RENAME TABLE #{quote_table_name(table_name)} TO #{quote_table_name(new_name)}#{on_cluster_clause}")
  rename_table_indexes(table_name, new_name)
end

#table_comment(table_name) ⇒ Object



357
358
359
360
361
362
363
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 357

def table_comment(table_name)
  comment = select_value(<<~SQL.squish, "SCHEMA")
    SELECT comment FROM system.tables
    WHERE database = currentDatabase() AND name = #{quote(table_name.to_s)}
  SQL
  comment.presence
end

#table_exists?(table_name) ⇒ Boolean

Returns:

  • (Boolean)


258
259
260
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 258

def table_exists?(table_name)
  tables.include?(table_name.to_s)
end

#table_options(table_name) ⇒ Object

Everything system.tables knows about the table beyond columns, in the shape our create_table DSL accepts — this is what the schema dumper emits.



332
333
334
335
336
337
338
339
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 332

def table_options(table_name)
  row = select_one(<<~SQL.squish, "SCHEMA")
    SELECT engine_full, sorting_key, partition_key, primary_key, sampling_key
    FROM system.tables
    WHERE database = currentDatabase() AND name = #{quote(table_name.to_s)}
  SQL
  row ? dumpable_table_options(row) : {}
end

#tablesObject



250
251
252
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 250

def tables
  select_values(data_source_sql(type: "BASE TABLE"), "SCHEMA")
end

#type_to_sql(type, limit: nil, precision: nil, scale: nil) ⇒ Object



310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 310

def type_to_sql(type, limit: nil, precision: nil, scale: nil, **)
  case type.to_s
  when "integer" then integer_to_sql(limit)
  when "bigint" then "Int64"
  when "string", "text" then "String"
  when "float" then "Float64"
  when "decimal", "numeric" then "Decimal(#{precision || 38}, #{scale || 10})"
  # Rails' shared tests pass mysql-style parenthesized precision ("datetime(6)");
  # the naked default matches Rails' microsecond convention, not CH's ms.
  when /\A(?:datetime|timestamp)(?:\((\d+)\))?\z/
    "DateTime64(#{Regexp.last_match(1) || precision || 6}, 'UTC')"
  when "date" then "Date32"
  when "boolean" then "Bool"
  when "uuid" then "UUID"
  when "json" then "JSON"
  else
    clickhouse_type_verbatim(type)
  end
end

#valid_column_definition_optionsObject



300
301
302
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 300

def valid_column_definition_options
  super + %i[low_cardinality codec materialized alias]
end

#valid_table_definition_optionsObject



296
297
298
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 296

def valid_table_definition_options
  super + %i[engine order partition ttl settings primary_key_clause sample]
end

#view_exists?(view_name) ⇒ Boolean

Returns:

  • (Boolean)


262
263
264
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 262

def view_exists?(view_name)
  views.include?(view_name.to_s)
end

#viewsObject



254
255
256
# File 'lib/active_record/connection_adapters/clickhouse/schema_statements.rb', line 254

def views
  select_values(data_source_sql(type: "VIEW"), "SCHEMA")
end