Skip to content

feat: Column Rename Detection#435

Open
NFUChen wants to merge 4 commits into
pgplex:mainfrom
NFUChen:feat/rename-detection
Open

feat: Column Rename Detection#435
NFUChen wants to merge 4 commits into
pgplex:mainfrom
NFUChen:feat/rename-detection

Conversation

@NFUChen
Copy link
Copy Markdown

@NFUChen NFUChen commented May 15, 2026

Summary

Adds automatic detection of column renames, generating ALTER TABLE ... RENAME COLUMN instead of DROP COLUMN + ADD COLUMN. This preserves data during migrations when a column is simply renamed.

Problem

Previously, renaming a column in a schema file (e.g. active -> is_active) produced a destructive plan:

ALTER TABLE users DROP COLUMN active;
ALTER TABLE users ADD COLUMN is_active boolean NOT NULL;

This loses all data in the column. The correct migration is:

ALTER TABLE users RENAME COLUMN active TO is_active;

Additionally, when a renamed column participates in constraints (foreign keys, primary keys, unique), PostgreSQL automatically updates constraint definitions to reference the new column name. The old behavior would emit spurious constraint drop/recreate statements.

Approach

Rename detection uses a position + properties heuristic: if a dropped column and an added column share the same ordinal position and all properties match (type, nullability, default, identity, generated expression) but differ only in name, it is treated as a rename.

Key design decisions

  • Position-based matching: Columns must be at the same ordinal position. This avoids false positives when unrelated columns happen to share the same type.
  • Type change = not a rename: If both name and type change, it is treated as a drop + add (no safe automatic migration exists). See the rename_column_type_change test case.
  • Constraint-aware: After detecting renames, the diff engine applies a rename map to old constraint column references before comparing. This prevents false constraint diffs when PostgreSQL would auto-update the references.
  • Comment changes allowed: A rename with a simultaneous comment change is still detected as a rename; the comment change is emitted separately.

Changes

Core logic (internal/diff/)

  • column.go - columnsMatchForRename(): compares two columns for rename eligibility (same position, type, nullability, default, identity, generated expr; different name)
  • column_test.go - Unit tests for columnsMatchForRename covering type mismatch, position mismatch, nullability mismatch, identity, schema-qualified types, etc.
  • table.go - detectColumnRenames(): pairs added/dropped columns by position, returns renames and remaining unpaired columns. Integrates into diffTables() and generateAlterTableStatements() to emit RENAME COLUMN SQL before any drops/adds. Handles comment changes on renamed columns.
  • constraint.go - applyRenameMapToConstraint(): shallow-copies a constraint with column names updated per the rename map, so constraint comparison ignores already-handled renames.
  • diff.go - Adds DiffTypeTableColumnRename diff type, ColumnRename struct, and sorting/serialization support.

Test cases (testdata/diff/create_table/)

Test case Scenario
rename_column/ Simple rename: active -> is_active (same type, same position)
rename_column_type_change/ Name + type change: treated as drop + add (not a rename)
issue_384_rename_column_constraint/ Updated: rename with FK constraint now emits RENAME COLUMN instead of drop/recreate constraint

Test plan

  • PGSCHEMA_TEST_FILTER="rename_column/" go test -v ./internal/diff -run TestDiffFromFiles - rename detection diff tests
  • PGSCHEMA_TEST_FILTER="rename_column_type_change/" go test -v ./internal/diff -run TestDiffFromFiles - type change falls back to drop/add
  • PGSCHEMA_TEST_FILTER="issue_384" go test -v ./internal/diff -run TestDiffFromFiles - constraint handling with renames
  • go test -v ./internal/diff -run TestColumnsMatchForRename - unit tests for match logic
  • go test -v ./internal/diff -run TestDiffFromFiles - all diff tests pass
  • PGSCHEMA_TEST_FILTER="rename_column/" go test -v ./cmd -run TestPlanAndApply - integration test
  • go test ./... - full test suite

William-W-Chen and others added 4 commits May 15, 2026 16:17
Add infrastructure for column rename detection:
- DiffTypeTableColumnRename enum value with String/UnmarshalJSON
- ColumnRename struct with Old/New column pointers
- RenamedColumns field on tableDiff
- GetObjectName implementation for ColumnRename
- Sorting and initialization for RenamedColumns

Co-Authored-By: Claude (claude-opus-4-6) <noreply@anthropic.com>
Implement columnsMatchForRename to check if two columns at the same
position with identical properties represent a rename. Add
detectColumnRenames to extract rename pairs from added/dropped columns,
and wire it into diffTables.

Co-Authored-By: Claude (claude-opus-4-6) <noreply@anthropic.com>
Add RENAME COLUMN SQL generation in generateAlterTableStatements,
emitted before constraint drops and column drops to preserve data.
Also handle constraint comparison with renamed columns by applying
the rename map to old constraint column names before comparing.

Co-Authored-By: Claude (claude-opus-4-6) <noreply@anthropic.com>
@NFUChen NFUChen changed the title Feat/rename detection feat: Column Rename Detection May 15, 2026
@NFUChen NFUChen marked this pull request as ready for review May 15, 2026 15:48
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 15, 2026

Greptile Summary

