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
2 changes: 1 addition & 1 deletion docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ or the only online form is a direct statement the operator runs).
| Operation | Status | Engine path | Online-safety problem? | Behavior and why |
| --- | --- | --- | --- | --- |
| `ADD PRIMARY KEY` / `ADD UNIQUE` (plain key columns) | ✅ | native, safer sequence | Yes | Rewritten to the online sequence: `CREATE UNIQUE INDEX CONCURRENTLY` → `ADD CONSTRAINT ... USING INDEX`. The sequence carries the key's `INCLUDE` columns, `NULLS NOT DISTINCT`, `WITH (...)` storage parameters, and index tablespace onto the index, and `DEFERRABLE` / `INITIALLY DEFERRED` onto the attach. A storage parameter the sequence cannot render faithfully (a value that is not a plain word, number, or string, a value containing a backslash, or a namespaced parameter name) gets no rewrite and is refused as `rewrite-required` with the `unique-index-then-constraint` guidance: build the index with it, then attach it. In a desired file, a new key whose name and index match a valid live unique index takes that index over with `ADD CONSTRAINT ... USING INDEX` at the key's declared deferrability, instead of a destructive drop and rebuild. Execution reads the index again under the table's lock and refuses with the permanent `adopted-index-drift` code, attaching nothing, if it was dropped, replaced, or left invalid since planning |
| `ADD CHECK` / `ADD FOREIGN KEY` (imperative) | ✅ | native, safer sequence | Yes | Rewritten to the online sequence: `ADD CONSTRAINT ... NOT VALID` (brief metadata lock) → `VALIDATE CONSTRAINT` (writes keep flowing during the scan) |
| `ADD CHECK` / `ADD FOREIGN KEY` (imperative) | ✅ | native, safer sequence | Yes | Rewritten to the online sequence: `ADD CONSTRAINT ... NOT VALID` (brief metadata lock) → `VALIDATE CONSTRAINT` (writes keep flowing during the scan). An unnamed constraint is given the name PostgreSQL would choose (`<table>_<column>_check`, `<table>_check`, `<table>_<columns>_fkey`, numbered `check1`, `fkey1`, ... past a name another constraint in the schema already holds), because the `VALIDATE` step has to name it |
| `ADD CONSTRAINT ... NOT VALID` / `... USING INDEX` / `VALIDATE CONSTRAINT` | ✅ | native, as-is | Yes | Already the online idiom; executed as-is |
| `ADD FOREIGN KEY ... NOT VALID` on a **partitioned parent** | ✅ | native, safer sequence | Yes — server version 18 or later | Supported on version 18 and later; refused on 14–17 with an environmental class |
| `EXCLUDE` constraints (and unrecognized constraint forms) | ❌ | — | Yes — unsolvable today | No online pattern exists in PostgreSQL — the build scans under `ACCESS EXCLUSIVE` with no `NOT VALID`/`USING INDEX` equivalent. Refused; revisit only if PostgreSQL grows one |
Expand Down
5 changes: 4 additions & 1 deletion docs/demos/diff-greenfield.tape
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@ Set Padding 16
Set TypingSpeed 30ms
Set Theme "Dracula"

# The seed names public explicitly: the compose role is named pgsprite, like
# the engine's own schema, so an unqualified name would land there once a
# copy-swap run has created it.
Hide
Type `export PATH="$(git rev-parse --show-toplevel)/bin:$PATH"` Enter
# The rendering machine's environment leaks into the recorded shell: strip
# color suppressors so the report's --color=auto detection sees a normal
# terminal and the GIF shows the diagnostic colors deterministically.
Type "unset NO_COLOR && export TERM=xterm-256color" Enter
Type "export PGSPRITE_URL='postgres://pgsprite:pgsprite@localhost:5432/pgsprite?sslmode=disable'" Enter
Type "docker exec compose-postgres-1 psql -U pgsprite -d pgsprite -c 'DROP TABLE IF EXISTS widgets' >/dev/null" Enter
Type "docker exec compose-postgres-1 psql -U pgsprite -d pgsprite -c 'DROP TABLE IF EXISTS public.widgets' >/dev/null" Enter
Type `demo_dir="$(mktemp -d)" && cd "$demo_dir"` Enter
Type "echo 'CREATE TABLE widgets (id bigint PRIMARY KEY, name text NOT NULL, price numeric);' > widgets.sql" Enter
# Prompt: directory cyan, branch magenta; typed input bright white. The DEBUG
Expand Down
5 changes: 4 additions & 1 deletion docs/demos/improve.tape
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@ Set Padding 16
Set TypingSpeed 30ms
Set Theme "Dracula"

