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_info hashrefs plus clickhouse_table options).

  • The DBIO::ClickHouse::Diff::* renderers render CREATE / DROP / ALTER statements from an introspected model.

  • DBIO::ClickHouse::Deploy issues the scratch-database CREATE DATABASE / DROP DATABASE pair.

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

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.