NAME
DBIO::ClickHouse::DDL::Emit - Single source of ClickHouse DDL statement shape
VERSION
version 0.900004
DESCRIPTION
The ClickHouse DDL string templates -- column and data-skipping-index definitions, the MergeTree engine clauses (ENGINE / PARTITION BY / PRIMARY KEY / ORDER BY / SETTINGS), and every statement wrapper this driver emits (CREATE TABLE, CREATE VIEW, DROP TABLE, the ALTER TABLE column and index forms, CREATE / DROP DATABASE) -- live here so that every producer of ClickHouse DDL agrees on syntax char-for-char:
DBIO::ClickHouse::DDL renders the desired schema from DBIO Result classes (
column_infohashrefs plusclickhouse_tableoptions).The
DBIO::ClickHouse::Diff::*renderers renderCREATE/DROP/ALTERstatements from an introspected model.DBIO::ClickHouse::Deploy issues the scratch-database
CREATE DATABASE/DROP DATABASEpair.
Each caller owns the field-extraction adapter for its own input shape (DBIO metadata vs introspected model) and feeds normalized pieces here. These functions are pure: identifiers and string literals are quoted, nothing else is interpreted. Clauses are returned without indentation; "create_table" indents the column body and appends the engine clauses one per line.
Fragments are passed in pre-rendered
The statement wrappers take an already-rendered definition rather than the pieces to build one -- "create_table" takes a list of "column_def" results, "alter_table_add_index" takes an "index_def" result. That is what lets the same fragment appear in two statements without a second copy of its syntax: a skipping index is rendered once by "index_def" and lands both inside a CREATE TABLE body and after an ALTER TABLE ... ADD.
Statement termination
The table-level statements end in a semicolon, because they are collected into a DDL script -- "install_ddl" in DBIO::ClickHouse::DDL joins them with a blank line, $diff->as_sql with a newline. "create_database" and "drop_database" do not: they never appear in such a script. They are the two statements DBIO::ClickHouse::Deploy issues on their own, around one.
Why this module quotes its own identifiers
Every other DBIO driver reaches for DBIO::SQL::Util::_quote_ident, which leaves simple identifiers bare and falls back to ANSI double quotes. This module deliberately does not: ClickHouse quotes with the backtick (the MySQL-wire character this driver already pins via sql_quote_char, see docs/adr/0001-mysql-wire-protocol-transport.md), and SHOW CREATE TABLE -- the round-trip source the diff layer will compare against -- backtick-quotes every identifier unconditionally. Emitting the same shape keeps the desired-state DDL and the server's own rendering comparable.
Clause order
"create_table" emits the engine clauses in the order SHOW CREATE TABLE prints them: ENGINE, PARTITION BY, PRIMARY KEY, ORDER BY, SETTINGS. The caller assembles the list; this module just joins it.
METHODS
quote_ident
quote_ident($name);
Backtick-quotes an identifier unconditionally, escaping embedded backslashes and backticks with a backslash (ClickHouse's escape convention).
quote_string
quote_string($value);
Single-quotes a string literal, escaping embedded backslashes and single quotes with a backslash.
column_def
column_def(name => $n, type => $t, default => $sql);
Renders `name` Type [DEFAULT $sql]. default is emitted verbatim -- the caller is responsible for quoting/escaping it. Pass undef for no default.
There is deliberately no not_null argument: ClickHouse columns are NOT NULL by default and nullability is part of the type (Nullable(T)), not a separate column constraint. The type string handed in already carries it.
index_def
index_def($index_info);
Renders one data-skipping index definition without any surrounding statement:
INDEX `name` expr TYPE minmax GRANULARITY 4
This is the shape ClickHouse uses in both places a skipping index can appear -- inline in the CREATE TABLE body (DBIO::ClickHouse::Diff::Table) and after ALTER TABLE ... ADD ("alter_table_add_index") -- so both render through this one function rather than each keeping a copy of the syntax.
Unlike the other renderers here this one takes the model hashref itself (index_name, expr, type_full / type, granularity), because the introspected model is the shape it is fed: DBIO::ClickHouse::DDL builds the same keys from a canonical index declaration, so a declared index and an introspected one render alike.
expr is emitted verbatim -- it is an expression, not an identifier. TYPE uses type_full when present, so that a parameterized type keeps its arguments (set(100), not set), and falls back to the bare type. GRANULARITY is omitted when the info carries none, leaving the server default.
engine_clause
engine_clause('MergeTree');
engine_clause('ReplacingMergeTree(version)');
Renders ENGINE = .... The engine string is emitted verbatim so that parameterized engines work. Defaults to MergeTree when undef.
partition_by_clause
partition_by_clause('toYYYYMM(ts)');
Renders PARTITION BY .... The argument is an expression and is emitted verbatim -- unlike the key lists below, it is not quoted as an identifier, because a partition key is almost always a function call.
primary_key_clause
primary_key_clause(@key);
Renders PRIMARY KEY (...). See "order_by_clause" for how entries are rendered.
In ClickHouse a PRIMARY KEY is only meaningful as a prefix of the sorting key; when it equals the sorting key it is omitted entirely and ORDER BY alone defines it.
order_by_clause
order_by_clause(@key);
order_by_clause(); # ORDER BY tuple()
order_by_clause('ts', \'cityHash64(id)');
Renders the MergeTree sorting key as ORDER BY (...). Plain strings are quoted as column identifiers; SCALAR refs are emitted verbatim as raw SQL expressions. An empty key list renders ORDER BY tuple(), ClickHouse's spelling for "no sorting key".
The tuple form is used even for a single-column key: it is always valid and keeps the rendering uniform.
settings_clause
settings_clause({ index_granularity => 8192 });
Renders SETTINGS name = value, ... with the setting names sorted for deterministic output. Numeric-looking values are emitted bare, SCALAR refs verbatim, everything else as a quoted string literal.
Returns undef when there is nothing to emit, so the caller can push the result into its clause list unconditionally-ish (push ... if defined).
create_table
create_table(
name => $table,
columns => \@column_defs,
clauses => \@engine_clauses,
if_not_exists => 0|1,
);
Wraps the (unindented) column definitions in a CREATE TABLE, indenting each by two spaces, then appends the engine clauses one per line. Undefined or empty clauses are dropped, so a caller may pass the result of "settings_clause" straight through.
Pass database => $db to write the table into that database (CREATE TABLE `db`.`name`; see "split_table_name").
Pass if_not_exists => 1 to render CREATE TABLE IF NOT EXISTS; the diff layer routes that through "should_emit_if_exists" in DBIO::Diff::Op on storage that declares the F12 capability, so a re-applied migration no-ops instead of erroring on the partial-failure path. The capability flag is the single source of truth -- this function does not inspect the engine.
create_view
create_view(name => $view, definition => $select_sql, if_not_exists => 0|1,
or_replace => 0|1, database => $db);
Renders CREATE VIEW `name` AS $select_sql;, or CREATE VIEW IF NOT EXISTS `name` AS ...; when asked. or_replace renders CREATE OR REPLACE VIEW, the form the diff replaces a changed view with (the server swaps the definition in one statement; it does not combine with IF NOT EXISTS, and or_replace wins). The definition is emitted verbatim. database writes the view into that database (CREATE VIEW `db`.`name`, see "split_table_name").
drop_view
drop_view(name => $view, if_exists => 0|1, database => $db);
Renders DROP VIEW `name`;, or DROP VIEW IF EXISTS `name`; when asked, gated by the diff layer on the same capability as the table forms. A materialized view is dropped with the same statement, which is why the diff never emits it for one (DBIO::ClickHouse::Diff::View).
split_table_name
my ($database, $table) = split_table_name('other.t'); # ('other', 't')
my ($database, $table) = split_table_name('t'); # (undef, 't')
Splits the name a source gives its table at its dot: database.table to ClickHouse, as the SQLMaker reads it; a view's name is read the same way. A name without a dot is the table alone. Throws a DBIO::Exception for a name with more than one dot or an empty half (.t, db.), which names nothing.
drop_table
drop_table(name => $table, if_exists => 0|1);
Renders DROP TABLE `name`;, or DROP TABLE IF EXISTS `name`; when asked.
Note: the only caller (DBIO::ClickHouse::Diff::Table) knows at diff time that the live model carries this table, and the guard is written all the same: a multi-statement migration that dropped this table on its first apply, then failed on a downstream statement, re-runs as a no-op only when the second-pass DROP TABLE IF EXISTS swallows the "unknown table" error. The diff layer gates it on the F12 capability exactly as CREATE TABLE does above.
alter_table_add_column
alter_table_add_column(
table => $table,
definition => column_def(...),
if_not_exists => 0|1,
);
Renders ALTER TABLE `t` ADD COLUMN `c` Type;, or the IF NOT EXISTS form when asked. The definition arrives pre-rendered (see "Fragments are passed in pre-rendered"), so a column added by a diff is spelled exactly as the same column would be inside a fresh CREATE TABLE.
The guard is gated on the F12 capability, the same as "create_table"'s IF NOT EXISTS. ALTER TABLE ... ADD COLUMN IF NOT EXISTS is a silent no-op when the column already exists with a different type -- not a repair -- so a partial-failure reapply on a manually-modified column leaves a stale type. The diff layer therefore sets the flag only on this engine's capability bit, not unconditionally; the actual reapply safety net on a real type change is the follow-up MODIFY COLUMN, which (see "alter_table_modify_column") does not accept the flag at all.
alter_table_modify_column
alter_table_modify_column(table => $table, definition => column_def(...));
Renders ALTER TABLE `t` MODIFY COLUMN `c` Type;. Restating the definition is how ClickHouse changes a column; the server accepts the statement synchronously and converts the data as a background mutation.
No IF EXISTS / IF NOT EXISTS argument. ClickHouse has no guard form for ALTER TABLE ... MODIFY COLUMN -- t/38 verifies this by negative space, and "HOW IT LINKS INTO PARTIAL-FAILURE REAPPLY" in DBIO::ClickHouse::Diff::Column documents why the diff layer never asks for one. An unexpected if_exists / if_not_exists argument is treated as a programming error.
alter_table_remove_default
alter_table_remove_default(table => $table, name => $column);
Renders ALTER TABLE `t` MODIFY COLUMN `c` REMOVE DEFAULT;. A column's default cannot be dropped by restating its definition without one -- the server keeps the old expression then -- and REMOVE DEFAULT takes no type beside it, so a type change is a statement of its own. Rows keep the value they were written with; only what an insert leaves out changes.
alter_table_drop_column
alter_table_drop_column(
table => $table,
name => $column,
if_exists => 0|1,
);
Renders ALTER TABLE `t` DROP COLUMN `c`;, or the IF EXISTS form when asked. The flag routes through the F12 capability probe ("DIFF AS SQL" in DBIO::ClickHouse::Diff::Column) on the same path as "drop_table"'s guard.
alter_table_add_index
alter_table_add_index(
table => $table,
definition => index_def($info),
if_not_exists => 0|1,
);
Renders ALTER TABLE `t` ADD INDEX `i` expr TYPE ...;, or the IF NOT EXISTS form when asked. The INDEX keyword comes from "index_def" -- this function only wraps it -- which is what makes the added index char-identical to the one a CREATE TABLE would carry inline.
alter_table_drop_index
alter_table_drop_index(
table => $table,
name => $index,
if_exists => 0|1,
);
Renders ALTER TABLE `t` DROP INDEX `i`;, or the IF EXISTS form when asked.
Note: in ClickHouse ALTER TABLE ... DROP INDEX is a data mutation, not pure DDL -- the server accepts the statement synchronously but the actual deletion runs in the background. Interactions with mutations_sync belong to the applier; this function renders the SQL.
create_database
create_database(name => $name);
Renders CREATE DATABASE `name`. No ENGINE clause, so the database gets the server default (Atomic on any supported version). Unterminated; see "Statement termination".
drop_database
drop_database(name => $name, if_exists => 1);
Renders DROP DATABASE `name`, prefixed with IF EXISTS when asked. DBIO::ClickHouse::Deploy always asks: its cleanup runs unconditionally, including on the path where the CREATE is the statement that failed. Unterminated; see "Statement termination".
The ALTER TABLE forms
The five alter_table_* functions below are what a ClickHouse schema change looks like once the table exists. There is no CREATE INDEX and no ALTER INDEX in this engine, and no way to touch a column outside ALTER TABLE, so every post-creation change routes through one of them. Each returns one complete, terminated statement.
They are DDL. ClickHouse also spells its row-level update and delete ALTER TABLE ... UPDATE / ALTER TABLE ... DELETE, but those are asynchronous data mutations rendered by DBIO::ClickHouse::SQLMaker from a resultset's condition -- a different input, a different execution path, and nothing in common with these beyond the two leading keywords.
SEE ALSO
DBIO::ClickHouse::DDL - the desired-state producer feeding this module
DBIO::ClickHouse::Diff - the diff renderers feeding this module
DBIO::ClickHouse::Deploy - the scratch-database lifecycle
DBIO::ClickHouse::Result - the
clickhouse_tableoptions this module renders
AUTHOR
DBIO Authors
COPYRIGHT AND LICENSE
Copyright (C) 2026 DBIO Authors
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.