Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 51 additions & 12 deletions internal/diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ type ddlDiff struct {
modifiedTypes []*typeDiff
addedSequences []*ir.Sequence
addedSerialSeqComments []*ir.Sequence // SERIAL-owned sequences skipped from addedSequences but with comments to emit
deferredSequenceOwners []*ir.Sequence // added sequences whose OWNED BY column is created in this migration; ownership is set after tables
droppedSequences []*ir.Sequence
modifiedSequences []*sequenceDiff
addedDefaultPrivileges []*ir.DefaultPrivilege
Expand Down Expand Up @@ -503,6 +504,7 @@ func GenerateMigrationWithOptions(oldIR, newIR *ir.IR, targetSchema string, qual
modifiedTypes: []*typeDiff{},
addedSequences: []*ir.Sequence{},
addedSerialSeqComments: []*ir.Sequence{},
deferredSequenceOwners: []*ir.Sequence{},
droppedSequences: []*ir.Sequence{},
modifiedSequences: []*sequenceDiff{},
addedDefaultPrivileges: []*ir.DefaultPrivilege{},
Expand Down Expand Up @@ -1064,18 +1066,30 @@ func GenerateMigrationWithOptions(oldIR, newIR *ir.IR, targetSchema string, qual
for _, key := range seqKeys {
seq := newSequences[key]
if _, exists := oldSequences[key]; !exists {
// 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) {
// Skip sequences created implicitly by a new SERIAL column
// (CREATE TABLE ... SERIAL). If the column already exists, we need
// to create the sequence explicitly for ALTER COLUMN to use.
if isSerialSequence(newTables, seq) && !columnExistsInTables(oldTables, seq.Schema, seq.OwnedByTable, seq.OwnedByColumn) {
// Sequence is created implicitly by CREATE TABLE (SERIAL). Emit its
// comment separately after all tables are created.
if seq.Comment != "" {
diff.addedSerialSeqComments = append(diff.addedSerialSeqComments, seq)
}
continue
}
if seq.OwnedByTable != "" && seq.OwnedByColumn != "" && !columnExistsInTables(newTables, seq.Schema, seq.OwnedByTable, seq.OwnedByColumn) {
// Owner column is not part of the desired state (ignored table or
// a table in another schema): the sequence cannot be created with
// its OWNED BY, so leave it to whoever manages that table.
continue
}
diff.addedSequences = append(diff.addedSequences, seq)
// An explicitly created sequence can only be OWNED BY a column that
// already exists; if the column is created by this migration, apply
// the ownership after all tables and columns are in place.
if seq.OwnedByTable != "" && seq.OwnedByColumn != "" && !columnExistsInTables(oldTables, seq.Schema, seq.OwnedByTable, seq.OwnedByColumn) {
diff.deferredSequenceOwners = append(diff.deferredSequenceOwners, seq)
}
}
}

Expand All @@ -1084,8 +1098,8 @@ func GenerateMigrationWithOptions(oldIR, newIR *ir.IR, targetSchema string, qual
for _, key := range oldSeqKeys {
seq := oldSequences[key]
if _, exists := newSequences[key]; !exists {
// Skip sequences owned by table columns (created by SERIAL)
if seq.OwnedByTable != "" && seq.OwnedByColumn != "" && !columnExistsInTables(newTables, seq.Schema, seq.OwnedByTable, seq.OwnedByColumn) {
// Skip sequences dropped implicitly with their SERIAL column
if isSerialSequence(oldTables, seq) && !columnExistsInTables(newTables, seq.Schema, seq.OwnedByTable, seq.OwnedByColumn) {
continue
}
diff.droppedSequences = append(diff.droppedSequences, seq)
Expand All @@ -1096,11 +1110,9 @@ func GenerateMigrationWithOptions(oldIR, newIR *ir.IR, targetSchema string, qual
for _, key := range seqKeys {
newSeq := newSequences[key]
if oldSeq, exists := oldSequences[key]; exists {
// Skip sequences owned by table columns (created by SERIAL) for structural changes,
// but allow comment-only changes through so COMMENT ON SEQUENCE can be deployed.
isOwned := (oldSeq.OwnedByTable != "" && oldSeq.OwnedByColumn != "") ||
(newSeq.OwnedByTable != "" && newSeq.OwnedByColumn != "")
if isOwned {
// Skip SERIAL-backed sequences for structural changes, but allow
// comment-only changes through so COMMENT ON SEQUENCE can be deployed.
if isSerialSequence(oldTables, oldSeq) || isSerialSequence(newTables, newSeq) {
if oldSeq.Comment != newSeq.Comment {
diff.modifiedSequences = append(diff.modifiedSequences, &sequenceDiff{
Old: oldSeq,
Expand Down Expand Up @@ -1826,7 +1838,7 @@ func (d *ddlDiff) generateCreateSQL(targetSchema string, collector *diffCollecto
generateCreateTypesSQL(typesWithoutDeps, targetSchema, collector)

// Create sequences
generateCreateSequencesSQL(d.addedSequences, targetSchema, collector)
generateCreateSequencesSQL(d.addedSequences, d.deferredSequenceOwners, targetSchema, collector)

// Build map of existing tables (tables being modified, so they already exist)
existingTables := make(map[string]bool, len(d.modifiedTables))
Expand Down Expand Up @@ -2100,6 +2112,14 @@ func (d *ddlDiff) generateModifySQL(targetSchema string, collector *diffCollecto
// Modify tables
generateModifyTablesSQL(d.modifiedTables, d.droppedTables, d.fkPreDrops, targetSchema, collector)

// Attach OWNED BY for explicitly created sequences whose owning column was
// created by this migration, either with a new table (create phase) or by
// ALTER TABLE ... ADD COLUMN just above. The sequence itself was created
// before the tables so column defaults could reference it.
for _, seq := range d.deferredSequenceOwners {
generateSequenceOwnedBySQL(seq, targetSchema, collector)
}

// (Re)create the dependent foreign keys now that the replacement constraints exist
generateDeferredConstraintsSQL(d.fkPostAdds, targetSchema, collector)

Expand Down Expand Up @@ -2411,6 +2431,25 @@ func sortedKeys[T any](m map[string]T) []string {
return keys
}

// isSerialSequence reports whether seq is the implicit sequence behind a
// SERIAL column in tables, i.e. it is created and dropped together with that
// column rather than by explicit CREATE/DROP SEQUENCE statements.
func isSerialSequence(tables map[string]*ir.Table, seq *ir.Sequence) bool {
if seq.OwnedByTable == "" || seq.OwnedByColumn == "" {
return false
}
table, exists := tables[seq.Schema+"."+seq.OwnedByTable]
if !exists {
return false
}
for _, col := range table.Columns {
if col.Name == seq.OwnedByColumn {
return col.IsSerial
}
}
return false
}

// 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
Expand Down
2 changes: 1 addition & 1 deletion internal/diff/identifier_quote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ func TestGenerateSequenceSQL_OwnedByQuoting(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := generateSequenceSQL(tt.seq, tt.targetSchema, false)
got := generateSequenceSQL(tt.seq, tt.targetSchema, false, true)
if got != tt.want {
t.Errorf("generateSequenceSQL() = %q, want %q", got, tt.want)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/diff/qualify_schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,12 @@ func TestQualifySchema_Sequence(t *testing.T) {
OwnedByColumn: "id",
}

def := generateSequenceSQL(seq, "public", false)
def := generateSequenceSQL(seq, "public", false, true)
if strings.Contains(def, "public.users_id_seq") || strings.Contains(def, "public.users") {
t.Errorf("default should not qualify the target schema: %q", def)
}

qualified := generateSequenceSQL(seq, "public", true)
qualified := generateSequenceSQL(seq, "public", true, true)
if !strings.Contains(qualified, "public.users_id_seq") {
t.Errorf("forced qualification should qualify the sequence name: %q", qualified)
}
Expand Down
35 changes: 29 additions & 6 deletions internal/diff/sequence.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@ const (
integerMaxValue int64 = math.MaxInt32 // integer max
)

// generateCreateSequencesSQL generates CREATE SEQUENCE statements
func generateCreateSequencesSQL(sequences []*ir.Sequence, targetSchema string, collector *diffCollector) {
// generateCreateSequencesSQL generates CREATE SEQUENCE statements. Sequences in
// deferredOwners are created without their OWNED BY clause because the owning
// column does not exist yet; see generateSequenceOwnedBySQL.
func generateCreateSequencesSQL(sequences []*ir.Sequence, deferredOwners []*ir.Sequence, targetSchema string, collector *diffCollector) {
deferOwner := make(map[*ir.Sequence]bool, len(deferredOwners))
for _, seq := range deferredOwners {
deferOwner[seq] = true
}
for _, seq := range sequences {
sql := generateSequenceSQL(seq, targetSchema, collector.qualifySchema)
sql := generateSequenceSQL(seq, targetSchema, collector.qualifySchema, !deferOwner[seq])

// Create context for this statement
context := &diffContext{
Expand All @@ -39,6 +45,22 @@ func generateCreateSequencesSQL(sequences []*ir.Sequence, targetSchema string, c
}
}

// generateSequenceOwnedBySQL emits ALTER SEQUENCE ... OWNED BY for a sequence
// created earlier without its owner (the owning column was created after it).
func generateSequenceOwnedBySQL(seq *ir.Sequence, targetSchema string, collector *diffCollector) {
seqName := qualifyEntityNameMode(seq.Schema, seq.Name, targetSchema, collector.qualifySchema)
ownerTable := ir.QualifyEntityNameWithQuotesMode(seq.Schema, seq.OwnedByTable, targetSchema, collector.qualifySchema)
sql := fmt.Sprintf("ALTER SEQUENCE %s OWNED BY %s.%s;", seqName, ownerTable, ir.QuoteIdentifier(seq.OwnedByColumn))
context := &diffContext{
Type: DiffTypeSequence,
Operation: DiffOperationCreate,
Path: fmt.Sprintf("%s.%s", seq.Schema, seq.Name),
Source: seq,
CanRunInTransaction: true,
}
Comment thread
tianzhou marked this conversation as resolved.
collector.collect(context, sql)
}

// generateSequenceComment emits a COMMENT ON SEQUENCE statement
func generateSequenceComment(seq *ir.Sequence, targetSchema string, operation DiffOperation, collector *diffCollector) {
seqName := qualifyEntityNameMode(seq.Schema, seq.Name, targetSchema, collector.qualifySchema)
Expand Down Expand Up @@ -103,8 +125,9 @@ func generateModifySequencesSQL(diffs []*sequenceDiff, targetSchema string, coll
}
}

// generateSequenceSQL generates CREATE SEQUENCE statement
func generateSequenceSQL(seq *ir.Sequence, targetSchema string, qualifySchema bool) string {
// generateSequenceSQL generates CREATE SEQUENCE statement. includeOwner controls
// whether the OWNED BY clause is emitted inline.
func generateSequenceSQL(seq *ir.Sequence, targetSchema string, qualifySchema bool, includeOwner bool) string {
var parts []string

seqName := qualifyEntityNameMode(seq.Schema, seq.Name, targetSchema, qualifySchema)
Expand Down Expand Up @@ -143,7 +166,7 @@ func generateSequenceSQL(seq *ir.Sequence, targetSchema string, qualifySchema bo
}

// Add sequence owner
if seq.OwnedByTable != "" && seq.OwnedByColumn != "" {
if includeOwner && seq.OwnedByTable != "" && seq.OwnedByColumn != "" {
ownerTable := ir.QualifyEntityNameWithQuotesMode(seq.Schema, seq.OwnedByTable, targetSchema, qualifySchema)
parts = append(parts, fmt.Sprintf("OWNED BY %s.%s", ownerTable, ir.QuoteIdentifier(seq.OwnedByColumn)))
}
Expand Down
18 changes: 5 additions & 13 deletions internal/diff/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -1864,20 +1864,12 @@ func buildColumnClauses(column *ir.Column, isPartOfAnyPK bool, tableSchema strin
return result
}

// isSerialColumn checks if a column is a SERIAL column (integer type with nextval default)
// isSerialColumn reports whether a column was created with the SERIAL
// shorthand. The IR flags this only when the default's sequence is owned by
// the column and named <table>_<column>_seq; a column that references a
// shared or custom-named sequence keeps its explicit DEFAULT (issue #573).
func isSerialColumn(column *ir.Column) bool {
// Check if column has nextval default
if column.DefaultValue == nil || !strings.Contains(*column.DefaultValue, "nextval") {
return false
}

// Check if column is an integer type
switch column.DataType {
case "integer", "int4", "smallint", "int2", "bigint", "int8":
return true
default:
return false
}
return column.IsSerial
}

// formatColumnDataType formats a column's data type with appropriate modifiers for ALTER TABLE statements
Expand Down
50 changes: 44 additions & 6 deletions internal/plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,20 @@ func FromJSON(jsonData []byte) (*Plan, error) {

// ========== PRIVATE METHODS ==========

// sameChangeKey identifies create steps that together add one object: same
// type and path, emitted from the same source object (e.g. CREATE SEQUENCE
// plus a deferred ALTER SEQUENCE ... OWNED BY). Such steps are counted as a
// single addition. Alter and drop steps, and steps without a source (plans
// loaded from JSON), get no key and are counted individually as before.
// Keying on the source keeps distinct objects that share a path, such as
// overloaded functions, counted separately.
func sameChangeKey(objType, path, operation string, source diff.DiffSource) string {
if source == nil || operation != "create" {
return ""
}
return fmt.Sprintf("%s.%s.%s.%p", objType, path, operation, source)
}

// calculateSummaryFromSteps calculates summary statistics from the plan diffs
func (p *Plan) calculateSummaryFromSteps() PlanSummary {
summary := PlanSummary{
Expand Down Expand Up @@ -437,28 +451,34 @@ func (p *Plan) calculateSummaryFromSteps() PlanSummary {
// These should be counted as modifications, not adds
materializedViewsRecreating := make(map[string]bool) // materialized_view_path -> true

// Track non-table/non-view/non-materialized-view operations
// Track non-table/non-view/non-materialized-view operations. A single
// object change may span several steps (e.g. CREATE SEQUENCE plus a
// deferred ALTER SEQUENCE ... OWNED BY); count those once.
nonTableOperations := make(map[string][]string) // objType -> []operations
seenNonTableOperations := make(map[string]bool) // sameChangeKey -> true

// Use source diffs for summary calculation if available,
// otherwise use steps metadata (for plans loaded from JSON)
var dataToProcess []struct {
Type string
Operation string
Path string
Source diff.DiffSource
}

if len(p.SourceDiffs) > 0 {
// Use SourceDiffs (for freshly generated plans)
for _, diff := range p.SourceDiffs {
for _, srcDiff := range p.SourceDiffs {
dataToProcess = append(dataToProcess, struct {
Type string
Operation string
Path string
Source diff.DiffSource
}{
Type: diff.Type.String(),
Operation: diff.Operation.String(),
Path: diff.Path,
Type: srcDiff.Type.String(),
Operation: srcDiff.Operation.String(),
Path: srcDiff.Path,
Source: srcDiff.Source,
})
}
} else {
Expand All @@ -470,6 +490,7 @@ func (p *Plan) calculateSummaryFromSteps() PlanSummary {
Type string
Operation string
Path string
Source diff.DiffSource
}{
Type: step.Type,
Operation: step.Operation,
Expand Down Expand Up @@ -523,7 +544,14 @@ func (p *Plan) calculateSummaryFromSteps() PlanSummary {
}
}
} else {
// For non-table/non-view objects, track each operation
// For non-table/non-view objects, track each operation once per source object
key := sameChangeKey(stepObjTypeStr, step.Path, step.Operation, step.Source)
if key != "" && seenNonTableOperations[key] {
continue
}
if key != "" {
seenNonTableOperations[key] = true
}
nonTableOperations[stepObjTypeStr] = append(nonTableOperations[stepObjTypeStr], step.Operation)
}
}
Expand Down Expand Up @@ -1087,6 +1115,9 @@ func (p *Plan) writeNonTableChanges(summary *strings.Builder, objType string, c
path string
}

// List a change once even when it spans several steps for the same object
seen := make(map[string]bool)

// Use source diffs for summary calculation
for _, step := range p.SourceDiffs {
// Normalize object type
Expand All @@ -1098,6 +1129,13 @@ func (p *Plan) writeNonTableChanges(summary *strings.Builder, objType string, c
stepObjTypeStr = strings.ReplaceAll(stepObjTypeStr, "_", " ")

if stepObjTypeStr == objType {
key := sameChangeKey(stepObjTypeStr, step.Path, step.Operation.String(), step.Source)
if key != "" && seen[key] {
continue
}
if key != "" {
seen[key] = true
}
changes = append(changes, struct {
operation string
path string
Expand Down
5 changes: 5 additions & 0 deletions ir/inspector.go
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,11 @@ func (i *Inspector) buildSequences(ctx context.Context, schema *IR, targetSchema
sequence.OwnedByColumn = seq.OwnedByColumn.String
}

// Skip sequences owned by an ignored table; they live and die with it
if i.ignoreConfig != nil && sequence.OwnedByTable != "" && i.ignoreConfig.ShouldIgnoreTable(sequence.OwnedByTable) {
continue
}

// Skip sequences that are owned by identity columns
// Identity sequences should be managed through the identity column, not as separate sequences
if sequence.OwnedByTable != "" && sequence.OwnedByColumn != "" {
Expand Down
7 changes: 7 additions & 0 deletions ir/ir.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ type Column struct {
GeneratedExpr *string `json:"generated_expr,omitempty"` // Expression for generated columns
IsGenerated bool `json:"is_generated,omitempty"` // True if this is a generated column
GeneratedKind string `json:"generated_kind,omitempty"` // "s" for STORED, "v" for VIRTUAL (PG18+)
// IsSerial is true when the column was created with the SERIAL shorthand:
// its default is nextval() on a sequence that is owned by this column
// (pg_depend) and that carries PostgreSQL's default <table>_<column>_seq
// name. Only such columns may be rendered back as SERIAL; a column that
// merely references a shared or custom-named sequence keeps its explicit
// DEFAULT (issue #573). Derived during normalization, not serialized.
IsSerial bool `json:"-"`
}

// Identity represents PostgreSQL identity column configuration
Expand Down
Loading