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
200 changes: 200 additions & 0 deletions internal/migration_acceptance_tests/composite_type_cases_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package migration_acceptance_tests

import (
"testing"

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

var compositeTypeAcceptanceTestCases = []acceptanceTestCase{
{
name: "no-op",
oldSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text);
`},
newSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text);
`},
expectEmptyPlan: true,
},
{
name: "create composite type",
oldSchemaDDL: []string{},
newSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text);
`},
},
{
name: "drop composite type",
oldSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text);
`},
newSchemaDDL: []string{},
},
{
name: "drop nested composite types",
oldSchemaDDL: []string{`
CREATE TYPE inner_t AS (n int);
CREATE TYPE outer_t AS (i inner_t, label text);
`},
newSchemaDDL: []string{},
},
{
name: "create composite type used by function",
oldSchemaDDL: []string{},
newSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text);
CREATE FUNCTION mk_pair(x int, y text) RETURNS pair LANGUAGE sql AS 'SELECT (x, y)::pair';
`},
},
{
name: "create schema-qualified composite type used by plpgsql function",
oldSchemaDDL: []string{},
newSchemaDDL: []string{`
CREATE SCHEMA app;
CREATE TYPE app.result AS (status text, reason text);
CREATE FUNCTION app.resolve() RETURNS app.result LANGUAGE plpgsql AS $$
DECLARE
v_result app.result;
BEGIN
SELECT ROW('ok', 'ready')::app.result INTO v_result;
RETURN v_result;
END
$$;
`},
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeHasUntrackableDependencies,
},
},
{
name: "drop composite type after dropping function that used it",
oldSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text);
CREATE FUNCTION mk_pair(x int, y text) RETURNS pair LANGUAGE sql AS 'SELECT (x, y)::pair';
`},
newSchemaDDL: []string{},
},
{
name: "create composite type with attributes that have collation",
oldSchemaDDL: []string{},
newSchemaDDL: []string{`
CREATE TYPE labelled AS (id int, label text COLLATE "C");
`},
},
{
name: "create nested composite types (one references the other)",
oldSchemaDDL: []string{},
newSchemaDDL: []string{`
CREATE TYPE inner_t AS (n int);
CREATE TYPE outer_t AS (i inner_t, label text);
`},
},
// ─── Phase 2: drop+recreate cascade for function-only dependents ───
{
name: "alter composite type attrs - cascade through dependent function",
oldSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text);
CREATE FUNCTION mk_pair(x int, y text) RETURNS pair LANGUAGE sql AS 'SELECT (x, y)::pair';
`},
newSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text, c boolean);
CREATE FUNCTION mk_pair(x int, y text, z boolean) RETURNS pair LANGUAGE sql AS 'SELECT (x, y, z)::pair';
`},
},
{
name: "alter composite type attrs - cascade through dependent procedure",
oldSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text);
CREATE PROCEDURE use_pair(p pair) LANGUAGE plpgsql AS $$ BEGIN END $$;
`},
newSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text, c boolean);
CREATE PROCEDURE use_pair(p pair) LANGUAGE plpgsql AS $$ BEGIN END $$;
`},
// Procedures always carry the untrackable-deps hazard regardless of the
// underlying composite-type recreation; pg-schema-diff cannot follow plpgsql
// body references through pg_depend.
expectedHazardTypes: []diff.MigrationHazardType{
diff.MigrationHazardTypeHasUntrackableDependencies,
},
},
{
name: "alter composite type attrs - cascade through multiple dependent functions",
oldSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text);
CREATE FUNCTION f_a(p pair) RETURNS int LANGUAGE sql AS 'SELECT (p).a';
CREATE FUNCTION f_b(p pair) RETURNS text LANGUAGE sql AS 'SELECT (p).b';
`},
newSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text, c boolean);
CREATE FUNCTION f_a(p pair) RETURNS int LANGUAGE sql AS 'SELECT (p).a';
CREATE FUNCTION f_b(p pair) RETURNS text LANGUAGE sql AS 'SELECT (p).b';
`},
},
{
name: "alter composite type attrs - cascade through dependent function using array argument",
oldSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text);
CREATE FUNCTION f_items(p pair[]) RETURNS int LANGUAGE sql AS 'SELECT pg_catalog.cardinality(p)';
`},
newSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text, c boolean);
CREATE FUNCTION f_items(p pair[]) RETURNS int LANGUAGE sql AS 'SELECT pg_catalog.cardinality(p)';
`},
},
{
name: "alter composite type attrs - cascade through dependent composite type and function",
oldSchemaDDL: []string{`
CREATE TYPE inner_t AS (n int);
CREATE TYPE outer_t AS (i inner_t, label text);
CREATE FUNCTION f_outer(p outer_t) RETURNS int LANGUAGE sql AS 'SELECT ((p).i).n';
`},
newSchemaDDL: []string{`
CREATE TYPE inner_t AS (n int, extra text);
CREATE TYPE outer_t AS (i inner_t, label text);
CREATE FUNCTION f_outer(p outer_t) RETURNS int LANGUAGE sql AS 'SELECT ((p).i).n';
`},
},
{
name: "alter composite type attrs is unsupported when used by a table column",
oldSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text);
CREATE TABLE users (id int, attrs pair);
`},
newSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text, c boolean);
CREATE TABLE users (id int, attrs pair);
`},
expectedPlanErrorIs: diff.ErrNotImplemented,
},
{
name: "alter composite type attrs is unsupported when dependent composite type is used by a table column",
oldSchemaDDL: []string{`
CREATE TYPE inner_t AS (n int);
CREATE TYPE outer_t AS (i inner_t, label text);
CREATE TABLE users (id int, attrs outer_t);
`},
newSchemaDDL: []string{`
CREATE TYPE inner_t AS (n int, extra text);
CREATE TYPE outer_t AS (i inner_t, label text);
CREATE TABLE users (id int, attrs outer_t);
`},
expectedPlanErrorIs: diff.ErrNotImplemented,
},
{
name: "alter composite type attrs is unsupported when used by a table array column",
oldSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text);
CREATE TABLE users (id int, attrs pair[]);
`},
newSchemaDDL: []string{`
CREATE TYPE pair AS (a int, b text, c boolean);
CREATE TABLE users (id int, attrs pair[]);
`},
expectedPlanErrorIs: diff.ErrNotImplemented,
},
}

func TestCompositeTypeTestCases(t *testing.T) {
runTestCases(t, compositeTypeAcceptanceTestCases)
}
112 changes: 112 additions & 0 deletions internal/queries/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,64 @@ WHERE
AND depend.deptype = 'e'
);

-- name: GetDependsOnCompositeTypes :many
-- Returns the composite types (typtype = 'c') that the given object depends on.
-- This includes dependencies through PostgreSQL's automatically-created array
-- type for a composite type, e.g. `some_type[]`.
-- Used to drive cascading drop+recreate of functions and procedures when the
-- attribute list of a composite type changes.
SELECT DISTINCT
pg_type.typname::TEXT AS type_name,
type_namespace.nspname::TEXT AS type_schema_name
FROM pg_catalog.pg_depend AS depend
INNER JOIN pg_catalog.pg_type AS referenced_type
ON
depend.refclassid = 'pg_type'::REGCLASS
AND depend.refobjid = referenced_type.oid
INNER JOIN pg_catalog.pg_type AS pg_type
ON
(
referenced_type.oid = pg_type.oid
OR referenced_type.typelem = pg_type.oid
)
AND pg_type.typtype = 'c'
INNER JOIN
pg_catalog.pg_namespace AS type_namespace
ON pg_type.typnamespace = type_namespace.oid
INNER JOIN pg_catalog.pg_class AS rel
ON pg_type.typrelid = rel.oid AND rel.relkind = 'c'
WHERE
depend.classid = sqlc.arg(system_catalog)::REGCLASS
AND depend.objid = sqlc.arg(object_id)
AND depend.deptype = 'n';

-- name: GetCompositeTypeTableConsumers :many
-- Returns the tables (relkind in r,p) whose columns are typed by the given
-- composite type. Used to refuse a `CREATE TYPE` attribute change when a
-- table column depends on the type — recreating the type would require
-- rewriting the table, which is out of scope for this generator.
SELECT
consumer_c.relname::TEXT AS table_name,
consumer_ns.nspname::TEXT AS table_schema_name
FROM pg_catalog.pg_attribute AS att
INNER JOIN pg_catalog.pg_class AS consumer_c
ON
att.attrelid = consumer_c.oid
AND consumer_c.relkind IN ('r', 'p')
INNER JOIN pg_catalog.pg_namespace AS consumer_ns
ON consumer_c.relnamespace = consumer_ns.oid
WHERE
(
att.atttypid = sqlc.arg(type_oid)
OR att.atttypid = (
SELECT typarray
FROM pg_catalog.pg_type
WHERE oid = sqlc.arg(type_oid)
)
)
AND att.attnum > 0
AND NOT att.attisdropped;

-- name: GetDependsOnFunctions :many
SELECT
pg_proc.proname::TEXT AS func_name,
Expand Down Expand Up @@ -401,6 +459,60 @@ WHERE
AND extension_namespace.nspname !~ '^pg_temp';


-- name: GetCompositeTypes :many
-- Returns one row per (composite type, attribute) pair, ordered so that the consumer
-- can rebuild attribute lists in their declared order. Types with zero attributes still
-- get a single row with attribute_name = '' so the type itself is not lost.
SELECT
pg_type.oid AS type_oid,
rel.oid AS type_rel_oid,
pg_type.typname::TEXT AS type_name,
type_namespace.nspname::TEXT AS type_schema_name,
COALESCE(att.attname, '')::TEXT AS attribute_name,
COALESCE(
pg_catalog.format_type(att.atttypid, att.atttypmod), ''
)::TEXT AS attribute_type,
COALESCE(coll.collname, '')::TEXT AS collation_name,
COALESCE(coll_ns.nspname, '')::TEXT AS collation_schema_name
FROM pg_catalog.pg_type AS pg_type
INNER JOIN
pg_catalog.pg_namespace AS type_namespace
ON pg_type.typnamespace = type_namespace.oid
INNER JOIN
pg_catalog.pg_class AS rel
-- A user-defined composite type's underlying class has relkind = 'c'. Implicit
-- row types created for tables/views/sequences have relkind in ('r','p','v','m','S')
-- and must be excluded.
ON pg_type.typrelid = rel.oid AND rel.relkind = 'c'
LEFT JOIN
pg_catalog.pg_attribute AS att
ON
att.attrelid = rel.oid
AND att.attnum > 0
AND NOT att.attisdropped
LEFT JOIN
pg_catalog.pg_collation AS coll
ON att.attcollation = coll.oid
LEFT JOIN
pg_catalog.pg_namespace AS coll_ns
ON coll.collnamespace = coll_ns.oid
WHERE
pg_type.typtype = 'c'
AND type_namespace.nspname NOT IN ('pg_catalog', 'information_schema')
AND type_namespace.nspname !~ '^pg_toast'
AND type_namespace.nspname !~ '^pg_temp'
-- Exclude composite types belonging to extensions
AND NOT EXISTS (
SELECT ext_depend.objid
FROM pg_catalog.pg_depend AS ext_depend
WHERE
ext_depend.classid = 'pg_type'::REGCLASS
AND ext_depend.objid = pg_type.oid
AND ext_depend.deptype = 'e'
)
ORDER BY pg_type.oid, att.attnum;


-- name: GetEnums :many
SELECT
pg_type.typname::TEXT AS enum_name,
Expand Down
Loading