Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cmd/pg-schema-diff/plan_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ type (
dataPackNewTables bool
disablePlanValidation bool
noConcurrentIndexOps bool
skipPrivileges bool

statementTimeoutModifiers []string
lockTimeoutModifiers []string
Expand Down Expand Up @@ -227,6 +228,8 @@ func createPlanOptionsFlags(cmd *cobra.Command) *planOptionsFlags {
"database with an identical schema to the original, asserting that the generated plan actually migrates the schema to the desired target.")
cmd.Flags().BoolVar(&flags.noConcurrentIndexOps, "no-concurrent-index-ops", false, "If set, will disable the use of CONCURRENTLY in CREATE INDEX and DROP INDEX statements. "+
"This may result in longer lock times and potential downtime during migrations.")
cmd.Flags().BoolVar(&flags.skipPrivileges, "skip-privileges", false, "If set, will skip diffing of table privileges (GRANT/REVOKE). "+
"This is useful when privileges on partitioned tables cause plan generation to fail.")

timeoutModifierFlagVar(cmd, &flags.statementTimeoutModifiers, "statement", "t")
timeoutModifierFlagVar(cmd, &flags.lockTimeoutModifiers, "lock", "l")
Expand Down Expand Up @@ -329,6 +332,9 @@ func parsePlanOptions(p planOptionsFlags) (planOptions, error) {
if p.noConcurrentIndexOps {
opts = append(opts, diff.WithNoConcurrentIndexOps())
}
if p.skipPrivileges {
opts = append(opts, diff.WithSkipTablePrivileges())
}

var statementTimeoutModifiers []timeoutModifier
for _, s := range p.statementTimeoutModifiers {
Expand Down
191 changes: 191 additions & 0 deletions internal/migration_acceptance_tests/function_body_dep_cases_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package migration_acceptance_tests

import (
"testing"

"github.com/stripe/pg-schema-diff/pkg/diff"
)

// Tests for body-text dependency detection (patch 02).
// These verify that pg-schema-diff correctly orders functions after the
// tables/views they reference in their bodies via FROM, JOIN, %ROWTYPE, [].
var functionBodyDepTestCases = []acceptanceTestCase{
{
name: "qualified FROM",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE public.orders(id INT PRIMARY KEY, total NUMERIC);
CREATE FUNCTION public.sum_orders() RETURNS NUMERIC LANGUAGE sql AS
$$ SELECT SUM(total) FROM public.orders; $$;
`},
},
{
name: "unqualified FROM",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE orders(id INT PRIMARY KEY, total NUMERIC);
CREATE FUNCTION sum_orders() RETURNS NUMERIC LANGUAGE sql AS
$$ SELECT SUM(total) FROM orders; $$;
`},
},
{
name: "qualified JOIN",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE public.customers(id INT PRIMARY KEY, name TEXT);
CREATE TABLE public.orders(id INT PRIMARY KEY, customer_id INT, total NUMERIC);
CREATE FUNCTION public.customer_totals() RETURNS TABLE(name TEXT, total NUMERIC) LANGUAGE sql AS
$$ SELECT c.name, SUM(o.total) FROM public.orders o JOIN public.customers c ON c.id = o.customer_id GROUP BY c.name; $$;
`},
},
{
name: "unqualified JOIN",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE customers(id INT PRIMARY KEY, name TEXT);
CREATE TABLE orders(id INT PRIMARY KEY, customer_id INT, total NUMERIC);
CREATE FUNCTION customer_totals() RETURNS TABLE(name TEXT, total NUMERIC) LANGUAGE sql AS
$$ SELECT c.name, SUM(o.total) FROM orders o JOIN customers c ON c.id = o.customer_id GROUP BY c.name; $$;
`},
},
{
name: "qualified %ROWTYPE",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE public.events(id INT PRIMARY KEY, event_type TEXT);
CREATE FUNCTION public.process_events() RETURNS void LANGUAGE plpgsql AS $$
DECLARE r public.events%ROWTYPE;
BEGIN
FOR r IN SELECT * FROM public.events LOOP
RAISE NOTICE '%', r.event_type;
END LOOP;
END; $$;
`},
expectedHazardTypes: []diff.MigrationHazardType{diff.MigrationHazardTypeHasUntrackableDependencies},
},
{
name: "unqualified %ROWTYPE",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE events(id INT PRIMARY KEY, event_type TEXT);
CREATE FUNCTION process_events() RETURNS void LANGUAGE plpgsql AS $$
DECLARE r events%ROWTYPE;
BEGIN
FOR r IN SELECT * FROM events LOOP
RAISE NOTICE '%', r.event_type;
END LOOP;
END; $$;
`},
expectedHazardTypes: []diff.MigrationHazardType{diff.MigrationHazardTypeHasUntrackableDependencies},
},
{
name: "qualified array of composite",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE public.items(id INT PRIMARY KEY, name TEXT, price NUMERIC);
CREATE FUNCTION public.batch_items() RETURNS void LANGUAGE plpgsql AS $$
DECLARE batch public.items[];
BEGIN
SELECT array_agg(i) INTO batch FROM public.items i;
END; $$;
`},
expectedHazardTypes: []diff.MigrationHazardType{diff.MigrationHazardTypeHasUntrackableDependencies},
},
{
name: "unqualified array of composite",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE items(id INT PRIMARY KEY, name TEXT, price NUMERIC);
CREATE FUNCTION batch_items() RETURNS void LANGUAGE plpgsql AS $$
DECLARE batch items[];
BEGIN
SELECT array_agg(i) INTO batch FROM items i;
END; $$;
`},
expectedHazardTypes: []diff.MigrationHazardType{diff.MigrationHazardTypeHasUntrackableDependencies},
},
{
name: "FROM in subquery",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE public.products(id INT PRIMARY KEY, category TEXT, price NUMERIC);
CREATE FUNCTION public.expensive_categories() RETURNS SETOF TEXT LANGUAGE sql AS
$$ SELECT DISTINCT category FROM public.products WHERE price > (SELECT AVG(price) FROM public.products); $$;
`},
},
{
name: "CTE referencing table",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE public.sales(id INT PRIMARY KEY, region TEXT, amount NUMERIC);
CREATE FUNCTION public.top_regions() RETURNS TABLE(region TEXT, total NUMERIC) LANGUAGE sql AS
$$ WITH regional AS (SELECT region, SUM(amount) as total FROM public.sales GROUP BY region)
SELECT region, total FROM regional ORDER BY total DESC LIMIT 5; $$;
`},
},
{
name: "function referencing view (qualified FROM)",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE public.logs(id INT PRIMARY KEY, level TEXT, msg TEXT, created_at TIMESTAMPTZ DEFAULT now());
CREATE VIEW public.error_logs AS SELECT id, msg, created_at FROM public.logs WHERE level = 'ERROR';
CREATE FUNCTION public.recent_errors() RETURNS BIGINT LANGUAGE sql AS
$$ SELECT COUNT(*) FROM public.error_logs WHERE created_at > now() - interval '1 hour'; $$;
`},
},
{
name: "function referencing view (unqualified FROM)",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE logs(id INT PRIMARY KEY, level TEXT, msg TEXT, created_at TIMESTAMPTZ DEFAULT now());
CREATE VIEW error_logs AS SELECT id, msg, created_at FROM logs WHERE level = 'ERROR';
CREATE FUNCTION recent_errors() RETURNS BIGINT LANGUAGE sql AS
$$ SELECT COUNT(*) FROM error_logs WHERE created_at > now() - interval '1 hour'; $$;
`},
},
}

