From 5a9153f53291e9aa91724a150f968a828c00da5c Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Sun, 19 Oct 2025 15:51:30 +0800 Subject: [PATCH 1/2] fix: add schema qualifier to cross-schema foreign key references (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When dumping a schema with foreign keys that reference tables in other schemas (e.g., tenant_schema.table → public.table), the REFERENCES clause was missing the schema qualifier. This caused: 1. Incorrect SQL output that depends on search_path 2. pgschema plan continuously generating plans to replace the constraint Root cause: generateConstraintSQL() ignored the targetSchema parameter and only used constraint.ReferencedTable without checking constraint.ReferencedSchema. Fix: Use ir.QualifyEntityNameWithQuotes() to add schema qualifier when the referenced table is in a different schema than the target schema. Enhanced testdata/dump/tenant/ test case to reproduce and prevent regression by adding a cross-schema FK from tenant schema to public.categories table. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- internal/diff/constraint.go | 6 ++++-- testdata/dump/tenant/pgschema.sql | 4 +++- testdata/dump/tenant/public.sql | 9 +++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/internal/diff/constraint.go b/internal/diff/constraint.go index ddf97cb8..8e3e52d5 100644 --- a/internal/diff/constraint.go +++ b/internal/diff/constraint.go @@ -8,7 +8,7 @@ import ( ) // generateConstraintSQL generates constraint definition for inline table constraints -func generateConstraintSQL(constraint *ir.Constraint, _ string) string { +func generateConstraintSQL(constraint *ir.Constraint, targetSchema string) string { // Helper function to get column names from ConstraintColumn array getColumnNames := func(columns []*ir.ConstraintColumn) []string { var names []string @@ -27,10 +27,12 @@ func generateConstraintSQL(constraint *ir.Constraint, _ string) string { return fmt.Sprintf("CONSTRAINT %s UNIQUE (%s)", constraint.Name, strings.Join(getColumnNames(constraint.Columns), ", ")) case ir.ConstraintTypeForeignKey: // Always include CONSTRAINT name to preserve explicit FK names + // Use QualifyEntityNameWithQuotes to add schema qualifier when referencing tables in other schemas + qualifiedRefTable := ir.QualifyEntityNameWithQuotes(constraint.ReferencedSchema, constraint.ReferencedTable, targetSchema) stmt := fmt.Sprintf("CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)", constraint.Name, strings.Join(getColumnNames(constraint.Columns), ", "), - ir.QuoteIdentifier(constraint.ReferencedTable), strings.Join(getColumnNames(constraint.ReferencedColumns), ", ")) + qualifiedRefTable, strings.Join(getColumnNames(constraint.ReferencedColumns), ", ")) // Only add ON UPDATE/DELETE if they are not the default "NO ACTION" if constraint.UpdateRule != "" && constraint.UpdateRule != "NO ACTION" { stmt += fmt.Sprintf(" ON UPDATE %s", constraint.UpdateRule) diff --git a/testdata/dump/tenant/pgschema.sql b/testdata/dump/tenant/pgschema.sql index 09a92de1..df235535 100644 --- a/testdata/dump/tenant/pgschema.sql +++ b/testdata/dump/tenant/pgschema.sql @@ -35,9 +35,11 @@ CREATE TABLE IF NOT EXISTS posts ( title varchar(200) NOT NULL, content text, author_id integer, + category_id integer NOT NULL, status public.status DEFAULT 'active', created_at timestamp DEFAULT now(), CONSTRAINT posts_pkey PRIMARY KEY (id), - CONSTRAINT posts_author_id_fkey FOREIGN KEY (author_id) REFERENCES users (id) + CONSTRAINT posts_author_id_fkey FOREIGN KEY (author_id) REFERENCES users (id), + CONSTRAINT posts_category_id_fkey FOREIGN KEY (category_id) REFERENCES public.categories (id) ); diff --git a/testdata/dump/tenant/public.sql b/testdata/dump/tenant/public.sql index 6357ebf5..c4985867 100644 --- a/testdata/dump/tenant/public.sql +++ b/testdata/dump/tenant/public.sql @@ -1,2 +1,11 @@ CREATE TYPE public.user_role AS ENUM ('admin', 'user'); CREATE TYPE public.status AS ENUM ('active', 'inactive'); + +-- +-- Shared table in public schema that tenant schemas will reference +-- +CREATE TABLE public.categories ( + id SERIAL PRIMARY KEY, + name varchar(100) NOT NULL UNIQUE, + description text +); From 9a9bfdd116ba5ede075bc6b0700d6249e5bc4533 Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Sun, 19 Oct 2025 15:58:31 +0800 Subject: [PATCH 2/2] test: add cross-schema FK test and fix existing FK test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added test case for cross-schema foreign key reference to verify schema qualifier is properly added when referencing tables in different schemas (e.g., tenant1.table -> public.table) - Fixed existing FK test by adding ReferencedSchema field to prevent empty schema qualifier (was generating ".users" instead of "users") - Updated test to use different target schemas to properly test both same-schema and cross-schema FK generation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- internal/diff/identifier_quote_test.go | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/internal/diff/identifier_quote_test.go b/internal/diff/identifier_quote_test.go index 4275497c..c5064884 100644 --- a/internal/diff/identifier_quote_test.go +++ b/internal/diff/identifier_quote_test.go @@ -44,6 +44,7 @@ func TestGenerateConstraintSQL_WithQuoting(t *testing.T) { Columns: []*ir.ConstraintColumn{ {Name: "userId", Position: 1}, }, + ReferencedSchema: "public", ReferencedTable: "users", ReferencedColumns: []*ir.ConstraintColumn{ {Name: "id", Position: 1}, @@ -65,11 +66,33 @@ func TestGenerateConstraintSQL_WithQuoting(t *testing.T) { }, want: `CONSTRAINT test_unique_lower UNIQUE (email, username)`, }, + { + name: "FOREIGN KEY with cross-schema reference", + constraint: &ir.Constraint{ + Name: "test_cross_schema_fk", + Type: ir.ConstraintTypeForeignKey, + Columns: []*ir.ConstraintColumn{ + {Name: "category_id", Position: 1}, + }, + ReferencedSchema: "public", + ReferencedTable: "categories", + ReferencedColumns: []*ir.ConstraintColumn{ + {Name: "id", Position: 1}, + }, + IsValid: true, + }, + want: `CONSTRAINT test_cross_schema_fk FOREIGN KEY (category_id) REFERENCES public.categories (id)`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := generateConstraintSQL(tt.constraint, "public") + // Use "tenant1" as target schema for cross-schema FK test, "public" for others + targetSchema := "public" + if tt.name == "FOREIGN KEY with cross-schema reference" { + targetSchema = "tenant1" + } + got := generateConstraintSQL(tt.constraint, targetSchema) if got != tt.want { t.Errorf("generateConstraintSQL() = %q, want %q", got, tt.want) }