From 79cf8475cdcb02fe1a837770d7502496cd5e6ed5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 06:09:40 +0000 Subject: [PATCH 1/7] fix: hint missing schema/relation when plan applies desired SQL (#548) Plan applies desired-state SQL before .pgschemaignore filtering, so REFERENCES auth.users fails on the embedded plan database. Hint 3F000 and 42P01 toward a stub CREATE SCHEMA/TABLE or an external plan database, and document that ignore cannot skip this check. Co-authored-by: Tianzhou --- docs/cli/ignore.mdx | 6 +- docs/cli/plan-db.mdx | 27 ++++++++- internal/postgres/desired_state.go | 51 ++++++++++++----- internal/postgres/desired_state_test.go | 73 +++++++++++++++++++++++++ internal/postgres/embedded.go | 1 + internal/postgres/external.go | 1 + 6 files changed, 141 insertions(+), 18 deletions(-) diff --git a/docs/cli/ignore.mdx b/docs/cli/ignore.mdx index 2148658b8..bddc627c5 100644 --- a/docs/cli/ignore.mdx +++ b/docs/cli/ignore.mdx @@ -128,7 +128,11 @@ 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** - When modules live in separate schemas with foreign keys between them, omit the FK from the desired SQL and ignore the live constraint so each schema can be bootstrapped independently. Drop the ignore later to let pgschema manage the FK once all tables exist. + + +Ignore cannot make `plan` succeed when the desired-state SQL itself contains `REFERENCES other_schema.table`. Plan applies that SQL to a temporary database **before** ignore filtering, so PostgreSQL still requires the referenced schema and table to exist. Add a stub `CREATE SCHEMA` / `CREATE TABLE` in your schema file, or use an [external plan database](/cli/plan-db) that already has those objects (for example Supabase `auth.users`). + 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. diff --git a/docs/cli/plan-db.mdx b/docs/cli/plan-db.mdx index 2153d8faf..d8e53efe1 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** - 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), [#548](https://github.com/pgplex/pgschema/issues/548)). You can also stub those objects in the desired SQL file itself (see below). ### How It Works @@ -104,7 +104,28 @@ 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, those referenced objects must exist when `plan` applies your desired-state SQL. `.pgschemaignore` cannot skip this: the SQL is applied before ignore filtering. + +Two approaches work: + +**Stub in the schema file** (works with the default embedded plan database). Keep managing only your app schema (`--schema public` or `--schema backend`); the stub exists so PostgreSQL can create the FK: + +```sql +-- schema.sql +CREATE SCHEMA IF NOT EXISTS auth; +CREATE TABLE IF NOT EXISTS auth.users ( + id UUID PRIMARY KEY +); + +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + auth_user_id UUID NOT NULL UNIQUE REFERENCES auth.users (id) +); +``` + +`pgschema dump` of your app schema will not emit the stub, so keep it in source (for example via `\i`). + +**Or create the objects in an external plan database:** ```sql -- In your plan database, create referenced schemas and tables @@ -146,7 +167,7 @@ pgschema apply \ --auto-approve ``` -Your `schema.sql` can now reference tables in other schemas: +Your `schema.sql` can now reference tables in other schemas (without inlining the stub): ```sql CREATE TABLE orders ( diff --git a/internal/postgres/desired_state.go b/internal/postgres/desired_state.go index 271d41d4e..c9eb461ff 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,46 @@ 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 cannot help: plan applies the SQL before ignore filtering, +// so PostgreSQL still requires the referenced objects to exist. Stub them in +// the desired SQL, or use a plan database that already has them. +// +// 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 ec1a77905..afdbabe9f 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 3113d9afd..86ccebb77 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. Add a stub CREATE SCHEMA/TABLE in your desired SQL, or use an external plan database that already has those objects (--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 5c9044714..2069f4233 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. Create those schemas/tables 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) } From 575bf4c564bf4c1a7aea7cfc3d55af31345b7eb8 Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Mon, 17 Aug 2026 23:26:38 -0700 Subject: [PATCH 2/7] Apply suggestions from code review Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- internal/postgres/desired_state.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/postgres/desired_state.go b/internal/postgres/desired_state.go index c9eb461ff..74c0b3325 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. From 9695c38e117c0d100d90c6e4d807036caeeb7f81 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 06:31:06 +0000 Subject: [PATCH 3/7] feat: stub ignored cross-schema FK targets at plan time (#548) When desired SQL references an ignored table (auth.users, [schemas] auth, or same-schema ignored tables) that is not defined in the file, clone a structural stub from the target database before applying SQL to the plan instance. Ignore now works for Supabase-style FKs without a hand-written CREATE TABLE stub. Co-authored-by: Tianzhou --- cmd/ignore_integration_test.go | 130 ++++++++++++ cmd/plan/ignore_stubs.go | 69 +++++++ cmd/plan/plan.go | 19 ++ cmd/util/ignoreloader.go | 8 + cmd/util/ignoreloader_test.go | 7 + docs/cli/ignore.mdx | 38 +++- docs/cli/plan-db.mdx | 16 +- internal/postgres/fk_refs.go | 329 ++++++++++++++++++++++++++++++ internal/postgres/fk_refs_test.go | 122 +++++++++++ ir/ignore.go | 32 ++- ir/ignore_test.go | 37 ++++ ir/stub.go | 144 +++++++++++++ 12 files changed, 942 insertions(+), 9 deletions(-) create mode 100644 cmd/plan/ignore_stubs.go create mode 100644 internal/postgres/fk_refs.go create mode 100644 internal/postgres/fk_refs_test.go create mode 100644 ir/stub.go diff --git a/cmd/ignore_integration_test.go b/cmd/ignore_integration_test.go index 51b30168d..7ea1c52fb 100644 --- a/cmd/ignore_integration_test.go +++ b/cmd/ignore_integration_test.go @@ -1835,3 +1835,133 @@ 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") && strings.Contains(output, "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 000000000..d9660f0d6 --- /dev/null +++ b/cmd/plan/ignore_stubs.go @@ -0,0 +1,69 @@ +package plan + +import ( + "context" + "fmt" + + "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 string + 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 += ddl + } + + return stubs + desiredSQL, nil +} diff --git a/cmd/plan/plan.go b/cmd/plan/plan.go index 93d2703cf..93e6c3301 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 c1f28e293..f8ef882dd 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 02fa82b3d..1dd4c4030 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/ignore.mdx b/docs/cli/ignore.mdx index bddc627c5..7393ea9aa 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,16 +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, omit the FK from the desired SQL and ignore the live constraint so each schema can be bootstrapped independently. Drop the ignore later to let pgschema manage the FK once all tables exist. - - -Ignore cannot make `plan` succeed when the desired-state SQL itself contains `REFERENCES other_schema.table`. Plan applies that SQL to a temporary database **before** ignore filtering, so PostgreSQL still requires the referenced schema and table to exist. Add a stub `CREATE SCHEMA` / `CREATE TABLE` in your schema file, or use an [external plan database](/cli/plan-db) that already has those objects (for example Supabase `auth.users`). - +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. The 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, add a stub `CREATE SCHEMA` / `CREATE TABLE` to your schema file, or use 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 d8e53efe1..a1e0ed031 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), [#548](https://github.com/pgplex/pgschema/issues/548)). You can also stub those objects in the desired SQL file itself (see below). +- 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), [#548](https://github.com/pgplex/pgschema/issues/548)). If the referenced table already exists on the target database, you can [ignore that schema or table](/cli/ignore) instead and pgschema will stub it for plan. Otherwise stub the objects in the desired SQL file, or use an external plan database (see below). ### How It Works @@ -104,9 +104,19 @@ CREATE TABLE products ( ### Handling Cross-Schema Foreign Keys -If your schema has foreign keys that reference tables in other schemas, those referenced objects must exist when `plan` applies your desired-state SQL. `.pgschemaignore` cannot skip this: the SQL is applied before ignore filtering. +If your schema has foreign keys that reference tables in other schemas, those referenced objects must exist when `plan` applies your desired-state SQL. -Two approaches work: +If the referenced table already exists on the **target** database and you do not want pgschema to manage it, ignore it and keep the FK in your schema file. `plan` clones a stub from the target: + +```toml +# .pgschemaignore +[schemas] +patterns = ["auth"] +``` + +See [Ignore](/cli/ignore) for schema-qualified table patterns (`auth.users`). + +Otherwise, two approaches work: **Stub in the schema file** (works with the default embedded plan database). Keep managing only your app schema (`--schema public` or `--schema backend`); the stub exists so PostgreSQL can create the FK: diff --git a/internal/postgres/fk_refs.go b/internal/postgres/fk_refs.go new file mode 100644 index 000000000..fb0227c31 --- /dev/null +++ b/internal/postgres/fk_refs.go @@ -0,0 +1,329 @@ +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, + "public": 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 000000000..c17c85f80 --- /dev/null +++ b/internal/postgres/fk_refs_test.go @@ -0,0 +1,122 @@ +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: "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 d9bd5cd0d..b990ee117 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 1c6fc0190..8628e5d80 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 000000000..081a3cff2 --- /dev/null +++ b/ir/stub.go @@ -0,0 +1,144 @@ +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(schema) + b.WriteString(".") + b.WriteString(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() +} From 5f73172aa27b9bf9238daa138b3d42464b899850 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 07:11:06 +0000 Subject: [PATCH 4/7] docs: recommend ignore over manual stub for cross-schema FKs (#548) Clarify that Supabase-style REFERENCES auth.users works with the default embedded plan DB when the schema is ignored and the table exists on target. Manual stubs and external plan DB are documented as fallbacks only. Update plan-time hints to point at .pgschemaignore first. Co-authored-by: Tianzhou --- docs/cli/ignore.mdx | 4 +-- docs/cli/plan-db.mdx | 63 ++++++++++++++++------------------- docs/cli/plan.mdx | 4 +-- internal/postgres/embedded.go | 2 +- internal/postgres/external.go | 2 +- 5 files changed, 34 insertions(+), 41 deletions(-) diff --git a/docs/cli/ignore.mdx b/docs/cli/ignore.mdx index 7393ea9aa..dadc17caa 100644 --- a/docs/cli/ignore.mdx +++ b/docs/cli/ignore.mdx @@ -158,9 +158,9 @@ CREATE TABLE profiles ( ); ``` -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. The stub is not part of the managed schema: dump does not emit `auth.users`, and plan will not create or drop it. +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, add a stub `CREATE SCHEMA` / `CREATE TABLE` to your schema file, or use an [external plan database](/cli/plan-db). +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`. diff --git a/docs/cli/plan-db.mdx b/docs/cli/plan-db.mdx index a1e0ed031..6c3f3ea24 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), [#548](https://github.com/pgplex/pgschema/issues/548)). If the referenced table already exists on the target database, you can [ignore that schema or table](/cli/ignore) instead and pgschema will stub it for plan. Otherwise stub the objects in the desired SQL file, or use an external plan database (see below). +- 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,30 +104,46 @@ CREATE TABLE products ( ### Handling Cross-Schema Foreign Keys -If your schema has foreign keys that reference tables in other schemas, those referenced objects must exist when `plan` applies your desired-state SQL. +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. -If the referenced table already exists on the **target** database and you do not want pgschema to manage it, ignore it and keep the FK in your schema file. `plan` clones a stub from the target: +**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 +); ``` -See [Ignore](/cli/ignore) for schema-qualified table patterns (`auth.users`). +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. -Otherwise, two approaches work: +Requirements: -**Stub in the schema file** (works with the default embedded plan database). Keep managing only your app schema (`--schema public` or `--schema backend`); the stub exists so PostgreSQL can create the FK: +- 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 --- schema.sql CREATE SCHEMA IF NOT EXISTS auth; CREATE TABLE IF NOT EXISTS auth.users ( id UUID PRIMARY KEY ); -CREATE TABLE users ( +CREATE TABLE profiles ( id SERIAL PRIMARY KEY, auth_user_id UUID NOT NULL UNIQUE REFERENCES auth.users (id) ); @@ -135,54 +151,31 @@ CREATE TABLE users ( `pgschema dump` of your app schema will not emit the stub, so keep it in source (for example via `\i`). -**Or create the objects in an external plan database:** +**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 referenced schemas and tables +-- 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 ); - -CREATE SCHEMA IF NOT EXISTS billing; -CREATE TABLE IF NOT EXISTS billing.customers ( - id SERIAL PRIMARY KEY, - user_id INTEGER REFERENCES auth.users(id) -); ``` -Then run plan or apply: - ```bash -# Set up referenced schemas in plan database -psql -h localhost -U postgres -d pgschema_plan << 'EOF' -CREATE SCHEMA IF NOT EXISTS auth; -CREATE TABLE IF NOT EXISTS auth.users (id SERIAL PRIMARY KEY, email TEXT NOT NULL); -EOF - -# Now run plan for your main schema that references auth.users 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 (without inlining the stub): +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 1c331519e..f6bb322be 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/embedded.go b/internal/postgres/embedded.go index 86ccebb77..75f8f6eb5 100644 --- a/internal/postgres/embedded.go +++ b/internal/postgres/embedded.go @@ -269,7 +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. Add a stub CREATE SCHEMA/TABLE in your desired SQL, or use an external plan database that already has those objects (--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 2069f4233..4ff201649 100644 --- a/internal/postgres/external.go +++ b/internal/postgres/external.go @@ -176,7 +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. Create those schemas/tables in the plan database, or add a stub CREATE SCHEMA/TABLE in your desired SQL, 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) } From ae7b7d8746526249c0078499a11a59f93c6ee5b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 07:11:19 +0000 Subject: [PATCH 5/7] docs: update apply.mdx cross-schema FK guidance (#548) Co-authored-by: Tianzhou --- docs/cli/apply.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli/apply.mdx b/docs/cli/apply.mdx index 05e682be4..ef46f54ab 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. From ab7da7888cf3c5440f37af88b9e70f7f7bf72d0f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 07:31:19 +0000 Subject: [PATCH 6/7] chore: address PR #549 review suggestions Co-authored-by: Tianzhou --- cmd/ignore_integration_test.go | 3 ++- cmd/plan/ignore_stubs.go | 7 ++++--- internal/postgres/desired_state.go | 11 ++++++----- internal/postgres/fk_refs.go | 1 - internal/postgres/fk_refs_test.go | 6 ++++++ 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/cmd/ignore_integration_test.go b/cmd/ignore_integration_test.go index 7ea1c52fb..717290032 100644 --- a/cmd/ignore_integration_test.go +++ b/cmd/ignore_integration_test.go @@ -1947,7 +1947,8 @@ CREATE TABLE profiles ( if !strings.Contains(output, "display_name") { t.Errorf("plan should add display_name; got:\n%s", output) } - if strings.Contains(output, "CREATE TABLE") && strings.Contains(output, "auth.users") { + 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) } }) diff --git a/cmd/plan/ignore_stubs.go b/cmd/plan/ignore_stubs.go index d9660f0d6..e133fa2c8 100644 --- a/cmd/plan/ignore_stubs.go +++ b/cmd/plan/ignore_stubs.go @@ -3,6 +3,7 @@ package plan import ( "context" "fmt" + "strings" "github.com/pgplex/pgschema/cmd/util" "github.com/pgplex/pgschema/internal/logger" @@ -51,7 +52,7 @@ func prependIgnoredTableStubs(ctx context.Context, cfg *util.ConnectionConfig, i } defer conn.Close() - var stubs string + var stubs strings.Builder for _, ref := range toStub { ddl, err := ir.BuildTableStubSQL(ctx, conn, ref.Schema, ref.Table, targetSchema) if err != nil { @@ -62,8 +63,8 @@ func prependIgnoredTableStubs(ctx context.Context, cfg *util.ConnectionConfig, i } logger.Get().Debug("prepending stub for ignored foreign key target", "schema", ref.Schema, "table", ref.Table) - stubs += ddl + stubs.WriteString(ddl) } - return stubs + desiredSQL, nil + return stubs.String() + desiredSQL, nil } diff --git a/internal/postgres/desired_state.go b/internal/postgres/desired_state.go index 74c0b3325..4b3be0e39 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. @@ -526,9 +526,10 @@ func hintExtensionDependency(err error, hint string) error { // references objects in another schema (e.g. REFERENCES auth.users) that the // plan database does not have (issues #122, #548). // -// .pgschemaignore cannot help: plan applies the SQL before ignore filtering, -// so PostgreSQL still requires the referenced objects to exist. Stub them in -// the desired SQL, or use a plan database that already has them. +// .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" diff --git a/internal/postgres/fk_refs.go b/internal/postgres/fk_refs.go index fb0227c31..215f57bca 100644 --- a/internal/postgres/fk_refs.go +++ b/internal/postgres/fk_refs.go @@ -19,7 +19,6 @@ var referencesFollowKeywords = map[string]bool{ "from": true, "where": true, "set": true, - "public": true, "all": true, "table": true, "schema": true, diff --git a/internal/postgres/fk_refs_test.go b/internal/postgres/fk_refs_test.go index c17c85f80..afbe35af4 100644 --- a/internal/postgres/fk_refs_test.go +++ b/internal/postgres/fk_refs_test.go @@ -30,6 +30,12 @@ func TestExtractForeignKeyTargets(t *testing.T) { 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));`, From 8bda49e1f0adcdfaf89584ac05f0beead5086250 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 07:38:28 +0000 Subject: [PATCH 7/7] fix: sanitize identifiers in stub comment to prevent SQL injection via newlines Co-authored-by: Tianzhou --- ir/stub.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/ir/stub.go b/ir/stub.go index 081a3cff2..b793a2623 100644 --- a/ir/stub.go +++ b/ir/stub.go @@ -37,9 +37,9 @@ func BuildTableStubSQL(ctx context.Context, db *sql.DB, schema, table, targetSch } b.WriteString("-- pgschema: stub for ignored table ") - b.WriteString(schema) + b.WriteString(sanitizeComment(schema)) b.WriteString(".") - b.WriteString(table) + b.WriteString(sanitizeComment(table)) b.WriteString("\nCREATE TABLE IF NOT EXISTS ") b.WriteString(qualified) b.WriteString(" (\n") @@ -142,3 +142,15 @@ ORDER BY con.contype, con.conname` } 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) +}