Procházet zdrojové kódy

Merge pull request #28 from database-pizza/engine/a-rename-column-indexes

feat: rewrite index metadata on ALTER TABLE RENAME COLUMN
Danilo Fragoso před 1 dnem
rodič
revize
2f06adacf2

+ 13 - 0
pkg/analyzer/analyzer.go

@@ -522,6 +522,19 @@ func (a *Analyzer) analyzeInsert(stmt *parser.InsertStmt) error {
 		}
 	}
 
+	// An ON CONFLICT target names columns of the target table. An unknown name
+	// must fail the statement rather than silently degrading to a scan. The
+	// hidden rowid aliases are valid targets unless a real column shadows them.
+	for _, colName := range stmt.ConflictTarget {
+		if _, ok := table.GetColumn(colName); ok || isRowIDAlias(colName) {
+			continue
+		}
+		return &AnalysisError{
+			Type:    ErrColumnNotFound,
+			Message: fmt.Sprintf("column not found: %s", colName),
+		}
+	}
+
 	// Validate VALUES
 	for _, row := range stmt.Values {
 		if len(row) != len(targetCols) {

+ 1 - 0
pkg/analyzer/analyzer_test.go

@@ -383,6 +383,7 @@ func TestAnalyzeInsertErrors(t *testing.T) {
 	}{
 		{"table not found", "INSERT INTO nonexistent VALUES (1)", ErrTableNotFound},
 		{"column not found", "INSERT INTO users (nonexistent) VALUES (1)", ErrColumnNotFound},
+		{"unknown on conflict target", "INSERT INTO users (id, name) VALUES (1, 'a') ON CONFLICT (nonexistent) DO NOTHING", ErrColumnNotFound},
 		{"wrong column count", "INSERT INTO users (id, name) VALUES (1)", ErrTypeMismatch},
 	}
 

+ 1 - 1
pkg/capabilities/capabilities.go

@@ -179,7 +179,7 @@ func Current() *Document {
 			AddColumnBackfill:      Feature{Supported: false, Status: "no_backfill", Notes: "existing rows read NULL; a DEFAULT applies to later INSERTs only"},
 			DropColumn:             Feature{Supported: true, Status: "rewrites_rows"},
 			RenameColumn:           Feature{Supported: true, Status: "rewrites_rows"},
-			RenameColumnIndexes:    Feature{Supported: false, Status: "stale_index_metadata", Notes: "drop and recreate indexes that reference a renamed column"},
+			RenameColumnIndexes:    Feature{Supported: true, Status: "rewrites_index_metadata", Notes: "column references in index definitions are rewritten in place, including composite and unique indexes; index names are unchanged; RENAME COLUMN is rejected when an expression index references the column (drop that index, rename, then recreate it)"},
 			CreateTableIfNotExists: true,
 			AddColumnIfNotExists:   true,
 		},

+ 3 - 0
pkg/capabilities/capabilities_test.go

@@ -39,4 +39,7 @@ func TestCurrentDocumentIsVersionedAndMarshalable(t *testing.T) {
 	if round.Notifications.PayloadLimit != 8000 || round.Notifications.DurableReplay {
 		t.Fatalf("unexpected notification limits: %+v", round.Notifications)
 	}
+	if !round.DDL.RenameColumnIndexes.Supported {
+		t.Fatal("RENAME COLUMN must advertise index metadata rewriting")
+	}
 }

+ 200 - 16
pkg/executor/alter_table_test.go

@@ -1,8 +1,11 @@
 package executor
 
 import (
+	"strings"
 	"sync"
 	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
 )
 
 // ADD COLUMN only appends schema metadata. Existing rows are not rewritten, so
@@ -112,33 +115,214 @@ func TestAlterTableDropColumnRewritesPopulatedRows(t *testing.T) {
 	}
 }
 
-// RENAME COLUMN rewrites row keys but deliberately leaves index metadata
-// unchanged. Documented guidance: drop and recreate indexes after a rename.
-func TestAlterTableRenameColumnLeavesIndexMetadataStale(t *testing.T) {
+// RENAME COLUMN rewrites the durable index definitions along with the schema
+// and rows, so single- and multi-column indexes name the new column while
+// keeping their original index names.
+func TestAlterTableRenameColumnUpdatesIndexMetadata(t *testing.T) {
+	pool, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, a TEXT, b TEXT)")
+	execMust(t, e, "INSERT INTO t (id, a, b) VALUES (1, 'x', 'y')")
+	execMust(t, e, "CREATE INDEX idx_a ON t(a)")
+	execMust(t, e, "CREATE INDEX idx_ab ON t(a, b)")
+
+	execMust(t, e, "ALTER TABLE t RENAME COLUMN a TO aa")
+
+	single, err := schema.GetIndex("idx_a")
+	if err != nil {
+		t.Fatalf("get idx_a: %v", err)
+	}
+	if len(single.Columns) != 1 || single.Columns[0].Name != "aa" {
+		t.Fatalf("idx_a columns = %#v, want [aa]", single.Columns)
+	}
+
+	composite, err := schema.GetIndex("idx_ab")
+	if err != nil {
+		t.Fatalf("get idx_ab: %v", err)
+	}
+	if len(composite.Columns) != 2 || composite.Columns[0].Name != "aa" || composite.Columns[1].Name != "b" {
+		t.Fatalf("idx_ab columns = %#v, want [aa b]", composite.Columns)
+	}
+
+	// index_info reports the renamed column and resolves its column id.
+	res := execMust(t, e, "PRAGMA index_info(idx_a)")
+	if len(res.Rows) != 1 || res.Rows[0][1] != int64(1) || res.Rows[0][2] != "aa" {
+		t.Fatalf("index_info(idx_a) = %v, want [[0 1 aa]]", res.Rows)
+	}
+
+	// sqlite_master renders the recreated index over the new column name.
+	res = execMust(t, e, "SELECT sql FROM sqlite_master WHERE type='index' AND name='idx_ab'")
+	if len(res.Rows) != 1 || !strings.Contains(res.Rows[0][0].(string), "(`aa`, `b`)") {
+		t.Fatalf("recreated idx_ab SQL = %v, want a definition over aa, b", res.Rows)
+	}
+
+	// The definition is durable, not just cached on this SchemaManager.
+	reloaded := storage.NewSchemaManager(pool, "testdb")
+	composite, err = reloaded.GetIndex("idx_ab")
+	if err != nil {
+		t.Fatalf("reload idx_ab: %v", err)
+	}
+	if len(composite.Columns) != 2 || composite.Columns[0].Name != "aa" || composite.Columns[1].Name != "b" {
+		t.Fatalf("reloaded idx_ab columns = %#v, want [aa b]", composite.Columns)
+	}
+
+	// Index-backed equality lookup still returns the row after the rename.
+	res = execMust(t, e, "SELECT id, aa FROM t WHERE aa = 'x'")
+	if res.RowCount != 1 || res.Rows[0][0] != int64(1) || res.Rows[0][1] != "x" {
+		t.Fatalf("indexed lookup after rename = %v, want [[1 x]]", res.Rows)
+	}
+}
+
+// A UNIQUE index keeps enforcing uniqueness after its column is renamed.
+func TestAlterTableRenameColumnKeepsUniqueIndexEnforcement(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT, tag TEXT)")
+	execMust(t, e, "CREATE UNIQUE INDEX uq_email ON t(email)")
+	execMust(t, e, "INSERT INTO t (id, email, tag) VALUES (1, 'a', 'first')")
+
+	if _, err := execSQL(e, "INSERT INTO t (id, email, tag) VALUES (2, 'a', 'dup')"); err == nil || !strings.Contains(err.Error(), "UNIQUE constraint failed") {
+		t.Fatalf("pre-rename duplicate error = %v, want UNIQUE constraint failure", err)
+	}
+
+	execMust(t, e, "ALTER TABLE t RENAME COLUMN email TO mail")
+
+	if _, err := execSQL(e, "INSERT INTO t (id, mail, tag) VALUES (3, 'a', 'dup')"); err == nil || !strings.Contains(err.Error(), "UNIQUE constraint failed") {
+		t.Fatalf("post-rename duplicate error = %v, want UNIQUE constraint failure", err)
+	}
+	execMust(t, e, "INSERT INTO t (id, mail, tag) VALUES (4, 'b', 'ok')")
+
+	res := execMust(t, e, "SELECT id, mail, tag FROM t ORDER BY id")
+	if res.RowCount != 2 || res.Rows[0][1] != "a" || res.Rows[1][1] != "b" {
+		t.Fatalf("rows after rename = %v, want two distinct emails", res.Rows)
+	}
+
+	res = execMust(t, e, "PRAGMA index_list(t)")
+	if len(res.Rows) != 1 || res.Rows[0][1] != "uq_email" || res.Rows[0][2] != int64(1) {
+		t.Fatalf("index_list(t) = %v, want the unique uq_email index", res.Rows)
+	}
+}
+
+// ON CONFLICT resolves against the renamed column and rejects the old name.
+func TestAlterTableRenameColumnResolvesOnConflictTarget(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT, tag TEXT)")
+	execMust(t, e, "CREATE UNIQUE INDEX uq_email ON t(email)")
+	execMust(t, e, "INSERT INTO t (id, email, tag) VALUES (1, 'a', 'old')")
+
+	execMust(t, e, "ALTER TABLE t RENAME COLUMN email TO mail")
+
+	execMust(t, e, "INSERT INTO t (id, mail, tag) VALUES (2, 'a', 'new') ON CONFLICT (mail) DO UPDATE SET tag = excluded.tag")
+	res := execMust(t, e, "SELECT id, mail, tag FROM t")
+	if res.RowCount != 1 || res.Rows[0][0] != int64(1) || res.Rows[0][2] != "new" {
+		t.Fatalf("upsert on renamed column = %v, want the existing row updated", res.Rows)
+	}
+
+	if _, err := execSQL(e, "INSERT INTO t (id, mail, tag) VALUES (3, 'a', 'x') ON CONFLICT (email) DO UPDATE SET tag = excluded.tag"); err == nil || !strings.Contains(err.Error(), "column not found: email") {
+		t.Fatalf("upsert on old column error = %v, want column not found: email", err)
+	}
+}
+
+// Renaming a column back restores the original index metadata, and renaming to
+// an existing column name still fails without touching that metadata.
+func TestAlterTableRenameColumnCycleRestoresIndexMetadata(t *testing.T) {
 	_, schema, table := newTestDB(t)
 	e := newExec(schema, table)
 	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, a TEXT)")
