diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a9eae72..c3721e7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: run-tests: strategy: matrix: - go: [ '1.21', '1.20', '1.19' ] + go: [ 'oldstable', 'stable' ] platform: [ ubuntu-latest ] runs-on: ubuntu-latest diff --git a/generated_test.go b/generated_test.go new file mode 100644 index 0000000..48f6163 --- /dev/null +++ b/generated_test.go @@ -0,0 +1,58 @@ +package sqlserver + +import ( + "testing" + + "gorm.io/gorm/schema" +) + +func TestDataTypeOfGeneratedColumn(t *testing.T) { + dialector := Dialector{Config: &Config{}} + tests := []struct { + name string + field *schema.Field + want string + }{ + { + // SQL Server infers a computed column's type from the expression, + // so the column type is omitted and the value is PERSISTED (stored). + name: "computed column renders a PERSISTED computed column", + field: &schema.Field{DataType: schema.Float, TagSettings: map[string]string{"GENERATED": "price * quantity"}}, + want: "AS (price * quantity) PERSISTED", + }, + { + name: "computed expression keeps commas", + field: &schema.Field{DataType: schema.String, TagSettings: map[string]string{"GENERATED": "concat(first_name, last_name)"}}, + want: "AS (concat(first_name, last_name)) PERSISTED", + }, + { + // `identity` is reserved for identity columns, which SQL Server + // renders through its native IDENTITY rather than a computed column. + name: "identity keyword is not treated as a computed column", + field: &schema.Field{DataType: schema.Int, Size: 64, AutoIncrement: true, TagSettings: map[string]string{"GENERATED": "identity"}}, + want: "bigint IDENTITY(1,1)", + }, + { + name: "identity with an explicit mode is also reserved", + field: &schema.Field{DataType: schema.Int, Size: 64, AutoIncrement: true, TagSettings: map[string]string{"GENERATED": "identity always"}}, + want: "bigint IDENTITY(1,1)", + }, + { + name: "a bare generated tag is ignored", + field: &schema.Field{DataType: schema.Float, TagSettings: map[string]string{"GENERATED": "GENERATED"}}, + want: "float", + }, + { + name: "a lowercase generated expression is not mistaken for a bare tag", + field: &schema.Field{DataType: schema.Float, TagSettings: map[string]string{"GENERATED": "generated"}}, + want: "AS (generated) PERSISTED", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := dialector.DataTypeOf(tt.field); got != tt.want { + t.Errorf("DataTypeOf() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/sqlserver.go b/sqlserver.go index 072df08..2c09a53 100644 --- a/sqlserver.go +++ b/sqlserver.go @@ -182,6 +182,18 @@ func (dialector Dialector) Explain(sql string, vars ...interface{}) string { } func (dialector Dialector) DataTypeOf(field *schema.Field) string { + // Computed (generated) column. SQL Server uses `AS (expr) PERSISTED` (the + // column type is inferred from the expression, so it is omitted). The + // expression is carried by the `generated` tag, separate from the type. + // https://learn.microsoft.com/en-us/sql/relational-databases/tables/specify-computed-columns-in-a-table + if expr, ok := generatedColumnExpr(field); ok { + return "AS (" + expr + ") PERSISTED" + } + + return dialector.dataTypeOf(field) +} + +func (dialector Dialector) dataTypeOf(field *schema.Field) string { switch field.DataType { case schema.Bool: return "bit" @@ -243,3 +255,40 @@ func (dialectopr Dialector) RollbackTo(tx *gorm.DB, name string) error { tx.Exec("ROLLBACK TRANSACTION " + name) return nil } + +// generatedColumnExpr returns the expression of a computed (generated) column +// declared via the `generated` tag, if any. The `identity` keyword is reserved +// for identity columns (rendered through the dialect's native IDENTITY) and is +// not a computed-column expression. +func generatedColumnExpr(field *schema.Field) (string, bool) { + value, ok := field.TagSettings["GENERATED"] + if !ok { + return "", false + } + // Ignore an empty value or a bare `generated` tag, which the tag parser + // stores as the upper-cased key, rather than treating it as an expression. + if value = strings.TrimSpace(value); value == "" || value == "GENERATED" { + return "", false + } + if isIdentityKeyword(value) { + return "", false + } + return value, true +} + +// isIdentityKeyword reports whether value is the `identity` keyword, optionally +// combined with the generation mode `always` / `by default`. Any other token +// means value is a computed-column expression. +func isIdentityKeyword(value string) bool { + identity := false + for _, token := range strings.Fields(strings.ToLower(value)) { + switch token { + case "identity": + identity = true + case "always", "by", "default": + default: + return false + } + } + return identity +}