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