func TestFunctionBodyDepTestCases(t *testing.T) {
runTestCases(t, functionBodyDepTestCases)
}

var functionBodyDepGapTestCases = []acceptanceTestCase{
{
name: "Function with LEFT JOIN reference",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE public.a(id INT PRIMARY KEY, val TEXT);
CREATE TABLE public.b(id INT PRIMARY KEY, a_id INT, extra TEXT);
CREATE FUNCTION public.joined() RETURNS TABLE(val TEXT, extra TEXT) LANGUAGE sql AS
$$ SELECT a.val, b.extra FROM public.a LEFT JOIN public.b ON b.a_id = a.id; $$;
`},
},
{
name: "Function with multiple FROM tables",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE public.t1(id INT PRIMARY KEY);
CREATE TABLE public.t2(id INT PRIMARY KEY);
CREATE TABLE public.t3(id INT PRIMARY KEY);
CREATE FUNCTION public.multi() RETURNS BIGINT LANGUAGE sql AS
$$ SELECT COUNT(*) FROM public.t1, public.t2, public.t3; $$;
`},
// Gap: comma-separated FROM list only detects first table.
// t2 and t3 after commas are not matched by the regex.
expectedPlanErrorContains: "does not exist",
},
{
name: "Function with unqualified LEFT JOIN",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE a(id INT PRIMARY KEY, val TEXT);
CREATE TABLE b(id INT PRIMARY KEY, a_id INT, extra TEXT);
CREATE FUNCTION joined() RETURNS TABLE(val TEXT, extra TEXT) LANGUAGE sql AS
$$ SELECT a.val, b.extra FROM a LEFT JOIN b ON b.a_id = a.id; $$;
`},
},
}

func TestFunctionBodyDepGapTestCases(t *testing.T) {
runTestCases(t, functionBodyDepGapTestCases)
}
159 changes: 159 additions & 0 deletions internal/migration_acceptance_tests/materialized_view_cases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -614,3 +614,162 @@ var materializedViewAcceptanceTestCases = []acceptanceTestCase{
func TestMaterializedViewTestCases(t *testing.T) {
runTestCases(t, materializedViewAcceptanceTestCases)
}

var materializedViewDepTestCases = []acceptanceTestCase{
{
name: "No-op: materialized view depends on a view (identical schemas)",
oldSchemaDDL: []string{`
CREATE TABLE events(id INT PRIMARY KEY, event_type TEXT, created_at TIMESTAMPTZ DEFAULT now());
CREATE VIEW recent_events AS SELECT id, event_type FROM events WHERE created_at > now() - interval '7 days';
CREATE MATERIALIZED VIEW event_stats AS SELECT event_type, COUNT(*) as cnt FROM recent_events GROUP BY event_type;
`},
newSchemaDDL: []string{`
CREATE TABLE events(id INT PRIMARY KEY, event_type TEXT, created_at TIMESTAMPTZ DEFAULT now());
CREATE VIEW recent_events AS SELECT id, event_type FROM events WHERE created_at > now() - interval '7 days';
CREATE MATERIALIZED VIEW event_stats AS SELECT event_type, COUNT(*) as cnt FROM recent_events GROUP BY event_type;
`},
expectEmptyPlan: true,
},
{
name: "Recreate matview when dependent view is recreated due to table change",
oldSchemaDDL: []string{`
CREATE TABLE table_c(c1 INT PRIMARY KEY, c2_old TEXT);
CREATE VIEW view_b AS SELECT c1, c2_old FROM table_c;
CREATE MATERIALIZED VIEW matview_a AS SELECT c1 FROM view_b;
`},
newSchemaDDL: []string{`
CREATE TABLE table_c(c1 INT PRIMARY KEY, c2_new TEXT);
CREATE VIEW view_b AS SELECT c1, c2_new FROM table_c;
CREATE MATERIALIZED VIEW matview_a AS SELECT c1 FROM view_b;
`},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeDeletesData,
},
},
{
name: "Add matview that depends on existing view",
oldSchemaDDL: []string{`
CREATE TABLE items(id INT PRIMARY KEY, active BOOLEAN DEFAULT true);
CREATE VIEW active_items AS SELECT id FROM items WHERE active = true;
`},
newSchemaDDL: []string{`
CREATE TABLE items(id INT PRIMARY KEY, active BOOLEAN DEFAULT true);
CREATE VIEW active_items AS SELECT id FROM items WHERE active = true;
CREATE MATERIALIZED VIEW active_count AS SELECT COUNT(*) as total FROM active_items;
`},
},
{
name: "Drop matview that depends on view",
oldSchemaDDL: []string{`
CREATE TABLE items(id INT PRIMARY KEY, active BOOLEAN DEFAULT true);
CREATE VIEW active_items AS SELECT id FROM items WHERE active = true;
CREATE MATERIALIZED VIEW active_count AS SELECT COUNT(*) as total FROM active_items;
`},
newSchemaDDL: []string{`
CREATE TABLE items(id INT PRIMARY KEY, active BOOLEAN DEFAULT true);
CREATE VIEW active_items AS SELECT id FROM items WHERE active = true;
`},
},
{
name: "Matview depends on function - no-op identical schemas",
oldSchemaDDL: []string{`
CREATE TABLE orders(id INT PRIMARY KEY, amount NUMERIC);
CREATE FUNCTION double_val(val NUMERIC) RETURNS NUMERIC LANGUAGE sql AS
$$ SELECT val * 2; $$;
CREATE MATERIALIZED VIEW order_doubles AS SELECT id, double_val(amount) as doubled FROM orders;
`},
newSchemaDDL: []string{`
CREATE TABLE orders(id INT PRIMARY KEY, amount NUMERIC);
CREATE FUNCTION double_val(val NUMERIC) RETURNS NUMERIC LANGUAGE sql AS
$$ SELECT val * 2; $$;
CREATE MATERIALIZED VIEW order_doubles AS SELECT id, double_val(amount) as doubled FROM orders;
`},
expectEmptyPlan: true,
},
{
name: "Recreate matview when dependent function signature changes",
oldSchemaDDL: []string{
`CREATE TABLE transactions(id INT PRIMARY KEY, amount NUMERIC);`,
`CREATE FUNCTION calc_fee(val NUMERIC) RETURNS NUMERIC LANGUAGE sql AS
$$ SELECT val * 0.1; $$;
CREATE MATERIALIZED VIEW transaction_fees AS SELECT id, calc_fee(amount) as fee FROM transactions;`,
},
newSchemaDDL: []string{
`CREATE TABLE transactions(id INT PRIMARY KEY, amount NUMERIC);`,
`CREATE FUNCTION calc_fee(val NUMERIC) RETURNS TEXT LANGUAGE sql AS
$$ SELECT (val * 0.1)::TEXT; $$;
CREATE MATERIALIZED VIEW transaction_fees AS SELECT id, calc_fee(amount) as fee FROM transactions;`,
},
// Upstream limitation: pg-schema-diff doesn't handle function return type
// changes as drop+create.
expectedPlanErrorContains: "cannot change return type of existing function",
},
}

func TestMaterializedViewDepTestCases(t *testing.T) {
runTestCases(t, materializedViewDepTestCases)
}

// Matview function dependency ordering tests — from-scratch creation.
var materializedViewFuncDepTestCases = []acceptanceTestCase{
{
name: "From scratch: matview calls a function",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE orders(id INT PRIMARY KEY, amount NUMERIC);
CREATE FUNCTION total_orders() RETURNS NUMERIC LANGUAGE sql AS
$$ SELECT COALESCE(SUM(amount), 0) FROM public.orders; $$;
CREATE MATERIALIZED VIEW order_summary AS SELECT total_orders() as total;
`},
},
}

