diff --git a/cmd/ignore_integration_test.go b/cmd/ignore_integration_test.go
index 51b30168..71729003 100644
--- a/cmd/ignore_integration_test.go
+++ b/cmd/ignore_integration_test.go
@@ -1835,3 +1835,134 @@ CREATE AGGREGATE group_concat(text) (
}
})
}
+
+// TestIgnoreCrossSchemaForeignKey verifies that ignoring a schema (or a
+// schema-qualified table) lets plan apply desired SQL that REFERENCES that
+// table without a stub CREATE TABLE in the schema file (issue #548).
+func TestIgnoreCrossSchemaForeignKey(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()
+
+ _, err := conn.Exec(`
+ CREATE SCHEMA auth;
+ CREATE TABLE auth.users (
+ id uuid PRIMARY KEY,
+ raw_app_meta_data jsonb
+ );
+ CREATE TABLE profiles (
+ id SERIAL PRIMARY KEY,
+ auth_user_id uuid NOT NULL UNIQUE REFERENCES auth.users (id) ON DELETE CASCADE
+ );
+ `)
+ if err != nil {
+ t.Fatalf("Failed to create cross-schema fixture: %v", err)
+ }
+
+ originalWd, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("Failed to get working directory: %v", err)
+ }
+ defer func() {
+ if err := os.Chdir(originalWd); err != nil {
+ t.Fatalf("Failed to restore working directory: %v", err)
+ }
+ }()
+ if err := os.Chdir(t.TempDir()); err != nil {
+ t.Fatalf("Failed to change to temp directory: %v", err)
+ }
+
+ // Stubs are created in the shared plan database, not the target. Drop
+ // leftover auth schema so this test and later tests are not polluted.
+ sharedConn, _, _, _, _, _ := testutil.ConnectToPostgres(t, sharedEmbeddedPG)
+ defer sharedConn.Close()
+ if _, err := sharedConn.Exec("DROP SCHEMA IF EXISTS auth CASCADE"); err != nil {
+ t.Fatalf("Failed to drop leftover auth schema: %v", err)
+ }
+ defer func() {
+ _, _ = sharedConn.Exec("DROP SCHEMA IF EXISTS auth CASCADE")
+ }()
+
+ schemaSQL := `
+CREATE TABLE profiles (
+ id SERIAL PRIMARY KEY,
+ auth_user_id uuid NOT NULL UNIQUE REFERENCES auth.users (id) ON DELETE CASCADE,
+ display_name text
+);
+`
+ if err := os.WriteFile("schema.sql", []byte(schemaSQL), 0644); err != nil {
+ t.Fatalf("Failed to write schema file: %v", err)
+ }
+
+ containerInfo := &struct {
+ Conn *sql.DB
+ Host string
+ Port int
+ DBName string
+ User string
+ Password string
+ }{
+ Conn: conn,
+ Host: host,
+ Port: port,
+ DBName: dbname,
+ User: user,
+ Password: password,
+ }
+
+ t.Run("without_ignore_fails", func(t *testing.T) {
+ os.Remove(".pgschemaignore")
+ config := &planCmd.PlanConfig{
+ Host: host,
+ Port: port,
+ DB: dbname,
+ User: user,
+ Password: password,
+ Schema: "public",
+ File: "schema.sql",
+ ApplicationName: "pgschema",
+ }
+ _, err := planCmd.GeneratePlan(config, sharedEmbeddedPG)
+ if err == nil {
+ t.Fatal("expected plan to fail without ignore when REFERENCES auth.users")
+ }
+ if !strings.Contains(err.Error(), "auth") {
+ t.Errorf("expected error to mention auth schema, got: %v", err)
+ }
+ })
+
+ t.Run("schemas_section", func(t *testing.T) {
+ if err := os.WriteFile(".pgschemaignore", []byte("[schemas]\npatterns = [\"auth\"]\n"), 0644); err != nil {
+ t.Fatalf("Failed to write ignore file: %v", err)
+ }
+ output := executeIgnorePlanCommand(t, containerInfo, "schema.sql")
+ if strings.Contains(output, "DROP") {
+ t.Errorf("plan should not drop objects; got:\n%s", output)
+ }
+ if !strings.Contains(output, "display_name") {
+ t.Errorf("plan should add display_name; got:\n%s", output)
+ }
+ if strings.Contains(output, "CREATE TABLE IF NOT EXISTS auth.users") ||
+ strings.Contains(output, "CREATE TABLE IF NOT EXISTS \"auth\".\"users\"") {
+ t.Errorf("plan should not create auth.users; got:\n%s", output)
+ }
+ })
+
+ t.Run("qualified_table_pattern", func(t *testing.T) {
+ if err := os.WriteFile(".pgschemaignore", []byte("[tables]\npatterns = [\"auth.users\"]\n"), 0644); err != nil {
+ t.Fatalf("Failed to write ignore file: %v", err)
+ }
+ output := executeIgnorePlanCommand(t, containerInfo, "schema.sql")
+ if strings.Contains(output, "DROP") {
+ t.Errorf("plan should not drop objects; got:\n%s", output)
+ }
+ if !strings.Contains(output, "display_name") {
+ t.Errorf("plan should add display_name; got:\n%s", output)
+ }
+ })
+}
diff --git a/cmd/plan/ignore_stubs.go b/cmd/plan/ignore_stubs.go
new file mode 100644
index 00000000..e133fa2c
--- /dev/null
+++ b/cmd/plan/ignore_stubs.go
@@ -0,0 +1,70 @@
+package plan
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/pgplex/pgschema/cmd/util"
+ "github.com/pgplex/pgschema/internal/logger"
+ "github.com/pgplex/pgschema/internal/postgres"
+ "github.com/pgplex/pgschema/ir"
+)
+
+// prependIgnoredTableStubs prepends CREATE TABLE stubs for ignored FK targets
+// referenced by desiredSQL but not defined in it. Stubs are cloned from the
+// target database so plan can apply REFERENCES to unmanaged tables (issue #548).
+func prependIgnoredTableStubs(ctx context.Context, cfg *util.ConnectionConfig, ignoreConfig *ir.IgnoreConfig, targetSchema, desiredSQL string) (string, error) {
+ if ignoreConfig == nil {
+ return desiredSQL, nil
+ }
+
+ refs := postgres.ExtractForeignKeyTargets(desiredSQL, targetSchema)
+ if len(refs) == 0 {
+ return desiredSQL, nil
+ }
+
+ created := make(map[string]bool)
+ for _, name := range postgres.ExtractCreateTableNames(desiredSQL, targetSchema) {
+ created[name.Schema+"."+name.Table] = true
+ }
+
+ var toStub []postgres.QualifiedName
+ seen := make(map[string]bool)
+ for _, ref := range refs {
+ if !ignoreConfig.ShouldIgnoreReferencedTable(ref.Schema, ref.Table, targetSchema) {
+ continue
+ }
+ key := ref.Schema + "." + ref.Table
+ if created[key] || seen[key] {
+ continue
+ }
+ seen[key] = true
+ toStub = append(toStub, ref)
+ }
+ if len(toStub) == 0 {
+ return desiredSQL, nil
+ }
+
+ conn, err := util.Connect(cfg)
+ if err != nil {
+ return "", fmt.Errorf("connect to build ignored table stubs: %w", err)
+ }
+ defer conn.Close()
+
+ var stubs strings.Builder
+ for _, ref := range toStub {
+ ddl, err := ir.BuildTableStubSQL(ctx, conn, ref.Schema, ref.Table, targetSchema)
+ if err != nil {
+ return "", err
+ }
+ if ddl == "" {
+ return "", fmt.Errorf("ignored table %s.%s is referenced by a foreign key but does not exist in the target database; add a stub CREATE TABLE to your schema file, see https://www.pgschema.com/cli/plan-db", ref.Schema, ref.Table)
+ }
+ logger.Get().Debug("prepending stub for ignored foreign key target",
+ "schema", ref.Schema, "table", ref.Table)
+ stubs.WriteString(ddl)
+ }
+
+ return stubs.String() + desiredSQL, nil
+}
diff --git a/cmd/plan/plan.go b/cmd/plan/plan.go
index 93d2703c..93e6c330 100644
--- a/cmd/plan/plan.go
+++ b/cmd/plan/plan.go
@@ -297,6 +297,25 @@ func GeneratePlan(config *PlanConfig, provider postgres.DesiredStateProvider) (*
ctx := context.Background()
+ // Clone ignored FK targets from the target database into the plan SQL so
+ // REFERENCES auth.users (and same-schema ignored tables) can apply without
+ // those tables appearing in the desired schema file (issue #548).
+ if ignoreConfig != nil {
+ connCfg := &util.ConnectionConfig{
+ Host: config.Host,
+ Port: config.Port,
+ Database: config.DB,
+ User: config.User,
+ Password: config.Password,
+ SSLMode: config.SSLMode,
+ ApplicationName: config.ApplicationName,
+ }
+ desiredState, err = prependIgnoredTableStubs(ctx, connCfg, ignoreConfig, config.Schema, desiredState)
+ if err != nil {
+ return nil, fmt.Errorf("failed to stub ignored foreign key targets: %w", err)
+ }
+ }
+
// Apply desired state SQL to the provider (embedded postgres or external database)
if err := provider.ApplySchema(ctx, config.Schema, desiredState); err != nil {
return nil, fmt.Errorf("failed to apply desired state: %w", err)
diff --git a/cmd/util/ignoreloader.go b/cmd/util/ignoreloader.go
index c1f28e29..f8ef882d 100644
--- a/cmd/util/ignoreloader.go
+++ b/cmd/util/ignoreloader.go
@@ -42,6 +42,7 @@ type TomlConfig struct {
Triggers TriggerIgnoreConfig `toml:"triggers,omitempty"`
Privileges PrivilegeIgnoreConfig `toml:"privileges,omitempty"`
DefaultPrivileges DefaultPrivilegeIgnoreConfig `toml:"default_privileges,omitempty"`
+ Schemas SchemaIgnoreConfig `toml:"schemas,omitempty"`
}
// TableIgnoreConfig represents table-specific ignore configuration
@@ -49,6 +50,12 @@ type TableIgnoreConfig struct {
Patterns []string `toml:"patterns,omitempty"`
}
+// SchemaIgnoreConfig represents schema-specific ignore configuration.
+// Used to stub cross-schema FK targets (e.g. Supabase auth) at plan time.
+type SchemaIgnoreConfig struct {
+ Patterns []string `toml:"patterns,omitempty"`
+}
+
// ViewIgnoreConfig represents view-specific ignore configuration
type ViewIgnoreConfig struct {
Patterns []string `toml:"patterns,omitempty"`
@@ -156,6 +163,7 @@ func LoadIgnoreFileWithStructureFromPath(filePath string) (*ir.IgnoreConfig, err
Triggers: tomlConfig.Triggers.Patterns,
Privileges: tomlConfig.Privileges.Patterns,
DefaultPrivileges: tomlConfig.DefaultPrivileges.Patterns,
+ Schemas: tomlConfig.Schemas.Patterns,
}
return config, nil
diff --git a/cmd/util/ignoreloader_test.go b/cmd/util/ignoreloader_test.go
index 02fa82b3..1dd4c403 100644
--- a/cmd/util/ignoreloader_test.go
+++ b/cmd/util/ignoreloader_test.go
@@ -50,6 +50,9 @@ patterns = ["fk_temp_*"]
[triggers]
patterns = ["trg_temp_*"]
+
+[schemas]
+patterns = ["auth"]
`
err := os.WriteFile(testFile, []byte(tomlContent), 0644)
@@ -100,6 +103,10 @@ patterns = ["trg_temp_*"]
if len(config.Triggers) != 1 || config.Triggers[0] != "trg_temp_*" {
t.Errorf("Expected triggers patterns [\"trg_temp_*\"], got %v", config.Triggers)
}
+
+ if len(config.Schemas) != 1 || config.Schemas[0] != "auth" {
+ t.Errorf("Expected schemas patterns [\"auth\"], got %v", config.Schemas)
+ }
}
func TestLoadIgnoreFileWithStructure_ValidTOML(t *testing.T) {
diff --git a/docs/cli/apply.mdx b/docs/cli/apply.mdx
index 05e682be..ef46f54a 100644
--- a/docs/cli/apply.mdx
+++ b/docs/cli/apply.mdx
@@ -16,7 +16,7 @@ The apply command supports two execution modes:
1. Display the plan for review
1. Apply the changes (with optional confirmation and safety checks)
-By default, File Mode uses an embedded PostgreSQL instance to validate the desired state. For schemas using PostgreSQL extensions or cross-schema references, you can use an external database instead via the `--plan-*` flags. See [External Plan Database](/cli/plan-db) for details.
+By default, File Mode uses an embedded PostgreSQL instance to validate the desired state. Cross-schema FKs to ignored tables are stubbed from the target automatically; for extensions or other cases see [External Plan Database](/cli/plan-db).
### Plan Mode (Execute Pre-generated Plan)
1. Load a pre-generated plan from JSON file
@@ -128,7 +128,7 @@ pgschema apply --host localhost --db myapp --user postgres --password mypassword
## Plan Database Options
-When using File Mode (`--file`), the apply command generates a plan internally using a temporary PostgreSQL instance. By default, this uses embedded PostgreSQL. For schemas that require PostgreSQL extensions or have cross-schema references, you can provide an external database. See [External Plan Database](/cli/plan-db) for complete documentation.
+When using File Mode (`--file`), the apply command generates a plan internally using a temporary PostgreSQL instance. Cross-schema FKs to ignored tables work with the default embedded instance; extensions and other edge cases may need an external database. See [External Plan Database](/cli/plan-db) for complete documentation.
**Note**: These options only apply when using `--file` mode. When using `--plan` mode, the plan has already been generated.
diff --git a/docs/cli/ignore.mdx b/docs/cli/ignore.mdx
index 2148658b..dadc17ca 100644
--- a/docs/cli/ignore.mdx
+++ b/docs/cli/ignore.mdx
@@ -23,6 +23,9 @@ The `.pgschemaignore` file is automatically loaded when present in the current d
Create a `.pgschemaignore` file in your project directory using TOML format:
```toml
+[schemas]
+patterns = ["auth", "storage"]
+
[tables]
patterns = ["temp_*", "test_*", "!test_core_*"]
@@ -128,12 +131,41 @@ patterns = ["fk_*", "!fk_core_*"]
This is useful when:
1. **Out-of-band constraints** - A constraint is added and managed manually (e.g. disabled during an AWS DMS migration and re-added afterward), and you don't want `pgschema plan` to flag it for drop.
-2. **Cross-schema foreign keys** - When modules live in separate schemas with foreign keys between them, ignore the cross-schema foreign keys so each schema can be bootstrapped independently, then drop the ignore to let pgschema manage them once all tables exist.
+2. **Cross-schema foreign keys** - Prefer ignoring the referenced schema or table (see below) so `plan` can stub it. Alternatively, omit the FK from the desired SQL and ignore the live constraint by name so each schema can be bootstrapped independently.
Patterns match the constraint name only, which is not necessarily unique across tables. Be careful with broad patterns like `*`, as ignoring a primary key or unique constraint can leave a table without the keys it needs.
+## Schemas and Cross-Schema Foreign Keys
+
+The `[schemas]` section matches **schema names**. Combined with schema-qualified `[tables]` patterns (`auth.users`, `auth.*`), this is the way to keep a foreign key to an unmanaged schema (for example Supabase `auth.users`) in your desired SQL.
+
+```toml
+[schemas]
+patterns = ["auth"]
+
+# Or ignore only specific tables in another schema:
+# [tables]
+# patterns = ["auth.users"]
+```
+
+```sql
+-- schema.sql (your app schema only — no auth stub required)
+CREATE TABLE profiles (
+ id UUID PRIMARY KEY,
+ auth_user_id UUID NOT NULL UNIQUE REFERENCES auth.users (id) ON DELETE CASCADE
+);
+```
+
+When `plan` applies this SQL to its temporary database, it clones a structural stub of each ignored FK target from the **target** database (columns plus PRIMARY KEY / UNIQUE constraints), so PostgreSQL can create the foreign key. You do **not** need a manual `CREATE TABLE auth.users` stub in your schema file or a separate external plan database for this case. The auto-stub is not part of the managed schema: dump does not emit `auth.users`, and plan will not create or drop it.
+
+The referenced table must already exist on the target database. If it does not, use a manual stub in your schema file or an [external plan database](/cli/plan-db).
+
+
+Cross-schema table patterns must be schema-qualified (`auth.users` or `auth.*`). A bare pattern like `users` only matches tables in the schema you are managing, so it will not stub `auth.users`.
+
+
## Triggers
The `[triggers]` section matches triggers by **trigger name**. When a trigger is ignored, pgschema neither creates, drops, nor reports drift on it — it is left entirely to be managed out-of-band.
diff --git a/docs/cli/plan-db.mdx b/docs/cli/plan-db.mdx
index 2153d8fa..6c3f3ea2 100644
--- a/docs/cli/plan-db.mdx
+++ b/docs/cli/plan-db.mdx
@@ -15,7 +15,7 @@ By default, the `plan` command (and `apply` command in File Mode) spins up a tem
Use an external database for plan generation when:
- Your schema uses **PostgreSQL extensions** (like `hstore`, `postgis`, `uuid-ossp`, etc.) - The embedded database doesn't have extensions pre-installed, causing plan generation to fail with "type does not exist" errors ([#121](https://github.com/pgplex/pgschema/issues/121))
-- Your schema has **cross-schema foreign key references** - The embedded approach only loads one schema at a time, breaking foreign key constraints that reference tables in other schemas ([#122](https://github.com/pgplex/pgschema/issues/122))
+- Your schema has **cross-schema foreign key references** to tables you do not manage (for example Supabase `auth.users`) - If the referenced table exists on the target database, [ignore that schema or table](/cli/ignore) and pgschema stubs it automatically during plan (embedded postgres is enough). Use the fallbacks below only when the table is missing on target ([#122](https://github.com/pgplex/pgschema/issues/122), [#548](https://github.com/pgplex/pgschema/issues/548))
### How It Works
@@ -104,54 +104,78 @@ CREATE TABLE products (
### Handling Cross-Schema Foreign Keys
-If your schema has foreign keys that reference tables in other schemas, you need to create those schemas in the plan database:
+If your schema has foreign keys that reference tables in other schemas (for example `REFERENCES auth.users`), those referenced objects must exist when `plan` applies your desired-state SQL to its temporary database.
+
+**Recommended: ignore the referenced schema or table** (default embedded plan database — no manual stub, no external plan DB):
+
+```toml
+# .pgschemaignore
+[schemas]
+patterns = ["auth"]
+
+# Or a single table:
+# [tables]
+# patterns = ["auth.users"]
+```
+
+```sql
+-- schema.sql — your app schema only
+CREATE TABLE profiles (
+ id SERIAL PRIMARY KEY,
+ auth_user_id UUID NOT NULL UNIQUE REFERENCES auth.users (id) ON DELETE CASCADE
+);
+```
+
+When you run `pgschema plan`, pgschema clones a structural stub of each ignored FK target from the **target** database (columns plus PRIMARY KEY / UNIQUE constraints) into the temporary plan instance. The stub is not managed: dump does not emit `auth.users`, and plan will not create or drop it on the target.
+
+Requirements:
+
+- The referenced table must already exist on the **target** database (for example Supabase already has `auth.users`).
+- Ignore patterns for cross-schema tables must be schema-qualified (`auth.users`, `auth.*`, or `[schemas] auth`). A bare `users` pattern only matches tables in the schema you are managing.
+
+See [Ignore](/cli/ignore) for full details.
+
+**Fallback: stub in the schema file** — when the referenced table is not on the target:
```sql
--- In your plan database, create referenced schemas and tables
CREATE SCHEMA IF NOT EXISTS auth;
CREATE TABLE IF NOT EXISTS auth.users (
- id SERIAL PRIMARY KEY,
- email TEXT NOT NULL
+ id UUID PRIMARY KEY
);
-CREATE SCHEMA IF NOT EXISTS billing;
-CREATE TABLE IF NOT EXISTS billing.customers (
+CREATE TABLE profiles (
id SERIAL PRIMARY KEY,
- user_id INTEGER REFERENCES auth.users(id)
+ auth_user_id UUID NOT NULL UNIQUE REFERENCES auth.users (id)
);
```
-Then run plan or apply:
+`pgschema dump` of your app schema will not emit the stub, so keep it in source (for example via `\i`).
-```bash
-# Set up referenced schemas in plan database
-psql -h localhost -U postgres -d pgschema_plan << 'EOF'
+**Fallback: external plan database** — when you cannot rely on the target during plan, or need objects that only exist in another database:
+
+```sql
+-- In your plan database
CREATE SCHEMA IF NOT EXISTS auth;
-CREATE TABLE IF NOT EXISTS auth.users (id SERIAL PRIMARY KEY, email TEXT NOT NULL);
-EOF
+CREATE TABLE IF NOT EXISTS auth.users (
+ id SERIAL PRIMARY KEY,
+ email TEXT NOT NULL
+);
+```
-# Now run plan for your main schema that references auth.users
+```bash
pgschema plan \
--file schema.sql \
--schema public \
--host localhost --db myapp --user postgres \
--plan-host localhost --plan-db pgschema_plan --plan-user postgres
-
-# Or use apply command to plan and apply in one step (File Mode)
-pgschema apply \
- --file schema.sql \
- --schema public \
- --host localhost --db myapp --user postgres \
- --plan-host localhost --plan-db pgschema_plan --plan-user postgres \
- --auto-approve
```
-Your `schema.sql` can now reference tables in other schemas:
+Your `schema.sql` references `auth.users` without inlining the stub:
```sql
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
- user_id INTEGER REFERENCES auth.users(id), -- Cross-schema FK works
+ user_id INTEGER REFERENCES auth.users(id),
total DECIMAL(10,2)
);
```
diff --git a/docs/cli/plan.mdx b/docs/cli/plan.mdx
index 1c331519..f6bb322b 100644
--- a/docs/cli/plan.mdx
+++ b/docs/cli/plan.mdx
@@ -14,7 +14,7 @@ The plan command follows infrastructure-as-code principles similar to Terraform:
1. Generate a detailed migration plan with proper dependency ordering
1. Display the plan without making any changes
-By default, pgschema uses an embedded PostgreSQL instance to validate your desired state SQL. For schemas using PostgreSQL extensions or cross-schema references, you can use an external database instead. See [External Plan Database](/cli/plan-db) for details.
+By default, pgschema uses an embedded PostgreSQL instance to validate your desired state SQL. Cross-schema foreign keys to unmanaged tables (for example Supabase `auth.users`) work with the default embedded instance when you [ignore those schemas](/cli/ignore). For PostgreSQL extensions, or when referenced tables are not on the target, see [External Plan Database](/cli/plan-db).
## Basic Usage
@@ -130,7 +130,7 @@ pgschema plan --host localhost --db myapp --user postgres --password mypassword
## Plan Database Options
-By default, the plan command uses an embedded PostgreSQL instance to validate your desired state SQL. For schemas that require PostgreSQL extensions or have cross-schema references, you can provide an external database. See [External Plan Database](/cli/plan-db) for complete documentation.
+By default, the plan command uses an embedded PostgreSQL instance to validate your desired state SQL. Cross-schema FKs to ignored tables are stubbed from the target automatically; extensions and other edge cases may need an external database. See [External Plan Database](/cli/plan-db) for complete documentation.
## Plan Options
diff --git a/internal/postgres/desired_state.go b/internal/postgres/desired_state.go
index 271d41d4..4b3be0e3 100644
--- a/internal/postgres/desired_state.go
+++ b/internal/postgres/desired_state.go
@@ -131,8 +131,8 @@ func stripSchemaQualifications(sql string, schemaName string) string {
// non-comment parts, and reassembles.
//
// Limitation: E'...' escape-string syntax uses backslash-escaped quotes (E'it\'s')
-// rather than doubled quotes ('it''s'). This parser only recognises the '' form.
-// With E'content\'', a backslash-escaped quote may cause the parser to mistrack
+// rather than doubled quotes ('it”s'). This parser only recognises the ” form.
+// With E'content\”, a backslash-escaped quote may cause the parser to mistrack
// string boundaries, which can result in either:
// - false-negative: schema qualifiers after the string are not stripped, or
// - false-positive: schema prefixes inside the E-string are incorrectly stripped.
@@ -492,23 +492,47 @@ func enhanceApplyError(err error, sql string) error {
return fmt.Errorf("%w\n\nError location (line %d, column %d):\n%s", err, line, col, snippet.String())
}
+// hintOnSQLState appends hint when the error (or a wrapped error) is a
+// PostgreSQL error whose SQLSTATE is one of codes.
+func hintOnSQLState(err error, hint string, codes ...string) error {
+ var pgErr *pgconn.PgError
+ if !errors.As(err, &pgErr) {
+ return err
+ }
+ for _, code := range codes {
+ if pgErr.Code == code {
+ return fmt.Errorf("%w\nHint: %s", err, hint)
+ }
+ }
+ return err
+}
+
// hintExtensionDependency appends hint to errors whose SQLSTATE indicates a
// missing type, function, operator, or operator class — the typical failure
// when the desired state depends on a PostgreSQL extension (e.g. btree_gist,
// citext, pgvector) that is not available in the plan database (issue #436).
// pgschema does not manage extension lifecycle, so the hint guides the user
// toward a plan database that has the extension installed.
+//
+// 42704 undefined_object: "type X does not exist", "data type X has no
+// default operator class for access method gist"
+// 42883 undefined_function: "function X does not exist", "operator does not exist"
func hintExtensionDependency(err error, hint string) error {
- var pgErr *pgconn.PgError
- if !errors.As(err, &pgErr) {
- return err
- }
- switch pgErr.Code {
- // 42704 undefined_object: "type X does not exist", "data type X has no
- // default operator class for access method gist"
- // 42883 undefined_function: "function X does not exist", "operator does not exist"
- case "42704", "42883":
- return fmt.Errorf("%w\nHint: %s", err, hint)
- }
- return err
+ return hintOnSQLState(err, hint, "42704", "42883")
+}
+
+// hintCrossSchemaReference appends hint to errors whose SQLSTATE indicates a
+// missing schema or relation — the typical failure when desired-state SQL
+// references objects in another schema (e.g. REFERENCES auth.users) that the
+// plan database does not have (issues #122, #548).
+//
+// .pgschemaignore can help when the referenced table is ignored and exists
+// on the target DB: plan stubs ignored FK targets before apply (issue #548).
+// If the table is missing entirely, users must still provide a manual stub or
+// a plan database that already has the referenced objects.
+//
+// 3F000 invalid_schema_name: "schema \"auth\" does not exist"
+// 42P01 undefined_table: "relation \"auth.users\" does not exist"
+func hintCrossSchemaReference(err error, hint string) error {
+ return hintOnSQLState(err, hint, "3F000", "42P01")
}
diff --git a/internal/postgres/desired_state_test.go b/internal/postgres/desired_state_test.go
index ec1a7790..afdbabe9 100644
--- a/internal/postgres/desired_state_test.go
+++ b/internal/postgres/desired_state_test.go
@@ -426,3 +426,76 @@ func TestHintExtensionDependency(t *testing.T) {
}
})
}
+
+func TestHintCrossSchemaReference(t *testing.T) {
+ const hint = "this schema may reference objects in another schema"
+
+ t.Run("invalid_schema_name gets hint", func(t *testing.T) {
+ pgErr := &pgconn.PgError{
+ Message: `schema "auth" does not exist`,
+ Code: "3F000",
+ }
+ result := hintCrossSchemaReference(pgErr, hint)
+ if !strings.Contains(result.Error(), "Hint: "+hint) {
+ t.Errorf("expected hint to be appended, got: %s", result.Error())
+ }
+ var unwrapped *pgconn.PgError
+ if !errors.As(result, &unwrapped) {
+ t.Error("expected wrapped error to preserve PgError")
+ }
+ })
+
+ t.Run("undefined_table gets hint", func(t *testing.T) {
+ pgErr := &pgconn.PgError{
+ Message: `relation "auth.users" does not exist`,
+ Code: "42P01",
+ }
+ result := hintCrossSchemaReference(pgErr, hint)
+ if !strings.Contains(result.Error(), "Hint: "+hint) {
+ t.Errorf("expected hint to be appended, got: %s", result.Error())
+ }
+ })
+
+ t.Run("hint applies after enhanceApplyError wrapping", func(t *testing.T) {
+ sql := "CREATE TABLE users (id uuid REFERENCES auth.users(id));"
+ pgErr := &pgconn.PgError{
+ Message: `schema "auth" does not exist`,
+ Code: "3F000",
+ Position: int32(strings.Index(sql, "auth.users") + 1),
+ }
+ result := hintCrossSchemaReference(enhanceApplyError(pgErr, sql), hint)
+ if !strings.Contains(result.Error(), "Hint: "+hint) {
+ t.Errorf("expected hint on enhanced error, got: %s", result.Error())
+ }
+ })
+
+ t.Run("extension SQLSTATE is not hinted", func(t *testing.T) {
+ pgErr := &pgconn.PgError{
+ Message: `type "citext" does not exist`,
+ Code: "42704",
+ }
+ result := hintCrossSchemaReference(pgErr, hint)
+ if result != error(pgErr) {
+ t.Errorf("expected same error instance, got: %s", result.Error())
+ }
+ })
+
+ t.Run("other SQLSTATE passes through", func(t *testing.T) {
+ pgErr := &pgconn.PgError{
+ Message: "syntax error",
+ Code: "42601",
+ }
+ result := hintCrossSchemaReference(pgErr, hint)
+ if result != error(pgErr) {
+ t.Errorf("expected same error instance, got: %s", result.Error())
+ }
+ })
+
+ t.Run("non-pg error passes through", func(t *testing.T) {
+ origErr := fmt.Errorf("some other error")
+ result := hintCrossSchemaReference(origErr, hint)
+ if result != origErr {
+ t.Errorf("expected same error instance, got: %s", result.Error())
+ }
+ })
+}
diff --git a/internal/postgres/embedded.go b/internal/postgres/embedded.go
index 3113d9af..75f8f6eb 100644
--- a/internal/postgres/embedded.go
+++ b/internal/postgres/embedded.go
@@ -269,6 +269,7 @@ func (ep *EmbeddedPostgres) ApplySchema(ctx context.Context, schema string, sql
if _, err := util.ExecContextWithLogging(ctx, conn, schemaAgnosticSQL, "apply desired state SQL to temporary schema"); err != nil {
enhanced := enhanceApplyError(err, schemaAgnosticSQL)
enhanced = hintExtensionDependency(enhanced, "this schema may depend on a PostgreSQL extension, which the embedded plan database cannot provide. Use an external plan database with the extension installed (--plan-host), see https://www.pgschema.com/cli/plan-db")
+ enhanced = hintCrossSchemaReference(enhanced, "this schema may reference objects in another schema that the embedded plan database does not have. If the table exists on the target database, add it to .pgschemaignore ([schemas] or schema-qualified [tables] pattern, e.g. auth or auth.users), see https://www.pgschema.com/cli/ignore. Otherwise add a stub CREATE SCHEMA/TABLE in your desired SQL, or use an external plan database (--plan-host), see https://www.pgschema.com/cli/plan-db")
return fmt.Errorf("failed to apply schema SQL to temporary schema %s: %w", ep.tempSchema, enhanced)
}
diff --git a/internal/postgres/external.go b/internal/postgres/external.go
index 5c904471..4ff20164 100644
--- a/internal/postgres/external.go
+++ b/internal/postgres/external.go
@@ -176,6 +176,7 @@ func (ed *ExternalDatabase) ApplySchema(ctx context.Context, schema string, sql
if _, err := util.ExecContextWithLogging(ctx, conn, schemaAgnosticSQL, "apply desired state SQL to temporary schema"); err != nil {
enhanced := enhanceApplyError(err, schemaAgnosticSQL)
enhanced = hintExtensionDependency(enhanced, "this schema may depend on a PostgreSQL extension that is not installed in the plan database. Install the extension in the plan database (CREATE EXTENSION) and re-run, see https://www.pgschema.com/cli/plan-db")
+ enhanced = hintCrossSchemaReference(enhanced, "this schema may reference objects in another schema that are not present in the plan database. If the table exists on the target database, add it to .pgschemaignore ([schemas] or schema-qualified [tables] pattern), see https://www.pgschema.com/cli/ignore. Otherwise create those objects in the plan database or add a stub CREATE SCHEMA/TABLE in your desired SQL, see https://www.pgschema.com/cli/plan-db")
return fmt.Errorf("failed to apply schema SQL to temporary schema %s: %w", ed.tempSchema, enhanced)
}
diff --git a/internal/postgres/fk_refs.go b/internal/postgres/fk_refs.go
new file mode 100644
index 00000000..215f57bc
--- /dev/null
+++ b/internal/postgres/fk_refs.go
@@ -0,0 +1,328 @@
+package postgres
+
+import (
+ "strings"
+ "unicode"
+)
+
+// QualifiedName is a schema-qualified table name extracted from SQL.
+type QualifiedName struct {
+ Schema string
+ Table string
+}
+
+// keywords that cannot be an unquoted table name immediately after REFERENCES
+// (e.g. GRANT REFERENCES ON TABLE ...).
+var referencesFollowKeywords = map[string]bool{
+ "on": true,
+ "to": true,
+ "from": true,
+ "where": true,
+ "set": true,
+ "all": true,
+ "table": true,
+ "schema": true,
+}
+
+// ExtractForeignKeyTargets returns schema-qualified table names that appear
+// as REFERENCES targets in sql. Unqualified names use defaultSchema.
+// String literals, comments, and dollar-quoted bodies are skipped.
+func ExtractForeignKeyTargets(sql, defaultSchema string) []QualifiedName {
+ seen := make(map[string]bool)
+ var out []QualifiedName
+
+ walkSQLCode(sql, func(code string) {
+ i := 0
+ for i < len(code) {
+ idx := indexKeyword(code, i, "references")
+ if idx < 0 {
+ return
+ }
+ i = idx + len("references")
+ schema, table, next, ok := parseQualifiedName(code, i)
+ if !ok {
+ i = next
+ continue
+ }
+ i = next
+ if referencesFollowKeywords[table] {
+ continue
+ }
+ if schema == "" {
+ schema = defaultSchema
+ }
+ key := schema + "." + table
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ out = append(out, QualifiedName{Schema: schema, Table: table})
+ }
+ })
+
+ return out
+}
+
+// ExtractCreateTableNames returns schema-qualified table names from CREATE TABLE
+// statements in sql. Unqualified names use defaultSchema.
+func ExtractCreateTableNames(sql, defaultSchema string) []QualifiedName {
+ seen := make(map[string]bool)
+ var out []QualifiedName
+
+ walkSQLCode(sql, func(code string) {
+ i := 0
+ for i < len(code) {
+ idx := indexKeyword(code, i, "create")
+ if idx < 0 {
+ return
+ }
+ i = idx + len("create")
+ i = skipSpace(code, i)
+ // Optional TEMP/TEMPORARY/UNLOGGED/GLOBAL/LOCAL modifiers before TABLE
+ for {
+ word, next, ok := parseUnquotedIdent(code, i)
+ if !ok {
+ break
+ }
+ switch word {
+ case "global", "local", "temp", "temporary", "unlogged":
+ i = next
+ i = skipSpace(code, i)
+ default:
+ goto afterMods
+ }
+ }
+ afterMods:
+ if !hasKeywordAt(code, i, "table") {
+ continue
+ }
+ i += len("table")
+ i = skipSpace(code, i)
+ if hasKeywordAt(code, i, "if") {
+ i += 2
+ i = skipSpace(code, i)
+ if hasKeywordAt(code, i, "not") {
+ i += 3
+ i = skipSpace(code, i)
+ if hasKeywordAt(code, i, "exists") {
+ i += 6
+ i = skipSpace(code, i)
+ }
+ }
+ }
+ if hasKeywordAt(code, i, "only") {
+ i += 4
+ i = skipSpace(code, i)
+ }
+ schema, table, next, ok := parseQualifiedName(code, i)
+ i = next
+ if !ok || table == "" {
+ continue
+ }
+ if schema == "" {
+ schema = defaultSchema
+ }
+ key := schema + "." + table
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ out = append(out, QualifiedName{Schema: schema, Table: table})
+ }
+ })
+
+ return out
+}
+
+func walkSQLCode(sql string, fn func(code string)) {
+ for _, seg := range splitDollarQuotedSegments(sql) {
+ if seg.quoted {
+ continue
+ }
+ walkSQLCodePreservingStringsAndComments(seg.text, fn)
+ }
+}
+
+func walkSQLCodePreservingStringsAndComments(text string, fn func(code string)) {
+ i := 0
+ segStart := 0
+ flushCode := func(end int) {
+ if end > segStart {
+ fn(text[segStart:end])
+ }
+ segStart = end
+ }
+
+ for i < len(text) {
+ ch := text[i]
+
+ if ch == '\'' {
+ flushCode(i)
+ i++
+ for i < len(text) {
+ if text[i] == '\'' {
+ if i+1 < len(text) && text[i+1] == '\'' {
+ i += 2
+ } else {
+ i++
+ break
+ }
+ } else {
+ i++
+ }
+ }
+ segStart = i
+ continue
+ }
+
+ if ch == '-' && i+1 < len(text) && text[i+1] == '-' {
+ flushCode(i)
+ i += 2
+ for i < len(text) && text[i] != '\n' {
+ i++
+ }
+ if i < len(text) {
+ i++
+ }
+ segStart = i
+ continue
+ }
+
+ if ch == '/' && i+1 < len(text) && text[i+1] == '*' {
+ flushCode(i)
+ i += 2
+ for i < len(text) {
+ if text[i] == '*' && i+1 < len(text) && text[i+1] == '/' {
+ i += 2
+ break
+ }
+ i++
+ }
+ segStart = i
+ continue
+ }
+
+ i++
+ }
+ flushCode(i)
+}
+
+// indexKeyword finds the next occurrence of keyword as a whole word, case-insensitive.
+func indexKeyword(s string, start int, keyword string) int {
+ n := len(keyword)
+ for i := start; i+n <= len(s); i++ {
+ if hasKeywordAt(s, i, keyword) {
+ return i
+ }
+ }
+ return -1
+}
+
+func hasKeywordAt(s string, i int, keyword string) bool {
+ n := len(keyword)
+ if i+n > len(s) {
+ return false
+ }
+ if !strings.EqualFold(s[i:i+n], keyword) {
+ return false
+ }
+ if i > 0 && isIdentChar(rune(s[i-1])) {
+ return false
+ }
+ if i+n < len(s) && isIdentChar(rune(s[i+n])) {
+ return false
+ }
+ return true
+}
+
+func skipSpace(s string, i int) int {
+ for i < len(s) && unicode.IsSpace(rune(s[i])) {
+ i++
+ }
+ return i
+}
+
+func parseQualifiedName(s string, i int) (schema, table string, next int, ok bool) {
+ i = skipSpace(s, i)
+ first, i, ok := parseIdent(s, i)
+ if !ok {
+ return "", "", i, false
+ }
+ i = skipSpace(s, i)
+ if i < len(s) && s[i] == '.' {
+ i++
+ second, j, ok2 := parseIdent(s, i)
+ if !ok2 {
+ return "", "", i, false
+ }
+ i = j
+ i = skipSpace(s, i)
+ if i < len(s) && s[i] == '.' {
+ // catalog.schema.table
+ i++
+ third, k, ok3 := parseIdent(s, i)
+ if !ok3 {
+ return "", "", i, false
+ }
+ return second, third, k, true
+ }
+ return first, second, i, true
+ }
+ return "", first, i, true
+}
+
+func parseIdent(s string, i int) (string, int, bool) {
+ i = skipSpace(s, i)
+ if i >= len(s) {
+ return "", i, false
+ }
+ if s[i] == '"' {
+ return parseQuotedIdent(s, i)
+ }
+ return parseUnquotedIdent(s, i)
+}
+
+func parseQuotedIdent(s string, i int) (string, int, bool) {
+ if i >= len(s) || s[i] != '"' {
+ return "", i, false
+ }
+ i++
+ var b strings.Builder
+ for i < len(s) {
+ if s[i] == '"' {
+ if i+1 < len(s) && s[i+1] == '"' {
+ b.WriteByte('"')
+ i += 2
+ continue
+ }
+ return b.String(), i + 1, b.Len() > 0
+ }
+ b.WriteByte(s[i])
+ i++
+ }
+ return "", i, false
+}
+
+func parseUnquotedIdent(s string, i int) (string, int, bool) {
+ i = skipSpace(s, i)
+ if i >= len(s) {
+ return "", i, false
+ }
+ if !isIdentStart(rune(s[i])) {
+ return "", i, false
+ }
+ start := i
+ i++
+ for i < len(s) && isIdentChar(rune(s[i])) {
+ i++
+ }
+ return strings.ToLower(s[start:i]), i, true
+}
+
+func isIdentStart(r rune) bool {
+ return (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || r == '_'
+}
+
+func isIdentChar(r rune) bool {
+ return isIdentStart(r) || (r >= '0' && r <= '9') || r == '$'
+}
diff --git a/internal/postgres/fk_refs_test.go b/internal/postgres/fk_refs_test.go
new file mode 100644
index 00000000..afbe35af
--- /dev/null
+++ b/internal/postgres/fk_refs_test.go
@@ -0,0 +1,128 @@
+package postgres
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestExtractForeignKeyTargets(t *testing.T) {
+ tests := []struct {
+ name string
+ sql string
+ defaultSchema string
+ want []QualifiedName
+ }{
+ {
+ name: "column-level cross-schema",
+ sql: "CREATE TABLE users (id uuid, auth_user_id uuid REFERENCES auth.users (id) ON DELETE CASCADE);",
+ defaultSchema: "public",
+ want: []QualifiedName{{Schema: "auth", Table: "users"}},
+ },
+ {
+ name: "table-level constraint",
+ sql: "CREATE TABLE profiles (user_id uuid, CONSTRAINT fk_auth FOREIGN KEY (user_id) REFERENCES auth.users (id));",
+ defaultSchema: "public",
+ want: []QualifiedName{{Schema: "auth", Table: "users"}},
+ },
+ {
+ name: "unqualified uses default schema",
+ sql: "CREATE TABLE orders (user_id int REFERENCES users(id));",
+ defaultSchema: "public",
+ want: []QualifiedName{{Schema: "public", Table: "users"}},
+ },
+ {
+ name: "table name can be public",
+ sql: "CREATE TABLE child (ref_id int REFERENCES public(id));",
+ defaultSchema: "public",
+ want: []QualifiedName{{Schema: "public", Table: "public"}},
+ },
+ {
+ name: "quoted identifiers",
+ sql: `CREATE TABLE t (id int REFERENCES "Auth"."Users" (id));`,
+ defaultSchema: "public",
+ want: []QualifiedName{{Schema: "Auth", Table: "Users"}},
+ },
+ {
+ name: "skips string literals",
+ sql: "CREATE TABLE t (id int, note text DEFAULT 'REFERENCES auth.users (id)');",
+ defaultSchema: "public",
+ want: nil,
+ },
+ {
+ name: "skips comments",
+ sql: "CREATE TABLE t (id int); -- REFERENCES auth.users (id)",
+ defaultSchema: "public",
+ want: nil,
+ },
+ {
+ name: "skips dollar-quoted bodies",
+ sql: "CREATE FUNCTION f() RETURNS void AS $$ BEGIN PERFORM REFERENCES auth.users; END; $$ LANGUAGE plpgsql;",
+ defaultSchema: "public",
+ want: nil,
+ },
+ {
+ name: "skips GRANT REFERENCES ON",
+ sql: "GRANT REFERENCES ON TABLE users TO app;",
+ defaultSchema: "public",
+ want: nil,
+ },
+ {
+ name: "deduplicates",
+ sql: "CREATE TABLE a (x uuid REFERENCES auth.users(id)); CREATE TABLE b (y uuid REFERENCES auth.users(id));",
+ defaultSchema: "public",
+ want: []QualifiedName{{Schema: "auth", Table: "users"}},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := ExtractForeignKeyTargets(tt.sql, tt.defaultSchema)
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("ExtractForeignKeyTargets() = %#v, want %#v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestExtractCreateTableNames(t *testing.T) {
+ tests := []struct {
+ name string
+ sql string
+ defaultSchema string
+ want []QualifiedName
+ }{
+ {
+ name: "simple",
+ sql: "CREATE TABLE users (id int);",
+ defaultSchema: "public",
+ want: []QualifiedName{{Schema: "public", Table: "users"}},
+ },
+ {
+ name: "if not exists qualified",
+ sql: "CREATE TABLE IF NOT EXISTS auth.users (id uuid PRIMARY KEY);",
+ defaultSchema: "public",
+ want: []QualifiedName{{Schema: "auth", Table: "users"}},
+ },
+ {
+ name: "unlogged",
+ sql: "CREATE UNLOGGED TABLE cache (k text);",
+ defaultSchema: "backend",
+ want: []QualifiedName{{Schema: "backend", Table: "cache"}},
+ },
+ {
+ name: "skips create schema",
+ sql: "CREATE SCHEMA auth; CREATE TABLE auth.users (id uuid);",
+ defaultSchema: "public",
+ want: []QualifiedName{{Schema: "auth", Table: "users"}},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := ExtractCreateTableNames(tt.sql, tt.defaultSchema)
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("ExtractCreateTableNames() = %#v, want %#v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/ir/ignore.go b/ir/ignore.go
index d9bd5cd0..b990ee11 100644
--- a/ir/ignore.go
+++ b/ir/ignore.go
@@ -28,9 +28,11 @@ type IgnoreConfig struct {
Triggers []string `toml:"triggers,omitempty"`
Privileges []string `toml:"privileges,omitempty"`
DefaultPrivileges []string `toml:"default_privileges,omitempty"`
+ Schemas []string `toml:"schemas,omitempty"`
}
-// ShouldIgnoreTable checks if a table should be ignored based on the patterns
+// ShouldIgnoreTable checks if a table should be ignored based on the patterns.
+// Patterns match the unqualified table name (e.g. "temp_*").
func (c *IgnoreConfig) ShouldIgnoreTable(tableName string) bool {
if c == nil {
return false
@@ -38,6 +40,34 @@ func (c *IgnoreConfig) ShouldIgnoreTable(tableName string) bool {
return c.shouldIgnore(tableName, c.Tables)
}
+// ShouldIgnoreSchema checks if a schema should be ignored based on the [schemas] patterns.
+func (c *IgnoreConfig) ShouldIgnoreSchema(schemaName string) bool {
+ if c == nil {
+ return false
+ }
+ return c.shouldIgnore(schemaName, c.Schemas)
+}
+
+// ShouldIgnoreReferencedTable reports whether a table used as an FK target should
+// be treated as ignored for plan-time stubbing.
+//
+// Cross-schema references (schema != targetSchema) match [schemas] patterns or
+// schema-qualified table patterns such as "auth.users" / "auth.*". Bare table
+// names are not matched across schemas, so ignoring local "users" does not
+// stub auth.users. Same-schema references use ordinary table-name patterns.
+func (c *IgnoreConfig) ShouldIgnoreReferencedTable(schema, table, targetSchema string) bool {
+ if c == nil {
+ return false
+ }
+ if schema != "" && schema != targetSchema {
+ if c.ShouldIgnoreSchema(schema) {
+ return true
+ }
+ return c.shouldIgnore(schema+"."+table, c.Tables)
+ }
+ return c.ShouldIgnoreTable(table)
+}
+
// ShouldIgnoreView checks if a view should be ignored based on the patterns
func (c *IgnoreConfig) ShouldIgnoreView(viewName string) bool {
if c == nil {
diff --git a/ir/ignore_test.go b/ir/ignore_test.go
index 1c6fc019..8628e5d8 100644
--- a/ir/ignore_test.go
+++ b/ir/ignore_test.go
@@ -183,6 +183,43 @@ func TestIgnoreConfig_NilConfig(t *testing.T) {
if config.ShouldIgnoreTrigger("any_trigger") {
t.Error("nil config should not ignore any trigger")
}
+ if config.ShouldIgnoreSchema("auth") {
+ t.Error("nil config should not ignore any schema")
+ }
+ if config.ShouldIgnoreReferencedTable("auth", "users", "public") {
+ t.Error("nil config should not ignore referenced tables")
+ }
+}
+
+func TestIgnoreConfig_ShouldIgnoreReferencedTable(t *testing.T) {
+ config := &IgnoreConfig{
+ Tables: []string{"temp_*", "auth.users", "auth.*_backup"},
+ Schemas: []string{"storage"},
+ }
+
+ tests := []struct {
+ schema string
+ table string
+ targetSchema string
+ want bool
+ }{
+ {schema: "auth", table: "users", targetSchema: "public", want: true},
+ {schema: "auth", table: "sessions", targetSchema: "public", want: false},
+ {schema: "auth", table: "foo_backup", targetSchema: "public", want: true},
+ {schema: "storage", table: "objects", targetSchema: "public", want: true},
+ {schema: "public", table: "temp_cache", targetSchema: "public", want: true},
+ {schema: "public", table: "users", targetSchema: "public", want: false},
+ // Bare table pattern must not match across schemas
+ {schema: "auth", table: "temp_cache", targetSchema: "public", want: false},
+ }
+
+ for _, tt := range tests {
+ got := config.ShouldIgnoreReferencedTable(tt.schema, tt.table, tt.targetSchema)
+ if got != tt.want {
+ t.Errorf("ShouldIgnoreReferencedTable(%q, %q, %q) = %v, want %v",
+ tt.schema, tt.table, tt.targetSchema, got, tt.want)
+ }
+ }
}
func TestMatchPattern(t *testing.T) {
diff --git a/ir/stub.go b/ir/stub.go
new file mode 100644
index 00000000..b793a262
--- /dev/null
+++ b/ir/stub.go
@@ -0,0 +1,156 @@
+package ir
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strings"
+)
+
+// BuildTableStubSQL returns CREATE SCHEMA / CREATE TABLE DDL that is sufficient
+// for foreign keys to reference schema.table: all columns plus PRIMARY KEY and
+// UNIQUE constraints. Defaults, identity, generated expressions, and foreign
+// keys on the ignored table itself are omitted.
+//
+// Returns an empty string if the table does not exist.
+func BuildTableStubSQL(ctx context.Context, db *sql.DB, schema, table, targetSchema string) (string, error) {
+ cols, err := queryStubColumns(ctx, db, schema, table)
+ if err != nil {
+ return "", err
+ }
+ if len(cols) == 0 {
+ return "", nil
+ }
+
+ constraints, err := queryStubConstraints(ctx, db, schema, table)
+ if err != nil {
+ return "", err
+ }
+
+ var b strings.Builder
+ qualified := QualifyEntityNameWithQuotesMode(schema, table, targetSchema, schema != targetSchema)
+
+ if schema != targetSchema {
+ b.WriteString("CREATE SCHEMA IF NOT EXISTS ")
+ b.WriteString(QuoteIdentifier(schema))
+ b.WriteString(";\n")
+ }
+
+ b.WriteString("-- pgschema: stub for ignored table ")
+ b.WriteString(sanitizeComment(schema))
+ b.WriteString(".")
+ b.WriteString(sanitizeComment(table))
+ b.WriteString("\nCREATE TABLE IF NOT EXISTS ")
+ b.WriteString(qualified)
+ b.WriteString(" (\n")
+
+ for i, col := range cols {
+ b.WriteString(" ")
+ b.WriteString(QuoteIdentifier(col.name))
+ b.WriteString(" ")
+ b.WriteString(col.dataType)
+ if col.notNull {
+ b.WriteString(" NOT NULL")
+ }
+ if i < len(cols)-1 || len(constraints) > 0 {
+ b.WriteString(",")
+ }
+ b.WriteString("\n")
+ }
+
+ for i, def := range constraints {
+ b.WriteString(" ")
+ b.WriteString(def)
+ if i < len(constraints)-1 {
+ b.WriteString(",")
+ }
+ b.WriteString("\n")
+ }
+
+ b.WriteString(");\n")
+ return b.String(), nil
+}
+
+type stubColumn struct {
+ name string
+ dataType string
+ notNull bool
+}
+
+func queryStubColumns(ctx context.Context, db *sql.DB, schema, table string) ([]stubColumn, error) {
+ const q = `
+SELECT
+ a.attname,
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
+ a.attnotnull
+FROM pg_catalog.pg_attribute a
+JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
+JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
+WHERE n.nspname = $1
+ AND c.relname = $2
+ AND c.relkind IN ('r', 'p')
+ AND a.attnum > 0
+ AND NOT a.attisdropped
+ORDER BY a.attnum`
+
+ rows, err := db.QueryContext(ctx, q, schema, table)
+ if err != nil {
+ return nil, fmt.Errorf("query columns for %s.%s: %w", schema, table, err)
+ }
+ defer rows.Close()
+
+ var cols []stubColumn
+ for rows.Next() {
+ var col stubColumn
+ if err := rows.Scan(&col.name, &col.dataType, &col.notNull); err != nil {
+ return nil, fmt.Errorf("scan columns for %s.%s: %w", schema, table, err)
+ }
+ cols = append(cols, col)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return cols, nil
+}
+
+func queryStubConstraints(ctx context.Context, db *sql.DB, schema, table string) ([]string, error) {
+ const q = `
+SELECT pg_catalog.pg_get_constraintdef(con.oid, true)
+FROM pg_catalog.pg_constraint con
+JOIN pg_catalog.pg_class c ON c.oid = con.conrelid
+JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
+WHERE n.nspname = $1
+ AND c.relname = $2
+ AND con.contype IN ('p', 'u')
+ORDER BY con.contype, con.conname`
+
+ rows, err := db.QueryContext(ctx, q, schema, table)
+ if err != nil {
+ return nil, fmt.Errorf("query constraints for %s.%s: %w", schema, table, err)
+ }
+ defer rows.Close()
+
+ var defs []string
+ for rows.Next() {
+ var def string
+ if err := rows.Scan(&def); err != nil {
+ return nil, fmt.Errorf("scan constraints for %s.%s: %w", schema, table, err)
+ }
+ if def != "" {
+ defs = append(defs, def)
+ }
+ }
+ return defs, rows.Err()
+}
+
+// sanitizeComment replaces control characters (newlines, tabs, etc.) in a
+// string destined for a SQL line comment so quoted identifiers cannot break
+// out of the comment and inject SQL.
+func sanitizeComment(s string) string {
+ return strings.Map(func(r rune) rune {
+ if r == '\n' || r == '\r' || r < 0x20 {
+ return ' '
+ }
+ return r
+ }, s)
+}