From 0b856f1afc9ebc81d0e73214b71276c544421b0c Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Tue, 21 Jul 2026 00:27:10 +0800 Subject: [PATCH 1/5] feat(dump): qualify same-schema type references under --qualify-schema (#493) `dump --qualify-schema` previously forced qualification only for object identifiers; same-schema user-defined type references stayed bare because the inspector/IR discarded their schema. This preserves the schema for the "Mechanism A" type references so the existing render seams (stripSchemaPrefixMode) qualify them under the flag and strip them by default. Inspector queries now emit qualified user-defined types for: - column types (domain/enum/composite) - domain base types - aggregate state types (STYPE/MSTYPE) - composite-type attributes format_type-based sites are rewritten to an explicit pg_type/pg_namespace join + CASE that keeps format_type's canonical spelling and typmod for built-in/array types while qualifying user-defined scalar types. Because the inspector now emits qualified (and possibly quote_ident-quoted) type references, extractTypeName in the type dependency sort is normalized to unquote each identifier component and split on the last *unquoted* dot, so edges still match the bare typeMap keys. Default (smart-qualification) output is byte-identical: the full diff and dump fixture suites pass unchanged across PG 14-18. A new dump integration test drives the whole pipeline and asserts both directions plus that built-ins are never pg_catalog-qualified. Function/procedure parameter and return types are intentionally out of scope (the inspector strips their schema during signature parsing) and tracked as follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/dump/dump.go | 2 +- cmd/dump/qualify_schema_integration_test.go | 112 ++++++++++++++++++++ internal/diff/qualify_schema_test.go | 33 +++--- internal/diff/topological.go | 42 ++++++-- internal/diff/topological_test.go | 60 +++++++++++ ir/queries/queries.sql | 80 ++++++++++---- ir/queries/queries.sql.go | 80 ++++++++++---- 7 files changed, 345 insertions(+), 64 deletions(-) create mode 100644 cmd/dump/qualify_schema_integration_test.go diff --git a/cmd/dump/dump.go b/cmd/dump/dump.go index ecb13202..f497a41d 100644 --- a/cmd/dump/dump.go +++ b/cmd/dump/dump.go @@ -59,7 +59,7 @@ func init() { DumpCmd.Flags().BoolVar(&multiFile, "multi-file", false, "Output schema to multiple files organized by object type") DumpCmd.Flags().StringVar(&file, "file", "", "Output file path (required when --multi-file is used)") DumpCmd.Flags().BoolVar(&noComments, "no-comments", false, "Do not output object comment headers") - DumpCmd.Flags().BoolVar(&qualifySchema, "qualify-schema", false, "Always schema-qualify object identifiers in the dump, even for the target schema (does not affect same-schema type references, which the IR stores without a schema)") + DumpCmd.Flags().BoolVar(&qualifySchema, "qualify-schema", false, "Always schema-qualify object identifiers and type references in the dump, even for the target schema (function/procedure parameter and return types are not yet qualified)") DumpCmd.Flags().StringVar(&sslmode, "sslmode", "prefer", "SSL mode for database connection (disable, allow, prefer, require, verify-ca, verify-full) (env: PGSSLMODE)") } diff --git a/cmd/dump/qualify_schema_integration_test.go b/cmd/dump/qualify_schema_integration_test.go new file mode 100644 index 00000000..94252d14 --- /dev/null +++ b/cmd/dump/qualify_schema_integration_test.go @@ -0,0 +1,112 @@ +package dump + +// Positive integration coverage for `dump --qualify-schema` type references (#493). +// +// Unlike the builder-level tests in internal/diff (which hand the builder a +// pre-qualified IR), this test drives the whole pipeline: pgdump SQL → embedded +// database → inspector → IR → dump. It proves the inspector now *preserves* the +// schema of same-schema user-defined type references for the four "Mechanism A" +// slices (column type, domain base type, composite attribute, aggregate state +// type), so --qualify-schema can emit them fully qualified — while the default +// (smart-qualification) dump keeps them bare. + +import ( + "context" + "strings" + "testing" + + "github.com/pgplex/pgschema/testutil" +) + +const qualifySchemaSetupSQL = ` +CREATE TYPE color AS ENUM ('r', 'g', 'b'); +-- column type: same-schema enum +CREATE TABLE swatch ( + id integer PRIMARY KEY, + shade color +); +-- domain base type: same-schema enum +CREATE DOMAIN color_domain AS color; +-- composite attribute: same-schema enum (plus a built-in that must stay bare) +CREATE TYPE money_amount AS ( + amount numeric, + currency color +); +-- aggregate state type: same-schema composite +CREATE TYPE acc AS (n integer); +CREATE FUNCTION acc_add(acc, integer) RETURNS acc + LANGUAGE sql IMMUTABLE AS $$ SELECT ROW(($1).n + $2)::acc $$; +CREATE AGGREGATE mysum(integer) (SFUNC = acc_add, STYPE = acc); +` + +func TestDumpCommand_QualifySchemaTypeReferences(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + embeddedPG := testutil.SetupPostgres(t) + defer embeddedPG.Stop() + + conn, host, port, dbname, user, password := testutil.ConnectToPostgres(t, embeddedPG) + defer conn.Close() + + if _, err := conn.ExecContext(context.Background(), qualifySchemaSetupSQL); err != nil { + t.Fatalf("Failed to set up schema: %v", err) + } + + baseConfig := func(qualify bool) *DumpConfig { + return &DumpConfig{ + Host: host, + Port: port, + DB: dbname, + User: user, + Password: password, + Schema: "public", + MultiFile: false, + File: "", + QualifySchema: qualify, + } + } + + // --qualify-schema: same-schema type references are fully qualified. + qualified, err := ExecuteDump(baseConfig(true)) + if err != nil { + t.Fatalf("qualified dump failed: %v", err) + } + for _, want := range []string{ + "shade public.color", // column type + "AS public.color", // domain base type (CREATE DOMAIN ... AS public.color) + "currency public.color", // composite attribute + "STYPE = public.acc", // aggregate state type + } { + if !strings.Contains(qualified, want) { + t.Errorf("qualified dump missing %q\n---\n%s", want, qualified) + } + } + // Built-in types must never be schema-qualified. + if strings.Contains(qualified, "pg_catalog.") { + t.Errorf("qualified dump must not qualify built-in types with pg_catalog:\n%s", qualified) + } + if !strings.Contains(qualified, "amount numeric") { + t.Errorf("qualified dump should keep built-in composite attr type bare (amount numeric):\n%s", qualified) + } + + // Default (smart qualification): the same references stay bare. + def, err := ExecuteDump(baseConfig(false)) + if err != nil { + t.Fatalf("default dump failed: %v", err) + } + for _, want := range []string{ + "shade color", + "AS color", + "currency color", + "STYPE = acc", + } { + if !strings.Contains(def, want) { + t.Errorf("default dump missing bare form %q\n---\n%s", want, def) + } + } + if strings.Contains(def, "public.color") || strings.Contains(def, "public.acc") { + t.Errorf("default dump must not qualify same-schema type references:\n%s", def) + } +} diff --git a/internal/diff/qualify_schema_test.go b/internal/diff/qualify_schema_test.go index a3d856bc..92fd6399 100644 --- a/internal/diff/qualify_schema_test.go +++ b/internal/diff/qualify_schema_test.go @@ -10,10 +10,13 @@ import ( // These tests cover the `dump --qualify-schema` behavior at the SQL-builder level: // when qualifySchema is true, structural entity names are schema-qualified, even for // the target schema; when false (the default), the standard "smart qualification" -// omits the target-schema prefix. Type references that arrive from the inspector -// without schema identity stay bare (the flag cannot invent a schema for them) until -// the IR preserves their schema separately (#493). The false cases double as the -// byte-identical default-output guardrail for each builder. +// omits the target-schema prefix. For type references whose schema the IR preserves +// (columns, domain base types, composite attributes, aggregate state types — #493), +// forced qualification keeps the schema and the default strips it. Function/procedure +// parameter and return types still arrive from the inspector without schema identity +// (it strips them during signature parsing), so for those the flag cannot invent a +// schema and the reference stays bare. The false cases double as the byte-identical +// default-output guardrail for each builder. func TestQualifySchema_ForeignKeyReference(t *testing.T) { fk := &ir.Constraint{ @@ -48,9 +51,11 @@ func TestQualifySchema_TableAndColumnType(t *testing.T) { Name: "account", Columns: []*ir.Column{ {Name: "id", Position: 1, DataType: "integer", IsNullable: false}, - // Same-schema user-defined type references arrive from the inspector - // without schema identity, so the IR stores them bare (#493). - {Name: "kind", Position: 2, DataType: "user_kind", IsNullable: true}, + // Post-#493 the inspector preserves the schema of same-schema + // user-defined type references, so the IR stores them qualified + // (e.g. public.user_kind). The render seam strips the target-schema + // prefix in default mode and keeps it under forced qualification. + {Name: "kind", Position: 2, DataType: "public.user_kind", IsNullable: true}, }, } empty := map[string]bool{} @@ -62,18 +67,18 @@ func TestQualifySchema_TableAndColumnType(t *testing.T) { if strings.Contains(def, "public.account") || strings.Contains(def, "public.user_kind") { t.Errorf("default should not qualify the target schema: %q", def) } + if !strings.Contains(def, "kind user_kind") { + t.Errorf("default should strip the target-schema prefix from the type ref: %q", def) + } qualified, _ := generateTableSQL(table, "public", true, empty, empty, empty, nil) if !strings.Contains(qualified, "CREATE TABLE IF NOT EXISTS public.account (") { t.Errorf("forced qualification should qualify the table name: %q", qualified) } - // #493 limitation: forced qualification cannot *add* a schema to a bare - // same-schema type reference, so the column type stays bare. - if strings.Contains(qualified, "public.user_kind") { - t.Errorf("forced qualification must not invent a schema for a bare type ref: %q", qualified) - } - if !strings.Contains(qualified, "kind user_kind") { - t.Errorf("forced qualification should preserve the bare same-schema type ref: %q", qualified) + // #493: forced qualification keeps the schema the IR now carries on the + // same-schema type reference. + if !strings.Contains(qualified, "kind public.user_kind") { + t.Errorf("forced qualification should qualify the same-schema type ref: %q", qualified) } } diff --git a/internal/diff/topological.go b/internal/diff/topological.go index 50c1b335..f1fb79e0 100644 --- a/internal/diff/topological.go +++ b/internal/diff/topological.go @@ -400,23 +400,43 @@ func extractTypeName(dataType, defaultSchema string) string { typeName = typeName[:len(typeName)-2] } - // Check if it's a schema-qualified name - if idx := findLastDot(typeName); idx != -1 { - return typeName // Already fully qualified + // Check if it's a schema-qualified name. The inspector emits user-defined + // type references via quote_ident (e.g. public."user"), but typeMap keys are + // built from bare, unquoted identifiers (Schema + "." + Name), so normalize + // each component by stripping quote_ident quoting before returning. + if idx := findLastUnquotedDot(typeName); idx != -1 { + return unquoteIdent(typeName[:idx]) + "." + unquoteIdent(typeName[idx+1:]) } // Not qualified - use default schema - return defaultSchema + "." + typeName + return unquoteIdent(defaultSchema) + "." + unquoteIdent(typeName) } -// findLastDot finds the last dot in a string, returning -1 if not found -func findLastDot(s string) int { - for i := len(s) - 1; i >= 0; i-- { - if s[i] == '.' { - return i - } +// findLastUnquotedDot finds the last '.' that is not inside a double-quoted +// identifier, returning -1 if none. This avoids splitting on a dot that is part +// of a quoted identifier (e.g. the name in public."odd.name"). +func findLastUnquotedDot(s string) int { + inQuote := false + last := -1 + for i := 0; i < len(s); i++ { + switch { + case s[i] == '"': + inQuote = !inQuote + case s[i] == '.' && !inQuote: + last = i + } + } + return last +} + +// unquoteIdent removes quote_ident double-quoting from a single identifier +// (e.g. "user" -> user, "Weird""Name" -> Weird"Name). Bare identifiers are +// returned unchanged so this is safe on already-unquoted names. +func unquoteIdent(s string) string { + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + return strings.ReplaceAll(s[1:len(s)-1], `""`, `"`) } - return -1 + return s } // topologicallySortFunctions sorts functions across all schemas in dependency order diff --git a/internal/diff/topological_test.go b/internal/diff/topological_test.go index adc145a0..04f4ccfb 100644 --- a/internal/diff/topological_test.go +++ b/internal/diff/topological_test.go @@ -187,6 +187,66 @@ func TestTopologicallySortTypesCompositeWithMultipleDependencies(t *testing.T) { assertBefore("task", "project") } +func TestExtractTypeNameNormalizesQuoting(t *testing.T) { + // Post-#493 the inspector emits user-defined type references via + // quote_ident (schema-qualified), while typeMap keys are bare + // (Schema + "." + Name). extractTypeName must reconcile the two. + cases := []struct{ in, schema, want string }{ + {"user_kind", "public", "public.user_kind"}, // bare -> default schema + {"public.user_kind", "public", "public.user_kind"}, // already qualified + {`public."user"`, "public", "public.user"}, // quoted reserved-word name + {`"MySchema"."MyType"`, "public", "MySchema.MyType"}, // quoted schema + name + {"public.user_kind[]", "public", "public.user_kind"}, // array notation stripped + {`other."odd.name"`, "public", "other.odd.name"}, // dot inside quotes is not a separator + } + for _, c := range cases { + if got := extractTypeName(c.in, c.schema); got != c.want { + t.Errorf("extractTypeName(%q, %q) = %q, want %q", c.in, c.schema, got, c.want) + } + } +} + +func TestTopologicallySortTypesQualifiedQuotedRefs(t *testing.T) { + // The inspector now qualifies same-schema composite-attribute and domain + // base-type references (e.g. public."odd name"). Dependency edges must + // still be found against bare typeMap keys. + referenced := &ir.Type{ + Schema: "public", + Name: "odd name", // needs quote_ident + Kind: ir.TypeKindEnum, + EnumValues: []string{"a", "b"}, + } + composite := &ir.Type{ + Schema: "public", + Name: "holder", + Kind: ir.TypeKindComposite, + Columns: []*ir.TypeColumn{ + {Name: "c", DataType: `public."odd name"`, Position: 1}, + }, + } + dom := &ir.Type{ + Schema: "public", + Name: "odd domain", + Kind: ir.TypeKindDomain, + BaseType: `public."odd name"`, + } + + sorted := topologicallySortTypes([]*ir.Type{composite, referenced, dom}) + if len(sorted) != 3 { + t.Fatalf("expected 3 types, got %d", len(sorted)) + } + order := make(map[string]int, len(sorted)) + for idx, typ := range sorted { + order[typ.Name] = idx + } + if order["odd name"] >= order["holder"] { + t.Fatalf(`expected "odd name" before holder in %v`, order) + } + if order["odd name"] >= order["odd domain"] { + t.Fatalf(`expected "odd name" before "odd domain" in %v`, order) + } +} + func newTestEnumType(name string) *ir.Type { return &ir.Type{ Schema: "public", diff --git a/ir/queries/queries.sql b/ir/queries/queries.sql index 585153cd..40ea28f8 100644 --- a/ir/queries/queries.sql +++ b/ir/queries/queries.sql @@ -74,13 +74,9 @@ WITH column_base AS ( COALESCE(d.description, '') AS column_comment, CASE WHEN dt.typtype = 'd' THEN - CASE WHEN dn.nspname = c.table_schema THEN quote_ident(dt.typname) - ELSE quote_ident(dn.nspname) || '.' || quote_ident(dt.typname) - END + quote_ident(dn.nspname) || '.' || quote_ident(dt.typname) WHEN dt.typtype = 'e' OR dt.typtype = 'c' THEN - CASE WHEN dn.nspname = c.table_schema THEN quote_ident(dt.typname) - ELSE quote_ident(dn.nspname) || '.' || quote_ident(dt.typname) - END + quote_ident(dn.nspname) || '.' || quote_ident(dt.typname) WHEN dt.typtype = 'b' AND dt.typcategory = 'A' THEN -- Array types: apply same schema qualification logic to element type -- Use typcategory = 'A' rather than typelem <> 0; the latter is true @@ -193,13 +189,9 @@ WITH column_base AS ( COALESCE(d.description, '') AS column_comment, CASE WHEN dt.typtype = 'd' THEN - CASE WHEN dn.nspname = c.table_schema THEN quote_ident(dt.typname) - ELSE quote_ident(dn.nspname) || '.' || quote_ident(dt.typname) - END + quote_ident(dn.nspname) || '.' || quote_ident(dt.typname) WHEN dt.typtype = 'e' OR dt.typtype = 'c' THEN - CASE WHEN dn.nspname = c.table_schema THEN quote_ident(dt.typname) - ELSE quote_ident(dn.nspname) || '.' || quote_ident(dt.typname) - END + quote_ident(dn.nspname) || '.' || quote_ident(dt.typname) WHEN dt.typtype = 'b' AND dt.typcategory = 'A' THEN -- Array types: apply same schema qualification logic to element type -- Use typcategory = 'A' rather than typelem <> 0; the latter is true @@ -625,7 +617,11 @@ SELECT COALESCE(tf.proname, '') AS transition_function, COALESCE(tfn.nspname, '') AS transition_function_schema, -- Get state type - format_type(a.aggtranstype, NULL) AS state_type, + CASE + WHEN stn.nspname IS NULL OR stn.nspname = 'pg_catalog' OR stt.typcategory = 'A' THEN + format_type(a.aggtranstype, NULL) + ELSE quote_ident(stn.nspname) || '.' || quote_ident(stt.typname) + END AS state_type, -- Get initial condition a.agginitval AS initial_condition, -- Get final function if exists @@ -636,6 +632,8 @@ SELECT FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid JOIN pg_aggregate a ON a.aggfnoid = p.oid +LEFT JOIN pg_type stt ON stt.oid = a.aggtranstype +LEFT JOIN pg_namespace stn ON stt.typnamespace = stn.oid LEFT JOIN pg_proc tf ON a.aggtransfn = tf.oid LEFT JOIN pg_namespace tfn ON tf.pronamespace = tfn.oid LEFT JOIN pg_proc ff ON a.aggfinalfn = ff.oid @@ -714,11 +712,19 @@ SELECT t.typname AS type_name, a.attname AS column_name, a.attnum AS column_position, - format_type(a.atttypid, a.atttypmod) AS column_type + CASE + WHEN atn.nspname IS NULL OR atn.nspname = 'pg_catalog' OR at.typcategory = 'A' THEN + format_type(a.atttypid, a.atttypmod) + ELSE + quote_ident(atn.nspname) || '.' || quote_ident(at.typname) + || COALESCE(substring(format_type(a.atttypid, a.atttypmod) FROM '\([^)]*\)'), '') + END AS column_type FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid JOIN pg_class c ON t.typrelid = c.oid JOIN pg_attribute a ON c.oid = a.attrelid +LEFT JOIN pg_type at ON at.oid = a.atttypid +LEFT JOIN pg_namespace atn ON at.typnamespace = atn.oid WHERE t.typtype = 'c' -- composite types only AND c.relkind = 'c' -- only true composite types, not table types AND a.attnum > 0 -- exclude system columns @@ -892,12 +898,20 @@ ORDER BY n.nspname, c.relname, pol.polname; SELECT n.nspname AS domain_schema, t.typname AS domain_name, - format_type(t.typbasetype, t.typtypmod) AS base_type, + CASE + WHEN bn.nspname IS NULL OR bn.nspname = 'pg_catalog' OR bt.typcategory = 'A' THEN + format_type(t.typbasetype, t.typtypmod) + ELSE + quote_ident(bn.nspname) || '.' || quote_ident(bt.typname) + || COALESCE(substring(format_type(t.typbasetype, t.typtypmod) FROM '\([^)]*\)'), '') + END AS base_type, t.typnotnull AS not_null, t.typdefault AS default_value, COALESCE(d.description, '') AS domain_comment FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid +LEFT JOIN pg_type bt ON bt.oid = t.typbasetype +LEFT JOIN pg_namespace bn ON bt.typnamespace = bn.oid LEFT JOIN pg_description d ON d.objoid = t.oid AND d.classoid = 'pg_type'::regclass WHERE t.typtype = 'd' -- Domain types only AND n.nspname NOT IN ('information_schema', 'pg_catalog', 'pg_toast') @@ -1165,7 +1179,11 @@ SELECT -- Transition function and state CASE WHEN tfn.nspname = n.nspname THEN quote_ident(tf.proname) ELSE quote_ident(tfn.nspname) || '.' || quote_ident(tf.proname) END AS transition_function, - format_type(a.aggtranstype, NULL) AS state_type, + CASE + WHEN stn.nspname IS NULL OR stn.nspname = 'pg_catalog' OR stt.typcategory = 'A' THEN + format_type(a.aggtranstype, NULL) + ELSE quote_ident(stn.nspname) || '.' || quote_ident(stt.typname) + END AS state_type, a.aggtransspace AS state_space, a.agginitval AS initial_condition, -- Final function @@ -1191,7 +1209,11 @@ SELECT CASE WHEN a.aggminvtransfn = 0 THEN '' WHEN mitfn.nspname = n.nspname THEN quote_ident(mitf.proname) ELSE quote_ident(mitfn.nspname) || '.' || quote_ident(mitf.proname) END AS minv_transition_function, - CASE WHEN a.aggmtransfn = 0 THEN '' ELSE format_type(a.aggmtranstype, NULL) END AS mstate_type, + CASE WHEN a.aggmtransfn = 0 THEN '' + WHEN mstn.nspname IS NULL OR mstn.nspname = 'pg_catalog' OR mstt.typcategory = 'A' THEN + format_type(a.aggmtranstype, NULL) + ELSE quote_ident(mstn.nspname) || '.' || quote_ident(mstt.typname) + END AS mstate_type, a.aggmtransspace AS mstate_space, CASE WHEN a.aggmfinalfn = 0 THEN '' WHEN mffn.nspname = n.nspname THEN quote_ident(mff.proname) @@ -1207,6 +1229,8 @@ SELECT FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid JOIN pg_aggregate a ON a.aggfnoid = p.oid +LEFT JOIN pg_type stt ON stt.oid = a.aggtranstype +LEFT JOIN pg_namespace stn ON stt.typnamespace = stn.oid LEFT JOIN pg_proc tf ON a.aggtransfn = tf.oid LEFT JOIN pg_namespace tfn ON tf.pronamespace = tfn.oid LEFT JOIN pg_proc ff ON a.aggfinalfn = ff.oid @@ -1223,6 +1247,8 @@ LEFT JOIN pg_proc mitf ON a.aggminvtransfn = mitf.oid LEFT JOIN pg_namespace mitfn ON mitf.pronamespace = mitfn.oid LEFT JOIN pg_proc mff ON a.aggmfinalfn = mff.oid LEFT JOIN pg_namespace mffn ON mff.pronamespace = mffn.oid +LEFT JOIN pg_type mstt ON mstt.oid = a.aggmtranstype +LEFT JOIN pg_namespace mstn ON mstt.typnamespace = mstn.oid LEFT JOIN pg_operator op ON op.oid = a.aggsortop LEFT JOIN pg_namespace opn ON op.oprnamespace = opn.oid LEFT JOIN pg_description d ON d.objoid = p.oid AND d.classoid = 'pg_proc'::regclass AND d.objsubid = 0 @@ -1350,12 +1376,20 @@ ORDER BY n.nspname, t.typname; SELECT n.nspname AS domain_schema, t.typname AS domain_name, - format_type(t.typbasetype, t.typtypmod) AS base_type, + CASE + WHEN bn.nspname IS NULL OR bn.nspname = 'pg_catalog' OR bt.typcategory = 'A' THEN + format_type(t.typbasetype, t.typtypmod) + ELSE + quote_ident(bn.nspname) || '.' || quote_ident(bt.typname) + || COALESCE(substring(format_type(t.typbasetype, t.typtypmod) FROM '\([^)]*\)'), '') + END AS base_type, t.typnotnull AS not_null, t.typdefault AS default_value, COALESCE(d.description, '') AS domain_comment FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid +LEFT JOIN pg_type bt ON bt.oid = t.typbasetype +LEFT JOIN pg_namespace bn ON bt.typnamespace = bn.oid LEFT JOIN pg_description d ON d.objoid = t.oid AND d.classoid = 'pg_type'::regclass WHERE t.typtype = 'd' -- Domain types only AND n.nspname = $1 @@ -1395,11 +1429,19 @@ SELECT t.typname AS type_name, a.attname AS column_name, a.attnum AS column_position, - format_type(a.atttypid, a.atttypmod) AS column_type + CASE + WHEN atn.nspname IS NULL OR atn.nspname = 'pg_catalog' OR at.typcategory = 'A' THEN + format_type(a.atttypid, a.atttypmod) + ELSE + quote_ident(atn.nspname) || '.' || quote_ident(at.typname) + || COALESCE(substring(format_type(a.atttypid, a.atttypmod) FROM '\([^)]*\)'), '') + END AS column_type FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid JOIN pg_class c ON t.typrelid = c.oid JOIN pg_attribute a ON c.oid = a.attrelid +LEFT JOIN pg_type at ON at.oid = a.atttypid +LEFT JOIN pg_namespace atn ON at.typnamespace = atn.oid WHERE t.typtype = 'c' -- composite types only AND c.relkind = 'c' -- only true composite types, not table types AND a.attnum > 0 -- exclude system columns diff --git a/ir/queries/queries.sql.go b/ir/queries/queries.sql.go index 918599a8..f49a3af9 100644 --- a/ir/queries/queries.sql.go +++ b/ir/queries/queries.sql.go @@ -23,7 +23,11 @@ SELECT COALESCE(tf.proname, '') AS transition_function, COALESCE(tfn.nspname, '') AS transition_function_schema, -- Get state type - format_type(a.aggtranstype, NULL) AS state_type, + CASE + WHEN stn.nspname IS NULL OR stn.nspname = 'pg_catalog' OR stt.typcategory = 'A' THEN + format_type(a.aggtranstype, NULL) + ELSE quote_ident(stn.nspname) || '.' || quote_ident(stt.typname) + END AS state_type, -- Get initial condition a.agginitval AS initial_condition, -- Get final function if exists @@ -34,6 +38,8 @@ SELECT FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid JOIN pg_aggregate a ON a.aggfnoid = p.oid +LEFT JOIN pg_type stt ON stt.oid = a.aggtranstype +LEFT JOIN pg_namespace stn ON stt.typnamespace = stn.oid LEFT JOIN pg_proc tf ON a.aggtransfn = tf.oid LEFT JOIN pg_namespace tfn ON tf.pronamespace = tfn.oid LEFT JOIN pg_proc ff ON a.aggfinalfn = ff.oid @@ -114,7 +120,11 @@ SELECT -- Transition function and state CASE WHEN tfn.nspname = n.nspname THEN quote_ident(tf.proname) ELSE quote_ident(tfn.nspname) || '.' || quote_ident(tf.proname) END AS transition_function, - format_type(a.aggtranstype, NULL) AS state_type, + CASE + WHEN stn.nspname IS NULL OR stn.nspname = 'pg_catalog' OR stt.typcategory = 'A' THEN + format_type(a.aggtranstype, NULL) + ELSE quote_ident(stn.nspname) || '.' || quote_ident(stt.typname) + END AS state_type, a.aggtransspace AS state_space, a.agginitval AS initial_condition, -- Final function @@ -140,7 +150,11 @@ SELECT CASE WHEN a.aggminvtransfn = 0 THEN '' WHEN mitfn.nspname = n.nspname THEN quote_ident(mitf.proname) ELSE quote_ident(mitfn.nspname) || '.' || quote_ident(mitf.proname) END AS minv_transition_function, - CASE WHEN a.aggmtransfn = 0 THEN '' ELSE format_type(a.aggmtranstype, NULL) END AS mstate_type, + CASE WHEN a.aggmtransfn = 0 THEN '' + WHEN mstn.nspname IS NULL OR mstn.nspname = 'pg_catalog' OR mstt.typcategory = 'A' THEN + format_type(a.aggmtranstype, NULL) + ELSE quote_ident(mstn.nspname) || '.' || quote_ident(mstt.typname) + END AS mstate_type, a.aggmtransspace AS mstate_space, CASE WHEN a.aggmfinalfn = 0 THEN '' WHEN mffn.nspname = n.nspname THEN quote_ident(mff.proname) @@ -156,6 +170,8 @@ SELECT FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid JOIN pg_aggregate a ON a.aggfnoid = p.oid +LEFT JOIN pg_type stt ON stt.oid = a.aggtranstype +LEFT JOIN pg_namespace stn ON stt.typnamespace = stn.oid LEFT JOIN pg_proc tf ON a.aggtransfn = tf.oid LEFT JOIN pg_namespace tfn ON tf.pronamespace = tfn.oid LEFT JOIN pg_proc ff ON a.aggfinalfn = ff.oid @@ -172,6 +188,8 @@ LEFT JOIN pg_proc mitf ON a.aggminvtransfn = mitf.oid LEFT JOIN pg_namespace mitfn ON mitf.pronamespace = mitfn.oid LEFT JOIN pg_proc mff ON a.aggmfinalfn = mff.oid LEFT JOIN pg_namespace mffn ON mff.pronamespace = mffn.oid +LEFT JOIN pg_type mstt ON mstt.oid = a.aggmtranstype +LEFT JOIN pg_namespace mstn ON mstt.typnamespace = mstn.oid LEFT JOIN pg_operator op ON op.oid = a.aggsortop LEFT JOIN pg_namespace opn ON op.oprnamespace = opn.oid LEFT JOIN pg_description d ON d.objoid = p.oid AND d.classoid = 'pg_proc'::regclass AND d.objsubid = 0 @@ -350,13 +368,9 @@ WITH column_base AS ( COALESCE(d.description, '') AS column_comment, CASE WHEN dt.typtype = 'd' THEN - CASE WHEN dn.nspname = c.table_schema THEN quote_ident(dt.typname) - ELSE quote_ident(dn.nspname) || '.' || quote_ident(dt.typname) - END + quote_ident(dn.nspname) || '.' || quote_ident(dt.typname) WHEN dt.typtype = 'e' OR dt.typtype = 'c' THEN - CASE WHEN dn.nspname = c.table_schema THEN quote_ident(dt.typname) - ELSE quote_ident(dn.nspname) || '.' || quote_ident(dt.typname) - END + quote_ident(dn.nspname) || '.' || quote_ident(dt.typname) WHEN dt.typtype = 'b' AND dt.typcategory = 'A' THEN -- Array types: apply same schema qualification logic to element type -- Use typcategory = 'A' rather than typelem <> 0; the latter is true @@ -541,13 +555,9 @@ WITH column_base AS ( COALESCE(d.description, '') AS column_comment, CASE WHEN dt.typtype = 'd' THEN - CASE WHEN dn.nspname = c.table_schema THEN quote_ident(dt.typname) - ELSE quote_ident(dn.nspname) || '.' || quote_ident(dt.typname) - END + quote_ident(dn.nspname) || '.' || quote_ident(dt.typname) WHEN dt.typtype = 'e' OR dt.typtype = 'c' THEN - CASE WHEN dn.nspname = c.table_schema THEN quote_ident(dt.typname) - ELSE quote_ident(dn.nspname) || '.' || quote_ident(dt.typname) - END + quote_ident(dn.nspname) || '.' || quote_ident(dt.typname) WHEN dt.typtype = 'b' AND dt.typcategory = 'A' THEN -- Array types: apply same schema qualification logic to element type -- Use typcategory = 'A' rather than typelem <> 0; the latter is true @@ -732,11 +742,19 @@ SELECT t.typname AS type_name, a.attname AS column_name, a.attnum AS column_position, - format_type(a.atttypid, a.atttypmod) AS column_type + CASE + WHEN atn.nspname IS NULL OR atn.nspname = 'pg_catalog' OR at.typcategory = 'A' THEN + format_type(a.atttypid, a.atttypmod) + ELSE + quote_ident(atn.nspname) || '.' || quote_ident(at.typname) + || COALESCE(substring(format_type(a.atttypid, a.atttypmod) FROM '\([^)]*\)'), '') + END AS column_type FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid JOIN pg_class c ON t.typrelid = c.oid JOIN pg_attribute a ON c.oid = a.attrelid +LEFT JOIN pg_type at ON at.oid = a.atttypid +LEFT JOIN pg_namespace atn ON at.typnamespace = atn.oid WHERE t.typtype = 'c' -- composite types only AND c.relkind = 'c' -- only true composite types, not table types AND a.attnum > 0 -- exclude system columns @@ -791,11 +809,19 @@ SELECT t.typname AS type_name, a.attname AS column_name, a.attnum AS column_position, - format_type(a.atttypid, a.atttypmod) AS column_type + CASE + WHEN atn.nspname IS NULL OR atn.nspname = 'pg_catalog' OR at.typcategory = 'A' THEN + format_type(a.atttypid, a.atttypmod) + ELSE + quote_ident(atn.nspname) || '.' || quote_ident(at.typname) + || COALESCE(substring(format_type(a.atttypid, a.atttypmod) FROM '\([^)]*\)'), '') + END AS column_type FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid JOIN pg_class c ON t.typrelid = c.oid JOIN pg_attribute a ON c.oid = a.attrelid +LEFT JOIN pg_type at ON at.oid = a.atttypid +LEFT JOIN pg_namespace atn ON at.typnamespace = atn.oid WHERE t.typtype = 'c' -- composite types only AND c.relkind = 'c' -- only true composite types, not table types AND a.attnum > 0 -- exclude system columns @@ -1297,12 +1323,20 @@ const getDomains = `-- name: GetDomains :many SELECT n.nspname AS domain_schema, t.typname AS domain_name, - format_type(t.typbasetype, t.typtypmod) AS base_type, + CASE + WHEN bn.nspname IS NULL OR bn.nspname = 'pg_catalog' OR bt.typcategory = 'A' THEN + format_type(t.typbasetype, t.typtypmod) + ELSE + quote_ident(bn.nspname) || '.' || quote_ident(bt.typname) + || COALESCE(substring(format_type(t.typbasetype, t.typtypmod) FROM '\([^)]*\)'), '') + END AS base_type, t.typnotnull AS not_null, t.typdefault AS default_value, COALESCE(d.description, '') AS domain_comment FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid +LEFT JOIN pg_type bt ON bt.oid = t.typbasetype +LEFT JOIN pg_namespace bn ON bt.typnamespace = bn.oid LEFT JOIN pg_description d ON d.objoid = t.oid AND d.classoid = 'pg_type'::regclass WHERE t.typtype = 'd' -- Domain types only AND n.nspname NOT IN ('information_schema', 'pg_catalog', 'pg_toast') @@ -1355,12 +1389,20 @@ const getDomainsForSchema = `-- name: GetDomainsForSchema :many SELECT n.nspname AS domain_schema, t.typname AS domain_name, - format_type(t.typbasetype, t.typtypmod) AS base_type, + CASE + WHEN bn.nspname IS NULL OR bn.nspname = 'pg_catalog' OR bt.typcategory = 'A' THEN + format_type(t.typbasetype, t.typtypmod) + ELSE + quote_ident(bn.nspname) || '.' || quote_ident(bt.typname) + || COALESCE(substring(format_type(t.typbasetype, t.typtypmod) FROM '\([^)]*\)'), '') + END AS base_type, t.typnotnull AS not_null, t.typdefault AS default_value, COALESCE(d.description, '') AS domain_comment FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid +LEFT JOIN pg_type bt ON bt.oid = t.typbasetype +LEFT JOIN pg_namespace bn ON bt.typnamespace = bn.oid LEFT JOIN pg_description d ON d.objoid = t.oid AND d.classoid = 'pg_type'::regclass WHERE t.typtype = 'd' -- Domain types only AND n.nspname = $1 From dbae4657f7166821ccef84adcd74b1a9d143700c Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Tue, 21 Jul 2026 01:38:06 +0800 Subject: [PATCH 2/5] fix(dump): handle quoted target schema + regenerate plan fingerprints (#493) Addresses CI failures and review feedback on the same-schema type-reference qualification (Mechanism A). - stripSchemaPrefixMode: the inspector emits schema prefixes via quote_ident, so for a target schema whose name requires quoting (e.g. "My Schema") the raw-prefix strip missed the quoted form and default output leaked the qualification. Strip both the raw and quote_ident forms (and the ::cast form). Regression test added. - topological type sort: replace the ambiguous "schema.name" graph key with a NUL-delimited typeGraphKey so legal-but-dotted identifiers (schema "a.b" type "c" vs schema "a" type "b.c") never collide. Collision test added. - Regenerate three plan.json fixtures whose source_fingerprint changed: the fingerprint hashes the serialized IR, which now stores qualified type strings, so schemas containing same-schema user-defined type references get a new hash. The plan DDL is byte-identical; only the hash line changed. Verified: internal/diff and TestPlanAndApply pass; dump fixtures pass (one CI/local flake was embedded-postgres startup port contention, unrelated). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/diff/qualify_schema_test.go | 23 +++++++++++++ internal/diff/table.go | 34 +++++++++++++------ internal/diff/topological.go | 25 +++++++++----- internal/diff/topological_test.go | 30 ++++++++++------ .../plan.json | 2 +- .../issue_399_schema_qualified_body/plan.json | 2 +- .../diff/online/add_partial_index/plan.json | 2 +- 7 files changed, 85 insertions(+), 33 deletions(-) diff --git a/internal/diff/qualify_schema_test.go b/internal/diff/qualify_schema_test.go index 92fd6399..dc882a74 100644 --- a/internal/diff/qualify_schema_test.go +++ b/internal/diff/qualify_schema_test.go @@ -82,6 +82,29 @@ func TestQualifySchema_TableAndColumnType(t *testing.T) { } } +func TestStripSchemaPrefixMode_QuotedTargetSchema(t *testing.T) { + // The inspector qualifies same-schema type references via quote_ident, so a + // target schema whose name requires quoting arrives as "My Schema".user_kind. + // Default (smart-qualification) mode must still strip it; forced + // qualification keeps it. (Regression: previously only the raw, unquoted + // prefix was stripped, so quoted target schemas leaked into default output.) + const typeRef = `"My Schema".user_kind` + if got := stripSchemaPrefixMode(typeRef, "My Schema", false); got != "user_kind" { + t.Errorf("default should strip quoted target-schema prefix: got %q", got) + } + if got := stripSchemaPrefixMode(typeRef, "My Schema", true); got != typeRef { + t.Errorf("forced qualification should keep the ref: got %q", got) + } + // Cast form (e.g. a parameter default) with a quoted schema. + if got := stripSchemaPrefixMode(`'a'::"My Schema".mood`, "My Schema", false); got != `'a'::mood` { + t.Errorf("default should strip quoted schema in cast: got %q", got) + } + // Lowercase schema (no quoting required) is unaffected. + if got := stripSchemaPrefixMode("public.user_kind", "public", false); got != "user_kind" { + t.Errorf("default should strip unquoted prefix: got %q", got) + } +} + func TestQualifySchema_Type(t *testing.T) { typ := &ir.Type{ Schema: "public", diff --git a/internal/diff/table.go b/internal/diff/table.go index 405cf855..97b28f75 100644 --- a/internal/diff/table.go +++ b/internal/diff/table.go @@ -25,17 +25,29 @@ func stripSchemaPrefixMode(typeName, targetSchema string, qualifySchema bool) st return typeName } - // Check if the type has the target schema prefix at the beginning - prefix := targetSchema + "." - if after, found := strings.CutPrefix(typeName, prefix); found { - return after - } - - // Also handle type casts within expressions: ::schema.typename -> ::typename - // This is needed for function parameter default values like 'value'::schema.enum_type - castPrefix := "::" + targetSchema + "." - if strings.Contains(typeName, castPrefix) { - return strings.ReplaceAll(typeName, castPrefix, "::") + // The inspector emits schema prefixes on type references via quote_ident, so + // the prefix is quoted when the schema name requires it (e.g. "My Schema"). + // Strip both the raw and quote_ident forms so default (smart-qualification) + // output omits the target-schema prefix regardless of whether it needs quoting. + // QuoteIdentifier is a no-op for names that don't require quoting, so the two + // forms coincide for the common lowercase case. + forms := []string{targetSchema} + if quoted := ir.QuoteIdentifier(targetSchema); quoted != targetSchema { + forms = append(forms, quoted) + } + for _, schema := range forms { + // Check if the type has the target schema prefix at the beginning + prefix := schema + "." + if after, found := strings.CutPrefix(typeName, prefix); found { + return after + } + + // Also handle type casts within expressions: ::schema.typename -> ::typename + // This is needed for function parameter default values like 'value'::schema.enum_type + castPrefix := "::" + schema + "." + if strings.Contains(typeName, castPrefix) { + return strings.ReplaceAll(typeName, castPrefix, "::") + } } return typeName diff --git a/internal/diff/topological.go b/internal/diff/topological.go index f1fb79e0..d61a7389 100644 --- a/internal/diff/topological.go +++ b/internal/diff/topological.go @@ -265,7 +265,7 @@ func topologicallySortTypes(types []*ir.Type) []*ir.Type { typeMap := make(map[string]*ir.Type) var insertionOrder []string for _, t := range types { - key := t.Schema + "." + t.Name + key := typeGraphKey(t.Schema, t.Name) typeMap[key] = t insertionOrder = append(insertionOrder, key) } @@ -387,8 +387,18 @@ func topologicallySortTypes(types []*ir.Type) []*ir.Type { return sortedTypes } -// extractTypeName extracts a fully qualified type name from a data type string -// It handles array notation (e.g., "status_type[]") and schema prefixes +// typeGraphKey builds the lookup key used to identify a type in the dependency +// graph. It joins schema and name with a NUL byte, which cannot appear in a +// PostgreSQL identifier, so distinct (schema, name) pairs never collide even when +// an identifier legally contains a '.' (e.g. schema "a.b" type "c" vs schema "a" +// type "b.c"). +func typeGraphKey(schema, name string) string { + return schema + "\x00" + name +} + +// extractTypeName maps a data type string to its dependency-graph key +// (see typeGraphKey). It handles array notation (e.g., "status_type[]") and +// schema prefixes. func extractTypeName(dataType, defaultSchema string) string { if dataType == "" { return "" @@ -401,15 +411,14 @@ func extractTypeName(dataType, defaultSchema string) string { } // Check if it's a schema-qualified name. The inspector emits user-defined - // type references via quote_ident (e.g. public."user"), but typeMap keys are - // built from bare, unquoted identifiers (Schema + "." + Name), so normalize - // each component by stripping quote_ident quoting before returning. + // type references via quote_ident (e.g. public."user"), so normalize each + // component by stripping quote_ident quoting before building the key. if idx := findLastUnquotedDot(typeName); idx != -1 { - return unquoteIdent(typeName[:idx]) + "." + unquoteIdent(typeName[idx+1:]) + return typeGraphKey(unquoteIdent(typeName[:idx]), unquoteIdent(typeName[idx+1:])) } // Not qualified - use default schema - return unquoteIdent(defaultSchema) + "." + unquoteIdent(typeName) + return typeGraphKey(unquoteIdent(defaultSchema), unquoteIdent(typeName)) } // findLastUnquotedDot finds the last '.' that is not inside a double-quoted diff --git a/internal/diff/topological_test.go b/internal/diff/topological_test.go index 04f4ccfb..86557f77 100644 --- a/internal/diff/topological_test.go +++ b/internal/diff/topological_test.go @@ -189,21 +189,29 @@ func TestTopologicallySortTypesCompositeWithMultipleDependencies(t *testing.T) { func TestExtractTypeNameNormalizesQuoting(t *testing.T) { // Post-#493 the inspector emits user-defined type references via - // quote_ident (schema-qualified), while typeMap keys are bare - // (Schema + "." + Name). extractTypeName must reconcile the two. - cases := []struct{ in, schema, want string }{ - {"user_kind", "public", "public.user_kind"}, // bare -> default schema - {"public.user_kind", "public", "public.user_kind"}, // already qualified - {`public."user"`, "public", "public.user"}, // quoted reserved-word name - {`"MySchema"."MyType"`, "public", "MySchema.MyType"}, // quoted schema + name - {"public.user_kind[]", "public", "public.user_kind"}, // array notation stripped - {`other."odd.name"`, "public", "other.odd.name"}, // dot inside quotes is not a separator + // quote_ident (schema-qualified). extractTypeName must normalize them to the + // same delimiter-safe key that typeGraphKey builds for typeMap entries. + cases := []struct{ in, schema, wantSchema, wantName string }{ + {"user_kind", "public", "public", "user_kind"}, // bare -> default schema + {"public.user_kind", "public", "public", "user_kind"}, // already qualified + {`public."user"`, "public", "public", "user"}, // quoted reserved-word name + {`"MySchema"."MyType"`, "public", "MySchema", "MyType"}, // quoted schema + name + {"public.user_kind[]", "public", "public", "user_kind"}, // array notation stripped + {`other."odd.name"`, "public", "other", "odd.name"}, // dot inside quotes is not a separator + {`"a.b".c`, "public", "a.b", "c"}, // dotted schema, bare type } for _, c := range cases { - if got := extractTypeName(c.in, c.schema); got != c.want { - t.Errorf("extractTypeName(%q, %q) = %q, want %q", c.in, c.schema, got, c.want) + want := typeGraphKey(c.wantSchema, c.wantName) + if got := extractTypeName(c.in, c.schema); got != want { + t.Errorf("extractTypeName(%q, %q) = %q, want %q", c.in, c.schema, got, want) } } + + // The delimiter-safe key keeps legal-but-dotted identifiers distinct: + // schema "a.b" type "c" must not collide with schema "a" type "b.c". + if k1, k2 := extractTypeName(`"a.b".c`, "public"), extractTypeName(`a."b.c"`, "public"); k1 == k2 { + t.Errorf("dotted identifiers collided: both keyed as %q", k1) + } } func TestTopologicallySortTypesQualifiedQuotedRefs(t *testing.T) { diff --git a/testdata/diff/create_function/issue_360_returns_table_custom_type/plan.json b/testdata/diff/create_function/issue_360_returns_table_custom_type/plan.json index 2bf5c377..d31a8057 100644 --- a/testdata/diff/create_function/issue_360_returns_table_custom_type/plan.json +++ b/testdata/diff/create_function/issue_360_returns_table_custom_type/plan.json @@ -3,7 +3,7 @@ "pgschema_version": "1.12.0", "created_at": "1970-01-01T00:00:00Z", "source_fingerprint": { - "hash": "bc4fc478f2d7ae4cc204de3447d992dface8f485a9227504fed99b21817cb888" + "hash": "2469dd87b8bff74b211d0c95a21c6921979bbc2098ae9b32ea391cdbdbd41c13" }, "groups": null } diff --git a/testdata/diff/create_function/issue_399_schema_qualified_body/plan.json b/testdata/diff/create_function/issue_399_schema_qualified_body/plan.json index bfc18d43..32d314b9 100644 --- a/testdata/diff/create_function/issue_399_schema_qualified_body/plan.json +++ b/testdata/diff/create_function/issue_399_schema_qualified_body/plan.json @@ -3,7 +3,7 @@ "pgschema_version": "1.12.0", "created_at": "1970-01-01T00:00:00Z", "source_fingerprint": { - "hash": "eb148b37b7b6325bdd5f0c1c120dfe0bd71a062ce69951aa946c452aff2dc662" + "hash": "6fedb719a3222c246e84fb6e5e84961e3041c6da2c767ce83d868d8631fb5ac7" }, "groups": [ { diff --git a/testdata/diff/online/add_partial_index/plan.json b/testdata/diff/online/add_partial_index/plan.json index c62fcb07..d0180319 100644 --- a/testdata/diff/online/add_partial_index/plan.json +++ b/testdata/diff/online/add_partial_index/plan.json @@ -3,7 +3,7 @@ "pgschema_version": "1.12.0", "created_at": "1970-01-01T00:00:00Z", "source_fingerprint": { - "hash": "d3818157c6994cfe669982e5174d7e36ada8c106bb8d340c392c7cbe64ebc135" + "hash": "1e4c207c91e5defc052872fd14ab387c061b0cbe470188ccee38f24bc2cfe149" }, "groups": [ { From 246373420a9dd07e6da673bc6c23de99da285706 Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Tue, 21 Jul 2026 02:00:37 +0800 Subject: [PATCH 3/5] fix(diff): strip typmod in type-dependency key matching (#493) Follow-up review hardening for extractTypeName: - Strip a trailing numeric typmod suffix (e.g. "(384)", "(10,2)") before building the dependency-graph key, so a qualified reference the inspector emits with typmod still matches the bare typeMap key. Conservative: only a digits/comma/space typmod is stripped, so a quoted identifier that legally contains parentheses (ends in '"', not ')') is left intact. In practice typmod is only appended to built-in/extension base types (never enum/ composite/domain typeMap entries), but this makes the matching robust. - Add test coverage for the typmod cases and for an escaped-quote ("") inside a quoted identifier, confirming findLastUnquotedDot's parity handling treats a dot inside such an identifier as non-separating. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/diff/topological.go | 14 ++++++++++++++ internal/diff/topological_test.go | 3 +++ 2 files changed, 17 insertions(+) diff --git a/internal/diff/topological.go b/internal/diff/topological.go index d61a7389..f629d8f1 100644 --- a/internal/diff/topological.go +++ b/internal/diff/topological.go @@ -410,6 +410,20 @@ func extractTypeName(dataType, defaultSchema string) string { typeName = typeName[:len(typeName)-2] } + // Strip a trailing typmod suffix (e.g. "(10,2)") that the inspector appends + // when qualifying user-defined scalar types, so it matches the bare typeMap + // key. Only a purely numeric/comma/space typmod is stripped, so a quoted + // identifier that legally contains parentheses (which ends in '"', not ')') + // is left intact. + if i := strings.LastIndexByte(typeName, '('); i > 0 && strings.HasSuffix(typeName, ")") { + inner := typeName[i+1 : len(typeName)-1] + if inner != "" && strings.IndexFunc(inner, func(r rune) bool { + return !(r >= '0' && r <= '9' || r == ',' || r == ' ') + }) == -1 { + typeName = typeName[:i] + } + } + // Check if it's a schema-qualified name. The inspector emits user-defined // type references via quote_ident (e.g. public."user"), so normalize each // component by stripping quote_ident quoting before building the key. diff --git a/internal/diff/topological_test.go b/internal/diff/topological_test.go index 86557f77..98d47c63 100644 --- a/internal/diff/topological_test.go +++ b/internal/diff/topological_test.go @@ -199,6 +199,9 @@ func TestExtractTypeNameNormalizesQuoting(t *testing.T) { {"public.user_kind[]", "public", "public", "user_kind"}, // array notation stripped {`other."odd.name"`, "public", "other", "odd.name"}, // dot inside quotes is not a separator {`"a.b".c`, "public", "a.b", "c"}, // dotted schema, bare type + {"public.vector(384)", "public", "public", "vector"}, // typmod suffix stripped + {"s.num(10, 2)", "public", "s", "num"}, // typmod with comma/space stripped + {`a."b"".c"`, "public", "a", `b".c`}, // escaped quote ("") + dot inside a quoted ident } for _, c := range cases { want := typeGraphKey(c.wantSchema, c.wantName) From 0f5b5ddd3e7e9bce13523864483ab5fac3281c62 Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Tue, 21 Jul 2026 02:27:55 +0800 Subject: [PATCH 4/5] fix(dump): qualify same-schema array element types (#493) The column resolved_type array branch still emitted same-schema user-defined element types unqualified (WHEN en.nspname = c.table_schema THEN quote_ident(et.typname)), so a column like color[] (enum/composite/ domain array in the same schema) arrived in the IR without schema identity and dump --qualify-schema could not qualify it. Remove the same-schema case so non-pg_catalog element types are always qualified, mirroring the scalar domain/enum/composite branches; pg_catalog element types stay bare. Default output is unchanged because stripSchemaPrefixMode strips the target schema. Extend the dump integration test with a same-schema array column asserting "shades public.color[]" under --qualify-schema and "shades color[]" by default. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/dump/qualify_schema_integration_test.go | 9 ++++++--- ir/queries/queries.sql | 2 -- ir/queries/queries.sql.go | 2 -- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/cmd/dump/qualify_schema_integration_test.go b/cmd/dump/qualify_schema_integration_test.go index 94252d14..456a181c 100644 --- a/cmd/dump/qualify_schema_integration_test.go +++ b/cmd/dump/qualify_schema_integration_test.go @@ -20,10 +20,11 @@ import ( const qualifySchemaSetupSQL = ` CREATE TYPE color AS ENUM ('r', 'g', 'b'); --- column type: same-schema enum +-- column type: same-schema enum (scalar and array) CREATE TABLE swatch ( - id integer PRIMARY KEY, - shade color + id integer PRIMARY KEY, + shade color, + shades color[] ); -- domain base type: same-schema enum CREATE DOMAIN color_domain AS color; @@ -75,6 +76,7 @@ func TestDumpCommand_QualifySchemaTypeReferences(t *testing.T) { } for _, want := range []string{ "shade public.color", // column type + "shades public.color[]", // same-schema array element type "AS public.color", // domain base type (CREATE DOMAIN ... AS public.color) "currency public.color", // composite attribute "STYPE = public.acc", // aggregate state type @@ -98,6 +100,7 @@ func TestDumpCommand_QualifySchemaTypeReferences(t *testing.T) { } for _, want := range []string{ "shade color", + "shades color[]", "AS color", "currency color", "STYPE = acc", diff --git a/ir/queries/queries.sql b/ir/queries/queries.sql index 40ea28f8..c4828fa0 100644 --- a/ir/queries/queries.sql +++ b/ir/queries/queries.sql @@ -84,7 +84,6 @@ WITH column_base AS ( -- Use format_type to preserve typmod for element types (e.g., varchar(128)[] for character varying(128)[]) CASE WHEN en.nspname = 'pg_catalog' THEN et.typname - WHEN en.nspname = c.table_schema THEN quote_ident(et.typname) ELSE quote_ident(en.nspname) || '.' || quote_ident(et.typname) END || COALESCE(substring(format_type(a.atttypid, a.atttypmod) FROM '\([^)]*\)'), '') || '[]' WHEN dt.typtype = 'b' THEN @@ -199,7 +198,6 @@ WITH column_base AS ( -- Use format_type to preserve typmod for element types (e.g., varchar(128)[] for character varying(128)[]) CASE WHEN en.nspname = 'pg_catalog' THEN et.typname - WHEN en.nspname = c.table_schema THEN quote_ident(et.typname) ELSE quote_ident(en.nspname) || '.' || quote_ident(et.typname) END || COALESCE(substring(format_type(a.atttypid, a.atttypmod) FROM '\([^)]*\)'), '') || '[]' WHEN dt.typtype = 'b' THEN diff --git a/ir/queries/queries.sql.go b/ir/queries/queries.sql.go index f49a3af9..33ef353e 100644 --- a/ir/queries/queries.sql.go +++ b/ir/queries/queries.sql.go @@ -378,7 +378,6 @@ WITH column_base AS ( -- Use format_type to preserve typmod for element types (e.g., varchar(128)[] for character varying(128)[]) CASE WHEN en.nspname = 'pg_catalog' THEN et.typname - WHEN en.nspname = c.table_schema THEN quote_ident(et.typname) ELSE quote_ident(en.nspname) || '.' || quote_ident(et.typname) END || COALESCE(substring(format_type(a.atttypid, a.atttypmod) FROM '\([^)]*\)'), '') || '[]' WHEN dt.typtype = 'b' THEN @@ -565,7 +564,6 @@ WITH column_base AS ( -- Use format_type to preserve typmod for element types (e.g., varchar(128)[] for character varying(128)[]) CASE WHEN en.nspname = 'pg_catalog' THEN et.typname - WHEN en.nspname = c.table_schema THEN quote_ident(et.typname) ELSE quote_ident(en.nspname) || '.' || quote_ident(et.typname) END || COALESCE(substring(format_type(a.atttypid, a.atttypmod) FROM '\([^)]*\)'), '') || '[]' WHEN dt.typtype = 'b' THEN From e6e4043274540e04ae8f1106636290ea63fda860 Mon Sep 17 00:00:00 2001 From: tianzhou Date: Tue, 21 Jul 2026 22:04:40 -0700 Subject: [PATCH 5/5] fix(dump): qualify same-schema user-defined array types in domains, composites, aggregates (#493) The domain base type, composite attribute, and aggregate STYPE/MSTYPE queries used format_type() for array types, which drops the schema for same-schema user-defined element types (e.g. color[]). Replace the format_type fallback with explicit element-type joins so --qualify-schema can qualify them. Extends the integration test with array variants for domain base type (color_list AS color[]) and composite attribute (palette color[]). Co-Authored-By: Claude Opus 4.6 --- cmd/dump/qualify_schema_integration_test.go | 24 ++++--- ir/queries/queries.sql | 63 ++++++++++++++++--- ir/queries/queries.sql.go | 69 ++++++++++++++++++--- 3 files changed, 130 insertions(+), 26 deletions(-) diff --git a/cmd/dump/qualify_schema_integration_test.go b/cmd/dump/qualify_schema_integration_test.go index 456a181c..62cb279b 100644 --- a/cmd/dump/qualify_schema_integration_test.go +++ b/cmd/dump/qualify_schema_integration_test.go @@ -26,12 +26,14 @@ CREATE TABLE swatch ( shade color, shades color[] ); --- domain base type: same-schema enum +-- domain base type: same-schema enum (scalar and array) CREATE DOMAIN color_domain AS color; --- composite attribute: same-schema enum (plus a built-in that must stay bare) +CREATE DOMAIN color_list AS color[]; +-- composite attribute: same-schema enum (scalar and array, plus a built-in that must stay bare) CREATE TYPE money_amount AS ( amount numeric, - currency color + currency color, + palette color[] ); -- aggregate state type: same-schema composite CREATE TYPE acc AS (n integer); @@ -75,11 +77,13 @@ func TestDumpCommand_QualifySchemaTypeReferences(t *testing.T) { t.Fatalf("qualified dump failed: %v", err) } for _, want := range []string{ - "shade public.color", // column type - "shades public.color[]", // same-schema array element type - "AS public.color", // domain base type (CREATE DOMAIN ... AS public.color) - "currency public.color", // composite attribute - "STYPE = public.acc", // aggregate state type + "shade public.color", // column type + "shades public.color[]", // column type: same-schema array + "AS public.color;", // domain base type: scalar (CREATE DOMAIN ... AS public.color) + "AS public.color[]", // domain base type: array (CREATE DOMAIN ... AS public.color[]) + "currency public.color", // composite attribute: scalar + "palette public.color[]", // composite attribute: array + "STYPE = public.acc", // aggregate state type } { if !strings.Contains(qualified, want) { t.Errorf("qualified dump missing %q\n---\n%s", want, qualified) @@ -101,8 +105,10 @@ func TestDumpCommand_QualifySchemaTypeReferences(t *testing.T) { for _, want := range []string{ "shade color", "shades color[]", - "AS color", + "AS color;", // domain base type: scalar + "AS color[]", // domain base type: array "currency color", + "palette color[]", "STYPE = acc", } { if !strings.Contains(def, want) { diff --git a/ir/queries/queries.sql b/ir/queries/queries.sql index c4828fa0..213bea58 100644 --- a/ir/queries/queries.sql +++ b/ir/queries/queries.sql @@ -616,8 +616,13 @@ SELECT COALESCE(tfn.nspname, '') AS transition_function_schema, -- Get state type CASE - WHEN stn.nspname IS NULL OR stn.nspname = 'pg_catalog' OR stt.typcategory = 'A' THEN + WHEN stn.nspname IS NULL OR stn.nspname = 'pg_catalog' THEN format_type(a.aggtranstype, NULL) + WHEN stt.typcategory = 'A' THEN + CASE + WHEN sten.nspname = 'pg_catalog' THEN stet.typname + ELSE quote_ident(sten.nspname) || '.' || quote_ident(stet.typname) + END || COALESCE(substring(format_type(a.aggtranstype, NULL) FROM '\([^)]*\)'), '') || '[]' ELSE quote_ident(stn.nspname) || '.' || quote_ident(stt.typname) END AS state_type, -- Get initial condition @@ -632,6 +637,8 @@ JOIN pg_namespace n ON p.pronamespace = n.oid JOIN pg_aggregate a ON a.aggfnoid = p.oid LEFT JOIN pg_type stt ON stt.oid = a.aggtranstype LEFT JOIN pg_namespace stn ON stt.typnamespace = stn.oid +LEFT JOIN pg_type stet ON stt.typelem = stet.oid +LEFT JOIN pg_namespace sten ON stet.typnamespace = sten.oid LEFT JOIN pg_proc tf ON a.aggtransfn = tf.oid LEFT JOIN pg_namespace tfn ON tf.pronamespace = tfn.oid LEFT JOIN pg_proc ff ON a.aggfinalfn = ff.oid @@ -711,8 +718,13 @@ SELECT a.attname AS column_name, a.attnum AS column_position, CASE - WHEN atn.nspname IS NULL OR atn.nspname = 'pg_catalog' OR at.typcategory = 'A' THEN + WHEN atn.nspname IS NULL OR atn.nspname = 'pg_catalog' THEN format_type(a.atttypid, a.atttypmod) + WHEN at.typcategory = 'A' THEN + CASE + WHEN aen.nspname = 'pg_catalog' THEN aet.typname + ELSE quote_ident(aen.nspname) || '.' || quote_ident(aet.typname) + END || COALESCE(substring(format_type(a.atttypid, a.atttypmod) FROM '\([^)]*\)'), '') || '[]' ELSE quote_ident(atn.nspname) || '.' || quote_ident(at.typname) || COALESCE(substring(format_type(a.atttypid, a.atttypmod) FROM '\([^)]*\)'), '') @@ -723,6 +735,8 @@ JOIN pg_class c ON t.typrelid = c.oid JOIN pg_attribute a ON c.oid = a.attrelid LEFT JOIN pg_type at ON at.oid = a.atttypid LEFT JOIN pg_namespace atn ON at.typnamespace = atn.oid +LEFT JOIN pg_type aet ON at.typelem = aet.oid +LEFT JOIN pg_namespace aen ON aet.typnamespace = aen.oid WHERE t.typtype = 'c' -- composite types only AND c.relkind = 'c' -- only true composite types, not table types AND a.attnum > 0 -- exclude system columns @@ -897,8 +911,13 @@ SELECT n.nspname AS domain_schema, t.typname AS domain_name, CASE - WHEN bn.nspname IS NULL OR bn.nspname = 'pg_catalog' OR bt.typcategory = 'A' THEN + WHEN bn.nspname IS NULL OR bn.nspname = 'pg_catalog' THEN format_type(t.typbasetype, t.typtypmod) + WHEN bt.typcategory = 'A' THEN + CASE + WHEN ben.nspname = 'pg_catalog' THEN bet.typname + ELSE quote_ident(ben.nspname) || '.' || quote_ident(bet.typname) + END || COALESCE(substring(format_type(t.typbasetype, t.typtypmod) FROM '\([^)]*\)'), '') || '[]' ELSE quote_ident(bn.nspname) || '.' || quote_ident(bt.typname) || COALESCE(substring(format_type(t.typbasetype, t.typtypmod) FROM '\([^)]*\)'), '') @@ -910,6 +929,8 @@ FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid LEFT JOIN pg_type bt ON bt.oid = t.typbasetype LEFT JOIN pg_namespace bn ON bt.typnamespace = bn.oid +LEFT JOIN pg_type bet ON bt.typelem = bet.oid +LEFT JOIN pg_namespace ben ON bet.typnamespace = ben.oid LEFT JOIN pg_description d ON d.objoid = t.oid AND d.classoid = 'pg_type'::regclass WHERE t.typtype = 'd' -- Domain types only AND n.nspname NOT IN ('information_schema', 'pg_catalog', 'pg_toast') @@ -1178,8 +1199,13 @@ SELECT CASE WHEN tfn.nspname = n.nspname THEN quote_ident(tf.proname) ELSE quote_ident(tfn.nspname) || '.' || quote_ident(tf.proname) END AS transition_function, CASE - WHEN stn.nspname IS NULL OR stn.nspname = 'pg_catalog' OR stt.typcategory = 'A' THEN + WHEN stn.nspname IS NULL OR stn.nspname = 'pg_catalog' THEN format_type(a.aggtranstype, NULL) + WHEN stt.typcategory = 'A' THEN + CASE + WHEN sten.nspname = 'pg_catalog' THEN stet.typname + ELSE quote_ident(sten.nspname) || '.' || quote_ident(stet.typname) + END || COALESCE(substring(format_type(a.aggtranstype, NULL) FROM '\([^)]*\)'), '') || '[]' ELSE quote_ident(stn.nspname) || '.' || quote_ident(stt.typname) END AS state_type, a.aggtransspace AS state_space, @@ -1208,8 +1234,13 @@ SELECT WHEN mitfn.nspname = n.nspname THEN quote_ident(mitf.proname) ELSE quote_ident(mitfn.nspname) || '.' || quote_ident(mitf.proname) END AS minv_transition_function, CASE WHEN a.aggmtransfn = 0 THEN '' - WHEN mstn.nspname IS NULL OR mstn.nspname = 'pg_catalog' OR mstt.typcategory = 'A' THEN + WHEN mstn.nspname IS NULL OR mstn.nspname = 'pg_catalog' THEN format_type(a.aggmtranstype, NULL) + WHEN mstt.typcategory = 'A' THEN + CASE + WHEN msten.nspname = 'pg_catalog' THEN mstet.typname + ELSE quote_ident(msten.nspname) || '.' || quote_ident(mstet.typname) + END || COALESCE(substring(format_type(a.aggmtranstype, NULL) FROM '\([^)]*\)'), '') || '[]' ELSE quote_ident(mstn.nspname) || '.' || quote_ident(mstt.typname) END AS mstate_type, a.aggmtransspace AS mstate_space, @@ -1229,6 +1260,8 @@ JOIN pg_namespace n ON p.pronamespace = n.oid JOIN pg_aggregate a ON a.aggfnoid = p.oid LEFT JOIN pg_type stt ON stt.oid = a.aggtranstype LEFT JOIN pg_namespace stn ON stt.typnamespace = stn.oid +LEFT JOIN pg_type stet ON stt.typelem = stet.oid +LEFT JOIN pg_namespace sten ON stet.typnamespace = sten.oid LEFT JOIN pg_proc tf ON a.aggtransfn = tf.oid LEFT JOIN pg_namespace tfn ON tf.pronamespace = tfn.oid LEFT JOIN pg_proc ff ON a.aggfinalfn = ff.oid @@ -1247,6 +1280,8 @@ LEFT JOIN pg_proc mff ON a.aggmfinalfn = mff.oid LEFT JOIN pg_namespace mffn ON mff.pronamespace = mffn.oid LEFT JOIN pg_type mstt ON mstt.oid = a.aggmtranstype LEFT JOIN pg_namespace mstn ON mstt.typnamespace = mstn.oid +LEFT JOIN pg_type mstet ON mstt.typelem = mstet.oid +LEFT JOIN pg_namespace msten ON mstet.typnamespace = msten.oid LEFT JOIN pg_operator op ON op.oid = a.aggsortop LEFT JOIN pg_namespace opn ON op.oprnamespace = opn.oid LEFT JOIN pg_description d ON d.objoid = p.oid AND d.classoid = 'pg_proc'::regclass AND d.objsubid = 0 @@ -1375,8 +1410,13 @@ SELECT n.nspname AS domain_schema, t.typname AS domain_name, CASE - WHEN bn.nspname IS NULL OR bn.nspname = 'pg_catalog' OR bt.typcategory = 'A' THEN + WHEN bn.nspname IS NULL OR bn.nspname = 'pg_catalog' THEN format_type(t.typbasetype, t.typtypmod) + WHEN bt.typcategory = 'A' THEN + CASE + WHEN ben.nspname = 'pg_catalog' THEN bet.typname + ELSE quote_ident(ben.nspname) || '.' || quote_ident(bet.typname) + END || COALESCE(substring(format_type(t.typbasetype, t.typtypmod) FROM '\([^)]*\)'), '') || '[]' ELSE quote_ident(bn.nspname) || '.' || quote_ident(bt.typname) || COALESCE(substring(format_type(t.typbasetype, t.typtypmod) FROM '\([^)]*\)'), '') @@ -1388,6 +1428,8 @@ FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid LEFT JOIN pg_type bt ON bt.oid = t.typbasetype LEFT JOIN pg_namespace bn ON bt.typnamespace = bn.oid +LEFT JOIN pg_type bet ON bt.typelem = bet.oid +LEFT JOIN pg_namespace ben ON bet.typnamespace = ben.oid LEFT JOIN pg_description d ON d.objoid = t.oid AND d.classoid = 'pg_type'::regclass WHERE t.typtype = 'd' -- Domain types only AND n.nspname = $1 @@ -1428,8 +1470,13 @@ SELECT a.attname AS column_name, a.attnum AS column_position, CASE - WHEN atn.nspname IS NULL OR atn.nspname = 'pg_catalog' OR at.typcategory = 'A' THEN + WHEN atn.nspname IS NULL OR atn.nspname = 'pg_catalog' THEN format_type(a.atttypid, a.atttypmod) + WHEN at.typcategory = 'A' THEN + CASE + WHEN aen.nspname = 'pg_catalog' THEN aet.typname + ELSE quote_ident(aen.nspname) || '.' || quote_ident(aet.typname) + END || COALESCE(substring(format_type(a.atttypid, a.atttypmod) FROM '\([^)]*\)'), '') || '[]' ELSE quote_ident(atn.nspname) || '.' || quote_ident(at.typname) || COALESCE(substring(format_type(a.atttypid, a.atttypmod) FROM '\([^)]*\)'), '') @@ -1440,6 +1487,8 @@ JOIN pg_class c ON t.typrelid = c.oid JOIN pg_attribute a ON c.oid = a.attrelid LEFT JOIN pg_type at ON at.oid = a.atttypid LEFT JOIN pg_namespace atn ON at.typnamespace = atn.oid +LEFT JOIN pg_type aet ON at.typelem = aet.oid +LEFT JOIN pg_namespace aen ON aet.typnamespace = aen.oid WHERE t.typtype = 'c' -- composite types only AND c.relkind = 'c' -- only true composite types, not table types AND a.attnum > 0 -- exclude system columns diff --git a/ir/queries/queries.sql.go b/ir/queries/queries.sql.go index 33ef353e..e4dac1b2 100644 --- a/ir/queries/queries.sql.go +++ b/ir/queries/queries.sql.go @@ -24,8 +24,13 @@ SELECT COALESCE(tfn.nspname, '') AS transition_function_schema, -- Get state type CASE - WHEN stn.nspname IS NULL OR stn.nspname = 'pg_catalog' OR stt.typcategory = 'A' THEN + WHEN stn.nspname IS NULL OR stn.nspname = 'pg_catalog' THEN format_type(a.aggtranstype, NULL) + WHEN stt.typcategory = 'A' THEN + CASE + WHEN sten.nspname = 'pg_catalog' THEN stet.typname + ELSE quote_ident(sten.nspname) || '.' || quote_ident(stet.typname) + END || COALESCE(substring(format_type(a.aggtranstype, NULL) FROM '\([^)]*\)'), '') || '[]' ELSE quote_ident(stn.nspname) || '.' || quote_ident(stt.typname) END AS state_type, -- Get initial condition @@ -40,6 +45,8 @@ JOIN pg_namespace n ON p.pronamespace = n.oid JOIN pg_aggregate a ON a.aggfnoid = p.oid LEFT JOIN pg_type stt ON stt.oid = a.aggtranstype LEFT JOIN pg_namespace stn ON stt.typnamespace = stn.oid +LEFT JOIN pg_type stet ON stt.typelem = stet.oid +LEFT JOIN pg_namespace sten ON stet.typnamespace = sten.oid LEFT JOIN pg_proc tf ON a.aggtransfn = tf.oid LEFT JOIN pg_namespace tfn ON tf.pronamespace = tfn.oid LEFT JOIN pg_proc ff ON a.aggfinalfn = ff.oid @@ -121,8 +128,13 @@ SELECT CASE WHEN tfn.nspname = n.nspname THEN quote_ident(tf.proname) ELSE quote_ident(tfn.nspname) || '.' || quote_ident(tf.proname) END AS transition_function, CASE - WHEN stn.nspname IS NULL OR stn.nspname = 'pg_catalog' OR stt.typcategory = 'A' THEN + WHEN stn.nspname IS NULL OR stn.nspname = 'pg_catalog' THEN format_type(a.aggtranstype, NULL) + WHEN stt.typcategory = 'A' THEN + CASE + WHEN sten.nspname = 'pg_catalog' THEN stet.typname + ELSE quote_ident(sten.nspname) || '.' || quote_ident(stet.typname) + END || COALESCE(substring(format_type(a.aggtranstype, NULL) FROM '\([^)]*\)'), '') || '[]' ELSE quote_ident(stn.nspname) || '.' || quote_ident(stt.typname) END AS state_type, a.aggtransspace AS state_space, @@ -151,8 +163,13 @@ SELECT WHEN mitfn.nspname = n.nspname THEN quote_ident(mitf.proname) ELSE quote_ident(mitfn.nspname) || '.' || quote_ident(mitf.proname) END AS minv_transition_function, CASE WHEN a.aggmtransfn = 0 THEN '' - WHEN mstn.nspname IS NULL OR mstn.nspname = 'pg_catalog' OR mstt.typcategory = 'A' THEN + WHEN mstn.nspname IS NULL OR mstn.nspname = 'pg_catalog' THEN format_type(a.aggmtranstype, NULL) + WHEN mstt.typcategory = 'A' THEN + CASE + WHEN msten.nspname = 'pg_catalog' THEN mstet.typname + ELSE quote_ident(msten.nspname) || '.' || quote_ident(mstet.typname) + END || COALESCE(substring(format_type(a.aggmtranstype, NULL) FROM '\([^)]*\)'), '') || '[]' ELSE quote_ident(mstn.nspname) || '.' || quote_ident(mstt.typname) END AS mstate_type, a.aggmtransspace AS mstate_space, @@ -172,6 +189,8 @@ JOIN pg_namespace n ON p.pronamespace = n.oid JOIN pg_aggregate a ON a.aggfnoid = p.oid LEFT JOIN pg_type stt ON stt.oid = a.aggtranstype LEFT JOIN pg_namespace stn ON stt.typnamespace = stn.oid +LEFT JOIN pg_type stet ON stt.typelem = stet.oid +LEFT JOIN pg_namespace sten ON stet.typnamespace = sten.oid LEFT JOIN pg_proc tf ON a.aggtransfn = tf.oid LEFT JOIN pg_namespace tfn ON tf.pronamespace = tfn.oid LEFT JOIN pg_proc ff ON a.aggfinalfn = ff.oid @@ -190,6 +209,8 @@ LEFT JOIN pg_proc mff ON a.aggmfinalfn = mff.oid LEFT JOIN pg_namespace mffn ON mff.pronamespace = mffn.oid LEFT JOIN pg_type mstt ON mstt.oid = a.aggmtranstype LEFT JOIN pg_namespace mstn ON mstt.typnamespace = mstn.oid +LEFT JOIN pg_type mstet ON mstt.typelem = mstet.oid +LEFT JOIN pg_namespace msten ON mstet.typnamespace = msten.oid LEFT JOIN pg_operator op ON op.oid = a.aggsortop LEFT JOIN pg_namespace opn ON op.oprnamespace = opn.oid LEFT JOIN pg_description d ON d.objoid = p.oid AND d.classoid = 'pg_proc'::regclass AND d.objsubid = 0 @@ -741,8 +762,13 @@ SELECT a.attname AS column_name, a.attnum AS column_position, CASE - WHEN atn.nspname IS NULL OR atn.nspname = 'pg_catalog' OR at.typcategory = 'A' THEN + WHEN atn.nspname IS NULL OR atn.nspname = 'pg_catalog' THEN format_type(a.atttypid, a.atttypmod) + WHEN at.typcategory = 'A' THEN + CASE + WHEN aen.nspname = 'pg_catalog' THEN aet.typname + ELSE quote_ident(aen.nspname) || '.' || quote_ident(aet.typname) + END || COALESCE(substring(format_type(a.atttypid, a.atttypmod) FROM '\([^)]*\)'), '') || '[]' ELSE quote_ident(atn.nspname) || '.' || quote_ident(at.typname) || COALESCE(substring(format_type(a.atttypid, a.atttypmod) FROM '\([^)]*\)'), '') @@ -753,6 +779,8 @@ JOIN pg_class c ON t.typrelid = c.oid JOIN pg_attribute a ON c.oid = a.attrelid LEFT JOIN pg_type at ON at.oid = a.atttypid LEFT JOIN pg_namespace atn ON at.typnamespace = atn.oid +LEFT JOIN pg_type aet ON at.typelem = aet.oid +LEFT JOIN pg_namespace aen ON aet.typnamespace = aen.oid WHERE t.typtype = 'c' -- composite types only AND c.relkind = 'c' -- only true composite types, not table types AND a.attnum > 0 -- exclude system columns @@ -802,14 +830,19 @@ func (q *Queries) GetCompositeTypeColumns(ctx context.Context) ([]GetCompositeTy } const getCompositeTypeColumnsForSchema = `-- name: GetCompositeTypeColumnsForSchema :many -SELECT +SELECT n.nspname AS type_schema, t.typname AS type_name, a.attname AS column_name, a.attnum AS column_position, CASE - WHEN atn.nspname IS NULL OR atn.nspname = 'pg_catalog' OR at.typcategory = 'A' THEN + WHEN atn.nspname IS NULL OR atn.nspname = 'pg_catalog' THEN format_type(a.atttypid, a.atttypmod) + WHEN at.typcategory = 'A' THEN + CASE + WHEN aen.nspname = 'pg_catalog' THEN aet.typname + ELSE quote_ident(aen.nspname) || '.' || quote_ident(aet.typname) + END || COALESCE(substring(format_type(a.atttypid, a.atttypmod) FROM '\([^)]*\)'), '') || '[]' ELSE quote_ident(atn.nspname) || '.' || quote_ident(at.typname) || COALESCE(substring(format_type(a.atttypid, a.atttypmod) FROM '\([^)]*\)'), '') @@ -820,6 +853,8 @@ JOIN pg_class c ON t.typrelid = c.oid JOIN pg_attribute a ON c.oid = a.attrelid LEFT JOIN pg_type at ON at.oid = a.atttypid LEFT JOIN pg_namespace atn ON at.typnamespace = atn.oid +LEFT JOIN pg_type aet ON at.typelem = aet.oid +LEFT JOIN pg_namespace aen ON aet.typnamespace = aen.oid WHERE t.typtype = 'c' -- composite types only AND c.relkind = 'c' -- only true composite types, not table types AND a.attnum > 0 -- exclude system columns @@ -1318,12 +1353,17 @@ func (q *Queries) GetDomainConstraintsForSchema(ctx context.Context, dollar_1 sq } const getDomains = `-- name: GetDomains :many -SELECT +SELECT n.nspname AS domain_schema, t.typname AS domain_name, CASE - WHEN bn.nspname IS NULL OR bn.nspname = 'pg_catalog' OR bt.typcategory = 'A' THEN + WHEN bn.nspname IS NULL OR bn.nspname = 'pg_catalog' THEN format_type(t.typbasetype, t.typtypmod) + WHEN bt.typcategory = 'A' THEN + CASE + WHEN ben.nspname = 'pg_catalog' THEN bet.typname + ELSE quote_ident(ben.nspname) || '.' || quote_ident(bet.typname) + END || COALESCE(substring(format_type(t.typbasetype, t.typtypmod) FROM '\([^)]*\)'), '') || '[]' ELSE quote_ident(bn.nspname) || '.' || quote_ident(bt.typname) || COALESCE(substring(format_type(t.typbasetype, t.typtypmod) FROM '\([^)]*\)'), '') @@ -1335,6 +1375,8 @@ FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid LEFT JOIN pg_type bt ON bt.oid = t.typbasetype LEFT JOIN pg_namespace bn ON bt.typnamespace = bn.oid +LEFT JOIN pg_type bet ON bt.typelem = bet.oid +LEFT JOIN pg_namespace ben ON bet.typnamespace = ben.oid LEFT JOIN pg_description d ON d.objoid = t.oid AND d.classoid = 'pg_type'::regclass WHERE t.typtype = 'd' -- Domain types only AND n.nspname NOT IN ('information_schema', 'pg_catalog', 'pg_toast') @@ -1384,12 +1426,17 @@ func (q *Queries) GetDomains(ctx context.Context) ([]GetDomainsRow, error) { } const getDomainsForSchema = `-- name: GetDomainsForSchema :many -SELECT +SELECT n.nspname AS domain_schema, t.typname AS domain_name, CASE - WHEN bn.nspname IS NULL OR bn.nspname = 'pg_catalog' OR bt.typcategory = 'A' THEN + WHEN bn.nspname IS NULL OR bn.nspname = 'pg_catalog' THEN format_type(t.typbasetype, t.typtypmod) + WHEN bt.typcategory = 'A' THEN + CASE + WHEN ben.nspname = 'pg_catalog' THEN bet.typname + ELSE quote_ident(ben.nspname) || '.' || quote_ident(bet.typname) + END || COALESCE(substring(format_type(t.typbasetype, t.typtypmod) FROM '\([^)]*\)'), '') || '[]' ELSE quote_ident(bn.nspname) || '.' || quote_ident(bt.typname) || COALESCE(substring(format_type(t.typbasetype, t.typtypmod) FROM '\([^)]*\)'), '') @@ -1401,6 +1448,8 @@ FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid LEFT JOIN pg_type bt ON bt.oid = t.typbasetype LEFT JOIN pg_namespace bn ON bt.typnamespace = bn.oid +LEFT JOIN pg_type bet ON bt.typelem = bet.oid +LEFT JOIN pg_namespace ben ON bet.typnamespace = ben.oid LEFT JOIN pg_description d ON d.objoid = t.oid AND d.classoid = 'pg_type'::regclass WHERE t.typtype = 'd' -- Domain types only AND n.nspname = $1