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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +313 -0
- data/lib/active_record/connection_adapters/clickhouse/database_statements.rb +464 -0
- data/lib/active_record/connection_adapters/clickhouse/error_translation.rb +52 -0
- data/lib/active_record/connection_adapters/clickhouse/gem_version.rb +9 -0
- data/lib/active_record/connection_adapters/clickhouse/http_connection.rb +233 -0
- data/lib/active_record/connection_adapters/clickhouse/querying.rb +417 -0
- data/lib/active_record/connection_adapters/clickhouse/quoting.rb +92 -0
- data/lib/active_record/connection_adapters/clickhouse/row_binary.rb +190 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_definitions.rb +146 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_dumper.rb +201 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +722 -0
- data/lib/active_record/connection_adapters/clickhouse/type_parser.rb +207 -0
- data/lib/active_record/connection_adapters/clickhouse/types.rb +238 -0
- data/lib/active_record/connection_adapters/clickhouse_adapter.rb +126 -0
- data/lib/active_record/tasks/clickhouse_database_tasks.rb +91 -0
- data/lib/activerecord-clickhouse-adapter.rb +21 -0
- data/lib/arel/visitors/clickhouse.rb +172 -0
- metadata +82 -0
|
@@ -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
|