diff --git a/pkg/models/models.go b/pkg/models/models.go index dc1d9dd..4d01bea 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -227,23 +227,27 @@ func (d *Sequence) SQLName() string { // Column represents a table column type Column struct { - Name string `json:"name" yaml:"name" xml:"name"` - Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"` - Table string `json:"table" yaml:"table" xml:"table"` - Schema string `json:"schema" yaml:"schema" xml:"schema"` - Type string `json:"type" yaml:"type" xml:"type"` - Length int `json:"length,omitempty" yaml:"length,omitempty" xml:"length,omitempty"` - Precision int `json:"precision,omitempty" yaml:"precision,omitempty" xml:"precision,omitempty"` - Scale int `json:"scale,omitempty" yaml:"scale,omitempty" xml:"scale,omitempty"` - NotNull bool `json:"not_null" yaml:"not_null" xml:"not_null"` - Default any `json:"default,omitempty" yaml:"default,omitempty" xml:"default,omitempty"` - AutoIncrement bool `json:"auto_increment" yaml:"auto_increment" xml:"auto_increment"` - IsPrimaryKey bool `json:"is_primary_key" yaml:"is_primary_key" xml:"is_primary_key"` - Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"` - Collation string `json:"collation,omitempty" yaml:"collation,omitempty" xml:"collation,omitempty"` - Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty" xml:"-"` - Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"` - GUID string `json:"guid" yaml:"guid" xml:"guid"` + Name string `json:"name" yaml:"name" xml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"` + Table string `json:"table" yaml:"table" xml:"table"` + Schema string `json:"schema" yaml:"schema" xml:"schema"` + Type string `json:"type" yaml:"type" xml:"type"` + Length int `json:"length,omitempty" yaml:"length,omitempty" xml:"length,omitempty"` + Precision int `json:"precision,omitempty" yaml:"precision,omitempty" xml:"precision,omitempty"` + Scale int `json:"scale,omitempty" yaml:"scale,omitempty" xml:"scale,omitempty"` + NotNull bool `json:"not_null" yaml:"not_null" xml:"not_null"` + Default any `json:"default,omitempty" yaml:"default,omitempty" xml:"default,omitempty"` + AutoIncrement bool `json:"auto_increment" yaml:"auto_increment" xml:"auto_increment"` + IsPrimaryKey bool `json:"is_primary_key" yaml:"is_primary_key" xml:"is_primary_key"` + Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"` + Collation string `json:"collation,omitempty" yaml:"collation,omitempty" xml:"collation,omitempty"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty" xml:"-"` + Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"` + GUID string `json:"guid" yaml:"guid" xml:"guid"` + Generated bool `json:"generated,omitempty" yaml:"generated,omitempty" xml:"generated,omitempty"` + GenerationExpression string `json:"generation_expression,omitempty" yaml:"generation_expression,omitempty" xml:"generation_expression,omitempty"` + Identity bool `json:"identity,omitempty" yaml:"identity,omitempty" xml:"identity,omitempty"` + IdentityGeneration string `json:"identity_generation,omitempty" yaml:"identity_generation,omitempty" xml:"identity_generation,omitempty"` // "ALWAYS" or "BY DEFAULT" } // SQLName returns the column name in lowercase for SQL compatibility. diff --git a/pkg/readers/pgsql/queries.go b/pkg/readers/pgsql/queries.go index 1da7a46..0c280b3 100644 --- a/pkg/readers/pgsql/queries.go +++ b/pkg/readers/pgsql/queries.go @@ -243,7 +243,11 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models. c.numeric_scale, c.udt_name, pg_catalog.format_type(a.atttypid, a.atttypmod) as formatted_data_type, - col_description((c.table_schema||'.'||c.table_name)::regclass, c.ordinal_position) as description + col_description((c.table_schema||'.'||c.table_name)::regclass, c.ordinal_position) as description, + c.is_generated, + c.generation_expression, + c.is_identity, + c.identity_generation FROM information_schema.columns c JOIN pg_catalog.pg_namespace n ON n.nspname = c.table_schema @@ -268,17 +272,36 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models. columnsMap := make(map[string]map[string]*models.Column) for rows.Next() { - var schema, tableName, columnName, isNullable, dataType, udtName, formattedDataType string + var schema, tableName, columnName, isNullable, dataType, udtName, formattedDataType, isGenerated, isIdentity string var ordinalPosition int - var columnDefault, description *string + var columnDefault, description, generationExpression, identityGeneration *string var charMaxLength, numPrecision, numScale *int - if err := rows.Scan(&schema, &tableName, &columnName, &ordinalPosition, &columnDefault, &isNullable, &dataType, &charMaxLength, &numPrecision, &numScale, &udtName, &formattedDataType, &description); err != nil { + if err := rows.Scan(&schema, &tableName, &columnName, &ordinalPosition, &columnDefault, &isNullable, &dataType, &charMaxLength, &numPrecision, &numScale, &udtName, &formattedDataType, &description, &isGenerated, &generationExpression, &isIdentity, &identityGeneration); err != nil { return nil, err } column := models.InitColumn(columnName, tableName, schema) + // GENERATED ALWAYS ... STORED columns are computed by Postgres and cannot + // have their default altered/dropped like a regular column. + column.Generated = isGenerated == "ALWAYS" + if generationExpression != nil { + column.GenerationExpression = *generationExpression + } + + // GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY columns are driven by an + // internal sequence rather than a literal default (unlike serial columns, + // they carry no pg_attrdef row at all), so they need the same DB-side + // handling as generated columns even though the underlying mechanism differs. + column.Identity = isIdentity == "YES" + if identityGeneration != nil { + column.IdentityGeneration = strings.ToUpper(strings.TrimSpace(*identityGeneration)) + } + if column.Identity { + column.AutoIncrement = true + } + // Check if this is a serial type (has nextval default) hasNextval := false if columnDefault != nil { @@ -301,6 +324,24 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models. column.Description = *description } + if column.Generated { + note := "GENERATED ALWAYS AS (" + column.GenerationExpression + ") STORED" + if column.Description != "" { + column.Description = column.Description + " " + note + } else { + column.Description = note + } + } + + if column.Identity { + note := "GENERATED " + column.IdentityGeneration + " AS IDENTITY" + if column.Description != "" { + column.Description = column.Description + " " + note + } else { + column.Description = note + } + } + if charMaxLength != nil { column.Length = *charMaxLength } diff --git a/pkg/writers/pgsql/migration_writer.go b/pkg/writers/pgsql/migration_writer.go index 77a1bde..7316cb4 100644 --- a/pkg/writers/pgsql/migration_writer.go +++ b/pkg/writers/pgsql/migration_writer.go @@ -433,6 +433,16 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model Body: sql, } scripts = append(scripts, script) + } else if currentCol.Generated || currentCol.Identity { + // GENERATED ALWAYS ... STORED columns and GENERATED ... AS IDENTITY + // columns are both driven by Postgres-side machinery (an expression, or + // a backing sequence + attidentity flag) that DBML cannot express, so + // the model side always looks like drift (missing type modifiers, no + // default, NOT NULL mismatches, etc). None of their properties are + // alterable the normal way (e.g. dropping a generated column's default + // requires DROP EXPRESSION, not DROP DEFAULT; identity columns have no + // default to alter at all), so these DB-side-only columns are left + // untouched rather than diffed. } else if !columnsEqual(modelCol, currentCol) { // Column exists but properties changed if !columnTypesEqual(modelCol, currentCol) { diff --git a/pkg/writers/pgsql/migration_writer_test.go b/pkg/writers/pgsql/migration_writer_test.go index 1f5d014..30386a3 100644 --- a/pkg/writers/pgsql/migration_writer_test.go +++ b/pkg/writers/pgsql/migration_writer_test.go @@ -219,6 +219,91 @@ func TestWriteMigration_AltersColumnNullabilityWhenNotNullDiffers(t *testing.T) } } +func TestWriteMigration_DoesNotDropDefaultOnGeneratedColumn(t *testing.T) { + current := models.InitDatabase("testdb") + currentSchema := models.InitSchema("public") + currentTable := models.InitTable("info_city", "public") + currentGeom := models.InitColumn("geom", "info_city", "public") + currentGeom.Type = "text" + currentGeom.Generated = true + currentGeom.GenerationExpression = "st_makepoint(lon, lat)" + currentTable.Columns["geom"] = currentGeom + currentSchema.Tables = append(currentSchema.Tables, currentTable) + current.Schemas = append(current.Schemas, currentSchema) + + model := models.InitDatabase("testdb") + modelSchema := models.InitSchema("public") + modelTable := models.InitTable("info_city", "public") + modelGeom := models.InitColumn("geom", "info_city", "public") + modelGeom.Type = "text" + // DBML cannot express GENERATED ALWAYS columns, so the model declares + // this as a plain column with no default — it must not be treated as a + // default-value drift against the DB-side generated column. + modelTable.Columns["geom"] = modelGeom + modelSchema.Tables = append(modelSchema.Tables, modelTable) + model.Schemas = append(model.Schemas, modelSchema) + + var buf bytes.Buffer + writer, err := NewMigrationWriter(&writers.WriterOptions{}) + if err != nil { + t.Fatalf("Failed to create writer: %v", err) + } + writer.writer = &buf + + if err := writer.WriteMigration(model, current); err != nil { + t.Fatalf("WriteMigration failed: %v", err) + } + + output := buf.String() + if strings.Contains(output, "DROP DEFAULT") { + t.Fatalf("did not expect DROP DEFAULT against a generated column, got:\n%s", output) + } +} + +func TestWriteMigration_DoesNotAlterIdentityColumn(t *testing.T) { + current := models.InitDatabase("testdb") + currentSchema := models.InitSchema("public") + currentTable := models.InitTable("users", "public") + currentID := models.InitColumn("id", "users", "public") + currentID.Type = "bigint" + currentID.NotNull = true + currentID.Identity = true + currentID.IdentityGeneration = "ALWAYS" + currentTable.Columns["id"] = currentID + currentSchema.Tables = append(currentSchema.Tables, currentTable) + current.Schemas = append(current.Schemas, currentSchema) + + model := models.InitDatabase("testdb") + modelSchema := models.InitSchema("public") + modelTable := models.InitTable("users", "public") + modelID := models.InitColumn("id", "users", "public") + modelID.Type = "bigint" + // DBML cannot express GENERATED ... AS IDENTITY, so the model declares this + // as a plain nullable column — it must not be diffed as NOT NULL drift + // against the DB-side identity column (Postgres rejects DROP NOT NULL on + // an identity column). + modelID.NotNull = false + modelTable.Columns["id"] = modelID + modelSchema.Tables = append(modelSchema.Tables, modelTable) + model.Schemas = append(model.Schemas, modelSchema) + + var buf bytes.Buffer + writer, err := NewMigrationWriter(&writers.WriterOptions{}) + if err != nil { + t.Fatalf("Failed to create writer: %v", err) + } + writer.writer = &buf + + if err := writer.WriteMigration(model, current); err != nil { + t.Fatalf("WriteMigration failed: %v", err) + } + + output := buf.String() + if strings.Contains(output, "DROP NOT NULL") { + t.Fatalf("did not expect DROP NOT NULL against an identity column, got:\n%s", output) + } +} + func TestWriteMigration_UsesStorageTypeForSerialAlterStatements(t *testing.T) { current := models.InitDatabase("testdb") currentSchema := models.InitSchema("public") diff --git a/pkg/writers/pgsql/writer.go b/pkg/writers/pgsql/writer.go index a184140..36f3d25 100644 --- a/pkg/writers/pgsql/writer.go +++ b/pkg/writers/pgsql/writer.go @@ -462,6 +462,12 @@ func (w *Writer) GenerateAlterColumnTypeStatements(schema *models.Schema) ([]str for _, table := range schema.Tables { columns := getSortedColumns(table.Columns) for _, col := range columns { + if col.Generated || col.Identity { + // Type is derived from the generation expression (generated columns) or + // tied to the backing sequence (identity columns); leave both alone here + // rather than risk an ALTER COLUMN TYPE Postgres won't accept cleanly. + continue + } targetType := effectiveAlterColumnSQLType(col) stmt, err := w.executor.ExecuteAlterColumnTypeWithCheck(AlterColumnTypeWithCheckData{ SchemaName: schema.Name, @@ -492,6 +498,14 @@ func (w *Writer) GenerateAlterColumnDefaultStatements(schema *models.Schema) ([] for _, table := range schema.Tables { columns := getSortedColumns(table.Columns) for _, col := range columns { + if col.Generated || col.Identity { + // Generated columns: Postgres stores the generation expression in + // pg_attrdef just like a real default, so the guard template would see + // it as a stray default and emit DROP DEFAULT, which Postgres rejects. + // Identity columns never carry a DEFAULT at all (they're driven by a + // backing sequence), so there is nothing for this generator to manage. + continue + } setDefault, defaultVal := formatColumnDefaultSQL(col) stmt, err := w.executor.ExecuteAlterColumnDefaultWithCheck(AlterColumnDefaultWithCheckData{ SchemaName: schema.Name, @@ -605,13 +619,25 @@ func (w *Writer) generateColumnDefinition(col *models.Column) string { parts = append(parts, effectiveColumnSQLType(col)) + // GENERATED ALWAYS ... STORED columns are computed from an expression and + // cannot also carry a DEFAULT, so this takes the place of the DEFAULT clause. + if col.Generated && col.GenerationExpression != "" { + parts = append(parts, fmt.Sprintf("GENERATED ALWAYS AS (%s) STORED", col.GenerationExpression)) + } + + // GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY columns are driven by an + // internal sequence, not a literal DEFAULT, and cannot carry one either. + if col.Identity && col.IdentityGeneration != "" { + parts = append(parts, fmt.Sprintf("GENERATED %s AS IDENTITY", col.IdentityGeneration)) + } + // NOT NULL if col.NotNull { parts = append(parts, "NOT NULL") } // DEFAULT - if col.Default != nil { + if !col.Generated && !col.Identity && col.Default != nil { switch v := col.Default.(type) { case string: parts = append(parts, fmt.Sprintf("DEFAULT %s", writers.QuoteDefaultValue(stripBackticks(v), col.Type))) diff --git a/pkg/writers/pgsql/writer_test.go b/pkg/writers/pgsql/writer_test.go index fdab68a..5ac6179 100644 --- a/pkg/writers/pgsql/writer_test.go +++ b/pkg/writers/pgsql/writer_test.go @@ -1510,3 +1510,42 @@ func TestIndexStorageParameters(t *testing.T) { }) } } + +func TestGenerateColumnDefinition_GeneratedColumnEmitsGeneratedClauseNotDefault(t *testing.T) { + w := &Writer{} + + col := models.InitColumn("geom", "info_city", "public") + col.Type = "text" + col.NotNull = true + col.Generated = true + col.GenerationExpression = "st_makepoint(lon, lat)" + // A generated column's introspected "default" is actually its generation + // expression surfaced via pg_attrdef; it must never be rendered as DEFAULT. + col.Default = "st_makepoint(lon, lat)" + + got := w.generateColumnDefinition(col) + + want := "geom text GENERATED ALWAYS AS (st_makepoint(lon, lat)) STORED NOT NULL" + if got != want { + t.Fatalf("generateColumnDefinition() = %q, want %q", got, want) + } +} + +func TestGenerateColumnDefinition_IdentityColumnEmitsIdentityClauseNotDefault(t *testing.T) { + w := &Writer{} + + col := models.InitColumn("id", "users", "public") + col.Type = "bigint" + col.NotNull = true + col.Identity = true + col.IdentityGeneration = "ALWAYS" + // Identity columns never carry a literal default; this must never surface. + col.Default = "some-stray-value" + + got := w.generateColumnDefinition(col) + + want := "id bigint GENERATED ALWAYS AS IDENTITY NOT NULL" + if got != want { + t.Fatalf("generateColumnDefinition() = %q, want %q", got, want) + } +}