Skip to content
Closed
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,16 @@ DDL to be executed:
ALTER TABLE users ADD COLUMN age integer NOT NULL;
```

#### Multi-schema `plan`

To compare **more than one** PostgreSQL namespace in one run, pass a comma-separated list as the first flag value is the *primary* schema (where unqualified DDL in `--file` is rooted); remaining names are also loaded from the target and from the plan database after applying your SQL. Example:

```bash
pgschema plan --schema public,app --file schema.sql ...
```

See [cmd/plan/README.md](cmd/plan/README.md) for full behaviour and caveats (including how this relates to `dump` / `apply`).

### Step 4: Apply plan with confirmation

```bash
Expand Down
63 changes: 60 additions & 3 deletions cmd/plan/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,66 @@
# Plan Command
# Plan command

## Running Tests
Compare a desired-state SQL file with the live database and print migration DDL (human, JSON, and/or SQL).

## Usage

```bash
pgschema plan \
--host localhost \
--port 5432 \
--db mydb \
--user postgres \
--schema public \
--file schema.sql \
--output-human stdout
```

- **`--file`**: path to the SQL that describes the desired schema (required).
- **`--schema`**: see [Single schema](#single-schema) and [Multi-schema](#multi-schema) below.
- **Outputs**: `--output-human`, `--output-json`, `--output-sql` (each can be `stdout` or a file path). If none are set, human output goes to stdout.

Optional **plan database** (instead of the default embedded Postgres): `--plan-host`, `--plan-port`, `--plan-db`, `--plan-user`, `--plan-password`, `--plan-sslmode`, or `PGSCHEMA_PLAN_*` env vars. See the main project docs for details.

## Single schema

`--schema` defaults to `public`. Only that PostgreSQL namespace is loaded from the target database and from the temporary plan database after your SQL is applied.

## Multi-schema

Pass a **comma-separated** list of schema names (spaces trimmed, duplicates removed):

```bash
pgschema plan \
--schema public,app \
--file schema.sql \
...
```

### Behaviour

1. **Target (current) state**
All listed schemas are introspected and merged into one IR, so the diff can see tables, views, functions, etc. in `public`, `app`, and any other name you include.

2. **Desired state**
- The **first** name in the list is the *primary* schema: your `--file` SQL is applied in the temporary plan database with that schema as the strip/normalize target (same as single-schema plan).
- After that, the temporary schema **and** every other listed schema are introspected on the plan database. That way, objects you created with explicit qualification (e.g. `app.some_table`) appear in the desired IR as long as `app` is included after the comma.

3. **Generated DDL**
Diffing still uses the primary schema for name normalization where applicable; cross-schema references in the IR are preserved as in single-schema mode.

### When to use it

Use multi-schema when a single migration touches more than one namespace (e.g. `public` facts and `app` dimensions) or when foreign keys span schemas you want in the same plan.

### Caveats

- **`dump` / `apply`**: today their `--schema` flag is still a **single** schema name for connection defaults and fingerprinting. A plan built with `--schema public,app` can include DDL for multiple namespaces; applying it may require running `apply` with a workflow that matches your process (for example separate apply runs per schema if you rely on `search_path`), or extending apply in the future.
- **Order matters**: always put the schema where the bulk of unqualified DDL in `--file` lives **first**.

## Running tests

```bash
# All plan tests
# All plan tests
go test -v ./cmd/plan/

# Specific plan tests
Expand Down
27 changes: 18 additions & 9 deletions cmd/plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ var (
var PlanCmd = &cobra.Command{
Use: "plan",
Short: "Generate migration plan for a specific schema",
Long: "Generate a migration plan to apply a desired schema state to a target database schema. Compares the desired state (from --file) with the current state of a specific schema (specified by --schema, defaults to 'public').",
Long: "Generate a migration plan to apply a desired schema state to a target database. Compares the desired state (from --file) with the current state. Use --schema with a comma-separated list (e.g. public,app) to compare and inspect multiple PostgreSQL namespaces.",
RunE: runPlan,
SilenceUsage: true,
PreRunE: util.PreRunEWithEnvVarsAndConnection(&planDB, &planUser, &planHost, &planPort),
Expand All @@ -58,7 +58,7 @@ func init() {
PlanCmd.Flags().StringVar(&planDB, "db", "", "Database name (required) (env: PGDATABASE)")
PlanCmd.Flags().StringVar(&planUser, "user", "", "Database user name (required) (env: PGUSER)")
PlanCmd.Flags().StringVar(&planPassword, "password", "", "Database password (optional, can also use PGPASSWORD env var)")
PlanCmd.Flags().StringVar(&planSchema, "schema", "public", "Schema name")
PlanCmd.Flags().StringVar(&planSchema, "schema", "public", "Schema name, or comma-separated list for multi-namespace plan (e.g. public,app)")

// Desired state schema file flag
PlanCmd.Flags().StringVar(&planFile, "file", "", "Path to desired state SQL schema file (required)")
Expand Down Expand Up @@ -267,6 +267,9 @@ func CreateEmbeddedPostgresForPlan(config *PlanConfig, pgVersion postgres.Postgr
// The caller must provide a non-nil provider instance for validating the desired state schema.
// The caller is responsible for managing the provider lifecycle (creation and cleanup).
func GeneratePlan(config *PlanConfig, provider postgres.DesiredStateProvider) (*plan.Plan, error) {
planSchemas := util.ParseSchemaList(config.Schema)
primarySchema := planSchemas[0]

// Load ignore configuration
ignoreConfig, err := util.LoadIgnoreFileWithStructure()
if err != nil {
Expand All @@ -287,15 +290,15 @@ func GeneratePlan(config *PlanConfig, provider postgres.DesiredStateProvider) (*
}

// Compute fingerprint of current database state
sourceFingerprint, err := fingerprint.ComputeFingerprint(currentStateIR, config.Schema)
sourceFingerprint, err := fingerprint.ComputeFingerprintForSchemas(currentStateIR, planSchemas)
if err != nil {
return nil, fmt.Errorf("failed to compute source fingerprint: %w", err)
}

ctx := context.Background()

// Apply desired state SQL to the provider (embedded postgres or external database)
if err := provider.ApplySchema(ctx, config.Schema, desiredState); err != nil {
if err := provider.ApplySchema(ctx, primarySchema, desiredState); err != nil {
return nil, fmt.Errorf("failed to apply desired state: %w", err)
}

Expand All @@ -307,8 +310,14 @@ func GeneratePlan(config *PlanConfig, provider postgres.DesiredStateProvider) (*
// (e.g., pgschema_tmp_20251030_154501_123456789) to ensure isolation and prevent conflicts.
schemaToInspect := provider.GetSchemaName()
if schemaToInspect == "" {
schemaToInspect = config.Schema
schemaToInspect = primarySchema
}

inspectSchemas := []string{schemaToInspect}
for _, s := range planSchemas[1:] {
inspectSchemas = append(inspectSchemas, s)
}
inspectSpec := strings.Join(inspectSchemas, ",")

// For embedded postgres, always use "disable" since it starts without SSL configured.
// For external plan databases, use the configured PlanDBSSLMode (defaulting to "prefer").
Expand All @@ -319,7 +328,7 @@ func GeneratePlan(config *PlanConfig, provider postgres.DesiredStateProvider) (*
providerSSLMode = "prefer"
}
}
desiredStateIR, err := util.GetIRFromDatabase(providerHost, providerPort, providerDB, providerUsername, providerPassword, providerSSLMode, schemaToInspect, config.ApplicationName, ignoreConfig)
desiredStateIR, err := util.GetIRFromDatabase(providerHost, providerPort, providerDB, providerUsername, providerPassword, providerSSLMode, inspectSpec, config.ApplicationName, ignoreConfig)
Comment on lines 316 to +331
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Secondary schemas bypass isolation

The desired side now inspects the provider's temporary schema plus the remaining schema names from --schema, but ApplySchema only rewrites the primary schema. With --schema public,app and desired SQL such as CREATE TABLE app.widgets (...), embedded planning fails because only the temp schema was created. With an external plan database, app is read from the real validation database, so stale or unrelated objects in that schema can be included in the desired IR.

if err != nil {
return nil, fmt.Errorf("failed to get desired state: %w", err)
}
Expand All @@ -329,12 +338,12 @@ func GeneratePlan(config *PlanConfig, provider postgres.DesiredStateProvider) (*
// because that's where objects were created. We need to replace these with the target
// schema name (e.g., "public") so that generated DDL references the correct schema.
// Without this normalization, DDL would reference non-existent temporary schemas and fail.
if schemaToInspect != config.Schema {
normalizeSchemaNames(desiredStateIR, schemaToInspect, config.Schema)
if schemaToInspect != primarySchema {
normalizeSchemaNames(desiredStateIR, schemaToInspect, primarySchema)
}

// Generate diff (current -> desired) using IR directly
diffs := diff.GenerateMigration(currentStateIR, desiredStateIR, config.Schema)
diffs := diff.GenerateMigration(currentStateIR, desiredStateIR, primarySchema)

// Create plan from diffs with fingerprint
migrationPlan := plan.NewPlanWithFingerprint(diffs, sourceFingerprint)
Expand Down
34 changes: 27 additions & 7 deletions cmd/util/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,31 @@ func ValidateSSLMode(mode string) error {
}
}

// GetIRFromDatabase gets the IR from a database with ignore configuration
// ParseSchemaList splits a comma-separated schema specification into non-empty names.
// Empty input yields ["public"]. Duplicates are removed while preserving order.
func ParseSchemaList(spec string) []string {
parts := strings.Split(spec, ",")
out := make([]string, 0, len(parts))
seen := make(map[string]struct{})
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
if len(out) == 0 {
return []string{"public"}
}
return out
}

// GetIRFromDatabase gets the IR from a database with ignore configuration.
// schemaName may be a comma-separated list (e.g. "public,app") to merge multiple namespaces.
func GetIRFromDatabase(host string, port int, db, user, password, sslmode, schemaName, applicationName string, ignoreConfig *ir.IgnoreConfig) (*ir.IR, error) {
if sslmode == "" {
sslmode = "prefer"
Expand Down Expand Up @@ -122,13 +146,9 @@ func GetIRFromDatabase(host string, port int, db, user, password, sslmode, schem
// Build IR using the IR system with ignore config
inspector := ir.NewInspector(conn, ignoreConfig)

// Default to public schema if none specified
targetSchema := schemaName
if targetSchema == "" {
targetSchema = "public"
}
schemas := ParseSchemaList(schemaName)

schemaIR, err := inspector.BuildIR(ctx, targetSchema)
schemaIR, err := inspector.BuildIRFromSchemas(ctx, schemas)
if err != nil {
return nil, fmt.Errorf("failed to build IR: %w", err)
}
Expand Down
39 changes: 32 additions & 7 deletions docs/cli/plan.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@
title: "Plan"
---

The `plan` command generates a migration plan to apply a desired schema state to a target database schema. It compares the desired state (from a file) with the current state of a specific schema and shows what changes would be applied.
The `plan` command generates a migration plan to apply a desired schema state to a target database. It compares the desired state (from a file) with the current state of one or more PostgreSQL schemas and shows what changes would be applied.

## Overview

The plan command follows infrastructure-as-code principles similar to Terraform:

1. Read the desired state from a SQL file (with include directive support)
1. Apply the desired state SQL to a temporary PostgreSQL instance (embedded by default, or external via `--plan-*` flags)
1. Connect to the target database and analyze current state of the specified schema
1. Compare the two states
1. Generate a detailed migration plan with proper dependency ordering
1. Display the plan without making any changes
2. Apply the desired state SQL to a temporary PostgreSQL instance (embedded by default, or external via `--plan-*` flags)
3. Connect to the target database and analyze current state of the specified schema(s)
4. Compare the two states
5. Generate a detailed migration plan with proper dependency ordering
6. 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.

Expand Down Expand Up @@ -125,7 +126,15 @@ pgschema plan --host localhost --db myapp --user postgres --password mypassword
</ParamField>

<ParamField path="--schema" type="string" default="public">
Schema name to target for comparison
One PostgreSQL schema name, or a **comma-separated** list to plan across multiple namespaces (e.g. `public,app`).

**Single schema** — Only that schema is read from the target database and from the plan database after applying `--file`.

**Multi-schema** — Every name in the list is read from the target. On the plan side, the **first** name is the *primary* schema: your SQL is applied with that schema as the strip/normalize target (same as today’s single-schema flow). The temporary schema plus each additional listed schema is then introspected so qualified objects (e.g. `app.table`) appear in the desired state as long as you include `app` in the list.

Put the schema that contains most **unqualified** DDL in `--file` **first**. Duplicate names are ignored; spaces around commas are trimmed.

The [apply](/cli/apply) command still documents a single `--schema` for fingerprinting and session defaults; review your workflow if the generated plan touches several namespaces.
</ParamField>

## Plan Database Options
Expand Down Expand Up @@ -359,6 +368,22 @@ pgschema plan \
--file tenant_schema.sql
```

### Plan for multiple schemas

When a migration spans more than one namespace (for example `public` and `app`), pass them as a comma-separated list:

```bash
pgschema plan \
--host localhost \
--db myapp \
--user postgres \
--schema public,app \
--file schema.sql
```

- **Target**: all listed schemas are loaded and merged for the “current” side of the diff.
- **Desired**: the first entry is primary (where unqualified objects in `--file` are rooted after temp-schema normalization); remaining entries are also introspected on the plan database so explicitly qualified DDL is visible to the diff.

## Use Cases

### Pre-deployment Validation
Expand Down
32 changes: 30 additions & 2 deletions internal/diff/collector.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package diff

import "github.com/pgplex/pgschema/ir"

// diffContext provides context about the SQL statement being generated
type diffContext struct {
Type DiffType // e.g., DiffTypeTable, DiffTypeView, DiffTypeFunction
Expand All @@ -11,14 +13,40 @@ type diffContext struct {

// diffCollector collects SQL statements with their context information
type diffCollector struct {
diffs []Diff
diffs []Diff
pendingForeignKeys []*deferredConstraint
}

// newDiffCollector creates a new diffCollector
func newDiffCollector() *diffCollector {
return &diffCollector{
diffs: []Diff{},
diffs: []Diff{},
pendingForeignKeys: nil,
}
}

// queueDeferredForeignKey schedules an ALTER TABLE ... ADD FOREIGN KEY for a later flush
// (after CREATE and MODIFY phases) so referenced tables and new PK/UNIQUE constraints exist.
func (c *diffCollector) queueDeferredForeignKey(table *ir.Table, constraint *ir.Constraint) {
if c == nil || table == nil || constraint == nil || constraint.Name == "" {
return
}
c.pendingForeignKeys = append(c.pendingForeignKeys, &deferredConstraint{
table: table,
constraint: constraint,
})
}

// flushDeferredForeignKeys emits pending foreign keys in dependency order.
func (c *diffCollector) flushDeferredForeignKeys(targetSchema string) {
if c == nil || len(c.pendingForeignKeys) == 0 {
return
}
sorted := sortDeferredForeignKeys(c.pendingForeignKeys)
for _, item := range sorted {
emitDeferredForeignKeyConstraint(item, targetSchema, c)
}
c.pendingForeignKeys = nil
}

// collect collects a single SQL statement with its context information
Expand Down
Loading
Loading