From 9afa2884187a54977abce479484154026f9ccbd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 09:01:58 +0200 Subject: [PATCH] sql: inherit replacement schemas in purification A replacement without a column list for a materialized view with a declared schema gets the target's column definitions during purification. The replacement's `create_sql` then states its schema, so re-planning it does not depend on the target, dependencies on types used by the definitions are recorded, and applying the replacement keeps the target's schema declared. Inherited columns behave like written-out ones, including assignment casts. Co-Authored-By: Claude Opus 5.5 --- .../checks/all_checks/materialized_views.py | 12 ++-- src/adapter/src/coord/command_handler.rs | 13 +++- src/adapter/src/error.rs | 4 +- src/sql/src/pure.rs | 41 +++++++++++- .../materialized_view_declared_schema.slt | 66 ++++++++++++++++++- 5 files changed, 126 insertions(+), 10 deletions(-) diff --git a/misc/python/materialize/checks/all_checks/materialized_views.py b/misc/python/materialize/checks/all_checks/materialized_views.py index 548dcbc4a6327..4d60ca76925db 100644 --- a/misc/python/materialize/checks/all_checks/materialized_views.py +++ b/misc/python/materialize/checks/all_checks/materialized_views.py @@ -440,11 +440,13 @@ def manipulate(self) -> list[Testdrive]: """ > INSERT INTO mv_declared_table VALUES (3, 'z') > CREATE MATERIALIZED VIEW mv_declared_schema2 (a int4 NOT NULL, b text) AS SELECT a, b FROM mv_declared_table + > CREATE REPLACEMENT MATERIALIZED VIEW mv_declared_replacement1 FOR mv_declared_schema1 AS SELECT a::int8, count(*) * 10 FROM mv_declared_table WHERE a IS NOT NULL GROUP BY a::int8 """, """ > INSERT INTO mv_declared_table VALUES (4, 'w') - > CREATE REPLACEMENT MATERIALIZED VIEW mv_declared_replacement (k int8 NOT NULL, n int8) FOR mv_declared_schema1 AS SELECT a, count(*) * 10 FROM mv_declared_table WHERE a IS NOT NULL GROUP BY a - > ALTER MATERIALIZED VIEW mv_declared_schema1 APPLY REPLACEMENT mv_declared_replacement + > ALTER MATERIALIZED VIEW mv_declared_schema1 APPLY REPLACEMENT mv_declared_replacement1 + > CREATE REPLACEMENT MATERIALIZED VIEW mv_declared_replacement2 (a int4 NOT NULL, b text) FOR mv_declared_schema2 AS SELECT a, upper(b) FROM mv_declared_table + > ALTER MATERIALIZED VIEW mv_declared_schema2 APPLY REPLACEMENT mv_declared_replacement2 """, ] ] @@ -458,10 +460,10 @@ def validate(self) -> Testdrive: 4 10 > SELECT * FROM mv_declared_schema2 - 1 x + 1 X 2 - 3 z - 4 w + 3 Z + 4 W > SELECT name, nullable, type FROM (SHOW COLUMNS FROM mv_declared_schema1) k false bigint diff --git a/src/adapter/src/coord/command_handler.rs b/src/adapter/src/coord/command_handler.rs index 625842e68d3de..549a6204f54ec 100644 --- a/src/adapter/src/coord/command_handler.rs +++ b/src/adapter/src/coord/command_handler.rs @@ -45,7 +45,8 @@ use mz_sql::plan::{ StatementClassification, TransactionType, }; use mz_sql::pure::{ - materialized_view_option_contains_temporal, purify_create_materialized_view_options, + materialized_view_option_contains_temporal, purify_create_materialized_view_columns, + purify_create_materialized_view_options, }; use mz_sql::rbac; use mz_sql::session::hint::ApplicationNameHint; @@ -1732,6 +1733,11 @@ impl Coordinator { let catalog = self.catalog().for_session(ctx.session()); + if let Err(e) = + purify_create_materialized_view_columns(&catalog, &mut cmvs, &mut resolved_ids) + { + return ctx.retire(Err(e.into())); + } purify_create_materialized_view_options( catalog, mz_now, @@ -1779,6 +1785,11 @@ impl Coordinator { let catalog = self.catalog().for_session(ctx.session()); + if let Err(e) = + purify_create_materialized_view_columns(&catalog, &mut cmvs, &mut resolved_ids) + { + return ctx.retire(Err(e.into())); + } purify_create_materialized_view_options( catalog, mz_now, diff --git a/src/adapter/src/error.rs b/src/adapter/src/error.rs index d975451990ee7..e023fa459e174 100644 --- a/src/adapter/src/error.rs +++ b/src/adapter/src/error.rs @@ -851,8 +851,8 @@ impl AdapterError { .to_string(), ), AdapterError::ReplacementSchemaNotDeclared => Some( - "Declare the target's columns with their types. EXPLAIN ... WITH (schema) prints \ - them for the target." + "Omit the column list to use the target's column definitions, or declare the \ + columns with their types." .to_string(), ), AdapterError::SubscribeFellBehind { .. } => Some( diff --git a/src/sql/src/pure.rs b/src/sql/src/pure.rs index 6f320fba6c9f5..76bbc08444529 100644 --- a/src/sql/src/pure.rs +++ b/src/sql/src/pure.rs @@ -44,7 +44,7 @@ use mz_sql_parser::ast::{ CsrConfigOptionName, CsrConnection, CsrSeedAvro, CsrSeedProtobuf, CsrSeedProtobufSchema, DeferredItemName, DocOnIdentifier, DocOnSchema, Expr, Function, FunctionArgs, GlueAvroOption, GlueAvroSeed, Ident, KafkaSourceConfigOption, KafkaSourceConfigOptionName, LoadGenerator, - LoadGeneratorOption, LoadGeneratorOptionName, MaterializedViewOption, + LoadGeneratorOption, LoadGeneratorOptionName, MaterializedViewColumns, MaterializedViewOption, MaterializedViewOptionName, MySqlConfigOption, MySqlConfigOptionName, PgConfigOption, PgConfigOptionName, RawItemName, ReaderSchemaSelectionStrategy, RefreshAtOptionValue, RefreshEveryOptionValue, RefreshOptionValue, SourceEnvelope, SqlServerConfigOption, @@ -2949,6 +2949,45 @@ async fn compile_proto( }) } +/// Gives a replacement materialized view without a column list the column +/// definitions of its target, if the target declares its schema. +/// +/// Writing the definitions into the statement keeps the replacement's +/// `create_sql` self-contained, so re-planning it does not depend on the +/// target, and records dependencies on the types the definitions use. Applying +/// the replacement copies its column list to the target, which keeps the +/// target's schema declared. +pub fn purify_create_materialized_view_columns( + catalog: &dyn SessionCatalog, + cmvs: &mut CreateMaterializedViewStatement, + resolved_ids: &mut ResolvedIds, +) -> Result<(), PlanError> { + // Planning reports a missing or invalid replacement target. + let Some(ResolvedItemName::Item { id, .. }) = &cmvs.replacement_for else { + return Ok(()); + }; + if !cmvs.columns.is_empty() { + return Ok(()); + } + let target = catalog.get_item(id); + let stmt = mz_sql_parser::parser::parse_statements(target.create_sql())? + .into_element() + .ast; + let Statement::CreateMaterializedView(target_stmt) = stmt else { + return Ok(()); + }; + if !matches!( + target_stmt.columns, + MaterializedViewColumns::Definitions { .. } + ) { + return Ok(()); + } + let (columns, ids) = crate::names::resolve(catalog, target_stmt.columns)?; + cmvs.columns = columns; + resolved_ids.extend_from(&ids); + Ok(()) +} + const MZ_NOW_NAME: &str = "mz_now"; const MZ_NOW_SCHEMA: &str = "mz_catalog"; diff --git a/test/sqllogictest/materialized_view_declared_schema.slt b/test/sqllogictest/materialized_view_declared_schema.slt index 837952cdb29d5..9abf83843d3a6 100644 --- a/test/sqllogictest/materialized_view_declared_schema.slt +++ b/test/sqllogictest/materialized_view_declared_schema.slt @@ -279,7 +279,7 @@ q text # Replacements are compared against the declared schema. statement error replacement must declare its schema because its target does -CREATE REPLACEMENT MATERIALIZED VIEW rp FOR mv AS SELECT a::int8 AS x, b AS y, c::numeric(10, 1) AS z FROM t +CREATE REPLACEMENT MATERIALIZED VIEW rp (x, y, z) FOR mv AS SELECT a::int8, b, c::numeric(10, 1) FROM t statement error replacement schema differs from target schema CREATE REPLACEMENT MATERIALIZED VIEW rp (x int8, y text, z numeric(10, 1)) FOR mv AS SELECT a, b, c::numeric(10, 1) FROM t @@ -301,6 +301,70 @@ SELECT create_sql LIKE '%("x" %int8% NOT NULL, "y" %text%, "z" %numeric%10%1%)%' ---- true +# A replacement without a column list gets the target's column definitions, +# including names, and is cast to them as if they were written out. +statement error materialized view materialize.public.rp declares 3 columns, but its query produces 2 +CREATE REPLACEMENT MATERIALIZED VIEW rp FOR mv AS SELECT a::int8, b FROM t + +# Inherited types are cast to with implicit casts only. +statement error column "z" is of type numeric but expression is of type numeric with different type modifiers +CREATE REPLACEMENT MATERIALIZED VIEW rp FOR mv AS SELECT a::int8, b, c FROM t + +statement ok +CREATE REPLACEMENT MATERIALIZED VIEW rp FOR mv AS SELECT a * 10 AS ignored, b, c::numeric(10, 1) FROM t + +query TTT rowsort +SELECT name, nullable, type FROM (SHOW COLUMNS FROM rp) +---- +x false bigint +y true text +z true numeric + +query T +SELECT create_sql LIKE '%("x" %int8% NOT NULL, "y" %text%, "z" %numeric%10%1%)%' FROM mz_materialized_views WHERE name = 'rp' +---- +true + +statement ok +ALTER MATERIALIZED VIEW mv APPLY REPLACEMENT rp + +query ITR rowsort +SELECT * FROM mv +---- +10 x 1.2 +20 y 2.5 + +query T +SELECT create_sql LIKE '%("x" %int8% NOT NULL, "y" %text%, "z" %numeric%10%1%)%' FROM mz_materialized_views WHERE name = 'mv' +---- +true + +# Types used by inherited column definitions are dependencies, also after the +# replacement is applied. +statement ok +CREATE TYPE int4_list AS LIST (ELEMENT TYPE = int4) + +statement ok +CREATE VIEW lists AS SELECT LIST[a]::int4_list AS l FROM t + +statement ok +CREATE MATERIALIZED VIEW mv_typed (l int4_list) AS SELECT l FROM lists + +statement ok +CREATE REPLACEMENT MATERIALIZED VIEW rp FOR mv_typed AS SELECT l FROM lists + +statement ok +ALTER MATERIALIZED VIEW mv_typed APPLY REPLACEMENT rp + +query I +SELECT count(*) +FROM mz_internal.mz_object_dependencies d +JOIN mz_materialized_views m ON d.object_id = m.id +JOIN mz_types ty ON d.referenced_object_id = ty.id +WHERE m.name = 'mv_typed' AND ty.name = 'int4_list' +---- +1 + # An implicit target can be given a declared schema that matches its current # one. statement ok