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.
This commit is contained in:
2026-10-02 22:52:53 +02:00
parent d7d1d99ebc
commit b6c0cd3d1b
2 changed files with 88 additions and 23 deletions
+27 -23
View File
@@ -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)
+61
View File
@@ -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")
}