# The seed names public explicitly: the compose role is named pgsprite, like
# the engine's own schema, so an unqualified name would land there once a
# copy-swap run has created it.
Hide
Type `export PATH="$(git rev-parse --show-toplevel)/bin:$PATH"` Enter
# The rendering machine's environment leaks into the recorded shell: strip
# color suppressors so the report's --color=auto detection sees a normal
# terminal and the GIF shows the diagnostic colors deterministically.
Type "unset NO_COLOR && export TERM=xterm-256color" Enter
Type "export PGSPRITE_URL='postgres://pgsprite:pgsprite@localhost:5432/pgsprite?sslmode=disable'" Enter
Type `docker exec compose-postgres-1 psql -U pgsprite -d pgsprite -c 'DROP TABLE IF EXISTS users' -c 'CREATE TABLE users (id bigint PRIMARY KEY, email text)' -c "INSERT INTO users SELECT g, 'u'||g||'@x.com' FROM generate_series(1,1000) g" >/dev/null` Enter
Type `docker exec compose-postgres-1 psql -U pgsprite -d pgsprite -c 'DROP TABLE IF EXISTS public.users' -c 'CREATE TABLE public.users (id bigint PRIMARY KEY, email text)' -c "INSERT INTO public.users SELECT g, 'u'||g||'@x.com' FROM generate_series(1,1000) g" >/dev/null` Enter
Type `demo_dir="$(mktemp -d)" && cd "$demo_dir"` Enter
Type "echo 'CREATE TABLE users (' > users.sql" Enter
Type "echo ' id bigint PRIMARY KEY,' >> users.sql" Enter
Expand Down
Binary file modified docs/demos/refuse.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 6 additions & 3 deletions docs/demos/refuse.tape
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Refuse with typed help and the CI exit-code contract:
# error[rewrite-required] + note[safer-idiom] + help[name-constraint-then-validate],
# error[rewrite-required] + note[safer-idiom] + help[add-column-then-constraint],
# doc anchors, and echo $? showing 2.
# Render from this directory: vhs refuse.tape (or `make demos` at the repo root).
# Needs: make build && make db-up first. Hidden setup reseeds the users table.
Expand All @@ -13,14 +13,17 @@ Set Padding 16
Set TypingSpeed 30ms
Set Theme "Dracula"

# The seed names public explicitly: the compose role is named pgsprite, like
# the engine's own schema, so an unqualified name would land there once a
# copy-swap run has created it.
Hide
Type `export PATH="$(git rev-parse --show-toplevel)/bin:$PATH"` Enter
# The rendering machine's environment leaks into the recorded shell: strip
# color suppressors so the report's --color=auto detection sees a normal
# terminal and the GIF shows the diagnostic colors deterministically.
Type "unset NO_COLOR && export TERM=xterm-256color" Enter
Type "export PGSPRITE_URL='postgres://pgsprite:pgsprite@localhost:5432/pgsprite?sslmode=disable'" Enter
Type `docker exec compose-postgres-1 psql -U pgsprite -d pgsprite -c 'DROP TABLE IF EXISTS users' -c 'CREATE TABLE users (id bigint PRIMARY KEY, email text)' -c "INSERT INTO users SELECT g, 'u'||g||'@x.com' FROM generate_series(1,1000) g" >/dev/null` Enter
Type `docker exec compose-postgres-1 psql -U pgsprite -d pgsprite -c 'DROP TABLE IF EXISTS public.users' -c 'CREATE TABLE public.users (id bigint PRIMARY KEY, email text)' -c "INSERT INTO public.users SELECT g, 'u'||g||'@x.com' FROM generate_series(1,1000) g" >/dev/null` Enter
# Prompt: directory cyan, branch magenta; typed input bright white. The DEBUG
# trap resets the color before each command runs, so output renders in the
# tool's own colors (the diagnostic labels) rather than the input style.
Expand All @@ -30,7 +33,7 @@ Type "clear" Enter
Sleep 1.5s
Show