This PR adds automatic column rename detection to table diffs. It changes:

  • Detects added/dropped column pairs as renames by position and matching properties.
  • Emits ALTER TABLE ... RENAME COLUMN instead of drop/add for detected renames.
  • Applies column rename mapping when comparing local constraint columns.
  • Adds rename-specific diff metadata and test fixtures.

Confidence Score: 3/5

This should be fixed before merging.

  • Column renames can still produce unnecessary dependent-object DDL.
  • Check constraints that PostgreSQL updates automatically can be dropped and re-added.
  • Standalone indexes that PostgreSQL updates automatically can be rebuilt.

Focus on rename-map handling in internal/diff/table.go and internal/diff/constraint.go.

Important Files Changed

Filename Overview
internal/diff/table.go Adds rename detection and rename SQL generation, but dependent index comparison does not account for renamed columns.
internal/diff/constraint.go Adds constraint column rename mapping, but constraint expressions are still compared with old column names.

Comments Outside Diff (2)

  1. internal/diff/constraint.go, line 157-158 (link)

    P1 Check expressions still diff

    When a renamed column is used inside a CHECK constraint, PostgreSQL updates the stored constraint expression during ALTER TABLE ... RENAME COLUMN. This comparison still checks the old CheckClause text before applying any rename to expressions, so an unchanged constraint like CHECK (active IS NOT NULL) becomes a modified constraint after renaming active to is_active. The generated plan then drops and re-adds the check constraint even though the rename already updated it, which can take unnecessary locks and force validation work during the migration.

  2. internal/diff/table.go, line 312-324 (link)

    P1 Indexes still rebuild

    Column renames are only applied to constraint comparison. Standalone indexes are compared with their original column names, include columns, and partial predicate text. If active is renamed to is_active while an unchanged index exists on active, PostgreSQL updates the index definition automatically, but this code still sees active versus is_active and adds the index to the drop/create lists. That makes a data-preserving rename rebuild indexes that should be left in place.

Reviews (2): Last reviewed commit: "feat: rename columns in multiple tables ..." | Re-trigger Greptile

Comment thread internal/diff/column.go
Comment on lines +182 to +190
// Identity must match
if (old.Identity == nil) != (new.Identity == nil) {
return false
}
if old.Identity != nil && new.Identity != nil {
if old.Identity.Generation != new.Identity.Generation {
return false
}
}
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 Identity options skipped

This only compares the identity generation mode, so a renamed identity column with changed sequence options is treated as a pure rename. For example, changing GENERATED ALWAYS AS IDENTITY START 1 to START 100 while renaming the column emits only ALTER TABLE ... RENAME COLUMN, leaving the identity sequence options unchanged in the database.

Comment thread internal/diff/column.go
Comment on lines +174 to +180
// Max length must match
if (old.MaxLength == nil) != (new.MaxLength == nil) {
return false
}
if old.MaxLength != nil && new.MaxLength != nil && *old.MaxLength != *new.MaxLength {
return false
}
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 Precision changes skipped

The rename matcher checks MaxLength but not Precision or Scale. A column renamed from amount numeric(10,2) to total numeric(10,4) has the same DataType and position, so this path classifies it as a rename and emits no type alteration, leaving the old precision/scale in place.

Comment on lines +231 to +255
func applyRenameMapToConstraint(c *ir.Constraint, renameMap map[string]string) *ir.Constraint {
needsUpdate := false
for _, col := range c.Columns {
if _, ok := renameMap[col.Name]; ok {
needsUpdate = true
break
}
}
if !needsUpdate {
return c
}

// Shallow copy the constraint and update column names
copy := *c
copy.Columns = make([]*ir.ConstraintColumn, len(c.Columns))
for i, col := range c.Columns {
if newName, ok := renameMap[col.Name]; ok {
colCopy := *col
colCopy.Name = newName
copy.Columns[i] = &colCopy
} else {
copy.Columns[i] = col
}
}
return &copy
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 Referenced columns untouched

This only rewrites the constraint's local Columns, not ReferencedColumns. When table a.id is renamed to a.account_id, a foreign key on another table that references a(id) is automatically updated by PostgreSQL, but the diff for that other table still compares old referenced column id against new referenced column account_id and emits a spurious FK drop/recreate.

Comment thread internal/diff/table.go
Comment on lines +789 to +803
// Rename columns before any drops/adds to preserve data
for _, rename := range td.RenamedColumns {
tableName := getTableNameWithSchema(td.Table.Schema, td.Table.Name, targetSchema)
sql := fmt.Sprintf("ALTER TABLE %s RENAME COLUMN %s TO %s;",
tableName, ir.QuoteIdentifier(rename.Old.Name), ir.QuoteIdentifier(rename.New.Name))

context := &diffContext{
Type: DiffTypeTableColumnRename,
Operation: DiffOperationAlter,
Path: fmt.Sprintf("%s.%s.%s", td.Table.Schema, td.Table.Name, rename.New.Name),
Source: rename,
CanRunInTransaction: true,
}
collector.collect(context, sql)
}
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 Rename before drop

Renames are emitted before remaining column drops, which fails when the rename target is the name of a column that is also being dropped. With old columns a, b and new column a representing b -> a plus dropping the old a, this emits RENAME COLUMN b TO a while a still exists, so PostgreSQL rejects the migration with a duplicate column name.

@NFUChen NFUChen marked this pull request as draft May 15, 2026 15:57
@NFUChen NFUChen marked this pull request as ready for review May 15, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants