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
9 changes: 8 additions & 1 deletion docs/binary-coercible-type-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,14 @@ change to or from an array, including `varchar(50)[] → varchar(100)[]`, is a
schema other than `pg_catalog`, such as `app.text`, is a user type that shares a
built-in's name, and matches no rule. So is a quoted name in another case, such as
`"TEXT"`: the parser folds only unquoted names to lower case, so the planner compares
type names case-sensitively.
type names case-sensitively. A collation on either side also matches no rule: a target
with a `COLLATE` clause changes the column's collation, and a live column with a
collation of its own loses it when retyped without one (`ALTER COLUMN TYPE` resets the
collation to the new type's default). Neither rewrites the heap, but both rebuild every
index on the column under `ACCESS EXCLUSIVE`
([a collation change alone](#things-that-are-not-rewrite-reasons)), so the planner takes
the rewrite route and the plan's `ALTER COLUMN … TYPE` line renders the target's `COLLATE`
clause so the verdict can be read from the report.

Everything else — including changes PostgreSQL would relabel, such as `varbit` or temporal
precision widening, unconstrained domains, `xml → text`, and the UTC `timestamp ↔
Expand Down
2 changes: 1 addition & 1 deletion docs/plan-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ consumer rendering either into a shared surface must clamp and escape them.
| `app-breaking-rename` | A column or table rename — metadata-only for PostgreSQL, but running application code still referencing the old name breaks the instant it commits. For a column the safe sequence is expand/contract: add the new column, dual-write and backfill, switch reads, then drop the old column as its own reviewed change. For a table, coordinate the rename with the application deploy that adopts the new name. Index renames stay `metadata-only` — SQL never references an index by name. |
| `volatile-default` | ADD COLUMN whose default PostgreSQL computes for every row, rewriting the table. Proven by the server, or, with `unverified`, assumed for an expression default nothing proved. |
| `generated-stored` | Adding a stored generated column computes every row — a full rewrite. |
| `type-rewrite` | A type PostgreSQL must convert or check row by row: a type change it cannot relabel (rewrite plus reindex), or an added column of a domain with constraints. With `unverified`, assumed for an added column the server could not prove. |
| `type-rewrite` | A type PostgreSQL must convert or check row by row: a type change it cannot relabel (rewrite plus reindex), a type change that moves the column's collation (every index on it rebuilt, no heap rewrite), or an added column of a domain with constraints. With `unverified`, assumed for an added column the server could not prove. |
| `relocation` | SET TABLESPACE moves the heap — a rewrite-scale copy. |
| `partition-parent-lock` | Creating a partition (`CREATE TABLE … PARTITION OF`): a brief `ACCESS EXCLUSIVE` on the parent, no scan. |
| `replica-identity` | The operation drops what a `REPLICA IDENTITY USING INDEX` of the table, or of a partition or inheritance child below it, is built on: the index, the constraint that owns it, or one of its columns, `INCLUDE` columns too. The setting outlives the index, so every `UPDATE` and `DELETE` on the table fails while a publication publishes them. Refused; move the identity first (`ALTER TABLE … REPLICA IDENTITY`), then re-plan. |
Expand Down
8 changes: 7 additions & 1 deletion docs/postgres-online-ddl-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,13 @@ backend.

The column type change is not binary-coercible (for example `int` → `bigint`,
`text` → `jsonb`), so PostgreSQL rewrites the whole table under
`ACCESS EXCLUSIVE`. Routed to the copy-and-swap backend. An `ADD COLUMN` of a
`ACCESS EXCLUSIVE`. Routed to the copy-and-swap backend. A type change that
moves the column's collation — a target with a `COLLATE` clause, or a column
with a collation of its own retyped without one, which resets it to the new
type's default — carries the same reason even when the type part is a plain
relabel: the heap is not rewritten, but every index on the column is rebuilt
under the same lock, and the native route would run that rebuild as if it were
free. An `ADD COLUMN` of a
domain type with a `CHECK` or `NOT NULL` carries the same reason: PostgreSQL
rewrites the table to check every row against the domain, even when the default
is a constant or one the catalog would store. An added column with a constant
Expand Down
57 changes: 57 additions & 0 deletions internal/cli/dryrun_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,63 @@ func TestMigrateDryRunUsesLiveFacts(t *testing.T) {
assert.Equal(t, 20, maxLen, "dry-run must not execute the change")
}

// timestamptz(3) -> timestamp(3) rewrites the table under ACCESS EXCLUSIVE,
// so the dry-run must route it to copy-and-swap and never call it a
// relabel. The live type is the real server's format_type rendering
// ("timestamp(3) with time zone"), whose text after the modifiers is the only
// thing that tells it apart from timestamp(3).
func TestMigrateDryRunRoutesTimestamptzToTimestampAsRewrite(t *testing.T) {
url := testutil.StartPostgres(t)
pool, err := dbconn.NewPool(t.Context(), dbconn.Config{URL: url})
require.NoError(t, err)
defer pool.Close()
schema := testutil.NewSchema(t, pool)
_, err = pool.Exec(t.Context(), fmt.Sprintf(`CREATE TABLE %s.t (
id int PRIMARY KEY,
ts timestamptz(3)
)`, schema))
require.NoError(t, err)

report := dryRunPlan(t, url, fmt.Sprintf("ALTER TABLE %s.t ALTER COLUMN ts TYPE timestamp(3)", schema))
assert.Equal(t, router.DispositionUnavailable, report.Disposition)
require.Len(t, report.Statements, 1)
st := report.Statements[0]
assert.Equal(t, planner.RouteCopyAndSwap, st.Route)
assert.Equal(t, router.BackendCopyAndSwap, st.Backend)
assert.Empty(t, st.ExecSQL)
require.Len(t, st.Decisions, 1)
assert.Equal(t, planner.ReasonTypeRewrite, st.Decisions[0].Reason)
}

// A column with an explicit collation that is retyped without a COLLATE
// clause takes the type's default collation, so every index on it is rebuilt
// under ACCESS EXCLUSIVE and its sort order changes. That is not a relabel:
// the dry-run must read the live collation and route to copy-and-swap even
// though the type part is a plain varchar widen.
func TestMigrateDryRunRoutesCollatedColumnRetypeAsRewrite(t *testing.T) {
url := testutil.StartPostgres(t)
pool, err := dbconn.NewPool(t.Context(), dbconn.Config{URL: url})
require.NoError(t, err)
defer pool.Close()
schema := testutil.NewSchema(t, pool)
_, err = pool.Exec(t.Context(), fmt.Sprintf(`CREATE TABLE %s.t (
id int PRIMARY KEY,
c varchar(50) COLLATE "C"
)`, schema))
require.NoError(t, err)
_, err = pool.Exec(t.Context(), fmt.Sprintf(`CREATE INDEX t_c ON %s.t (c)`, schema))
require.NoError(t, err)

report := dryRunPlan(t, url, fmt.Sprintf("ALTER TABLE %s.t ALTER COLUMN c TYPE varchar(100)", schema))
require.Len(t, report.Statements, 1)
st := report.Statements[0]
assert.Equal(t, planner.RouteCopyAndSwap, st.Route)
assert.Empty(t, st.ExecSQL, "dry-run must not offer the native statement")
require.Len(t, st.Decisions, 1)
assert.Equal(t, planner.ReasonTypeRewrite, st.Decisions[0].Reason)
assert.False(t, st.Decisions[0].Unverified)
}

// The dry-run advisory covers statements the execute gate refuses: a plain
// CREATE INDEX comes back native with its concurrent rewrite, not created.
func TestMigrateDryRunSuggestsConcurrentIndex(t *testing.T) {
Expand Down
47 changes: 34 additions & 13 deletions pkg/planner/planner.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,9 @@ const (
// every row — a full rewrite.
ReasonGeneratedStored Reason = "generated-stored"
// ReasonTypeRewrite: a type PostgreSQL must check or convert row by
// row — a type conversion it cannot relabel (rewrite plus reindex), or
// an added column of a domain type with constraints.
// row — a type conversion it cannot relabel (rewrite plus reindex), a
// type change that moves the column's collation (reindex alone), or an
// added column of a domain type with constraints.
ReasonTypeRewrite Reason = "type-rewrite"
// ReasonRelocation: SET TABLESPACE moves the heap — a rewrite-scale
// copy.
Expand Down Expand Up @@ -258,6 +259,13 @@ type Facts struct {
// ColumnTypes maps a column name to its live type as rendered by
// PostgreSQL's format_type (e.g. "character varying(50)").
ColumnTypes map[string]string
// ColumnCollations maps each live column whose collation is not its
// type's default to that collation, as schemadiff renders it. A column
// absent from the map has the default collation. Retyping a column
// without a COLLATE clause resets it to the new type's default, so a
// column present here loses its collation and every index on it is
// rebuilt: never a relabel.
ColumnCollations map[string]string
// ReplicaIdentity is what the replica identity indexes of the table and
// its partitions key on; the zero value when none selects an index.
ReplicaIdentity schemadiff.ReplicaIdentityKeys
Expand All @@ -277,16 +285,21 @@ type Facts struct {
}

// FactsFrom extracts the facts a live introspection model provides: the
// canonical type of every live column and what the table's replica
// identity keys on. Every front door — the declarative diff and the
// imperative dry-run — extracts facts through this one function so
// equivalent changes classify identically.
// canonical type of every live column, the collation of every column that
// has one of its own, and what the table's replica identity keys on. Every
// front door — the declarative diff and the imperative dry-run — extracts
// facts through this one function so equivalent changes classify
// identically.
func FactsFrom(live schemadiff.Model) Facts {
types := make(map[string]string, len(live.Columns))
collations := make(map[string]string)
for _, col := range live.Columns {
types[col.Name] = col.Type
if col.Collation != "" {
collations[col.Name] = col.Collation
}
}
return Facts{ColumnTypes: types, ReplicaIdentity: live.ReplicaIdentity}
return Facts{ColumnTypes: types, ColumnCollations: collations, ReplicaIdentity: live.ReplicaIdentity}
}

// addColumnProof returns what the server proved about adding op's
Expand Down Expand Up @@ -563,6 +576,12 @@ func classifyTypeChange(op statement.Op, facts Facts) (Route, Reason, bool) {
if op.HasUsing {
return RouteCopyAndSwap, ReasonTypeRewrite, false
}
if _, collated := facts.ColumnCollations[op.Column]; collated {
// The live column has a collation of its own, which the new type
// either resets to its default or (with a COLLATE clause) replaces;
// either way every index on the column is rebuilt.
return RouteCopyAndSwap, ReasonTypeRewrite, false
}
oldType, ok := facts.ColumnTypes[op.Column]
if !ok {
// No live type for the column: fail closed to the rewrite route,
Expand All @@ -587,13 +606,15 @@ type typeShape struct {

// targetTypeShape reads the statement's target type into the shape
// parseTypeText gives the live one. A type qualified with a schema other
// than pg_catalog is a user type that only shares a built-in's name, so it
// gets no shape and matches no rule. The name is taken as the parser left
// it: the grammar already spells every built-in the rules know by its
// catalog name (varchar, numeric, timestamptz), so a target named
// "decimal" or "character varying" was quoted and names a user type.
// than pg_catalog is a user type that only shares a built-in's name, and a
// COLLATE clause changes the collation every index on the column is built
// with, so neither gets a shape and neither matches a rule. The name is
// taken as the parser left it: the grammar already spells every built-in
// the rules know by its catalog name (varchar, numeric, timestamptz), so a
// target named "decimal" or "character varying" was quoted and names a
// user type.
func targetTypeShape(op statement.Op) typeShape {
if op.NewTypeSchema != "" {
if op.NewTypeSchema != "" || op.NewTypeCollation != "" {
return typeShape{}
}
return typeShape{name: strings.TrimSpace(op.NewType), mods: op.NewTypeMods, array: op.NewTypeArray}
Expand Down
16 changes: 11 additions & 5 deletions pkg/planner/planner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,22 @@ import (

// FactsFrom is the one seam between live introspection and classification:
// every column's canonical type must come through so type-narrowing
// decisions see the real live types.
// decisions see the real live types, and a column's own collation must
// come through so retyping it is never read as a relabel.
func TestFactsFromExtractsColumnTypes(t *testing.T) {
live := schemadiff.Model{Columns: []schemadiff.Column{
{Name: "id", Type: "bigint"},
{Name: "name", Type: "character varying(50)"},
{Name: "code", Type: "text", Collation: `pg_catalog."C"`},
}}
assert.Equal(t, planner.Facts{ColumnTypes: map[string]string{
"id": "bigint",
"name": "character varying(50)",
}}, planner.FactsFrom(live))
assert.Equal(t, planner.Facts{
ColumnTypes: map[string]string{
"id": "bigint",
"name": "character varying(50)",
"code": "text",
},
ColumnCollations: map[string]string{"code": `pg_catalog."C"`},
}, planner.FactsFrom(live))
}

// FactsFrom carries what the live table's replica identity keys on, so a
Expand Down
108 changes: 108 additions & 0 deletions pkg/planner/planner_type_change_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package planner_test

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/block/pg-sprite/pkg/planner"
)

// A type change is a catalog relabel only when both the live type and the
// target type are fully understood. Each case here is a change PostgreSQL
// rewrites the table for, or fails to convert, even though the part of the
// type the planner models (family plus modifiers) looks identical on both
// sides; every one must take the rewrite route, never the relabel route.
func TestClassifyTypeChangeRewritesWhatTheModelCannotSee(t *testing.T) {
cases := []struct {
name string
liveType string
sql string
}{
{
// timestamptz(3) -> timestamp(3) rewrites the table under
// ACCESS EXCLUSIVE: the live type's "with time zone" suffix is
// what distinguishes the two.
name: "timestamptz to timestamp with the same precision",
liveType: "timestamp(3) with time zone",
sql: "ALTER TABLE t ALTER COLUMN c TYPE timestamp(3)",
},
{
name: "timetz to time with the same precision",
liveType: "time(3) with time zone",
sql: "ALTER TABLE t ALTER COLUMN c TYPE time(3)",
},
{
// A COLLATE clause changes the column's collation, which
// rebuilds every index on it.
name: "target with a collation",
liveType: "character varying(50)",
sql: `ALTER TABLE t ALTER COLUMN c TYPE text COLLATE "C"`,
},
{
name: "live type with trailing text after the modifiers",
liveType: "character varying(50) extra",
sql: "ALTER TABLE t ALTER COLUMN c TYPE varchar(100)",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
plan, err := planner.Classify(tc.sql, planner.Facts{ColumnTypes: map[string]string{"c": tc.liveType}})
require.NoError(t, err)
require.Len(t, plan.Decisions, 1)
assert.Equal(t, planner.RouteCopyAndSwap, plan.Decisions[0].Route)
assert.Equal(t, planner.ReasonTypeRewrite, plan.Decisions[0].Reason)
assert.False(t, plan.Decisions[0].Unverified, "the live type was known; this is a proven rewrite route")
})
}
}

// A column with a collation of its own that is retyped without a COLLATE
// clause takes the new type's default collation, so every index on it is
// rebuilt under ACCESS EXCLUSIVE and its sort order changes. The type part
// of the change is a textbook relabel (varchar widen); the collation makes
// it a rewrite, and the live fact is known, so the route is proven.
func TestClassifyTypeChangeRewritesACollatedLiveColumn(t *testing.T) {
facts := planner.Facts{
ColumnTypes: map[string]string{"c": "character varying(50)"},
ColumnCollations: map[string]string{"c": `pg_catalog."C"`},
}
plan, err := planner.Classify("ALTER TABLE t ALTER COLUMN c TYPE varchar(100)", facts)
require.NoError(t, err)
require.Len(t, plan.Decisions, 1)
assert.Equal(t, planner.RouteCopyAndSwap, plan.Decisions[0].Route)
assert.Equal(t, planner.ReasonTypeRewrite, plan.Decisions[0].Reason)
assert.False(t, plan.Decisions[0].Unverified, "the live collation was known; this is a proven rewrite route")
}

// The same widen on a column with the default collation stays a relabel:
// the collation fact must only ever take the route away from native for
// the column it names.
func TestClassifyTypeChangeKeepsTheRelabelForAnUncollatedColumn(t *testing.T) {
facts := planner.Facts{
ColumnTypes: map[string]string{"c": "character varying(50)", "other": "text"},
ColumnCollations: map[string]string{"other": `pg_catalog."C"`},
}
plan, err := planner.Classify("ALTER TABLE t ALTER COLUMN c TYPE varchar(100)", facts)
require.NoError(t, err)
require.Len(t, plan.Decisions, 1)
assert.Equal(t, planner.RouteNative, plan.Decisions[0].Route)
assert.Equal(t, planner.ReasonBinaryCoercible, plan.Decisions[0].Reason)
}

// A pg_catalog qualifier names the built-in type, so it must not defeat the
// relabel proof: the SQL-standard spelling and the explicit qualifier are the
// same varchar widen as the bare form.
func TestClassifyTypeChangeAcceptsPgCatalogQualifiedTarget(t *testing.T) {
for _, sql := range []string{
"ALTER TABLE t ALTER COLUMN c TYPE pg_catalog.varchar(100)",
"ALTER TABLE t ALTER COLUMN c TYPE character varying(100)",
} {
plan, err := planner.Classify(sql, planner.Facts{ColumnTypes: map[string]string{"c": "character varying(50)"}})
require.NoError(t, err)
require.Len(t, plan.Decisions, 1)
assert.Equal(t, planner.RouteNative, plan.Decisions[0].Route, sql)
assert.Equal(t, planner.ReasonBinaryCoercible, plan.Decisions[0].Reason, sql)
}
}
Loading
Loading