Add --dump-schema option to clickhouse-client and clickhouse-local - #114098
Add --dump-schema option to clickhouse-client and clickhouse-local#114098valerypetrov wants to merge 65 commits into
Conversation
Adds a `--dump-schema[=<database>]` flag that dumps `CREATE DATABASE`/ `CREATE TABLE`/`CREATE DICTIONARY`/`CREATE MATERIALIZED VIEW` statements for one database, or all non-system user databases if no value is given, to stdout, then exits. Tables are ordered so a table is only printed after the tables/views/dictionaries it depends on, using the existing `system.tables.loading_dependencies_*` columns. This is a client-only feature: it reuses `create_table_query` and `SHOW CREATE DATABASE`, which the server already computes, so no server-side or wire-protocol changes are needed. Implicit materialized-view inner storage tables (`.inner_id.*`/`.inner.*`, plus `.tmp.` variants left over from an interrupted refresh) are excluded, since they are recreated automatically by their owning `CREATE MATERIALIZED VIEW` statement. A genuine loading-dependency cycle is reported as an error rather than silently reordered. Closes: ClickHouse#60681 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…hema Extends --dump-schema with: - A comma-separated database list (`--dump-schema=db1,db2`), instead of just a single database or "all". - `--dump-schema-exclude=db1,db2` to dump all user databases except the listed ones (mutually exclusive with an explicit database list). - `--dump-schema-dir=<path>` to write one `<database>.sql` file per dumped database into that directory (created if missing) instead of stdout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@groeneai, please check this one. If you won't have any major findings, please tag someone from the ClickHouse team to review this PR. |
…idation Fixes found during review of the --dump-schema-dir addition: - Escape database names with escapeForFileName before using them as filenames under --dump-schema-dir, and reject the dump outright if two target databases would collide on a case-insensitive filesystem, instead of one silently overwriting the other's file. - Detect and surface write failures (e.g. disk full) on the per-database files instead of reporting success unconditionally. - Order the --dump-schema-dir files (and their confirmation lines) by cross-database table dependency, not alphabetically, and print a note when that ordering matters; a database-level dependency cycle is now a clear error instead of producing files that can't be replayed correctly in any order. - Move the "--dump-schema-exclude/--dump-schema-dir require --dump-schema" and (clickhouse-local only) "--dump-schema conflicts with --file/ --structure" checks into processConfig(), before connect() is attempted, matching how the existing --query/--queries-file conflict check already works -- these are pure argument errors and shouldn't require a connection (or an interactive password prompt) to fail on. - Replace the hand-rolled topological sort in orderTablesByDependencies with TablesDependencyGraph, the same dependency-ordering class DatabaseCatalog uses to load tables at startup and backups use to order their restore, keeping the deterministic (database, name) tie-break within each dependency level. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Reviewed. One blocker, then some smaller notes. Blocker: views and materialized views are not dependency-ordered, so the dump does not replay
Reproduced on master (26.8.1.1), realistic names, no cycle involved:
Same failure for a plain Two things fall out of this:
Smaller notes
Given the blocker I have not tagged a reviewer yet. Ping me once the ordering is fixed and I will route it. |
…ncies loading_dependencies_* (the only signal the dump previously used) is empty for View and MaterializedView, so every view landed in dependency level 0 alongside its own source table and the (database, name) tie-break decided the order -- whenever a view sorted before its source by name, the dump replayed in the wrong order and failed. Fixes it by combining every dependency signal system.tables exposes: loading_dependencies_* (dictionaries), dependencies_* inverted (a materialized view's source, which the server only records on the source row), target_database/target_table (a materialized view's TO target, explicit or implicit), and -- since the server tracks none of this for plain views -- a best-effort scan of each view's stored SELECT text for other dumped tables' names. Also narrows the implicit-inner-table filter: a table is now only excluded when it's confirmed to be a live materialized view's own generated-name target, not merely name-shaped like one, so a real user table literally named `.inner.foo` is no longer silently dropped from the dump. Strengthens 04836_client_dump_schema.sh accordingly: adversarial naming so the ordering assertion can't pass by alphabetical luck, coverage for plain views, view-on-view chains, and explicit TO targets (each round-tripped through a real replay), the `.inner.foo` case, and a clickhouse-client invocation alongside the existing clickhouse-local ones. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@groeneai Fixed, thanks for the very thorough repro — I reproduced your exact case locally ( Root cause was exactly what you found: Also fixed the Test ( One thing I want to flag rather than claim silently: for plain-view-on-plain-view (and view-on-table) ordering, there's no server-tracked signal at all, so I'm relying on a text scan of the resolved, server-formatted |
|
Correction to my earlier caveat: I did reproduce the case-collision guard — mounted a case-sensitive APFS volume to stand in for a Linux source, created Note: the guard isn't destination-aware — it fires unconditionally regardless of the actual target filesystem, by design (portably detecting case-sensitivity isn't worth it for a dump meant to be replayed elsewhere anyway). |
|
Answering your question directly: I would not pick either contract, because the text scan does not only under-collect. It also over-collects, and that direction is worse: a spurious edge fabricates a dependency cycle, and Two distinct false-positive classes, both reproduced on master (26.8.1.1) by running the predicate at 1. Substring, not identifier, matching. CREATE TABLE analytics.metrics_source (a UInt64) ENGINE=MergeTree ORDER BY a;
CREATE TABLE analytics.metrics_daily_raw (a UInt64) ENGINE=MergeTree ORDER BY a;
CREATE VIEW analytics.metrics AS SELECT * FROM analytics.metrics_daily_raw;
CREATE VIEW analytics.metrics_daily AS SELECT * FROM analytics.metrics_source;
2. String literals. A qualified name inside a literal matches too, and this class survives any word-boundary tightening, so tightening the match is not a fix: CREATE VIEW d.audit_orders AS SELECT a, 'd.audit_users' AS src FROM d.orders;
CREATE VIEW d.audit_users AS SELECT a, 'd.audit_orders' AS src FROM d.users;Same outcome: fake mutual dependency, whole dump refused. Suggested direction, which removes your question rather than answering it: collect referenced tables from the parsed AST instead of the text. So subqueries and CTEs (the residual you flagged) resolve correctly, and both false-positive classes disappear. Better still, Smaller notes:
Thanks for the ordering and |
The substring scan added for plain View dependency detection could fabricate dependency edges two ways: a qualified name that's a textual prefix of another table's name (e.g. `analytics.metrics_daily` inside `analytics.metrics_daily_raw`), and a qualified name appearing inside a string literal rather than an actual table reference. Either can create a false mutual dependency between two views that don't reference each other, which made the whole dump abort with a fabricated INFINITE_LOOP cycle error for an entirely valid schema. Replaces the substring scan with a proper parse: `as_select` is parsed with `ParserSelectWithUnionQuery` (the same grammar the server used to produce it), and every `ASTTableIdentifier` in the resulting tree is collected and matched against the known table set by (database, name) equality rather than text search. A CTE alias reference stays unqualified in server- formatted SQL, so filtering to identifiers with a non-empty database part is enough to exclude them without separately tracking WITH-bound names. Also fixes the same false-exclusion class the `.inner.foo` fix addressed for `.tmp.inner_id.*`/`.tmp.inner.*`: those are now only treated as implicit mid-refresh storage when they can be traced to a live materialized view's own uuid/name, so a literal user table sharing that name prefix is no longer silently dropped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@groeneai Fixed — you're right that the substring scan was worse than the original bug, and your two repro classes were exactly the right proof. Replaced it with a proper parse: Your CTE-alias caveat turned out to be free: I confirmed empirically that a CTE alias reference stays unqualified in server-formatted SQL ( Went with the local AST-walk fallback you flagged, not Verified against both your exact repros ( Test now covers both false-positive classes, a genuine view-on-view/view-on-table chain (round-tripped through actual replay), and the |
|
The AST walk is the right call, and I verified all three things you claimed. The empty-database-part filter ( One new blocker, same class as round 1. A materialized view with two or more sources is ordered by only one of them. The AST scan is gated Measured on master 26.8.1.1: CREATE VIEW d.zzz_view AS SELECT id, b FROM d.base;
CREATE MATERIALIZED VIEW d.aaa_join_mv ENGINE = MergeTree ORDER BY id AS
SELECT s.id AS id, s.a AS a, v.b AS b FROM d.src AS s INNER JOIN d.zzz_view AS v ON s.id = v.id;
Emitting the view first replays cleanly and data flows through the MV, so it is the ordering and not the fixture. Reproduces the same way with Two precisions so you do not over-fix, both measured:
Fix direction: the scan you already wrote is the right tool, just gated too narrowly. Dropping the The test cannot catch this: Holding the reviewer routing one more round, since your condition was no major findings. Ping me once this is in and I will route it. |
The AST-based dependency scan only ran for row.engine == "View", so a materialized view with two or more sources (e.g. a JOIN or UNION ALL combining a table and a view) only got an edge to one source: the server records mv_from_dependency as a single std::optional<StorageID>, not a set, so the second source went untracked. Whenever that untracked source sorted after the MV by name, the dump emitted the MV first and replay failed with UNKNOWN_TABLE. Widening the scan to also run for MaterializedView rows gives it the missing edge from the MV's own as_select, which is populated for MVs including TO-target ones and never mentions the MV's own name or storage, so it can't conflict with the already-correct target-based edge. A duplicate edge to the already-tracked source is harmless (collapses in TablesDependencyGraph's own dependency set). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@groeneai Fixed — good catch, and the precision on when it does/doesn't bite (second-and-later sources, only when the untracked one outranks level 0) made this trivial to scope correctly. Went with your exact fix direction: widened the Verified against your exact JOIN repro ( |
|
Round-4 blocker is discharged. I verified the widened gate at One more blocker in the same place, though.
Negative control for both: emit the missed object first and replay is RC=0 with data flowing ( Two precisions, since they scope it:
The test cannot catch either. Fix direction, verified only to this depth: the server's own extractor already handles exactly these argument positions in Holding the reviewer routing one more round, for the same reason as before. Ping me once this is in and I will route it. |
collectTableIdentifiers only caught ASTTableIdentifier nodes (FROM/JOIN
position), so a reference the grammar puts in a function argument was
invisible to the dependency scan: dictGet('db.dict', ...), joinGet(...),
the dictionary(db.dict) table function, and WHERE x IN db.table. Each
could leave the referenced object dumped after the view/materialized view
that needs it, breaking replay.
Adds collectFunctionArgumentReferences, which walks the same parsed AST
for ASTFunction nodes matching functionIsDictGet/functionIsJoinGet/
"dictionary" (argument 0) or functionIsInOrGlobalInOperator (argument 1),
and reads that argument as either an identifier or a plain (unescaped)
db.name string literal via QualifiedTableName::tryParseFromString --
mirroring the argument positions DDLDependencyVisitor::visitFunction reads
for the same function families. remote()/cluster() and non-literal
argument evaluation are deliberately excluded: both need a live
Context (to resolve cluster locality, or evaluate a constant expression)
that a client talking to a remote server doesn't have, and extracting
text blindly for remote()/cluster() risks a spurious dependency between
unrelated servers, which is worse than the bug being fixed given a false
cycle aborts the whole dump.
Verified this doesn't need to cover DEFAULT/MATERIALIZED column
expressions (already reachable via loading_dependencies_*, which walks
the full CREATE query, not just the SELECT body) or merge() (resolves its
source lazily at query time, not at CREATE time, so dump order doesn't
affect whether it replays).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@groeneai Fixed. Went further than the two cases you'd verified — audited Deliberately excluded Given the pattern of this review (a new gap in the same mechanism each round), I ran this one through a fuller pipeline before pushing: architect audited the full extractor and verified every case empirically, a dev implementation, then a senior-style review (confirmed no correctness bugs, and — critically — empirically confirmed each new test case actually forces the target and its referencing MV to the same dependency level, so the fix is what's deciding the order, not an unrelated level gap that would pass either way), and an adversarial pass specifically hunting for what a round 5 would find: tested Your exact |
|
Verified at The arm discriminates, which is the part the previous rounds' suites failed. Restoring the old I also checked the widening for a round-2 style false cycle, since arg 0 of One correction, not a blocker. CREATE MATERIALIZED VIEW d.aaa_mv_mergejoin TO d.sink AS
SELECT s.id AS id, s.val AS val, m.w AS w FROM d.src AS s
LEFT JOIN merge('d','^zzz_merge_view$') AS m ON s.id = m.id;With cc @azat @yakov-olkhovskiy, could you review this? It adds a client-side |
`merge` and `loop` resolve at `CREATE` rather than lazily at query time, so dump order does matter for a view holding one in a `JOIN`. Neither is tracked by the server and neither is extracted here, so record both as known limitations next to the `remote`/`cluster` exclusion instead of leaving the reason unstated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XvXEBdaJTCCnUdunCf8icr
Build profile diff (arm_release)Comparing Binary sizes
Only the stripped binary is compared: the official master build keeps debug symbols while PR builds strip them, so the other binaries differ by construction. Object file sizes
|
| Object file | Master | PR | Δ |
|---|---|---|---|
src/CMakeFiles/dbms.dir/Client/SchemaDumper.cpp.o |
new | 462.25 KiB | +462.25 KiB |
716 more object files are built by the master warmup baseline only (it builds every object-file target, a pull request build only clickhouse-bundle) and not compared.
Compile time of recompiled translation units
22 translation units recompiled, 137 s compile time in total, 21 of them have a recent master baseline.
Translation units without a recent master baseline:
src/Client/SchemaDumper.cpp: 6.8 s
Symbol sizes
| Binary | Symbol | Master | PR | Δ |
|---|---|---|---|---|
programs/clickhouse |
DB::attachSystemTablesServer(std::__1::shared_ptr<DB::Context const>, DB::IDatabase&, bool, bool) |
274.74 KiB | removed | -274.74 KiB (-100.00%) |
programs/clickhouse |
DB::attachSystemTablesServerExceptOne(std::__1::shared_ptr<DB::Context const>, DB::IDatabase&, bool… |
new | 272.20 KiB | +272.20 KiB |
The implicit-inner filter classified a materialized view's target purely by name, so a real user table called `.inner.foo`/`.inner_id.foo` that an MV points at with an explicit `TO` was dropped from the dump entirely. Replay then failed: the view was created before a target that was never emitted at all. `ASTCreateQuery` already records the two cases distinctly -- an explicit `TO db.table` sets the `To` target's table id, while an auto-generated inner table renders as `TO INNER UUID '...'`, which the parser stores as an inner UUID and no table id. So `hasExplicitTargetTable` parses `create_table_query` with `ParserCreateQuery` and reads `is_materialized_view_with_external_target()`; a substring test for `TO` cannot work here, since the implicit form literally contains `TO INNER UUID`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XvXEBdaJTCCnUdunCf8icr
- merge(['db'|REGEXP('db_regexp'),] 'tables_regexp') and loop(db.table |
'db','table') resolve their source(s) against the local catalog at
CREATE time, same as remote()/cluster() would if they had a live
Context available -- but unlike those, merge/loop are local and can be
matched here. A materialized view reading through either previously
got no dependency edge to its source, so a name tie-break could dump
it before that source and produce a replay failure. Only explicitly-
qualified forms are handled (matching the file's existing convention
for plain unqualified FROM references, which are already skipped for
the same reason: no "current database" to resolve against statically).
- A `.tmp.inner.<name>` row was classified as a materialized view's own
mid-refresh leftover storage whenever a live MV happened to share that
name, with no server-side proof of ownership (unlike `.tmp.inner_id.
<uuid>`, confirmed via a live MV's actual UUID). A real user table
named this way was silently dropped from the dump. Since there's no
reliable signal to distinguish the two, treat `.tmp.inner.<name>` as
an ordinary table instead of guessing by name alone.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PUMFxWmof1zGB7KtqUyPk6
…erences Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PUMFxWmof1zGB7KtqUyPk6
In a database without UUIDs every materialized view reports the nil UUID, so the classifier inserted the all-zero key and silently dropped a user table literally named .tmp.inner_id.00000000-... from the dump. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
test.py's cluster leaves enable_client_options_passing at its default (false), so the env request was silently dropped and the session exited 0 before any refusal could fire; test_options_propagation_enabled.py's cluster is the one where the option actually reaches the embedded client. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
…s it A target kind named explicitly in the CREATE has no generated storage, so a table carrying that kind's generated name is an ordinary user table (verified: a TimeSeries with explicit METRICS generates only the samples and tags helpers, a window view with explicit TO generates no .inner.target one). The classifier now consults the stored targets. merge() also resolves its regexp against helper tables, so its walker matches the unfiltered name set and the resulting edge is remapped onto the owning object instead of vanishing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
buildTargets skips a kind the CREATE does not declare, so a TimeSeries without a RECENT SAMPLES clause has no recentsamples helper and a table carrying that name is an ordinary one. The runtime also prefers the modern samples helper and only falls back to the legacy data alias when it is absent, so that alias is only implicit while no samples row exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
TimeSeries tables, window views and Ordinary databases are gated behind settings that are off by default, so replaying a dump containing them stopped at the first such statement. The needed SETs are now written ahead of the statements in both the single-stream and per-file paths, and only when the dump actually contains such an object. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
The dump's metadata queries were sent with no settings at all, so system.tables hid every data lake catalog table and the dump emitted their CREATE DATABASE with nothing in it. Those queries now pass show_data_lake_catalogs_in_system_tables explicitly. StorageWindowView also refuses to be created while the analyzer is on, so a dump containing one needs that turned off alongside the feature flag or the replay just fails one statement later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Turning the analyzer off for the whole replay broke every other statement that needs it - a view using QUALIFY, for instance - so the downgrade is now wrapped around the one CREATE that requires it and restored straight after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Extends the prelude beyond the three engines it started with: the Replicated / MaterializedPostgreSQL / MaterializedMySQL database engines, all five DataLakeCatalog kinds (the kind lives inside the engine arguments, so all are enabled rather than guessed), UNIQUE KEY, the Paimon table engines and YTsaurus. Verified every emitted setting name is accepted by a server. This is still a whitelist that will lag a new gate; a server-driven required-settings mechanism remains the follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Three more create-time gates the dump can already emit statements for: the MaterializedPostgreSQL table engine (distinct from the database one), the Kafka engine storing offsets in Keeper, which is selected by kafka_keeper_path or kafka_replica_name being set, and use_hive_partitioning on the object-storage queue engines. Matching on the setting names is deliberately fail-close: an unneeded SET replays harmlessly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
SHOW CREATE keeps the deprecated positional *MergeTree(...) definition verbatim, and replaying it needs allow_deprecated_syntax_for_merge_tree; a YTsaurus dictionary source needs its own gate, which nothing on the table or database markers reached. New-syntax Replicated tables also carry engine arguments and match the MergeTree marker, which only emits a setting their replay ignores. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
The legacy-alias and gated-object cases sit above the block that set DB,
so ${DB} expanded to empty: the setup ran CREATE DATABASE ENGINE =
Ordinary, which is the position-25 syntax error those runs reported, and
--dump-schema= then dumped every database, so the replay also tried to
recreate default. Every assertion in those two sections was reading an
empty dump. Move the three database-name assignments above the first use.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
A view's columns are inferred by analysing its SELECT, so a table function in it is rejected at CREATE time: eval() and ytsaurus() each have their own gate and neither is reachable from the engine or dictionary-source markers. Match on a leading space so a name ending in the same letters, such as retrieval(, does not trip eval(. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
| { | ||
| std::set<String> settings; | ||
|
|
||
| /// Every create-time gate identifiable in-tree today, keyed by what the stored `CREATE` spells. |
There was a problem hiding this comment.
settingsPreludeFor still misses create-time settings on stored table definitions, not just engine names. A concrete repro is any MergeTree created under allow_suspicious_primary_key = 1: SHOW CREATE preserves the ORDER BY with SimpleAggregateFunction, but the dump emits no matching SET, so replay into a default session re-enters registerStorageMergeTree.cpp:731-732 and fails with DATA_TYPE_CANNOT_BE_USED_IN_KEY (see tests/queries/0_stateless/03020_order_by_SimpleAggregateFunction.sql).
The same gap exists for other already-supported stored DDL such as allow_suspicious_ttl_expressions (registerStorageMergeTree.cpp:876-882) and gated codecs in MergeTree settings (registerStorageMergeTree.cpp:906-913). That means --dump-schema can still succeed on a valid catalog but emit a script that a fresh default session cannot replay. I think this needs either broader prelude detection for these create-time settings, or a fail-close check when the stored DDL contains one of these gated forms.
There was a problem hiding this comment.
Added in 64f7a66. I verified each gate and each setting name, and confirmed the mechanism you point at: is_fresh_definition is true for a CREATE, so a replay is re-validated in full even though the source already accepted the DDL — TTLValidationMode::Validate rather than ::Attach, and the codec check likewise.
This round is a different kind from the previous four, and I want to name that rather than let it look like more of the same. The earlier markers stood for features — an engine, a source, a table function — and the statement text names them exactly. These stand for validations, and the conditions are not decidable from the text: whether an ORDER BY key is "suspicious" depends on resolved column types, and whether a TTL expression is depends on what it references. So the markers are deliberately coarse — any SimpleAggregateFunction, any TTL , any of the three stored compression-codec settings — and a definition that would have passed anyway just carries a spare SET.
I think that is the right trade here specifically because these are validations. The definitions in a dump were already accepted once by the source; relaxing re-validation on replay is closer to the intent of a restore than failing it. That is not an argument I would make for a feature gate, where the setting actually changes what the server will do.
The honest summary is that this is the fifth round of gates on this table, each found by inspection rather than by anything systematic, and the classes keep widening: engines, then database engines, then dictionary sources, then table functions, now validation settings. That is the concrete case for the server-driven "settings this statement requires" mechanism you scoped as the follow-up — I do not think any amount of auditing converges here, and I would rather that be visible from the thread than discovered again in a sixth round.
There was a problem hiding this comment.
This thread is not fully fixed yet. replaySettingsPrelude() now sets only allow_experimental_codecs at src/Client/SchemaDumper.cpp:1260, but fresh replay still has another codec validator behind allow_suspicious_codecs.
registerStorageMergeTree.cpp:920-925 re-runs validateCodecString() for fresh CREATEs, and CompressionFactoryAdditions.cpp:263-315 still rejects several codec combinations unless allow_suspicious_codecs is enabled. So a table that was originally created under SET allow_suspicious_codecs = 1 can still dump successfully and then fail to replay in a default session. I think this prelude still needs allow_suspicious_codecs, plus a focused 04836 regression for a stored codec string that depends on it.
A replay is a fresh definition, so the suspicious-key, suspicious-TTL and experimental-codec checks all run again even though the source already accepted the DDL. None of these are engine names, so nothing on the existing markers reached them. The markers are coarser than the checks they stand for -- any SimpleAggregateFunction, any TTL, any stored compression codec -- because the conditions are not decidable from the statement text; a definition that would pass anyway just gets a spare SET. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
validateDataType() and MergeTreeData::checkProperties() re-run on every non-ATTACH CREATE, so a table accepted under a relaxed session setting replays into a default session and is rejected. Unlike the engine and validation gates, these settings leave no trace in the statement at all -- only the shape they object to is visible -- so the markers match the shape and take the false positives. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Seven rounds of review found gates the marker table missed, and the last two classes cannot be detected at all: the suspicious-type validators and the analyzer-side settings a view's AS SELECT is re-analysed under are session state that the stored CREATE does not record. The markers that remained (Tuple(, INDEX , TTL ) fired on nearly every statement anyway, so matching bought precision only on paper. Emit the whole set, which is complete by construction rather than by audit; each is inert when unused. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
GROUP BY on a Dynamic column is rejected when the view is recreated, since CREATE re-analyses the stored SELECT, and the setting that allows it is session state that appears nowhere in the statement. Verified both directions locally: the CREATE fails with ILLEGAL_COLUMN naming allow_suspicious_types_in_group_by, and succeeds with it set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Those four sections were added at the top of the script but their lines were appended to the end of the reference, so the recorded order never matched the emitted one. The empty-database bug fixed in 3387aef was masking this: the test failed on stderr before the diff was reached. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
| /// Validators that re-run because a replay is a fresh definition. | ||
| "allow_suspicious_primary_key", | ||
| "allow_suspicious_ttl_expressions", | ||
| "allow_experimental_codecs", |
There was a problem hiding this comment.
replaySettingsPrelude() is still incomplete here. The emitted script only enables allow_experimental_codecs, but replaying a dumped CREATE TABLE can still fail on the other codec gates that are revalidated during CREATE:
- the dedicated per-codec settings (
enable_alp_codec,enable_zxc_codec,enable_quantized_codec,enable_sz3_codec, etc.) - the sanity-bypass setting
allow_suspicious_codecs
Those are all real create-time settings in this tree (enableAllExperimentalSettings() already treats them that way), so a table accepted under one of them will dump successfully and then fail to replay in a default session. A simple example is a table created under SET enable_alp_codec = 1 with CODEC(ALP): the stored CREATE still needs that setting when the dump re-runs it.
I think this prelude needs to add allow_suspicious_codecs plus the codec gate list from CompressionCodecFactory::instance().getGateSettingNames(), otherwise --dump-schema still emits non-self-contained dumps for codec-gated schemas.
| "enable_nullable_tuple_type", | ||
| "allow_suspicious_indices", | ||
| "allow_minmax_index_for_json", | ||
| "allow_suspicious_types_in_group_by", |
There was a problem hiding this comment.
This unconditional prelude still misses allow_dynamic_type_in_join_keys. CREATE VIEW / CREATE MATERIALIZED VIEW re-analyze their stored AS SELECT on replay, and join analysis still throws on Dynamic join keys unless that session setting is enabled.
So a schema like SET allow_dynamic_type_in_join_keys = 1; CREATE VIEW v AS SELECT * FROM lhs JOIN rhs USING d; (with d of type Dynamic) will dump successfully, but the emitted script will fail when it recreates the view in a default session because this prelude never restores the setting. That is the same replay-compatibility class as the allow_suspicious_types_in_group_by gap fixed above: the setting lives in session state, not in the stored CREATE.
Please add allow_dynamic_type_in_join_keys to replaySettingsPrelude() and cover it with a round-trip regression.
| "allow_suspicious_indices", | ||
| "allow_minmax_index_for_json", | ||
| "allow_suspicious_types_in_group_by", | ||
| "allow_suspicious_types_in_order_by", |
There was a problem hiding this comment.
replaySettingsPrelude() still misses one of the analyzer-time replay gates for VIEW / MATERIALIZED VIEW: allow_dynamic_type_in_join_keys.
The same stored-AS SELECT re-analysis that justified allow_suspicious_types_in_group_by / allow_suspicious_types_in_order_by still re-enters TableJoin.cpp:950-960, where a JOIN on Dynamic keys throws unless this setting is enabled. Because SHOW CREATE does not serialize that session setting, a view created under SET allow_dynamic_type_in_join_keys = 1 will dump successfully but the replayed CREATE VIEW / CREATE MATERIALIZED VIEW still fails in a default session.
Please add allow_dynamic_type_in_join_keys to this unconditional prelude and cover it with a focused 04836 arm.
Closes: #60681
Adds a
--dump-schema[=<db1,db2,...>]option that dumpsCREATE DATABASE/CREATE TABLE/CREATE DICTIONARY/CREATE MATERIALIZED VIEW/CREATE VIEWstatements for one or more databases, or all non-system user databases if no value is given, to stdout, then exits — similar topg_dump --schema-only.Tables are ordered (via
TablesDependencyGraph, the same classDatabaseCataloguses to load tables at startup and backups use to order their restore; ties within a level broken by(database, name)for determinism) using every dependency signalsystem.tablesexposes, combined:loading_dependencies_*(dictionaries), inverteddependencies_*(a materialized view's source — recorded only on the source row, not the view),target_database/target_table(a materialized view'sTOtarget, explicit or implicit), and — since the server tracks none of this for plain views at all (DROP TABLEon a view's source succeeds even with the view still referencing it) — a best-effort scan of each view's stored, server-formattedSELECTtext for other dumped tables' names. A genuine dependency cycle is a clear error rather than being silently reordered.This is entirely a client-side feature:
system.tables.create_table_queryandSHOW CREATE DATABASEalready give the server-computed DDL text, so no server-side or wire-protocol changes are needed — the new code (src/Client/SchemaDumper.{h,cpp}) just issues a couple of queries over the existing connection (same pattern asSuggest.cpp) and prints the results in dependency order.--dump-schemaalso accepts a comma-separated database list (--dump-schema=db1,db2), and two modifiers:--dump-schema-exclude=db1,db2dumps all user databases except the listed ones (rejected if combined with an explicit database list — the two are mutually exclusive ways of selecting the same thing).--dump-schema-dir=<path>writes one<database>.sqlfile per dumped database into that directory (created if it doesn't exist, e.g.mkdir clickhouse-lab && clickhouse-client --dump-schema --dump-schema-exclude=... --dump-schema-dir=clickhouse-lab) instead of writing everything to stdout. Database names are escaped withescapeForFileNamebefore being used as filenames; two target databases whose escaped names collide case-insensitively are rejected unconditionally (regardless of the destination filesystem's actual case sensitivity — reliably probing that portably isn't worth it when the point of a dump is to be replayed somewhere else later anyway), rather than one silently overwriting the other's file. Per-database write failures are detected instead of reported as success, and when a table in one dumped database depends on a table in another, the files (and their confirmation lines) are ordered — and a note printed — so replaying them in the printed order works; a database-level circular dependency (distinct from, and not implied by, a table-level cycle) is rejected with a clear error since no file order could satisfy it.A few things worth calling out for review:
CREATE MATERIALIZED VIEWstatement) — but only when confirmed to be a live materialized view's own target with the generated-name shape (.inner_id.*/.inner.*), not merely name-shaped like one, so a real user table literally named`.inner.foo`stays in the dump. Orphaned mid-refresh leftovers (.tmp.inner_id.*/.tmp.inner.*) are filtered by name, since they're never any live target.--dump-schemaforces non-interactive mode (like--query/--queries-file) and is rejected if combined with either of them, so its stdout is never mixed with an interactive banner, warnings, or another query's output. Forclickhouse-local, it's also rejected combined with--file/--structure.--dump-schema-exclude/--dump-schema-dirare rejected if--dump-schemaitself isn't given — all validated inprocessConfig(), beforeconnect(), so a plain argument mistake fails fast instead of after a connection attempt (which could otherwise block on an interactive password prompt).clickhouse-localinstance: a source table with a materialized view (implicit storage), a dictionary, a plain view, a view-on-view chain, and an explicitTOtarget, named so that alphabetical order alone would produce an unreplayable dump without the fix; single/list/exclude/all-databases selection;--dump-schema-diroutput with a genuine cross-database dependency; the case-insensitive filename collision guard (using a case-sensitive APFS volume to stand in for a Linux source, confirming both thatFoo/foocoexist there and that the guard fires when dumping them); and all the rejection cases (unknown database, combined with--query/--file/--structure, list + exclude together, dir without--dump-schema).Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Added a
--dump-schema[=<db1,db2,...>]option toclickhouse-clientandclickhouse-localthat dumpsCREATE DATABASE/CREATE TABLE/CREATE DICTIONARY/CREATE MATERIALIZED VIEW/CREATE VIEWstatements for the given database(s), or all user databases if no value is given, to stdout in dependency order, then exits — similar topg_dump --schema-only.--dump-schema-exclude=<db1,db2,...>skips the listed databases when dumping all of them, and--dump-schema-dir=<path>writes one<database>.sqlfile per dumped database into that directory instead of stdout.Workflow [PR]
Sync PR [sync-upstream/pr/114098]