func TestMaterializedViewFuncDepTestCases(t *testing.T) {
runTestCases(t, materializedViewFuncDepTestCases)
}

var materializedViewGapTestCases = []acceptanceTestCase{
{
name: "From scratch: matview depends on view that calls a function",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE t(id INT PRIMARY KEY, val NUMERIC);
CREATE FUNCTION double_val(v NUMERIC) RETURNS NUMERIC LANGUAGE sql AS $$ SELECT v * 2; $$;
CREATE VIEW v AS SELECT id, double_val(val) as doubled FROM t;
CREATE MATERIALIZED VIEW mv AS SELECT * FROM v;
`},
},
{
name: "From scratch: matview depends on another matview",
oldSchemaDDL: []string{``},
newSchemaDDL: []string{`
CREATE TABLE t(id INT PRIMARY KEY, category TEXT, amount NUMERIC);
CREATE MATERIALIZED VIEW mv_base AS SELECT category, SUM(amount) as total FROM t GROUP BY category;
CREATE MATERIALIZED VIEW mv_top AS SELECT category FROM mv_base WHERE total > 1000;
`},
// vertex ID namespace, not table vertex IDs).
},
{
name: "Recreate matview when dependent matview is recreated",
oldSchemaDDL: []string{`
CREATE TABLE t(id INT PRIMARY KEY, old_col TEXT, amount NUMERIC);
CREATE MATERIALIZED VIEW mv_base AS SELECT old_col, SUM(amount) as total FROM t GROUP BY old_col;
CREATE MATERIALIZED VIEW mv_top AS SELECT old_col FROM mv_base WHERE total > 0;
`},
newSchemaDDL: []string{`
CREATE TABLE t(id INT PRIMARY KEY, new_col TEXT, amount NUMERIC);
CREATE MATERIALIZED VIEW mv_base AS SELECT new_col, SUM(amount) as total FROM t GROUP BY new_col;
CREATE MATERIALIZED VIEW mv_top AS SELECT new_col FROM mv_base WHERE total > 0;
`},
expectedHazardTypes: []diff.MigrationHazardType{diff.MigrationHazardTypeDeletesData},
expectedDBSchemaDDL: []string{`
CREATE TABLE t(id INT PRIMARY KEY, amount NUMERIC, new_col TEXT);
CREATE MATERIALIZED VIEW mv_base AS SELECT new_col, SUM(amount) as total FROM t GROUP BY new_col WITH NO DATA;
CREATE MATERIALIZED VIEW mv_top AS SELECT new_col FROM mv_base WHERE total > 0 WITH NO DATA;
`},
},
}

func TestMaterializedViewGapTestCases(t *testing.T) {
runTestCases(t, materializedViewGapTestCases)
}
Loading