diff --git a/docs/binary-coercible-type-changes.md b/docs/binary-coercible-type-changes.md index 648d9997..64d6dd38 100644 --- a/docs/binary-coercible-type-changes.md +++ b/docs/binary-coercible-type-changes.md @@ -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 ↔ diff --git a/docs/plan-report.md b/docs/plan-report.md index c335ec8e..ecc590ac 100644 --- a/docs/plan-report.md +++ b/docs/plan-report.md @@ -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. | diff --git a/docs/postgres-online-ddl-reference.md b/docs/postgres-online-ddl-reference.md index 326daef9..beaa7616 100644 --- a/docs/postgres-online-ddl-reference.md +++ b/docs/postgres-online-ddl-reference.md @@ -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 diff --git a/internal/cli/dryrun_integration_test.go b/internal/cli/dryrun_integration_test.go index c9e6bdba..23bbd947 100644 --- a/internal/cli/dryrun_integration_test.go +++ b/internal/cli/dryrun_integration_test.go @@ -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) { diff --git a/pkg/planner/planner.go b/pkg/planner/planner.go index 41437e20..8f67b807 100644 --- a/pkg/planner/planner.go +++ b/pkg/planner/planner.go @@ -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. @@ -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 @@ -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 @@ -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, @@ -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} diff --git a/pkg/planner/planner_test.go b/pkg/planner/planner_test.go index bb662891..c9f4cefb 100644 --- a/pkg/planner/planner_test.go +++ b/pkg/planner/planner_test.go @@ -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 diff --git a/pkg/planner/planner_type_change_test.go b/pkg/planner/planner_type_change_test.go new file mode 100644 index 00000000..735b5c1e --- /dev/null +++ b/pkg/planner/planner_type_change_test.go @@ -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) + } +} diff --git a/pkg/statement/ops.go b/pkg/statement/ops.go index 5eb5980b..4b2ad994 100644 --- a/pkg/statement/ops.go +++ b/pkg/statement/ops.go @@ -180,6 +180,12 @@ type Op struct { // NewTypeArray is true when the target type is an array of NewType // (varchar(50)[], text ARRAY). NewTypeArray bool + // NewTypeCollation is the collation the target's COLLATE clause names, + // as the statement spells it with any schema (C, pg_catalog.C); empty + // when there is no clause. A collation change rebuilds every index on + // the column, so a consumer comparing NewType to a live type must not + // read a collated target as a relabel. + NewTypeCollation string // HasUsing is true for ALTER COLUMN TYPE ... USING , which always // means a conversion, never a binary-coercible relabel. HasUsing bool @@ -286,10 +292,11 @@ func droppedIndexes(drop *pganalyze.DropStmt) []string { return names } -// formatNewType renders the target type with its schema, modifiers, and -// array marker — varchar(50), numeric(12,2), app.email, varchar(50)[]. Each -// part can turn a relabel into a rewrite (a widen is not a narrow, a -// varchar is not a varchar array), so a rendering that drops one would +// formatNewType renders the target type with its schema, modifiers, array +// marker, and collation — varchar(50), numeric(12,2), app.email, +// varchar(50)[], text COLLATE C. Each part can turn a relabel into a +// rewrite (a widen is not a narrow, a varchar is not a varchar array, a +// collated text is not a text), so a rendering that drops one would // collapse changes that route in opposite directions. func (o Op) formatNewType() string { name := o.NewType @@ -306,6 +313,9 @@ func (o Op) formatNewType() string { if o.NewTypeArray { name += "[]" } + if o.NewTypeCollation != "" { + name += " COLLATE " + o.NewTypeCollation + } return name } @@ -394,12 +404,13 @@ func alterTableOp(cmd *pganalyze.AlterTableCmd) Op { name, mods := typeRef(def.GetTypeName()) schema, array := typeQualifiers(def.GetTypeName()) return Op{ - Kind: OpAlterColumnType, - Column: cmd.GetName(), - NewType: name, - NewTypeMods: mods, - NewTypeSchema: schema, - NewTypeArray: array, + Kind: OpAlterColumnType, + Column: cmd.GetName(), + NewType: name, + NewTypeMods: mods, + NewTypeSchema: schema, + NewTypeArray: array, + NewTypeCollation: collationName(def.GetCollClause()), // For ALTER COLUMN TYPE the grammar carries the USING // expression in the column definition's raw default slot. HasUsing: def.GetRawDefault() != nil, @@ -660,6 +671,20 @@ func typeQualifiers(tn *pganalyze.TypeName) (string, bool) { return schema, array } +// collationName renders a COLLATE clause's collation as the statement +// spells it, schema and all (C, pg_catalog.C), or "" when there is no +// clause. +func collationName(coll *pganalyze.CollateClause) string { + if coll == nil { + return "" + } + parts := make([]string, 0, len(coll.GetCollname())) + for _, part := range coll.GetCollname() { + parts = append(parts, part.GetString_().GetSval()) + } + return strings.Join(parts, ".") +} + // isSerialType reports whether the grammar type name is one of the serial // pseudo-types, which expand to a sequence-backed default. func isSerialType(name string) bool { diff --git a/pkg/statement/ops_test.go b/pkg/statement/ops_test.go index 6b9d9c22..7d5b5405 100644 --- a/pkg/statement/ops_test.go +++ b/pkg/statement/ops_test.go @@ -140,6 +140,26 @@ func TestParseOpsShapes(t *testing.T) { sql: "ALTER TABLE t ALTER COLUMN doc TYPE jsonb USING doc::jsonb", want: statement.Op{Kind: statement.OpAlterColumnType, Column: "doc", NewType: "jsonb", HasUsing: true}, }, + { + name: "alter type pg_catalog qualified stays bare", + sql: "ALTER TABLE t ALTER COLUMN name TYPE pg_catalog.varchar(100)", + want: statement.Op{Kind: statement.OpAlterColumnType, Column: "name", NewType: "varchar", NewTypeMods: []int32{100}}, + }, + { + name: "alter type SQL-standard spelling stays bare", + sql: "ALTER TABLE t ALTER COLUMN name TYPE character varying(100)", + want: statement.Op{Kind: statement.OpAlterColumnType, Column: "name", NewType: "varchar", NewTypeMods: []int32{100}}, + }, + { + name: "alter type with a collation", + sql: `ALTER TABLE t ALTER COLUMN name TYPE text COLLATE "C"`, + want: statement.Op{Kind: statement.OpAlterColumnType, Column: "name", NewType: "text", NewTypeCollation: "C"}, + }, + { + name: "alter type with a schema-qualified collation", + sql: `ALTER TABLE t ALTER COLUMN name TYPE text COLLATE pg_catalog."C"`, + want: statement.Op{Kind: statement.OpAlterColumnType, Column: "name", NewType: "text", NewTypeCollation: "pg_catalog.C"}, + }, { name: "set default", sql: "ALTER TABLE t ALTER COLUMN age SET DEFAULT 1", @@ -355,11 +375,12 @@ func TestParseOpsShapes(t *testing.T) { } } -// Describe must render the type modifier, the array marker, and the -// schema: each distinguishes changes that route in opposite directions — a -// widen (varchar(100), native) from a narrow (varchar(30), copy-and-swap), -// text from a text array, the built-in text from a user type named text — -// and a label that drops one would render them identically. +// Describe must render the type modifier, the array marker, the schema, +// and the collation: each distinguishes changes that route in opposite +// directions — a widen (varchar(100), native) from a narrow (varchar(30), +// copy-and-swap), text from a text array, the built-in text from a user +// type named text, text from text under another collation — and a label +// that drops one would render them identically. func TestDescribeRendersTypeModifiers(t *testing.T) { cases := []struct { sql string @@ -370,6 +391,8 @@ func TestDescribeRendersTypeModifiers(t *testing.T) { {"ALTER TABLE t ALTER COLUMN v TYPE text", "ALTER COLUMN v TYPE text"}, {"ALTER TABLE t ALTER COLUMN v TYPE varchar(50)[]", "ALTER COLUMN v TYPE varchar(50)[]"}, {"ALTER TABLE t ALTER COLUMN v TYPE app.text", "ALTER COLUMN v TYPE app.text"}, + {`ALTER TABLE t ALTER COLUMN v TYPE text COLLATE "C"`, "ALTER COLUMN v TYPE text COLLATE C"}, + {`ALTER TABLE t ALTER COLUMN v TYPE varchar(50)[] COLLATE pg_catalog."POSIX"`, "ALTER COLUMN v TYPE varchar(50)[] COLLATE pg_catalog.POSIX"}, } for _, tc := range cases { assert.Equal(t, tc.want, parseOneOp(t, tc.sql).Describe(), tc.sql)