From b6c0cd3d1b3c6123d3fb48a1baba166512c7a1f7 Mon Sep 17 00:00:00 2001 From: Hein Date: Fri, 2 Oct 2026 22:52:53 +0200 Subject: [PATCH] fix(pgsql-reader): pair composite foreign key columns by position key_column_usage and constraint_column_usage were joined on constraint name only, so an N-column foreign key returned N*N column pairs. Read from pg_constraint with unnest(conkey, confkey) instead. --- pkg/readers/pgsql/queries.go | 50 ++++++++++++++------------ pkg/readers/pgsql/reader_test.go | 61 ++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 23 deletions(-) diff --git a/pkg/readers/pgsql/queries.go b/pkg/readers/pgsql/queries.go index 0c280b3..b11094c 100644 --- a/pkg/readers/pgsql/queries.go +++ b/pkg/readers/pgsql/queries.go @@ -422,31 +422,35 @@ func (r *Reader) queryPrimaryKeys(schemaName string) (map[string]*models.Constra // queryForeignKeys retrieves all foreign key constraints for a schema // Returns map[schema.table][]*Constraint func (r *Reader) queryForeignKeys(schemaName string) (map[string][]*models.Constraint, error) { + // Columns are paired by position from pg_constraint so composite keys are not + // cross-joined (information_schema.constraint_column_usage has no ordering). + actionCase := func(col string) string { + return `CASE ` + col + ` WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END` + } query := ` SELECT - tc.table_schema, - tc.table_name, - tc.constraint_name, - kcu.table_schema as foreign_table_schema, - kcu.table_name as foreign_table_name, - kcu.column_name as foreign_column, - ccu.table_schema as referenced_table_schema, - ccu.table_name as referenced_table_name, - ccu.column_name as referenced_column, - rc.update_rule, - rc.delete_rule - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - JOIN information_schema.constraint_column_usage ccu - ON ccu.constraint_name = tc.constraint_name - JOIN information_schema.referential_constraints rc - ON rc.constraint_name = tc.constraint_name - AND rc.constraint_schema = tc.table_schema - WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = $1 - ORDER BY tc.table_schema, tc.table_name, tc.constraint_name, kcu.ordinal_position + ns.nspname AS table_schema, + cl.relname AS table_name, + con.conname AS constraint_name, + ns.nspname AS foreign_table_schema, + cl.relname AS foreign_table_name, + att.attname AS foreign_column, + fns.nspname AS referenced_table_schema, + fcl.relname AS referenced_table_name, + fatt.attname AS referenced_column, + ` + actionCase("con.confupdtype") + ` AS update_rule, + ` + actionCase("con.confdeltype") + ` AS delete_rule + FROM pg_catalog.pg_constraint con + JOIN pg_catalog.pg_class cl ON cl.oid = con.conrelid + JOIN pg_catalog.pg_namespace ns ON ns.oid = cl.relnamespace + JOIN pg_catalog.pg_class fcl ON fcl.oid = con.confrelid + JOIN pg_catalog.pg_namespace fns ON fns.oid = fcl.relnamespace + CROSS JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS k(attnum, fattnum, ord) + JOIN pg_catalog.pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = k.attnum + JOIN pg_catalog.pg_attribute fatt ON fatt.attrelid = con.confrelid AND fatt.attnum = k.fattnum + WHERE con.contype = 'f' + AND ns.nspname = $1 + ORDER BY ns.nspname, cl.relname, con.conname, k.ord ` rows, err := r.conn.Query(r.ctx, query, schemaName) diff --git a/pkg/readers/pgsql/reader_test.go b/pkg/readers/pgsql/reader_test.go index 2afbcfa..a1374ce 100644 --- a/pkg/readers/pgsql/reader_test.go +++ b/pkg/readers/pgsql/reader_test.go @@ -1,10 +1,14 @@ package pgsql import ( + "context" "os" "reflect" + "strings" "testing" + "github.com/jackc/pgx/v5" + "git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/readers" ) @@ -499,3 +503,60 @@ func TestMapDataType_ExtensionTypesPreserveModifiers(t *testing.T) { }) } } + +func TestReader_CompositeForeignKeyColumnsArePairedOnce(t *testing.T) { + connStr := getTestConnectionString(t) + + ctx := context.Background() + conn, err := pgx.Connect(ctx, connStr) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer conn.Close(ctx) + + const schema = "relspec_fk_test" + setup := []string{ + "DROP SCHEMA IF EXISTS " + schema + " CASCADE", + "CREATE SCHEMA " + schema, + "CREATE TABLE " + schema + ".parent (a int, b int, PRIMARY KEY (a, b))", + "CREATE TABLE " + schema + ".child (x int, y int, CONSTRAINT fk_child_parent FOREIGN KEY (x, y) REFERENCES " + schema + ".parent (a, b) ON DELETE CASCADE)", + } + for _, stmt := range setup { + if _, err := conn.Exec(ctx, stmt); err != nil { + t.Fatalf("setup %q: %v", stmt, err) + } + } + defer conn.Exec(ctx, "DROP SCHEMA IF EXISTS "+schema+" CASCADE") + + reader := NewReader(&readers.ReaderOptions{ConnectionString: connStr}) + db, err := reader.ReadDatabase() + if err != nil { + t.Fatalf("ReadDatabase: %v", err) + } + + for _, s := range db.Schemas { + if s.Name != schema { + continue + } + for _, tbl := range s.Tables { + if tbl.Name != "child" { + continue + } + fk := tbl.Constraints["fk_child_parent"] + if fk == nil { + t.Fatal("foreign key fk_child_parent not read") + } + if got := strings.Join(fk.Columns, ","); got != "x,y" { + t.Errorf("columns = %q, want x,y", got) + } + if got := strings.Join(fk.ReferencedColumns, ","); got != "a,b" { + t.Errorf("referenced columns = %q, want a,b", got) + } + if fk.OnDelete != "CASCADE" || fk.OnUpdate != "NO ACTION" { + t.Errorf("rules = %s/%s, want CASCADE/NO ACTION", fk.OnDelete, fk.OnUpdate) + } + return + } + } + t.Fatal("test schema/table not found in read result") +}