Type "pg-sprite migrate --alter 'ALTER TABLE users ADD CHECK (id > 0)' --dry-run"
Type "pg-sprite migrate --alter 'ALTER TABLE users ADD COLUMN code text UNIQUE' --dry-run"
Sleep 500ms
Enter
Sleep 8s
Expand Down
2 changes: 1 addition & 1 deletion docs/plan-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ consumer rendering either into a shared surface must clamp and escape them.
|---|---|---|---|
| `format_version` | int | always | Contract version; reject unknown versions. |
| `source` | string | always | Front door that derived the plan (see Sources). |
| `schema` | string | when resolved | Target schema. The **resolved** name the engine planned against — an unqualified alter reports the schema the engine introspected (`public`), never an empty echo of the submitted text. Absent only when the statement has no single table target. |
| `schema` | string | when resolved | Target schema. The **resolved** name the engine planned against — an unqualified alter reports the schema the session `search_path` resolves its table to, the one execution acts on (`public` when the `search_path` shows no such table), never an empty echo of the submitted text. Absent only when the statement has no single table target. |
| `table` | string | when targeted | Target table; absent for statements with no single table target (index maintenance). |
| `server_version` | string | when connected | The PostgreSQL `server_version` the plan was derived against. Classification is version-sensitive; a stored or forwarded report names the server whose rules produced it. |
| `table_exists` | bool | when introspected | Whether the live table was found. Set by both sources that introspect the target (diff, and the alter dry run); absent means the plan has no single table target to introspect. For diff, `false` means the plan is the full desired schema; for an alter dry run, `false` means the plan was classified from zero facts, executing it would fail, and the dry run exits with the refusal code. |
Expand Down
24 changes: 14 additions & 10 deletions docs/suggest-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,8 @@ and advise again.
**Emitted for:** `ADD COLUMN` with an inline UNIQUE / PRIMARY KEY / FOREIGN KEY / CHECK.

