diff --git a/pkg/diff/materialized_view_sql_generator.go b/pkg/diff/materialized_view_sql_generator.go index 9b5d216..6a8cc64 100644 --- a/pkg/diff/materialized_view_sql_generator.go +++ b/pkg/diff/materialized_view_sql_generator.go @@ -94,7 +94,14 @@ func (mvsg *materializedViewSQLGenerator) Add(mv schema.MaterializedView) (parti materializedViewSb.WriteString(fmt.Sprintf(" TABLESPACE %s", schema.EscapeIdentifier(mv.Tablespace))) } materializedViewSb.WriteString(" AS\n") - materializedViewSb.WriteString(mv.ViewDefinition) + // pg_get_viewdef() may include a trailing semicolon in the view definition. Strip it so + // that WITH NO DATA is part of the same CREATE MATERIALIZED VIEW statement. + materializedViewSb.WriteString(strings.TrimRight(mv.ViewDefinition, "; \n\t")) + // Prevent the materialized view's stored query from being executed during schema + // reconstruction. Without WITH NO DATA, Postgres defaults to WITH DATA, which populates + // the view by running the query with the current connection's privileges. A low-privileged + // user could exploit this by planting a materialized view whose body escalates privileges. + materializedViewSb.WriteString("\nWITH NO DATA") addVertexId := buildMaterializedViewVertexId(mv.SchemaQualifiedName, diffTypeAddAlter) diff --git a/pkg/diff/schema_migration_plan_test.go b/pkg/diff/schema_migration_plan_test.go index 6d8544b..41e3a5d 100644 --- a/pkg/diff/schema_migration_plan_test.go +++ b/pkg/diff/schema_migration_plan_test.go @@ -343,6 +343,88 @@ var ( }, expectedDiffErrContains: "loop detected", }, + { + name: "Add materialized view generates WITH NO DATA", + oldSchema: schema.Schema{}, + newSchema: schema.Schema{ + MaterializedViews: []schema.MaterializedView{ + { + SchemaQualifiedName: schema.SchemaQualifiedName{ + SchemaName: "public", + EscapedName: schema.EscapeIdentifier("test_mv"), + }, + ViewDefinition: " SELECT 1 AS x", + }, + }, + }, + expectedStatements: []Statement{ + { + DDL: "CREATE MATERIALIZED VIEW \"public\".\"test_mv\" AS\n SELECT 1 AS x\nWITH NO DATA", + Timeout: statementTimeoutDefault, + LockTimeout: lockTimeoutDefault, + }, + }, + }, + { + name: "Alter materialized view (recreation) generates WITH NO DATA", + oldSchema: schema.Schema{ + MaterializedViews: []schema.MaterializedView{ + { + SchemaQualifiedName: schema.SchemaQualifiedName{ + SchemaName: "public", + EscapedName: schema.EscapeIdentifier("test_mv"), + }, + ViewDefinition: " SELECT 1 AS x", + }, + }, + }, + newSchema: schema.Schema{ + MaterializedViews: []schema.MaterializedView{ + { + SchemaQualifiedName: schema.SchemaQualifiedName{ + SchemaName: "public", + EscapedName: schema.EscapeIdentifier("test_mv"), + }, + ViewDefinition: " SELECT 2 AS x", + }, + }, + }, + expectedStatements: []Statement{ + { + DDL: "DROP MATERIALIZED VIEW \"public\".\"test_mv\"", + Timeout: statementTimeoutDefault, + LockTimeout: lockTimeoutDefault, + }, + { + DDL: "CREATE MATERIALIZED VIEW \"public\".\"test_mv\" AS\n SELECT 2 AS x\nWITH NO DATA", + Timeout: statementTimeoutDefault, + LockTimeout: lockTimeoutDefault, + }, + }, + }, + { + name: "Add materialized view with options generates WITH NO DATA", + oldSchema: schema.Schema{}, + newSchema: schema.Schema{ + MaterializedViews: []schema.MaterializedView{ + { + SchemaQualifiedName: schema.SchemaQualifiedName{ + SchemaName: "public", + EscapedName: schema.EscapeIdentifier("test_mv"), + }, + ViewDefinition: " SELECT 1 AS x", + Options: map[string]string{"fillfactor": "70"}, + }, + }, + }, + expectedStatements: []Statement{ + { + DDL: "CREATE MATERIALIZED VIEW \"public\".\"test_mv\" WITH (fillfactor=70) AS\n SELECT 1 AS x\nWITH NO DATA", + Timeout: statementTimeoutDefault, + LockTimeout: lockTimeoutDefault, + }, + }, + }, } )