From 101e4cb65f2f8e115fa00f156771ef777bcdacd9 Mon Sep 17 00:00:00 2001 From: Sam Mosleh Date: Tue, 3 Feb 2026 13:29:35 +0400 Subject: [PATCH 1/5] fix: alter identities --- internal/diff/column.go | 46 +++++++++++++++++++ .../diff/create_table/alter_identity/diff.sql | 7 +++ .../diff/create_table/alter_identity/new.sql | 5 ++ .../diff/create_table/alter_identity/old.sql | 5 ++ .../create_table/alter_identity/plan.json | 38 +++++++++++++++ .../diff/create_table/alter_identity/plan.sql | 7 +++ .../diff/create_table/alter_identity/plan.txt | 21 +++++++++ 7 files changed, 129 insertions(+) create mode 100644 testdata/diff/create_table/alter_identity/diff.sql create mode 100644 testdata/diff/create_table/alter_identity/new.sql create mode 100644 testdata/diff/create_table/alter_identity/old.sql create mode 100644 testdata/diff/create_table/alter_identity/plan.json create mode 100644 testdata/diff/create_table/alter_identity/plan.sql create mode 100644 testdata/diff/create_table/alter_identity/plan.txt diff --git a/internal/diff/column.go b/internal/diff/column.go index fdacac45..e85dbb13 100644 --- a/internal/diff/column.go +++ b/internal/diff/column.go @@ -2,10 +2,13 @@ package diff import ( "fmt" + "regexp" "github.com/pgschema/pgschema/ir" ) +var sequenceNextvalPattern = regexp.MustCompile(`nextval\('([^']+)'::regclass\)`) + // generateColumnSQL generates SQL statements for column modifications func (cd *ColumnDiff) generateColumnSQL(tableSchema, tableName string, targetSchema string) []string { var statements []string @@ -47,6 +50,30 @@ func (cd *ColumnDiff) generateColumnSQL(tableSchema, tableName string, targetSch } } + // Check if converting to serial (gaining a nextval default) + oldSeqName := extractSequenceNameFromDefault(cd.Old.DefaultValue) + newSeqName := extractSequenceNameFromDefault(cd.New.DefaultValue) + + // If converting to serial, we need to create the sequence first + if oldSeqName == nil && newSeqName != nil { + // Create the sequence with ownership + sql := fmt.Sprintf("CREATE SEQUENCE IF NOT EXISTS %s OWNED BY %s.%s;", + *newSeqName, qualifiedTableName, ir.QuoteIdentifier(cd.New.Name)) + statements = append(statements, sql) + } + + // Handle identity column changes + if cd.Old.Identity != nil && (cd.New.Identity == nil || cd.Old.Identity.Generation != cd.New.Identity.Generation) { + sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s DROP IDENTITY;", + qualifiedTableName, ir.QuoteIdentifier(cd.New.Name)) + statements = append(statements, sql) + } + if cd.New.Identity != nil && (cd.Old.Identity == nil || cd.Old.Identity.Generation != cd.New.Identity.Generation) { + sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s ADD GENERATED %s AS IDENTITY;", + qualifiedTableName, ir.QuoteIdentifier(cd.New.Name), cd.New.Identity.Generation) + statements = append(statements, sql) + } + // Handle nullable changes if cd.Old.IsNullable != cd.New.IsNullable { if cd.New.IsNullable { @@ -93,6 +120,25 @@ func (cd *ColumnDiff) generateColumnSQL(tableSchema, tableName string, targetSch return statements } +// extractSequenceNameFromDefault extracts the sequence name from a PostgreSQL +// nextval default value. +// Examples: +// +// "nextval('table1_c1_seq'::regclass)" -> "table1_c1_seq" +// "nextval('public.table1_c1_seq'::regclass)" -> "public.table1_c1_seq" +func extractSequenceNameFromDefault(defaultValue *string) *string { + if defaultValue == nil { + return nil + } + + matches := sequenceNextvalPattern.FindStringSubmatch(*defaultValue) + if len(matches) < 2 { + return nil + } + + return &matches[1] +} + // needsUsingClause determines if a type conversion requires a USING clause. // // This is especially important when converting to or from custom types (like ENUMs), diff --git a/testdata/diff/create_table/alter_identity/diff.sql b/testdata/diff/create_table/alter_identity/diff.sql new file mode 100644 index 00000000..77271889 --- /dev/null +++ b/testdata/diff/create_table/alter_identity/diff.sql @@ -0,0 +1,7 @@ +CREATE SEQUENCE IF NOT EXISTS table1_c1_seq OWNED BY table1.c1; + +ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass); + +ALTER TABLE table1 ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY; + +ALTER TABLE table1 ALTER COLUMN c3 DROP IDENTITY; diff --git a/testdata/diff/create_table/alter_identity/new.sql b/testdata/diff/create_table/alter_identity/new.sql new file mode 100644 index 00000000..0b8049ca --- /dev/null +++ b/testdata/diff/create_table/alter_identity/new.sql @@ -0,0 +1,5 @@ +CREATE TABLE public.table1 ( + c1 serial NOT NULL, + c2 int GENERATED ALWAYS AS IDENTITY, + c3 int NOT NULL +); diff --git a/testdata/diff/create_table/alter_identity/old.sql b/testdata/diff/create_table/alter_identity/old.sql new file mode 100644 index 00000000..22bca807 --- /dev/null +++ b/testdata/diff/create_table/alter_identity/old.sql @@ -0,0 +1,5 @@ +CREATE TABLE public.table1 ( + c1 int NOT NULL, + c2 int NOT NULL, + c3 int GENERATED ALWAYS AS IDENTITY +); diff --git a/testdata/diff/create_table/alter_identity/plan.json b/testdata/diff/create_table/alter_identity/plan.json new file mode 100644 index 00000000..c5770802 --- /dev/null +++ b/testdata/diff/create_table/alter_identity/plan.json @@ -0,0 +1,38 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.6.2", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "235a55c1bfd95f375ec3adde322def53078f026a1d1c3fc457368145de221b3a" + }, + "groups": [ + { + "steps": [ + { + "sql": "CREATE SEQUENCE IF NOT EXISTS table1_c1_seq OWNED BY table1.c1;", + "type": "table.column", + "operation": "alter", + "path": "public.table1.c1" + }, + { + "sql": "ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass);", + "type": "table.column", + "operation": "alter", + "path": "public.table1.c1" + }, + { + "sql": "ALTER TABLE table1 ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY;", + "type": "table.column", + "operation": "alter", + "path": "public.table1.c2" + }, + { + "sql": "ALTER TABLE table1 ALTER COLUMN c3 DROP IDENTITY;", + "type": "table.column", + "operation": "alter", + "path": "public.table1.c3" + } + ] + } + ] +} diff --git a/testdata/diff/create_table/alter_identity/plan.sql b/testdata/diff/create_table/alter_identity/plan.sql new file mode 100644 index 00000000..77271889 --- /dev/null +++ b/testdata/diff/create_table/alter_identity/plan.sql @@ -0,0 +1,7 @@ +CREATE SEQUENCE IF NOT EXISTS table1_c1_seq OWNED BY table1.c1; + +ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass); + +ALTER TABLE table1 ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY; + +ALTER TABLE table1 ALTER COLUMN c3 DROP IDENTITY; diff --git a/testdata/diff/create_table/alter_identity/plan.txt b/testdata/diff/create_table/alter_identity/plan.txt new file mode 100644 index 00000000..524b02af --- /dev/null +++ b/testdata/diff/create_table/alter_identity/plan.txt @@ -0,0 +1,21 @@ +Plan: 1 to modify. + +Summary by type: + tables: 1 to modify + +Tables: + ~ table1 + ~ c1 (column) + ~ c2 (column) + ~ c3 (column) + +DDL to be executed: +-------------------------------------------------- + +CREATE SEQUENCE IF NOT EXISTS table1_c1_seq OWNED BY table1.c1; + +ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass); + +ALTER TABLE table1 ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY; + +ALTER TABLE table1 ALTER COLUMN c3 DROP IDENTITY; From 28a7eb1a8d443d7e81c876b128b84288d1c7477b Mon Sep 17 00:00:00 2001 From: Sam Mosleh Date: Tue, 3 Feb 2026 13:38:51 +0400 Subject: [PATCH 2/5] improve tests --- testdata/diff/create_table/alter_identity/diff.sql | 2 ++ testdata/diff/create_table/alter_identity/new.sql | 2 +- testdata/diff/create_table/alter_identity/plan.json | 6 ++++++ testdata/diff/create_table/alter_identity/plan.sql | 2 ++ testdata/diff/create_table/alter_identity/plan.txt | 2 ++ 5 files changed, 13 insertions(+), 1 deletion(-) diff --git a/testdata/diff/create_table/alter_identity/diff.sql b/testdata/diff/create_table/alter_identity/diff.sql index 77271889..ef813b62 100644 --- a/testdata/diff/create_table/alter_identity/diff.sql +++ b/testdata/diff/create_table/alter_identity/diff.sql @@ -5,3 +5,5 @@ ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass ALTER TABLE table1 ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY; ALTER TABLE table1 ALTER COLUMN c3 DROP IDENTITY; + +ALTER TABLE table1 ALTER COLUMN c3 ADD GENERATED BY DEFAULT AS IDENTITY; diff --git a/testdata/diff/create_table/alter_identity/new.sql b/testdata/diff/create_table/alter_identity/new.sql index 0b8049ca..a5d2330d 100644 --- a/testdata/diff/create_table/alter_identity/new.sql +++ b/testdata/diff/create_table/alter_identity/new.sql @@ -1,5 +1,5 @@ CREATE TABLE public.table1 ( c1 serial NOT NULL, c2 int GENERATED ALWAYS AS IDENTITY, - c3 int NOT NULL + c3 int GENERATED BY DEFAULT AS IDENTITY ); diff --git a/testdata/diff/create_table/alter_identity/plan.json b/testdata/diff/create_table/alter_identity/plan.json index c5770802..a370f19c 100644 --- a/testdata/diff/create_table/alter_identity/plan.json +++ b/testdata/diff/create_table/alter_identity/plan.json @@ -31,6 +31,12 @@ "type": "table.column", "operation": "alter", "path": "public.table1.c3" + }, + { + "sql": "ALTER TABLE table1 ALTER COLUMN c3 ADD GENERATED BY DEFAULT AS IDENTITY;", + "type": "table.column", + "operation": "alter", + "path": "public.table1.c3" } ] } diff --git a/testdata/diff/create_table/alter_identity/plan.sql b/testdata/diff/create_table/alter_identity/plan.sql index 77271889..ef813b62 100644 --- a/testdata/diff/create_table/alter_identity/plan.sql +++ b/testdata/diff/create_table/alter_identity/plan.sql @@ -5,3 +5,5 @@ ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass ALTER TABLE table1 ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY; ALTER TABLE table1 ALTER COLUMN c3 DROP IDENTITY; + +ALTER TABLE table1 ALTER COLUMN c3 ADD GENERATED BY DEFAULT AS IDENTITY; diff --git a/testdata/diff/create_table/alter_identity/plan.txt b/testdata/diff/create_table/alter_identity/plan.txt index 524b02af..3b8ec5ee 100644 --- a/testdata/diff/create_table/alter_identity/plan.txt +++ b/testdata/diff/create_table/alter_identity/plan.txt @@ -19,3 +19,5 @@ ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass ALTER TABLE table1 ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY; ALTER TABLE table1 ALTER COLUMN c3 DROP IDENTITY; + +ALTER TABLE table1 ALTER COLUMN c3 ADD GENERATED BY DEFAULT AS IDENTITY; From 5e5d71b5c8f634246f8b12b01f42e4da66d2d8a1 Mon Sep 17 00:00:00 2001 From: Sam Mosleh Date: Tue, 3 Feb 2026 16:48:54 +0400 Subject: [PATCH 3/5] fix: drop sequence --- internal/diff/column.go | 53 ++++++++++--------- internal/plan/rewrite.go | 44 +++++++++++++++ .../diff/create_table/alter_identity/diff.sql | 4 ++ .../diff/create_table/alter_identity/old.sql | 2 +- .../create_table/alter_identity/plan.json | 26 ++++++++- .../diff/create_table/alter_identity/plan.sql | 8 +++ .../diff/create_table/alter_identity/plan.txt | 8 +++ 7 files changed, 119 insertions(+), 26 deletions(-) diff --git a/internal/diff/column.go b/internal/diff/column.go index e85dbb13..2c9a3a76 100644 --- a/internal/diff/column.go +++ b/internal/diff/column.go @@ -50,30 +50,6 @@ func (cd *ColumnDiff) generateColumnSQL(tableSchema, tableName string, targetSch } } - // Check if converting to serial (gaining a nextval default) - oldSeqName := extractSequenceNameFromDefault(cd.Old.DefaultValue) - newSeqName := extractSequenceNameFromDefault(cd.New.DefaultValue) - - // If converting to serial, we need to create the sequence first - if oldSeqName == nil && newSeqName != nil { - // Create the sequence with ownership - sql := fmt.Sprintf("CREATE SEQUENCE IF NOT EXISTS %s OWNED BY %s.%s;", - *newSeqName, qualifiedTableName, ir.QuoteIdentifier(cd.New.Name)) - statements = append(statements, sql) - } - - // Handle identity column changes - if cd.Old.Identity != nil && (cd.New.Identity == nil || cd.Old.Identity.Generation != cd.New.Identity.Generation) { - sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s DROP IDENTITY;", - qualifiedTableName, ir.QuoteIdentifier(cd.New.Name)) - statements = append(statements, sql) - } - if cd.New.Identity != nil && (cd.Old.Identity == nil || cd.Old.Identity.Generation != cd.New.Identity.Generation) { - sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s ADD GENERATED %s AS IDENTITY;", - qualifiedTableName, ir.QuoteIdentifier(cd.New.Name), cd.New.Identity.Generation) - statements = append(statements, sql) - } - // Handle nullable changes if cd.Old.IsNullable != cd.New.IsNullable { if cd.New.IsNullable { @@ -89,6 +65,17 @@ func (cd *ColumnDiff) generateColumnSQL(tableSchema, tableName string, targetSch } } + oldSeqName := extractSequenceNameFromDefault(cd.Old.DefaultValue) + newSeqName := extractSequenceNameFromDefault(cd.New.DefaultValue) + + // If converting to serial, we need to create the sequence first + if oldSeqName == nil && newSeqName != nil { + // Create the sequence with ownership + sql := fmt.Sprintf("CREATE SEQUENCE IF NOT EXISTS %s OWNED BY %s.%s;", + *newSeqName, qualifiedTableName, ir.QuoteIdentifier(cd.New.Name)) + statements = append(statements, sql) + } + // Handle default value changes // When USING clause was needed, we dropped the default above, so re-add it if there's a new default // When USING clause was NOT needed, handle default changes normally @@ -117,6 +104,24 @@ func (cd *ColumnDiff) generateColumnSQL(tableSchema, tableName string, targetSch } } + // Drop sequence if it's not used + if oldSeqName != nil && newSeqName == nil { + sql := fmt.Sprintf("DROP SEQUENCE %s;", *oldSeqName) + statements = append(statements, sql) + } + + // Handle identity column changes + if cd.Old.Identity != nil && (cd.New.Identity == nil || cd.Old.Identity.Generation != cd.New.Identity.Generation) { + sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s DROP IDENTITY;", + qualifiedTableName, ir.QuoteIdentifier(cd.New.Name)) + statements = append(statements, sql) + } + if cd.New.Identity != nil && (cd.Old.Identity == nil || cd.Old.Identity.Generation != cd.New.Identity.Generation) { + sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s ADD GENERATED %s AS IDENTITY;", + qualifiedTableName, ir.QuoteIdentifier(cd.New.Name), cd.New.Identity.Generation) + statements = append(statements, sql) + } + return statements } diff --git a/internal/plan/rewrite.go b/internal/plan/rewrite.go index 8498261e..1239e11c 100644 --- a/internal/plan/rewrite.go +++ b/internal/plan/rewrite.go @@ -89,6 +89,16 @@ func generateRewrite(d diff.Diff, newlyCreatedTables map[string]bool, newlyCreat } } } + // Check if identity is being added or changed on an existing column + // This includes: adding identity, or changing identity generation (drop + re-add) + if columnDiff.New.Identity != nil { + // Verify this diff's SQL actually contains ADD GENERATED + for _, stmt := range d.Statements { + if strings.Contains(stmt.SQL, "ADD GENERATED") { + return generateColumnIdentityRewrite(columnDiff, d.Path) + } + } + } } } } @@ -324,6 +334,40 @@ func generateColumnNotNullRewrite(_ *diff.ColumnDiff, path string) []RewriteStep } } +// generateColumnIdentityRewrite generates rewrite steps for ADD GENERATED AS IDENTITY operations +// It syncs the identity sequence with existing data to prevent conflicts +func generateColumnIdentityRewrite(columnDiff *diff.ColumnDiff, path string) []RewriteStep { + // Parse path (schema.table.column) to extract schema, table, and column names + parts := strings.Split(path, ".") + if len(parts) != 3 { + return nil + } + schema := parts[0] + table := parts[1] + column := parts[2] + + tableName := getTableNameWithSchema(schema, table) + + // Step 1: Add identity column + addIdentitySQL := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s ADD GENERATED %s AS IDENTITY;", + tableName, ir.QuoteIdentifier(column), columnDiff.New.Identity.Generation) + + // Step 2: Sync sequence with existing data + setvalSQL := fmt.Sprintf("SELECT setval(pg_get_serial_sequence('%s', '%s'), COALESCE(MAX(%s), 0) + 1) FROM %s;", + tableName, column, ir.QuoteIdentifier(column), tableName) + + return []RewriteStep{ + { + SQL: addIdentitySQL, + CanRunInTransaction: true, + }, + { + SQL: setvalSQL, + CanRunInTransaction: true, + }, + } +} + // generateIndexSQL generates CREATE INDEX statement func generateIndexSQL(index *ir.Index, isConcurrent bool) string { var sql strings.Builder diff --git a/testdata/diff/create_table/alter_identity/diff.sql b/testdata/diff/create_table/alter_identity/diff.sql index ef813b62..a0806d72 100644 --- a/testdata/diff/create_table/alter_identity/diff.sql +++ b/testdata/diff/create_table/alter_identity/diff.sql @@ -2,6 +2,10 @@ CREATE SEQUENCE IF NOT EXISTS table1_c1_seq OWNED BY table1.c1; ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass); +ALTER TABLE table1 ALTER COLUMN c2 DROP DEFAULT; + +DROP SEQUENCE table1_c2_seq; + ALTER TABLE table1 ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY; ALTER TABLE table1 ALTER COLUMN c3 DROP IDENTITY; diff --git a/testdata/diff/create_table/alter_identity/old.sql b/testdata/diff/create_table/alter_identity/old.sql index 22bca807..a38eb3c2 100644 --- a/testdata/diff/create_table/alter_identity/old.sql +++ b/testdata/diff/create_table/alter_identity/old.sql @@ -1,5 +1,5 @@ CREATE TABLE public.table1 ( c1 int NOT NULL, - c2 int NOT NULL, + c2 serial, c3 int GENERATED ALWAYS AS IDENTITY ); diff --git a/testdata/diff/create_table/alter_identity/plan.json b/testdata/diff/create_table/alter_identity/plan.json index a370f19c..8709f423 100644 --- a/testdata/diff/create_table/alter_identity/plan.json +++ b/testdata/diff/create_table/alter_identity/plan.json @@ -3,7 +3,7 @@ "pgschema_version": "1.6.2", "created_at": "1970-01-01T00:00:00Z", "source_fingerprint": { - "hash": "235a55c1bfd95f375ec3adde322def53078f026a1d1c3fc457368145de221b3a" + "hash": "5b91475214f7a1b4e4928c9480533b61f841d70494784aff431f1f392fba1e58" }, "groups": [ { @@ -20,12 +20,30 @@ "operation": "alter", "path": "public.table1.c1" }, + { + "sql": "ALTER TABLE table1 ALTER COLUMN c2 DROP DEFAULT;", + "type": "table.column", + "operation": "alter", + "path": "public.table1.c2" + }, + { + "sql": "DROP SEQUENCE table1_c2_seq;", + "type": "table.column", + "operation": "alter", + "path": "public.table1.c2" + }, { "sql": "ALTER TABLE table1 ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY;", "type": "table.column", "operation": "alter", "path": "public.table1.c2" }, + { + "sql": "SELECT setval(pg_get_serial_sequence('table1', 'c2'), COALESCE(MAX(c2), 0) + 1) FROM table1;", + "type": "table.column", + "operation": "alter", + "path": "public.table1.c2" + }, { "sql": "ALTER TABLE table1 ALTER COLUMN c3 DROP IDENTITY;", "type": "table.column", @@ -37,6 +55,12 @@ "type": "table.column", "operation": "alter", "path": "public.table1.c3" + }, + { + "sql": "SELECT setval(pg_get_serial_sequence('table1', 'c3'), COALESCE(MAX(c3), 0) + 1) FROM table1;", + "type": "table.column", + "operation": "alter", + "path": "public.table1.c3" } ] } diff --git a/testdata/diff/create_table/alter_identity/plan.sql b/testdata/diff/create_table/alter_identity/plan.sql index ef813b62..873cc0dd 100644 --- a/testdata/diff/create_table/alter_identity/plan.sql +++ b/testdata/diff/create_table/alter_identity/plan.sql @@ -2,8 +2,16 @@ CREATE SEQUENCE IF NOT EXISTS table1_c1_seq OWNED BY table1.c1; ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass); +ALTER TABLE table1 ALTER COLUMN c2 DROP DEFAULT; + +DROP SEQUENCE table1_c2_seq; + ALTER TABLE table1 ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY; +SELECT setval(pg_get_serial_sequence('table1', 'c2'), COALESCE(MAX(c2), 0) + 1) FROM table1; + ALTER TABLE table1 ALTER COLUMN c3 DROP IDENTITY; ALTER TABLE table1 ALTER COLUMN c3 ADD GENERATED BY DEFAULT AS IDENTITY; + +SELECT setval(pg_get_serial_sequence('table1', 'c3'), COALESCE(MAX(c3), 0) + 1) FROM table1; diff --git a/testdata/diff/create_table/alter_identity/plan.txt b/testdata/diff/create_table/alter_identity/plan.txt index 3b8ec5ee..a05c9653 100644 --- a/testdata/diff/create_table/alter_identity/plan.txt +++ b/testdata/diff/create_table/alter_identity/plan.txt @@ -16,8 +16,16 @@ CREATE SEQUENCE IF NOT EXISTS table1_c1_seq OWNED BY table1.c1; ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass); +ALTER TABLE table1 ALTER COLUMN c2 DROP DEFAULT; + +DROP SEQUENCE table1_c2_seq; + ALTER TABLE table1 ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY; +SELECT setval(pg_get_serial_sequence('table1', 'c2'), COALESCE(MAX(c2), 0) + 1) FROM table1; + ALTER TABLE table1 ALTER COLUMN c3 DROP IDENTITY; ALTER TABLE table1 ALTER COLUMN c3 ADD GENERATED BY DEFAULT AS IDENTITY; + +SELECT setval(pg_get_serial_sequence('table1', 'c3'), COALESCE(MAX(c3), 0) + 1) FROM table1; From c4f64ce0547c2ac1b31fc84b253c87cf2cc5c910 Mon Sep 17 00:00:00 2001 From: Sam Mosleh Date: Sat, 7 Feb 2026 01:03:52 +0400 Subject: [PATCH 4/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Move=20sequence=20logi?= =?UTF-8?q?c=20to=20where=20it=20belongs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/diff/column.go | 60 +++---------------- internal/diff/diff.go | 33 +++++++--- internal/diff/sequence.go | 15 +++-- .../diff/create_table/alter_identity/diff.sql | 11 +--- .../create_table/alter_identity/plan.json | 26 ++++---- .../diff/create_table/alter_identity/plan.sql | 8 +-- .../diff/create_table/alter_identity/plan.txt | 15 +++-- 7 files changed, 67 insertions(+), 101 deletions(-) diff --git a/internal/diff/column.go b/internal/diff/column.go index 2c9a3a76..fe3b0445 100644 --- a/internal/diff/column.go +++ b/internal/diff/column.go @@ -2,13 +2,11 @@ package diff import ( "fmt" - "regexp" + "strings" "github.com/pgschema/pgschema/ir" ) -var sequenceNextvalPattern = regexp.MustCompile(`nextval\('([^']+)'::regclass\)`) - // generateColumnSQL generates SQL statements for column modifications func (cd *ColumnDiff) generateColumnSQL(tableSchema, tableName string, targetSchema string) []string { var statements []string @@ -65,17 +63,6 @@ func (cd *ColumnDiff) generateColumnSQL(tableSchema, tableName string, targetSch } } - oldSeqName := extractSequenceNameFromDefault(cd.Old.DefaultValue) - newSeqName := extractSequenceNameFromDefault(cd.New.DefaultValue) - - // If converting to serial, we need to create the sequence first - if oldSeqName == nil && newSeqName != nil { - // Create the sequence with ownership - sql := fmt.Sprintf("CREATE SEQUENCE IF NOT EXISTS %s OWNED BY %s.%s;", - *newSeqName, qualifiedTableName, ir.QuoteIdentifier(cd.New.Name)) - statements = append(statements, sql) - } - // Handle default value changes // When USING clause was needed, we dropped the default above, so re-add it if there's a new default // When USING clause was NOT needed, handle default changes normally @@ -88,26 +75,16 @@ func (cd *ColumnDiff) generateColumnSQL(tableSchema, tableName string, targetSch } } else { // Normal default value change handling (no USING clause involved) - if (oldDefault == nil) != (newDefault == nil) || - (oldDefault != nil && newDefault != nil && *oldDefault != *newDefault) { - - var sql string - if newDefault == nil { - sql = fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s DROP DEFAULT;", - qualifiedTableName, ir.QuoteIdentifier(cd.New.Name)) - } else { - sql = fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s SET DEFAULT %s;", - qualifiedTableName, ir.QuoteIdentifier(cd.New.Name), *newDefault) - } - + if oldDefault != nil && newDefault == nil && !strings.HasPrefix(*oldDefault, "nextval(") { + sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s DROP DEFAULT;", + qualifiedTableName, ir.QuoteIdentifier(cd.New.Name)) + statements = append(statements, sql) + } + if (oldDefault == nil && newDefault != nil) || (oldDefault != nil && newDefault != nil && *oldDefault != *newDefault) { + sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s SET DEFAULT %s;", + qualifiedTableName, ir.QuoteIdentifier(cd.New.Name), *newDefault) statements = append(statements, sql) } - } - - // Drop sequence if it's not used - if oldSeqName != nil && newSeqName == nil { - sql := fmt.Sprintf("DROP SEQUENCE %s;", *oldSeqName) - statements = append(statements, sql) } // Handle identity column changes @@ -125,25 +102,6 @@ func (cd *ColumnDiff) generateColumnSQL(tableSchema, tableName string, targetSch return statements } -// extractSequenceNameFromDefault extracts the sequence name from a PostgreSQL -// nextval default value. -// Examples: -// -// "nextval('table1_c1_seq'::regclass)" -> "table1_c1_seq" -// "nextval('public.table1_c1_seq'::regclass)" -> "public.table1_c1_seq" -func extractSequenceNameFromDefault(defaultValue *string) *string { - if defaultValue == nil { - return nil - } - - matches := sequenceNextvalPattern.FindStringSubmatch(*defaultValue) - if len(matches) < 2 { - return nil - } - - return &matches[1] -} - // needsUsingClause determines if a type conversion requires a USING clause. // // This is especially important when converting to or from custom types (like ENUMs), diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 01fd298e..06006dcb 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -276,12 +276,12 @@ type ddlDiff struct { droppedDefaultPrivileges []*ir.DefaultPrivilege modifiedDefaultPrivileges []*defaultPrivilegeDiff // Explicit object privileges - addedPrivileges []*ir.Privilege - droppedPrivileges []*ir.Privilege - modifiedPrivileges []*privilegeDiff - revokedDefaultGrantsOnNewTables []*ir.Privilege // Privileges to revoke on newly created tables (issue #253) - addedRevokedDefaultPrivs []*ir.RevokedDefaultPrivilege - droppedRevokedDefaultPrivs []*ir.RevokedDefaultPrivilege + addedPrivileges []*ir.Privilege + droppedPrivileges []*ir.Privilege + modifiedPrivileges []*privilegeDiff + revokedDefaultGrantsOnNewTables []*ir.Privilege // Privileges to revoke on newly created tables (issue #253) + addedRevokedDefaultPrivs []*ir.RevokedDefaultPrivilege + droppedRevokedDefaultPrivs []*ir.RevokedDefaultPrivilege // Column-level privileges addedColumnPrivileges []*ir.ColumnPrivilege droppedColumnPrivileges []*ir.ColumnPrivilege @@ -915,8 +915,10 @@ func GenerateMigration(oldIR, newIR *ir.IR, targetSchema string) []Diff { for _, key := range seqKeys { seq := newSequences[key] if _, exists := oldSequences[key]; !exists { - // Skip sequences owned by table columns (created by SERIAL) - if seq.OwnedByTable != "" && seq.OwnedByColumn != "" { + // Skip sequences owned by table columns only if the column is also new + // (created by SERIAL in CREATE TABLE). If the column already exists, + // we need to create the sequence explicitly for ALTER COLUMN to use. + if seq.OwnedByTable != "" && seq.OwnedByColumn != "" && !columnExistsInTables(oldTables, seq.Schema, seq.OwnedByTable, seq.OwnedByColumn) { continue } diff.addedSequences = append(diff.addedSequences, seq) @@ -929,7 +931,7 @@ func GenerateMigration(oldIR, newIR *ir.IR, targetSchema string) []Diff { seq := oldSequences[key] if _, exists := newSequences[key]; !exists { // Skip sequences owned by table columns (created by SERIAL) - if seq.OwnedByTable != "" && seq.OwnedByColumn != "" { + if seq.OwnedByTable != "" && seq.OwnedByColumn != "" && !columnExistsInTables(newTables, seq.Schema, seq.OwnedByTable, seq.OwnedByColumn) { continue } diff.droppedSequences = append(diff.droppedSequences, seq) @@ -1797,6 +1799,19 @@ func sortedKeys[T any](m map[string]T) []string { return keys } +// columnExistsInTables checks if a column exists in the given tables map +func columnExistsInTables(tables map[string]*ir.Table, schema, tableName, columnName string) bool { + tableKey := schema + "." + tableName + if table, exists := tables[tableKey]; exists { + for _, col := range table.Columns { + if col.Name == columnName { + return true + } + } + } + return false +} + // buildFunctionLookup returns case-insensitive lookup keys for newly added functions. // Keys include both unqualified (function name only) and schema-qualified identifiers. func buildFunctionLookup(functions []*ir.Function) map[string]struct{} { diff --git a/internal/diff/sequence.go b/internal/diff/sequence.go index 2526c56b..588d1c06 100644 --- a/internal/diff/sequence.go +++ b/internal/diff/sequence.go @@ -11,9 +11,9 @@ import ( // Default values for PostgreSQL sequences by data type const ( defaultSequenceMinValue int64 = 1 - defaultSequenceMaxValue int64 = math.MaxInt64 // bigint max - smallintMaxValue int64 = math.MaxInt16 // smallint max - integerMaxValue int64 = math.MaxInt32 // integer max + defaultSequenceMaxValue int64 = math.MaxInt64 // bigint max + smallintMaxValue int64 = math.MaxInt16 // smallint max + integerMaxValue int64 = math.MaxInt32 // integer max ) // generateCreateSequencesSQL generates CREATE SEQUENCE statements @@ -113,6 +113,11 @@ func generateSequenceSQL(seq *ir.Sequence, targetSchema string) string { parts = append(parts, "CYCLE") } + // Add sequence owner + if seq.OwnedByTable != "" && seq.OwnedByColumn != "" { + parts = append(parts, fmt.Sprintf("OWNED BY %s.%s", seq.OwnedByTable, seq.OwnedByColumn)) + } + // Join with proper formatting if len(parts) > 1 { return parts[0] + " " + strings.Join(parts[1:], " ") + ";" @@ -201,7 +206,7 @@ func sequencesEqual(old, new *ir.Sequence) bool { if old.Name != new.Name { return false } - + // Compare DataType (default is bigint if empty) oldDataType := old.DataType if oldDataType == "" { @@ -214,7 +219,7 @@ func sequencesEqual(old, new *ir.Sequence) bool { if oldDataType != newDataType { return false } - + if old.StartValue != new.StartValue { return false } diff --git a/testdata/diff/create_table/alter_identity/diff.sql b/testdata/diff/create_table/alter_identity/diff.sql index a0806d72..3734e7d2 100644 --- a/testdata/diff/create_table/alter_identity/diff.sql +++ b/testdata/diff/create_table/alter_identity/diff.sql @@ -1,13 +1,6 @@ -CREATE SEQUENCE IF NOT EXISTS table1_c1_seq OWNED BY table1.c1; - +DROP SEQUENCE IF EXISTS table1_c2_seq CASCADE; +CREATE SEQUENCE IF NOT EXISTS table1_c1_seq AS integer OWNED BY table1.c1; ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass); - -ALTER TABLE table1 ALTER COLUMN c2 DROP DEFAULT; - -DROP SEQUENCE table1_c2_seq; - ALTER TABLE table1 ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY; - ALTER TABLE table1 ALTER COLUMN c3 DROP IDENTITY; - ALTER TABLE table1 ALTER COLUMN c3 ADD GENERATED BY DEFAULT AS IDENTITY; diff --git a/testdata/diff/create_table/alter_identity/plan.json b/testdata/diff/create_table/alter_identity/plan.json index 8709f423..49f71839 100644 --- a/testdata/diff/create_table/alter_identity/plan.json +++ b/testdata/diff/create_table/alter_identity/plan.json @@ -9,28 +9,22 @@ { "steps": [ { - "sql": "CREATE SEQUENCE IF NOT EXISTS table1_c1_seq OWNED BY table1.c1;", - "type": "table.column", - "operation": "alter", - "path": "public.table1.c1" + "sql": "DROP SEQUENCE IF EXISTS table1_c2_seq CASCADE;", + "type": "sequence", + "operation": "drop", + "path": "public.table1_c2_seq" }, { - "sql": "ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass);", - "type": "table.column", - "operation": "alter", - "path": "public.table1.c1" + "sql": "CREATE SEQUENCE IF NOT EXISTS table1_c1_seq AS integer OWNED BY table1.c1;", + "type": "sequence", + "operation": "create", + "path": "public.table1_c1_seq" }, { - "sql": "ALTER TABLE table1 ALTER COLUMN c2 DROP DEFAULT;", - "type": "table.column", - "operation": "alter", - "path": "public.table1.c2" - }, - { - "sql": "DROP SEQUENCE table1_c2_seq;", + "sql": "ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass);", "type": "table.column", "operation": "alter", - "path": "public.table1.c2" + "path": "public.table1.c1" }, { "sql": "ALTER TABLE table1 ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY;", diff --git a/testdata/diff/create_table/alter_identity/plan.sql b/testdata/diff/create_table/alter_identity/plan.sql index 873cc0dd..d5031a70 100644 --- a/testdata/diff/create_table/alter_identity/plan.sql +++ b/testdata/diff/create_table/alter_identity/plan.sql @@ -1,10 +1,8 @@ -CREATE SEQUENCE IF NOT EXISTS table1_c1_seq OWNED BY table1.c1; +DROP SEQUENCE IF EXISTS table1_c2_seq CASCADE; -ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass); - -ALTER TABLE table1 ALTER COLUMN c2 DROP DEFAULT; +CREATE SEQUENCE IF NOT EXISTS table1_c1_seq AS integer OWNED BY table1.c1; -DROP SEQUENCE table1_c2_seq; +ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass); ALTER TABLE table1 ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY; diff --git a/testdata/diff/create_table/alter_identity/plan.txt b/testdata/diff/create_table/alter_identity/plan.txt index a05c9653..531a2bd1 100644 --- a/testdata/diff/create_table/alter_identity/plan.txt +++ b/testdata/diff/create_table/alter_identity/plan.txt @@ -1,8 +1,13 @@ -Plan: 1 to modify. +Plan: 1 to add, 1 to modify, 1 to drop. Summary by type: + sequences: 1 to add, 1 to drop tables: 1 to modify +Sequences: + + table1_c1_seq + - table1_c2_seq + Tables: ~ table1 ~ c1 (column) @@ -12,13 +17,11 @@ Tables: DDL to be executed: -------------------------------------------------- -CREATE SEQUENCE IF NOT EXISTS table1_c1_seq OWNED BY table1.c1; - -ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass); +DROP SEQUENCE IF EXISTS table1_c2_seq CASCADE; -ALTER TABLE table1 ALTER COLUMN c2 DROP DEFAULT; +CREATE SEQUENCE IF NOT EXISTS table1_c1_seq AS integer OWNED BY table1.c1; -DROP SEQUENCE table1_c2_seq; +ALTER TABLE table1 ALTER COLUMN c1 SET DEFAULT nextval('table1_c1_seq'::regclass); ALTER TABLE table1 ALTER COLUMN c2 ADD GENERATED ALWAYS AS IDENTITY; From ebd077a8242e9261af416483c49665149fa2b08f Mon Sep 17 00:00:00 2001 From: Sam Mosleh Date: Sat, 7 Feb 2026 01:14:01 +0400 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=92=A1=20Add=20sequence=20diff=20rela?= =?UTF-8?q?ted=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/diff/column.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/diff/column.go b/internal/diff/column.go index fe3b0445..bc197e98 100644 --- a/internal/diff/column.go +++ b/internal/diff/column.go @@ -75,6 +75,8 @@ func (cd *ColumnDiff) generateColumnSQL(tableSchema, tableName string, targetSch } } else { // Normal default value change handling (no USING clause involved) + // We only drop default values when they are not sequences + // Sequences are automatically handled by the DROP CASCADE statement if oldDefault != nil && newDefault == nil && !strings.HasPrefix(*oldDefault, "nextval(") { sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s DROP DEFAULT;", qualifiedTableName, ir.QuoteIdentifier(cd.New.Name))