From 13d7cf08a23177ee5cf70f8db76a545b52f7e488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Thu, 25 Jun 2026 20:06:33 +0800 Subject: [PATCH] feat: support generated (computed) columns via the `generated` tag Render SQLite STORED generated columns from a `generated` tag, keeping the generation expression separate from the column type: Total float64 `gorm:"->;generated:price * quantity"` // -> "total" real GENERATED ALWAYS AS (price * quantity) STORED The expression is taken verbatim, so commas inside it (e.g. coalesce(a, b)) are preserved; combine with the `->` read-only permission. The `identity` keyword is reserved for identity columns and is rendered through SQLite's native AUTOINCREMENT, so it is not treated as a computed-column expression. Relates to https://github.com/go-gorm/gorm/issues/7191 Co-Authored-By: Claude Opus 4.8 --- generated_test.go | 56 +++++++++++++++++++++++++++++++++++++++++++++++ sqlite.go | 49 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 generated_test.go diff --git a/generated_test.go b/generated_test.go new file mode 100644 index 0000000..70fdfc0 --- /dev/null +++ b/generated_test.go @@ -0,0 +1,56 @@ +package sqlite + +import ( + "testing" + + "gorm.io/gorm/schema" +) + +func TestDataTypeOfGeneratedColumn(t *testing.T) { + dialector := Dialector{} + tests := []struct { + name string + field *schema.Field + want string + }{ + { + name: "computed column renders a STORED generated column", + field: &schema.Field{DataType: schema.Float, TagSettings: map[string]string{"GENERATED": "price * quantity"}}, + want: "real GENERATED ALWAYS AS (price * quantity) STORED", + }, + { + name: "computed expression keeps commas", + field: &schema.Field{DataType: schema.String, TagSettings: map[string]string{"GENERATED": "coalesce(first_name, last_name)"}}, + want: "text GENERATED ALWAYS AS (coalesce(first_name, last_name)) STORED", + }, + { + // `identity` is reserved for identity columns, which SQLite renders + // through its native AUTOINCREMENT rather than a computed column. + name: "identity keyword is not treated as a computed column", + field: &schema.Field{DataType: schema.Int, AutoIncrement: true, TagSettings: map[string]string{"GENERATED": "identity"}}, + want: "integer PRIMARY KEY AUTOINCREMENT", + }, + { + name: "identity with an explicit mode is also reserved", + field: &schema.Field{DataType: schema.Int, AutoIncrement: true, TagSettings: map[string]string{"GENERATED": "identity always"}}, + want: "integer PRIMARY KEY AUTOINCREMENT", + }, + { + name: "a bare generated tag is ignored", + field: &schema.Field{DataType: schema.Float, TagSettings: map[string]string{"GENERATED": "GENERATED"}}, + want: "real", + }, + { + 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: "real GENERATED ALWAYS AS (generated) STORED", + }, + } + 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/sqlite.go b/sqlite.go index 418842a..b3c8e31 100644 --- a/sqlite.go +++ b/sqlite.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "strconv" + "strings" "gorm.io/gorm/callbacks" @@ -206,6 +207,17 @@ func (dialector Dialector) Explain(sql string, vars ...interface{}) string { } func (dialector Dialector) DataTypeOf(field *schema.Field) string { + // Computed (generated) column. SQLite 3.31+ supports STORED generated + // columns; the expression is carried by the `generated` tag, separate from + // the column type. https://www.sqlite.org/gencol.html + if expr, ok := generatedColumnExpr(field); ok { + return dialector.dataTypeOf(field) + " GENERATED ALWAYS AS (" + expr + ") STORED" + } + + return dialector.dataTypeOf(field) +} + +func (dialector Dialector) dataTypeOf(field *schema.Field) string { switch field.DataType { case schema.Bool: return "numeric" @@ -268,3 +280,40 @@ func compareVersion(version1, version2 string) int { } return 0 } + +// 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 auto-increment) +// 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 +}