The inline constraint builds or validates under the ADD COLUMN's ACCESS EXCLUSIVE lock.
Add the plain column first, then build the constraint as a separate, **named**
`ADD CONSTRAINT` with its online pattern — named, because the unnamed `ADD CHECK` /
`ADD FOREIGN KEY` forms are themselves refused
([`name-constraint-then-validate`](#name-constraint-then-validate)).
Add the plain column first, then build the constraint as a separate `ADD CONSTRAINT`
with its online pattern.

### `pre-add-validated-check`

Expand All @@ -126,12 +124,18 @@ then the NOT NULL constraint is a catalog flip — the same scaffold sequence th

### `name-constraint-then-validate`

**Emitted for:** unnamed `ADD CHECK` / `ADD FOREIGN KEY`.

The online sequence's `VALIDATE CONSTRAINT` step needs the constraint's name, and the
server assigns one only at creation — so no rewrite can be constructed from the statement
alone. Name the constraint, add it `NOT VALID`, then `VALIDATE` it online (the same
sequence the named form gets constructed).
**Emitted for:** `ADD CHECK` / `ADD FOREIGN KEY` whose `NOT VALID` rewrite could not be
constructed from the statement.

Name the constraint, add it `NOT VALID`, then `VALIDATE` it online: the same sequence the
constructed rewrite emits. A statement that names no constraint gets that rewrite too, under
the name PostgreSQL would choose first (`<table>_<column>_check`, `<table>_check`,
`<table>_<columns>_fkey`), because the `VALIDATE` step has to name it. A name an earlier
statement of the same script adds is numbered past (`<table>_<column>_check1`), as the
server would number it. Suggest reads no live table, so it cannot see a name the schema
already uses, nor tell a bare reference to the table's own name, which
reads as its whole row unless the table has a column by that name. A dry-run against the
target reads both and names the constraint as the server would.

### `unique-index-then-constraint`

Expand Down
2 changes: 1 addition & 1 deletion internal/cli/dryrun.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func (c *MigrateCmd) runDryRun(ctx context.Context, out io.Writer) error {
"route", string(classified.Route), "disposition", string(routed.Disposition))

report := plan.NewReport(plan.SourceAlter)
report.Schema = migrate.ResolvedSchema(st)
report.Schema = facts.Schema
report.Table = st.Table()
report.TableExists = facts.TableExists
if report.ServerVersion, err = dbconn.ServerVersion(ctx, pool); err != nil {
Expand Down
47 changes: 45 additions & 2 deletions internal/cli/dryrun_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"encoding/json"
"fmt"
neturl "net/url"
"strings"
"testing"

Expand Down Expand Up @@ -216,8 +217,9 @@ func TestDryRunAndDiffAgreeOnDestructive(t *testing.T) {
}

// An unqualified statement resolves to the schema the engine actually
// introspected — public — and the report says so: a stored plan must not
// depend on the reader's search_path to name its target. The report also
// introspected (public, on the default search_path) and the report says
// so: a stored plan must not depend on the reader's search_path to name
// its target. The report also
// stamps the server version its classification was derived against.
func TestDryRunResolvesUnqualifiedSchemaAndStampsServerVersion(t *testing.T) {
url := testutil.StartPostgres(t)
Expand All @@ -241,6 +243,47 @@ func TestDryRunResolvesUnqualifiedSchemaAndStampsServerVersion(t *testing.T) {
"resolving to public must feed the live facts to the classifier")
}

// A dry-run reads an unqualified table where the session search_path
// resolves it, as execution does, even when public holds a table of the
// same name. The schema it resolves to already holds the server's first
// choice for an unnamed CHECK, so the plan names the constraint as the
// server would there, and execution runs exactly the planned SQL.
func TestMigrateDryRunResolvesTheTableThroughSearchPath(t *testing.T) {
url := testutil.StartPostgres(t)
pool, err := dbconn.NewPool(t.Context(), dbconn.Config{URL: url})
require.NoError(t, err)
defer pool.Close()
table := testutil.NewPublicTable(t, pool, "(qty int)")
schema := testutil.NewSchema(t, pool)
_, err = pool.Exec(t.Context(), fmt.Sprintf(
"CREATE TABLE %s.%s (qty int, CONSTRAINT %s_qty_check CHECK (qty < 1000))", schema, table, table))
require.NoError(t, err)

u, err := neturl.Parse(url)
require.NoError(t, err)
q := u.Query()
q.Set("options", "-csearch_path="+schema+",public")
u.RawQuery = q.Encode()
alter := fmt.Sprintf("ALTER TABLE %s ADD CHECK (qty > 0)", table)

report := dryRunPlan(t, u.String(), alter)
assert.Equal(t, schema, report.Schema, "the plan names the schema the search_path resolves the table to")
require.Len(t, report.Statements, 1)
planned := report.Statements[0].ExecSQL
require.Len(t, planned, 2)
assert.Contains(t, planned[0], table+"_qty_check1", "the plan numbers past the name the resolved schema holds")

cmd := newMigrateCmd(u.String(), alter)
cmd.JSON = true
var out strings.Builder
require.NoError(t, cmd.run(t.Context(), &out))
var v verdict.Verdict
require.NoError(t, json.Unmarshal([]byte(out.String()), &v))
assert.Equal(t, verdict.OutcomeExecuted, v.Outcome)
require.Len(t, v.ExecutedSQL, 2)
assert.Contains(t, v.ExecutedSQL[0], table+"_qty_check1", "execution names the constraint as the plan did")
}

// A dry-run against a table that does not exist classifies with zero facts:
// the unprovable type change routes conservatively instead of failing, and
// the report records the missing target.
Expand Down
2 changes: 1 addition & 1 deletion pkg/capabilities/capabilities.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ rows:
front_doors:
migrate: supported
diff: supported
reason_notes: "Rewritten to the online sequence: `ADD CONSTRAINT ... NOT VALID` (brief metadata lock) → `VALIDATE CONSTRAINT` (writes keep flowing during the scan)"
reason_notes: "Rewritten to the online sequence: `ADD CONSTRAINT ... NOT VALID` (brief metadata lock) → `VALIDATE CONSTRAINT` (writes keep flowing during the scan). An unnamed constraint is given the name PostgreSQL would choose (`<table>_<column>_check`, `<table>_check`, `<table>_<columns>_fkey`, numbered `check1`, `fkey1`, ... past a name another constraint in the schema already holds), because the `VALIDATE` step has to name it"
- id: add-constraint-not-valid-using-index-validate-constraint
area: "constraints"
operation: "`ADD CONSTRAINT ... NOT VALID` / `... USING INDEX` / `VALIDATE CONSTRAINT`"
Expand Down
10 changes: 6 additions & 4 deletions pkg/lint/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ type Report struct {
}

// Check lints a DDL script: every statement is parsed with the PostgreSQL
// grammar and classified with zero live facts. A parse failure is an
// grammar and classified with zero live facts beyond the constraint names
// the script's earlier statements claim (planner.ScriptClassifier). A parse failure is an
// error; an unsupported operation is not — it is an error-severity
// finding, so one bad statement never hides the rest of the report.
func Check(sql string) (Report, error) {
Expand All @@ -141,8 +142,9 @@ func Check(sql string) (Report, error) {
PostgresVersions: planner.RulesPostgresVersions,
Findings: []Finding{},
}
var classifier planner.ScriptClassifier
for i, stmt := range stmts {
findings, err := checkStatement(i+1, stmt)
findings, err := checkStatement(&classifier, i+1, stmt)
if err != nil {
return Report{}, fmt.Errorf("statement %d: %w", i+1, err)
}
Expand All @@ -163,8 +165,8 @@ func Check(sql string) (Report, error) {
// route warrants one, and a destructive finding where the decision is
// marked destructive. The classifier is the single rulebook — the linter
// adds severity and presentation, never a second opinion.
func checkStatement(index int, stmt statement.SourceStatement) ([]Finding, error) {
classified, err := planner.Classify(stmt.SQL, planner.Facts{})
func checkStatement(classifier *planner.ScriptClassifier, index int, stmt statement.SourceStatement) ([]Finding, error) {
classified, err := classifier.Classify(stmt.SQL)
if err != nil {
return nil, err
}
Expand Down
17 changes: 17 additions & 0 deletions pkg/lint/lint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,20 @@ func TestReportJSONCleanFindingsAreEmptyArray(t *testing.T) {
"warnings": 0
}`, string(raw))
}

// Two unnamed CHECKs on one column in one script: the second finding's
// suggestion names its constraint past the first's, as the server would.
func TestCheckUnnamedChecksInOneScriptSuggestDistinctNames(t *testing.T) {
report, err := lint.Check("ALTER TABLE t ADD CHECK (qty > 0); ALTER TABLE t ADD CHECK (qty < 100);")
require.NoError(t, err)
var suggested []string
for _, f := range report.Findings {
if len(f.Suggestion) > 0 {
suggested = append(suggested, f.Suggestion[0])
}
}
assert.Equal(t, []string{
"ALTER TABLE t ADD CONSTRAINT t_qty_check CHECK (qty > 0) NOT VALID",
"ALTER TABLE t ADD CONSTRAINT t_qty_check1 CHECK (qty < 100) NOT VALID",
}, suggested)
}
Loading
Loading