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:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user