+	execMust(t, e, "CREATE UNIQUE INDEX uq_a ON t(a)")
 	execMust(t, e, "INSERT INTO t (id, a) VALUES (1, 'x')")
-	execMust(t, e, "CREATE INDEX idx_a ON t(a)")
 
-	execMust(t, e, "ALTER TABLE t RENAME COLUMN a TO aa")
+	execMust(t, e, "ALTER TABLE t RENAME COLUMN a TO b")
+	execMust(t, e, "ALTER TABLE t RENAME COLUMN b TO a")
 
-	res := execMust(t, e, "SELECT aa FROM t WHERE id = 1")
-	if got := res.Rows[0][0]; got != "x" {
-		t.Fatalf("renamed value = %v, want x", got)
+	idx, err := schema.GetIndex("uq_a")
+	if err != nil {
+		t.Fatalf("get uq_a: %v", err)
+	}
+	if len(idx.Columns) != 1 || idx.Columns[0].Name != "a" {
+		t.Fatalf("uq_a columns after cycle = %#v, want [a]", idx.Columns)
+	}
+	if _, err := execSQL(e, "INSERT INTO t (id, a) VALUES (2, 'x')"); err == nil || !strings.Contains(err.Error(), "UNIQUE constraint failed") {
+		t.Fatalf("duplicate error after rename cycle = %v, want UNIQUE constraint failure", err)
 	}
 
-	names, err := schema.ListIndexes()
+	if _, err := execSQL(e, "ALTER TABLE t RENAME COLUMN a TO id"); err == nil || !strings.Contains(err.Error(), "column already exists: id") {
+		t.Fatalf("rename to existing column error = %v, want column already exists: id", err)
+	}
+	idx, err = schema.GetIndex("uq_a")
 	if err != nil {
-		t.Fatalf("list indexes: %v", err)
+		t.Fatalf("get uq_a after rejected rename: %v", err)
+	}
+	if len(idx.Columns) != 1 || idx.Columns[0].Name != "a" {
+		t.Fatalf("uq_a columns after rejected rename = %#v, want [a]", idx.Columns)
+	}
+}
+
+// A column referenced by an expression index cannot be renamed: the engine
+// stores expression text verbatim, so the rename is rejected instead of leaving
+// a UNIQUE expression index silently unbound from the column it enforces.
+func TestAlterTableRenameColumnRejectsExpressionIndexDependency(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT, tag TEXT)")
+	execMust(t, e, "CREATE UNIQUE INDEX uq_lower_email ON t(lower(email))")
+	execMust(t, e, "INSERT INTO t (id, email, tag) VALUES (1, 'a@x', 'keep')")
+
+	_, err := execSQL(e, "ALTER TABLE t RENAME COLUMN email TO mail")
+	if err == nil {
+		t.Fatal("rename of a column used by an expression index must be rejected")
 	}
