Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions cmd/ignore_integration_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
}
})
}
70 changes: 70 additions & 0 deletions cmd/plan/ignore_stubs.go
Original file line numberDiff line numberDiff line change
@@ -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
}
19 changes: 19 additions & 0 deletions cmd/plan/plan.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
8 changes: 8 additions & 0 deletions cmd/util/ignoreloader.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,13 +42,20 @@ 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
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"`
Expand DownExpand Up@@ -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
Expand Down
7 changes: 7 additions & 0 deletions cmd/util/ignoreloader_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,6 +50,9 @@ patterns = ["fk_temp_*"]

[triggers]
patterns = ["trg_temp_*"]

[schemas]
patterns = ["auth"]
`

err := os.WriteFile(testFile, []byte(tomlContent), 0644)
Expand DownExpand Up@@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions docs/cli/apply.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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.

Expand Down
34 changes: 33 additions & 1 deletion docs/cli/ignore.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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_*"]

Expand DownExpand Up@@ -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.

<Warning>
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.
</Warning>

## 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).

<Note>
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`.
</Note>

## 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.
Expand Down
Loading