-	found := false
-	for _, name := range names {
-		if name == "idx_a" {
-			found = true
+	for _, want := range []string{"uq_lower_email", "lower(email)"} {
+		if !strings.Contains(err.Error(), want) {
+			t.Fatalf("rename error = %v, want it to name %q", err, want)
 		}
 	}
-	if !found {
-		t.Fatalf("indexes = %v, want idx_a to still exist (rename does not rewrite index metadata)", names)
+
+	// Nothing was renamed, and the UNIQUE expression index still enforces.
+	sch, err := schema.GetSchema("t")
+	if err != nil {
+		t.Fatalf("get schema: %v", err)
+	}
+	if _, ok := sch.GetColumn("email"); !ok {
+		t.Fatal("rejected rename removed the email column")
+	}
+	if _, ok := sch.GetColumn("mail"); ok {
+		t.Fatal("rejected rename added the mail column")
+	}
+	if _, err := execSQL(e, "INSERT INTO t (id, email, tag) VALUES (2, 'a@x', 'dup')"); err == nil || !strings.Contains(err.Error(), "UNIQUE constraint failed") {
+		t.Fatalf("duplicate on expression index after rejected rename = %v, want UNIQUE constraint failure", err)
+	}
+
+	// A column the expression does not reference can still be renamed.
+	execMust(t, e, "ALTER TABLE t RENAME COLUMN tag TO label")
+	idx, err := schema.GetIndex("uq_lower_email")
+	if err != nil {
+		t.Fatalf("get uq_lower_email: %v", err)
+	}
+	if len(idx.Columns) != 1 || idx.Columns[0].Expression != "lower(email)" {
+		t.Fatalf("uq_lower_email columns = %#v, want the untouched lower(email) expression", idx.Columns)
+	}
+	if res := execMust(t, e, "SELECT label FROM t WHERE id = 1"); res.RowCount != 1 || res.Rows[0][0] != "keep" {
+		t.Fatalf("renamed label = %v, want keep", res.Rows)
+	}
+}
+
+// Conflict targets that name a hidden rowid alias are not table columns but are
+// still valid targets, including after a rename.
+func TestAlterTableRenameColumnKeepsRowIDConflictTarget(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, a TEXT)")
+	execMust(t, e, "INSERT INTO t (id, a) VALUES (1, 'x')")
+
+	execMust(t, e, "ALTER TABLE t RENAME COLUMN a TO b")
+
+	execMust(t, e, "INSERT INTO t (id, b) VALUES (2, 'y') ON CONFLICT (rowid) DO NOTHING")
+	execMust(t, e, "INSERT INTO t (id, b) VALUES (3, 'z') ON CONFLICT (oid) DO NOTHING")
+	execMust(t, e, "INSERT INTO t (id, b) VALUES (4, 'w') ON CONFLICT (_rowid_) DO NOTHING")
+
+	if res := execMust(t, e, "SELECT count(*) FROM t"); res.Rows[0][0] != int64(4) {
+		t.Fatalf("row count = %v, want 4", res.Rows[0][0])
 	}
 }

+ 33 - 0
pkg/executor/executor.go

@@ -4203,6 +4203,9 @@ func (e *Executor) executeAlterTableRename(table string, action *parser.RenameTa
 
 // executeAlterTableRenameColumn renames a column.
 func (e *Executor) executeAlterTableRenameColumn(table string, action *parser.RenameColumnAction) (*Result, error) {
+	if err := e.rejectExpressionIndexRename(table, action.OldName); err != nil {
+		return nil, err
+	}
 	if err := e.schema.RenameColumn(table, action.OldName, action.NewName); err != nil {
 		return nil, err
 	}
@@ -4214,6 +4217,36 @@ func (e *Executor) executeAlterTableRenameColumn(table string, action *parser.Re
 	return result, nil
 }
 
+// rejectExpressionIndexRename refuses to rename a column that a stored index
+// expression references. Expression text is persisted verbatim, so the rename
+// cannot rewrite it; leaving the old identifier behind would silently unbind a
+// UNIQUE expression index, because its rows no longer resolve the referenced
+// column.
+func (e *Executor) rejectExpressionIndexRename(table, column string) error {
+	indexes, err := e.schema.ListTableIndexes(table)
+	if err != nil {
+		return err
+	}
+	for _, idx := range indexes {
+		for _, ic := range idx.Columns {
+			if ic.Expression == "" {
+				continue
+			}
+			references, err := storedExpressionReferencesColumn(ic.Expression, column)
+			if err != nil {
+				return fmt.Errorf("index %s: %w", idx.Name, err)
+			}
+			if references {
+				return pgerror.Unsupported(
+					"cannot rename column %s: index %s depends on it through the expression %s; drop the index, rename the column, then recreate the index",
+					column, idx.Name, ic.Expression,
+				)
+			}
+		}
+	}
+	return nil
+}
+
 // Transaction execution methods
 
 // executeBegin starts a new transaction.

+ 16 - 0
pkg/executor/indexexpr.go

@@ -51,6 +51,22 @@ func EvalStoredExpression(text string, row storage.Row) (interface{}, error) {
 	return statelessEval.evalExpr(expr, row)
 }
 
+// storedExpressionReferencesColumn reports whether a persisted index expression
+// names column. The engine stores expression SQL text verbatim, so a column
+// rename cannot rewrite the reference.
+func storedExpressionReferencesColumn(text, column string) (bool, error) {
+	expr, err := parseStoredExpr(text)
+	if err != nil {
+		return false, err
+	}
+	for _, ref := range collectColumnRefs(expr) {
+		if strings.EqualFold(ref, column) {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
 // ValidateDeterministicExpr rejects an expression that SQLite would not allow in
 // an index or generated column: subqueries, window functions, aggregates, and
 // non-deterministic or unknown functions.

+ 83 - 16
pkg/storage/schema.go

@@ -1152,10 +1152,14 @@ func (m *SchemaManager) renameDataKeys(oldName, newName string) error {
 	})
 }
 
-// repointIndexesLocked updates indexes whose table was renamed. The caller holds
-// m.mu; it reads the durable index list directly rather than calling the
-// self-locking ListIndexes helper.
-func (m *SchemaManager) repointIndexesLocked(oldName, newName string) error {
+// updateIndexesOfTableLocked applies mutate to the durable definition of every
+// index on table and persists the definitions it changed. mutate reports
+// whether it modified the definition; unmodified indexes are left untouched.
+// The changed definitions are written in one batch, so a table's indexes never
+// split between the old and new shape. The caller holds m.mu; it reads the
+// durable index list directly rather than calling the self-locking ListIndexes
+// helper.
+func (m *SchemaManager) updateIndexesOfTableLocked(table string, mutate func(*Index) bool) error {
 	var names []string
 	if m.indexListCached {
 		names = append([]string(nil), m.indexListCache...)
@@ -1177,6 +1181,12 @@ func (m *SchemaManager) repointIndexesLocked(oldName, newName string) error {
 		}
 	}
 
+	type indexUpdate struct {
+		index Index
+		key   string
+		value []byte
+	}
+	var updates []indexUpdate
 	for _, name := range names {
 		key := m.indexKey(name)
 		var data string
@@ -1195,27 +1205,74 @@ func (m *SchemaManager) repointIndexesLocked(oldName, newName string) error {
 		if err := json.Unmarshal([]byte(data), &idx); err != nil {
 			return err
 		}
-		if !strings.EqualFold(idx.Table, oldName) {
+		if !strings.EqualFold(idx.Table, table) {
+			continue
+		}
+		if !mutate(&idx) {
 			continue
 		}
-		idx.Table = newName
 		updated, err := json.Marshal(&idx)
 		if err != nil {
 			return err
 		}
-		if err := m.pool.WithClient(func(c *KVClient) error {
-			return c.Write(key, string(updated))
-		}); err != nil {
-			return err
-		}
-		if cached, ok := m.indexCache[strings.ToLower(name)]; ok {
-			cached.Table = newName
+		updates = append(updates, indexUpdate{index: idx, key: key, value: updated})
+	}
+	if len(updates) == 0 {
+		return nil
+	}
+
+	ops := make([]BatchOp, 0, len(updates))
+	for _, update := range updates {
+		ops = append(ops, BatchOp{Op: batchPut, Key: []byte(update.key), Value: update.value})
+	}
+	if err := m.pool.WithClient(func(c *KVClient) error {
+		_, err := c.BatchWrite(ops, nil)
+		return err
+	}); err != nil {
+		return fmt.Errorf("update index definitions for table %s: %w", table, err)
+	}
+
+	for _, update := range updates {
+		if cached, ok := m.indexCache[strings.ToLower(update.index.Name)]; ok {
+			*cached = update.index
 		}
 	}
 	return nil
 }
 
-// RenameColumn renames a column in a table.
+// repointIndexesLocked updates indexes whose table was renamed.
+func (m *SchemaManager) repointIndexesLocked(oldName, newName string) error {
+	return m.updateIndexesOfTableLocked(oldName, func(idx *Index) bool {
+		idx.Table = newName
+		return true
+	})
+}
+
+// renameIndexColumnsLocked rewrites the stored definition of every index on
+// table so a reference to oldName becomes newName. Index names stay unchanged:
+// they are independent identifiers that SQLite does not derive from column
+// names, and callers may have chosen them explicitly. A rename to an existing
+// column name is rejected before this runs. Expression columns keep their SQL
+// text; callers must reject a rename that such an expression depends on,
+// because the text is not rewritten.
+func (m *SchemaManager) renameIndexColumnsLocked(table, oldName, newName string) error {
+	return m.updateIndexesOfTableLocked(table, func(idx *Index) bool {
+		changed := false
+		for i := range idx.Columns {
+			// Expression columns persist SQL text, not a column reference; their
+			// text is left for the caller to drop and recreate the index.
+			if idx.Columns[i].Expression == "" && strings.EqualFold(idx.Columns[i].Name, oldName) {
+				idx.Columns[i].Name = newName
+				changed = true
+			}
+		}
+		return changed
+	})
+}
+
+// RenameColumn renames a column in a table. Column references in index
+// definitions are rewritten; expression-index text is not, so a SQL caller must
+// reject a rename that an expression depends on.
 func (m *SchemaManager) RenameColumn(table, oldName, newName string) error {
 	tableLock := m.tableLock(table)
 	tableLock.Lock()
@@ -1268,8 +1325,18 @@ func (m *SchemaManager) RenameColumn(table, oldName, newName string) error {
 		return err
 	}
 
-	// Update schema
-	return m.updateSchemaUnsafe(schema)
+	// Publish the renamed schema before the index definitions: reads of the new
+	// column name resolve against rewritten rows as soon as the schema lands.
+	// Index DDL is not transactional (as elsewhere in this engine), so a failure
+	// here leaves stale index metadata for the caller to drop and recreate.
+	if err := m.updateSchemaUnsafe(schema); err != nil {
+		return err
+	}
+
+	// Index metadata drives secondary-index values, UNIQUE enforcement,
+	// ON CONFLICT target resolution, and catalog introspection, so it must name
+	// the renamed column before any later statement uses the table.
+	return m.renameIndexColumnsLocked(table, oldName, newName)
 }
 
 // rewriteRows applies an idempotent name-keyed row transformation in bounded