From 949526531f31cb0127720fa2aede0c2f3e010e2d Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Mon, 25 Aug 2025 13:38:03 -0600 Subject: [PATCH 01/14] Implement AS OF SYSTEM TIME feature for CockroachDB with corresponding tests --- chainable_api.go | 46 ++++++++++++++++ clause/as_of_system_time.go | 33 +++++++++++ clause/as_of_system_time_test.go | 94 ++++++++++++++++++++++++++++++++ clause/from.go | 11 +++- 4 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 clause/as_of_system_time.go create mode 100644 clause/as_of_system_time_test.go diff --git a/chainable_api.go b/chainable_api.go index 8a6aea3437..d1042ae143 100644 --- a/chainable_api.go +++ b/chainable_api.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "strings" + "time" "gorm.io/gorm/clause" "gorm.io/gorm/utils" @@ -85,6 +86,51 @@ func (db *DB) Table(name string, args ...interface{}) (tx *DB) { return } +// AsOfSystemTime sets the system time for CockroachDB temporal queries +// This allows querying data as it existed at a specific point in time +// +// // Query data as it existed 1 hour ago +// db.AsOfSystemTime("-1h").Find(&users) +// // Query data as it existed at a specific timestamp +// db.AsOfSystemTime(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)).Find(&users) +// +// Note: This feature is only supported by CockroachDB +func (db *DB) AsOfSystemTime(timestamp interface{}) (tx *DB) { + tx = db.getInstance() + + // Check if the dialect supports AS OF SYSTEM TIME (CockroachDB) + if tx.Dialector.Name() != "cockroachdb" { + tx.AddError(fmt.Errorf("AS OF SYSTEM TIME is only supported by CockroachDB, current dialect: %s", tx.Dialector.Name())) + return tx + } + + var asOfClause *clause.AsOfSystemTime + + switch v := timestamp.(type) { + case string: + // Raw SQL expression like "-1h", "now() - interval '1 hour'" + asOfClause = &clause.AsOfSystemTime{Raw: v} + case time.Time: + // Specific timestamp + asOfClause = &clause.AsOfSystemTime{Timestamp: v} + default: + tx.AddError(fmt.Errorf("unsupported timestamp type for AS OF SYSTEM TIME: %T", timestamp)) + return tx + } + + // Get or create the FROM clause + var fromClause clause.From + if v, ok := tx.Statement.Clauses["FROM"].Expression.(clause.From); ok { + fromClause = v + } + + // Set the AS OF SYSTEM TIME + fromClause.AsOfSystemTime = asOfClause + tx.Statement.AddClause(fromClause) + + return tx +} + // Distinct specify distinct fields that you want querying // // // Select distinct names of users diff --git a/clause/as_of_system_time.go b/clause/as_of_system_time.go new file mode 100644 index 0000000000..d8ac390efb --- /dev/null +++ b/clause/as_of_system_time.go @@ -0,0 +1,33 @@ +package clause + +import ( + "time" +) + +// AsOfSystemTime represents CockroachDB's "AS OF SYSTEM TIME" clause +// This allows querying data as it existed at a specific point in time +type AsOfSystemTime struct { + Timestamp time.Time + Raw string // For raw SQL expressions like "AS OF SYSTEM TIME '-1h'" +} + +// Name returns the clause name +func (a AsOfSystemTime) Name() string { + return "AS OF SYSTEM TIME" +} + +// Build builds the "AS OF SYSTEM TIME" clause +func (a AsOfSystemTime) Build(builder Builder) { + if a.Raw != "" { + builder.WriteString("AS OF SYSTEM TIME ") + builder.WriteString(a.Raw) + } else if !a.Timestamp.IsZero() { + builder.WriteString("AS OF SYSTEM TIME ") + builder.AddVar(builder, a.Timestamp) + } +} + +// MergeClause merges the "AS OF SYSTEM TIME" clause +func (a AsOfSystemTime) MergeClause(clause *Clause) { + clause.Expression = a +} diff --git a/clause/as_of_system_time_test.go b/clause/as_of_system_time_test.go new file mode 100644 index 0000000000..60b198ee1c --- /dev/null +++ b/clause/as_of_system_time_test.go @@ -0,0 +1,94 @@ +package clause_test + +import ( + "fmt" + "testing" + "time" + + "gorm.io/gorm/clause" +) + +func TestAsOfSystemTime(t *testing.T) { + results := []struct { + Clauses []clause.Interface + Result string + Vars []interface{} + }{ + { + []clause.Interface{ + clause.Select{}, + clause.From{ + Tables: []clause.Table{{Name: "users"}}, + AsOfSystemTime: &clause.AsOfSystemTime{Timestamp: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)}, + }, + }, + "SELECT * FROM `users` AS OF SYSTEM TIME ?", + []interface{}{time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)}, + }, + { + []clause.Interface{ + clause.Select{}, + clause.From{ + Tables: []clause.Table{{Name: "users"}}, + AsOfSystemTime: &clause.AsOfSystemTime{Raw: "-1h"}, + }, + }, + "SELECT * FROM `users` AS OF SYSTEM TIME -1h", + nil, + }, + { + []clause.Interface{ + clause.Select{}, + clause.From{ + Tables: []clause.Table{{Name: "users"}}, + AsOfSystemTime: &clause.AsOfSystemTime{Raw: "now() - interval '1 hour'"}, + }, + }, + "SELECT * FROM `users` AS OF SYSTEM TIME now() - interval '1 hour'", + nil, + }, + { + []clause.Interface{ + clause.Select{}, + clause.From{ + Tables: []clause.Table{{Name: "users"}}, + Joins: []clause.Join{ + { + Type: clause.InnerJoin, + Table: clause.Table{Name: "companies"}, + ON: clause.Where{ + []clause.Expression{clause.Eq{clause.Column{Table: "companies", Name: "id"}, clause.Column{Table: "users", Name: "company_id"}}}, + }, + }, + }, + AsOfSystemTime: &clause.AsOfSystemTime{Raw: "-1h"}, + }, + }, + "SELECT * FROM `users` INNER JOIN `companies` ON `companies`.`id` = `users`.`company_id` AS OF SYSTEM TIME -1h", + nil, + }, + } + + for idx, result := range results { + t.Run(fmt.Sprintf("case #%v", idx), func(t *testing.T) { + checkBuildClauses(t, result.Clauses, result.Result, result.Vars) + }) + } +} + +func TestAsOfSystemTimeName(t *testing.T) { + asOfClause := clause.AsOfSystemTime{} + if asOfClause.Name() != "AS OF SYSTEM TIME" { + t.Errorf("expected name 'AS OF SYSTEM TIME', got %q", asOfClause.Name()) + } +} + +func TestAsOfSystemTimeMergeClause(t *testing.T) { + asOfClause := clause.AsOfSystemTime{Timestamp: time.Now()} + var c clause.Clause + asOfClause.MergeClause(&c) + + if c.Expression != asOfClause { + t.Error("expected expression to be set to the clause") + } +} diff --git a/clause/from.go b/clause/from.go index 1ea2d5951c..d22268f4cc 100644 --- a/clause/from.go +++ b/clause/from.go @@ -2,8 +2,9 @@ package clause // From from clause type From struct { - Tables []Table - Joins []Join + Tables []Table + Joins []Join + AsOfSystemTime *AsOfSystemTime // CockroachDB specific: AS OF SYSTEM TIME } // Name from clause name @@ -29,6 +30,12 @@ func (from From) Build(builder Builder) { builder.WriteByte(' ') join.Build(builder) } + + // Add AS OF SYSTEM TIME clause if specified + if from.AsOfSystemTime != nil { + builder.WriteByte(' ') + from.AsOfSystemTime.Build(builder) + } } // MergeClause merge from clause From afc5aacaafd783bf277f4b2b58570f5dd3d300e4 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Mon, 25 Aug 2025 14:11:37 -0600 Subject: [PATCH 02/14] Remove dialect check for AS OF SYSTEM TIME in CockroachDB implementation --- chainable_api.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/chainable_api.go b/chainable_api.go index d1042ae143..a99d45adb5 100644 --- a/chainable_api.go +++ b/chainable_api.go @@ -98,12 +98,6 @@ func (db *DB) Table(name string, args ...interface{}) (tx *DB) { func (db *DB) AsOfSystemTime(timestamp interface{}) (tx *DB) { tx = db.getInstance() - // Check if the dialect supports AS OF SYSTEM TIME (CockroachDB) - if tx.Dialector.Name() != "cockroachdb" { - tx.AddError(fmt.Errorf("AS OF SYSTEM TIME is only supported by CockroachDB, current dialect: %s", tx.Dialector.Name())) - return tx - } - var asOfClause *clause.AsOfSystemTime switch v := timestamp.(type) { From 829ccabb81e75c376afe8106a84a5906d3e7a20d Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Mon, 25 Aug 2025 14:20:47 -0600 Subject: [PATCH 03/14] Refactor AS OF SYSTEM TIME formatting to use fmt.Sprintf for timestamp output in clause/as_of_system_time.go --- clause/as_of_system_time.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/clause/as_of_system_time.go b/clause/as_of_system_time.go index d8ac390efb..e5ce2e2405 100644 --- a/clause/as_of_system_time.go +++ b/clause/as_of_system_time.go @@ -1,6 +1,7 @@ package clause import ( + "fmt" "time" ) @@ -22,8 +23,7 @@ func (a AsOfSystemTime) Build(builder Builder) { builder.WriteString("AS OF SYSTEM TIME ") builder.WriteString(a.Raw) } else if !a.Timestamp.IsZero() { - builder.WriteString("AS OF SYSTEM TIME ") - builder.AddVar(builder, a.Timestamp) + builder.WriteString(fmt.Sprintf("AS OF SYSTEM TIME '%s'", a.Timestamp.Format("2006-01-02 15:04:05.000000"))) } } From 8dfa319bafd55e2b2e6bce098eaf132f71c028c4 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Mon, 25 Aug 2025 14:27:18 -0600 Subject: [PATCH 04/14] Update AS OF SYSTEM TIME clause to standardize raw SQL expression formatting in chainable_api.go and related tests --- chainable_api.go | 2 +- clause/as_of_system_time.go | 3 +-- clause/as_of_system_time_test.go | 19 ++++--------------- 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/chainable_api.go b/chainable_api.go index a99d45adb5..a9bf654c3e 100644 --- a/chainable_api.go +++ b/chainable_api.go @@ -102,7 +102,7 @@ func (db *DB) AsOfSystemTime(timestamp interface{}) (tx *DB) { switch v := timestamp.(type) { case string: - // Raw SQL expression like "-1h", "now() - interval '1 hour'" + // Raw SQL expression like "-1h" asOfClause = &clause.AsOfSystemTime{Raw: v} case time.Time: // Specific timestamp diff --git a/clause/as_of_system_time.go b/clause/as_of_system_time.go index e5ce2e2405..a0eae2a87a 100644 --- a/clause/as_of_system_time.go +++ b/clause/as_of_system_time.go @@ -20,8 +20,7 @@ func (a AsOfSystemTime) Name() string { // Build builds the "AS OF SYSTEM TIME" clause func (a AsOfSystemTime) Build(builder Builder) { if a.Raw != "" { - builder.WriteString("AS OF SYSTEM TIME ") - builder.WriteString(a.Raw) + builder.WriteString(fmt.Sprintf("AS OF SYSTEM TIME '%s'", a.Raw)) } else if !a.Timestamp.IsZero() { builder.WriteString(fmt.Sprintf("AS OF SYSTEM TIME '%s'", a.Timestamp.Format("2006-01-02 15:04:05.000000"))) } diff --git a/clause/as_of_system_time_test.go b/clause/as_of_system_time_test.go index 60b198ee1c..9dcb09952d 100644 --- a/clause/as_of_system_time_test.go +++ b/clause/as_of_system_time_test.go @@ -22,18 +22,7 @@ func TestAsOfSystemTime(t *testing.T) { AsOfSystemTime: &clause.AsOfSystemTime{Timestamp: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)}, }, }, - "SELECT * FROM `users` AS OF SYSTEM TIME ?", - []interface{}{time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)}, - }, - { - []clause.Interface{ - clause.Select{}, - clause.From{ - Tables: []clause.Table{{Name: "users"}}, - AsOfSystemTime: &clause.AsOfSystemTime{Raw: "-1h"}, - }, - }, - "SELECT * FROM `users` AS OF SYSTEM TIME -1h", + "SELECT * FROM `users` AS OF SYSTEM TIME '2023-01-01 12:00:00.000000'", nil, }, { @@ -41,10 +30,10 @@ func TestAsOfSystemTime(t *testing.T) { clause.Select{}, clause.From{ Tables: []clause.Table{{Name: "users"}}, - AsOfSystemTime: &clause.AsOfSystemTime{Raw: "now() - interval '1 hour'"}, + AsOfSystemTime: &clause.AsOfSystemTime{Raw: "-1h"}, }, }, - "SELECT * FROM `users` AS OF SYSTEM TIME now() - interval '1 hour'", + "SELECT * FROM `users` AS OF SYSTEM TIME '-1h'", nil, }, { @@ -64,7 +53,7 @@ func TestAsOfSystemTime(t *testing.T) { AsOfSystemTime: &clause.AsOfSystemTime{Raw: "-1h"}, }, }, - "SELECT * FROM `users` INNER JOIN `companies` ON `companies`.`id` = `users`.`company_id` AS OF SYSTEM TIME -1h", + "SELECT * FROM `users` INNER JOIN `companies` ON `companies`.`id` = `users`.`company_id` AS OF SYSTEM TIME '-1h'", nil, }, } From a3fac446bce3b3a01db97cabcf6dbf58f6a34a59 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Thu, 28 Aug 2025 04:57:17 +0200 Subject: [PATCH 05/14] avoid copying structures with embedded mutexs (#7571) Fixes warning like this: assignment copies lock value to relationships: gorm.io/gorm/schema.Relationships contains sync.RWMutex --- generics.go | 4 ++-- schema/schema_helper_test.go | 2 +- schema/schema_test.go | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/generics.go b/generics.go index f3c3e55382..5f1fce8b8b 100644 --- a/generics.go +++ b/generics.go @@ -425,12 +425,12 @@ func (c chainG[T]) Preload(association string, query func(db PreloadBuilder) err relation, ok := db.Statement.Schema.Relationships.Relations[association] if !ok { if preloadFields := strings.Split(association, "."); len(preloadFields) > 1 { - relationships := db.Statement.Schema.Relationships + relationships := &db.Statement.Schema.Relationships for _, field := range preloadFields { var ok bool relation, ok = relationships.Relations[field] if ok { - relationships = relation.FieldSchema.Relationships + relationships = &relation.FieldSchema.Relationships } else { db.AddError(fmt.Errorf("relation %s not found", association)) return nil diff --git a/schema/schema_helper_test.go b/schema/schema_helper_test.go index bc32668656..608625822b 100644 --- a/schema/schema_helper_test.go +++ b/schema/schema_helper_test.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm/utils/tests" ) -func checkSchema(t *testing.T, s *schema.Schema, v schema.Schema, primaryFields []string) { +func checkSchema(t *testing.T, s *schema.Schema, v *schema.Schema, primaryFields []string) { t.Run("CheckSchema/"+s.Name, func(t *testing.T) { tests.AssertObjEqual(t, s, v, "Name", "Table") diff --git a/schema/schema_test.go b/schema/schema_test.go index a7115f60ac..63f0e50016 100644 --- a/schema/schema_test.go +++ b/schema/schema_test.go @@ -46,7 +46,7 @@ func TestParseSchemaWithPointerFields(t *testing.T) { func checkUserSchema(t *testing.T, user *schema.Schema) { // check schema - checkSchema(t, user, schema.Schema{Name: "User", Table: "users"}, []string{"ID"}) + checkSchema(t, user, &schema.Schema{Name: "User", Table: "users"}, []string{"ID"}) // check fields fields := []schema.Field{ @@ -139,7 +139,7 @@ func TestParseSchemaWithAdvancedDataType(t *testing.T) { } // check schema - checkSchema(t, user, schema.Schema{Name: "AdvancedDataTypeUser", Table: "advanced_data_type_users"}, []string{"ID"}) + checkSchema(t, user, &schema.Schema{Name: "AdvancedDataTypeUser", Table: "advanced_data_type_users"}, []string{"ID"}) // check fields fields := []schema.Field{ From 8dbd45e0e4cc53ca1e396222c7caf8d97132445e Mon Sep 17 00:00:00 2001 From: Jinzhu Date: Thu, 4 Sep 2025 21:13:16 +0800 Subject: [PATCH 06/14] fix(generics): resolve CurrentTable in Raw/Exec --- generics.go | 6 ++++-- statement.go | 4 +++- tests/generics_test.go | 7 ++++++- tests/go.mod | 4 ++-- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/generics.go b/generics.go index 5f1fce8b8b..f31bdc5e0d 100644 --- a/generics.go +++ b/generics.go @@ -142,13 +142,15 @@ func (c *g[T]) Raw(sql string, values ...interface{}) ExecInterface[T] { return execG[T]{g: &g[T]{ db: c.db, ops: append(c.ops, func(db *DB) *DB { - return db.Raw(sql, values...) + var r T + return db.Model(r).Raw(sql, values...) }), }} } func (c *g[T]) Exec(ctx context.Context, sql string, values ...interface{}) error { - return c.apply(ctx).Exec(sql, values...).Error + var r T + return c.apply(ctx).Model(r).Exec(sql, values...).Error } type createG[T any] struct { diff --git a/statement.go b/statement.go index 74feaedd88..cd7369e310 100644 --- a/statement.go +++ b/statement.go @@ -96,7 +96,9 @@ func (stmt *Statement) QuoteTo(writer clause.Writer, field interface{}) { if v.Name == clause.CurrentTable { if stmt.TableExpr != nil { stmt.TableExpr.Build(stmt) - } else { + } else if stmt.Table != "" { + write(v.Raw, stmt.Table) + } else if stmt.AddError(stmt.Parse(stmt.Model)) == nil { write(v.Raw, stmt.Table) } } else { diff --git a/tests/generics_test.go b/tests/generics_test.go index 9357f5e165..e6900a47af 100644 --- a/tests/generics_test.go +++ b/tests/generics_test.go @@ -121,6 +121,11 @@ func TestGenericsExecAndUpdate(t *testing.T) { t.Fatalf("Exec insert failed: %v", err) } + name2 := "GenericsExec2" + if err := gorm.G[User](DB).Exec(ctx, "INSERT INTO ?(name) VALUES(?)", clause.Table{Name: clause.CurrentTable}, name2); err != nil { + t.Fatalf("Exec insert failed: %v", err) + } + u, err := gorm.G[User](DB).Table("users as u").Where("u.name = ?", name).First(ctx) if err != nil { t.Fatalf("failed to find user, got error: %v", err) @@ -162,7 +167,7 @@ func TestGenericsRow(t *testing.T) { t.Fatalf("Create failed: %v", err) } - row := gorm.G[User](DB).Raw("SELECT name FROM users WHERE id = ?", user.ID).Row(ctx) + row := gorm.G[User](DB).Raw("SELECT name FROM ? WHERE id = ?", clause.Table{Name: clause.CurrentTable}, user.ID).Row(ctx) var name string if err := row.Scan(&name); err != nil { t.Fatalf("Row scan failed: %v", err) diff --git a/tests/go.mod b/tests/go.mod index 7f81556213..3434159016 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -6,13 +6,13 @@ require ( github.com/google/uuid v1.6.0 github.com/jinzhu/now v1.1.5 github.com/lib/pq v1.10.9 - github.com/stretchr/testify v1.11.0 + github.com/stretchr/testify v1.11.1 gorm.io/driver/gaussdb v0.1.0 gorm.io/driver/mysql v1.6.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlserver v1.6.1 - gorm.io/gorm v1.30.0 + gorm.io/gorm v1.30.2 ) require ( From 94811fac810973a2e76e276e8d444a17999b0174 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Mon, 8 Sep 2025 16:26:57 +0800 Subject: [PATCH 07/14] fix slogLogger to support ParameterizedQueries Config (#7574) --- logger/slog.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/logger/slog.go b/logger/slog.go index 44f289e637..37fd3aa264 100644 --- a/logger/slog.go +++ b/logger/slog.go @@ -88,3 +88,11 @@ func (l *slogLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql }) } } + +// ParamsFilter filter params +func (l *slogLogger) ParamsFilter(ctx context.Context, sql string, params ...interface{}) (string, []interface{}) { + if l.Parameterized { + return sql, nil + } + return sql, params +} From 08124fa1bf8370b82e8fce17da8ef72d37e3e323 Mon Sep 17 00:00:00 2001 From: iTanken <23544702+iTanken@users.noreply.github.com> Date: Mon, 8 Sep 2025 16:27:25 +0800 Subject: [PATCH 08/14] fix: build failure on Go versions below 1.21, add build constraint for slog.go (#7572) --- logger/slog.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/logger/slog.go b/logger/slog.go index 37fd3aa264..613234cac4 100644 --- a/logger/slog.go +++ b/logger/slog.go @@ -1,3 +1,5 @@ +//go:build go1.21 + package logger import ( From 1f011fb69bcedc74c8e7f70763f96ca738b810d1 Mon Sep 17 00:00:00 2001 From: Jinzhu Date: Mon, 8 Sep 2025 17:19:22 +0800 Subject: [PATCH 09/14] Add Set-based Create and Update support to Generics API (#7578) --- .github/workflows/tests.yml | 16 ++++---- generics.go | 82 +++++++++++++++++++++++++++++++++---- tests/connection_test.go | 3 +- tests/count_test.go | 13 +++--- tests/generics_test.go | 58 ++++++++++++++++++++++++++ tests/go.mod | 8 ++-- tests/lru_test.go | 3 +- tests/submodel_test.go | 5 ++- tests/transaction_test.go | 2 +- 9 files changed, 158 insertions(+), 32 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f1d27d7675..60e806d934 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,7 +16,7 @@ jobs: sqlite: strategy: matrix: - go: ['1.23', '1.24'] + go: ['1.24', '1.25'] platform: [ubuntu-latest] # can not run in windows OS runs-on: ${{ matrix.platform }} @@ -42,7 +42,7 @@ jobs: strategy: matrix: dbversion: ['mysql:9', 'mysql:8', 'mysql:5.7'] - go: ['1.23', '1.24'] + go: ['1.24', '1.25'] platform: [ubuntu-latest] runs-on: ${{ matrix.platform }} @@ -85,7 +85,7 @@ jobs: strategy: matrix: dbversion: [ 'mariadb:latest' ] - go: ['1.23', '1.24'] + go: ['1.24', '1.25'] platform: [ ubuntu-latest ] runs-on: ${{ matrix.platform }} @@ -128,7 +128,7 @@ jobs: strategy: matrix: dbversion: ['postgres:latest', 'postgres:15', 'postgres:14', 'postgres:13'] - go: ['1.23', '1.24'] + go: ['1.24', '1.25'] platform: [ubuntu-latest] # can not run in macOS and Windows runs-on: ${{ matrix.platform }} @@ -170,7 +170,7 @@ jobs: sqlserver: strategy: matrix: - go: ['1.23', '1.24'] + go: ['1.24', '1.25'] platform: [ubuntu-latest] # can not run test in macOS and windows runs-on: ${{ matrix.platform }} @@ -212,7 +212,7 @@ jobs: strategy: matrix: dbversion: [ 'v6.5.0' ] - go: ['1.23', '1.24'] + go: ['1.24', '1.25'] platform: [ ubuntu-latest ] runs-on: ${{ matrix.platform }} @@ -245,7 +245,7 @@ jobs: strategy: matrix: dbversion: ['opengauss/opengauss:7.0.0-RC1.B023'] - go: ['1.23', '1.24'] + go: ['1.24', '1.25'] platform: [ubuntu-latest] # can not run in macOS and Windows runs-on: ${{ matrix.platform }} @@ -307,4 +307,4 @@ jobs: key: ${{ runner.os }}-go-${{ matrix.go }}-${{ hashFiles('tests/go.mod') }} - name: Tests - run: GITHUB_ACTION=true GORM_DIALECT=gaussdb GORM_DSN="user=gaussdb password=Gaussdb@123 dbname=gorm host=localhost port=9950 sslmode=disable TimeZone=Asia/Shanghai" ./tests/tests_all.sh \ No newline at end of file + run: GITHUB_ACTION=true GORM_DIALECT=gaussdb GORM_DSN="user=gaussdb password=Gaussdb@123 dbname=gorm host=localhost port=9950 sslmode=disable TimeZone=Asia/Shanghai" ./tests/tests_all.sh diff --git a/generics.go b/generics.go index f31bdc5e0d..0c2384e037 100644 --- a/generics.go +++ b/generics.go @@ -35,10 +35,33 @@ type Interface[T any] interface { } type CreateInterface[T any] interface { - ChainInterface[T] + ExecInterface[T] + // chain methods available at start; return ChainInterface + Scopes(scopes ...func(db *Statement)) ChainInterface[T] + Where(query interface{}, args ...interface{}) ChainInterface[T] + Not(query interface{}, args ...interface{}) ChainInterface[T] + Or(query interface{}, args ...interface{}) ChainInterface[T] + Limit(offset int) ChainInterface[T] + Offset(offset int) ChainInterface[T] + Joins(query clause.JoinTarget, on func(db JoinBuilder, joinTable clause.Table, curTable clause.Table) error) ChainInterface[T] + Preload(association string, query func(db PreloadBuilder) error) ChainInterface[T] + Select(query string, args ...interface{}) ChainInterface[T] + Omit(columns ...string) ChainInterface[T] + MapColumns(m map[string]string) ChainInterface[T] + Distinct(args ...interface{}) ChainInterface[T] + Group(name string) ChainInterface[T] + Having(query interface{}, args ...interface{}) ChainInterface[T] + Order(value interface{}) ChainInterface[T] + Build(builder clause.Builder) + + Delete(ctx context.Context) (rowsAffected int, err error) + Update(ctx context.Context, name string, value any) (rowsAffected int, err error) + Updates(ctx context.Context, t T) (rowsAffected int, err error) + Table(name string, args ...interface{}) CreateInterface[T] Create(ctx context.Context, r *T) error CreateInBatches(ctx context.Context, r *[]T, batchSize int) error + Set(assignments ...clause.Assignment) SetCreateOrUpdateInterface[T] } type ChainInterface[T any] interface { @@ -58,15 +81,28 @@ type ChainInterface[T any] interface { Group(name string) ChainInterface[T] Having(query interface{}, args ...interface{}) ChainInterface[T] Order(value interface{}) ChainInterface[T] + Set(assignments ...clause.Assignment) SetUpdateOnlyInterface[T] Build(builder clause.Builder) + Table(name string, args ...interface{}) ChainInterface[T] Delete(ctx context.Context) (rowsAffected int, err error) Update(ctx context.Context, name string, value any) (rowsAffected int, err error) Updates(ctx context.Context, t T) (rowsAffected int, err error) Count(ctx context.Context, column string) (result int64, err error) } +// SetUpdateOnlyInterface is returned by Set after chaining; only Update is allowed +type SetUpdateOnlyInterface[T any] interface { + Update(ctx context.Context) (rowsAffected int, err error) +} + +// SetCreateOrUpdateInterface is returned by Set at start; Create or Update are allowed +type SetCreateOrUpdateInterface[T any] interface { + Create(ctx context.Context) error + Update(ctx context.Context) (rowsAffected int, err error) +} + type ExecInterface[T any] interface { Scan(ctx context.Context, r interface{}) error First(context.Context) (T, error) @@ -163,6 +199,12 @@ func (c createG[T]) Table(name string, args ...interface{}) CreateInterface[T] { })} } +func (c createG[T]) Set(assignments ...clause.Assignment) SetCreateOrUpdateInterface[T] { + assigns := make([]clause.Assignment, len(assignments)) + copy(assigns, assignments) + return setCreateOrUpdateG[T]{c: c.chainG, assigns: assigns} +} + func (c createG[T]) Create(ctx context.Context, r *T) error { return c.g.apply(ctx).Create(r).Error } @@ -189,6 +231,12 @@ func (c chainG[T]) with(v op) chainG[T] { } } +func (c chainG[T]) Table(name string, args ...interface{}) ChainInterface[T] { + return c.with(func(db *DB) *DB { + return db.Table(name, args...) + }) +} + func (c chainG[T]) Scopes(scopes ...func(db *Statement)) ChainInterface[T] { return c.with(func(db *DB) *DB { for _, fc := range scopes { @@ -198,12 +246,6 @@ func (c chainG[T]) Scopes(scopes ...func(db *Statement)) ChainInterface[T] { }) } -func (c chainG[T]) Table(name string, args ...interface{}) ChainInterface[T] { - return c.with(func(db *DB) *DB { - return db.Table(name, args...) - }) -} - func (c chainG[T]) Where(query interface{}, args ...interface{}) ChainInterface[T] { return c.with(func(db *DB) *DB { return db.Where(query, args...) @@ -390,6 +432,12 @@ func (c chainG[T]) MapColumns(m map[string]string) ChainInterface[T] { }) } +func (c chainG[T]) Set(assignments ...clause.Assignment) SetUpdateOnlyInterface[T] { + assigns := make([]clause.Assignment, len(assignments)) + copy(assigns, assignments) + return setCreateOrUpdateG[T]{c: c, assigns: assigns} +} + func (c chainG[T]) Distinct(args ...interface{}) ChainInterface[T] { return c.with(func(db *DB) *DB { return db.Distinct(args...) @@ -557,6 +605,26 @@ func (c chainG[T]) Build(builder clause.Builder) { } } +type setCreateOrUpdateG[T any] struct { + c chainG[T] + assigns []clause.Assignment +} + +func (s setCreateOrUpdateG[T]) Update(ctx context.Context) (rowsAffected int, err error) { + var r T + res := s.c.g.apply(ctx).Model(r).Clauses(clause.Set(s.assigns)).Updates(map[string]interface{}{}) + return int(res.RowsAffected), res.Error +} + +func (s setCreateOrUpdateG[T]) Create(ctx context.Context) error { + var r T + data := make(map[string]interface{}, len(s.assigns)) + for _, a := range s.assigns { + data[a.Column.Name] = a.Value + } + return s.c.g.apply(ctx).Model(r).Create(data).Error +} + type execG[T any] struct { g *g[T] } diff --git a/tests/connection_test.go b/tests/connection_test.go index 7bc23009d7..ea021eb785 100644 --- a/tests/connection_test.go +++ b/tests/connection_test.go @@ -1,7 +1,6 @@ package tests_test import ( - "fmt" "testing" "gorm.io/driver/mysql" @@ -28,7 +27,7 @@ func TestWithSingleConnection(t *testing.T) { return nil }) if err != nil { - t.Errorf(fmt.Sprintf("WithSingleConnection should work, but got err %v", err)) + t.Errorf("WithSingleConnection should work, but got err %v", err) } if actualName != expectedName { diff --git a/tests/count_test.go b/tests/count_test.go index 4449515bd3..bdeba8f049 100644 --- a/tests/count_test.go +++ b/tests/count_test.go @@ -1,7 +1,6 @@ package tests_test import ( - "fmt" "regexp" "sort" "strings" @@ -22,7 +21,7 @@ func TestCountWithGroup(t *testing.T) { var count1 int64 if err := DB.Model(&Company{}).Where("name = ?", "company_count_group_a").Group("name").Count(&count1).Error; err != nil { - t.Errorf(fmt.Sprintf("Count should work, but got err %v", err)) + t.Errorf("Count should work, but got err %v", err) } if count1 != 1 { t.Errorf("Count with group should be 1, but got count: %v", count1) @@ -30,7 +29,7 @@ func TestCountWithGroup(t *testing.T) { var count2 int64 if err := DB.Model(&Company{}).Where("name in ?", []string{"company_count_group_b", "company_count_group_c"}).Group("name").Count(&count2).Error; err != nil { - t.Errorf(fmt.Sprintf("Count should work, but got err %v", err)) + t.Errorf("Count should work, but got err %v", err) } if count2 != 2 { t.Errorf("Count with group should be 2, but got count: %v", count2) @@ -49,7 +48,7 @@ func TestCount(t *testing.T) { DB.Save(&user1).Save(&user2).Save(&user3) if err := DB.Where("name = ?", user1.Name).Or("name = ?", user3.Name).Find(&users).Count(&count).Error; err != nil { - t.Errorf(fmt.Sprintf("Count should work, but got err %v", err)) + t.Errorf("Count should work, but got err %v", err) } if count != int64(len(users)) { @@ -57,7 +56,7 @@ func TestCount(t *testing.T) { } if err := DB.Model(&User{}).Where("name = ?", user1.Name).Or("name = ?", user3.Name).Count(&count).Find(&users).Error; err != nil { - t.Errorf(fmt.Sprintf("Count should work, but got err %v", err)) + t.Errorf("Count should work, but got err %v", err) } if count != int64(len(users)) { @@ -110,7 +109,7 @@ func TestCount(t *testing.T) { if err := DB.Model(&User{}).Where("name in ?", []string{user1.Name, user2.Name, user3.Name}).Select( "(CASE WHEN name=? THEN ? ELSE ? END) as name", "count-1", "main", "other", ).Count(&count6).Find(&users).Error; err != nil || count6 != 3 { - t.Fatalf(fmt.Sprintf("Count should work, but got err %v", err)) + t.Fatalf("Count should work, but got err %v", err) } expects := []User{{Name: "main"}, {Name: "other"}, {Name: "other"}} @@ -124,7 +123,7 @@ func TestCount(t *testing.T) { if err := DB.Model(&User{}).Where("name in ?", []string{user1.Name, user2.Name, user3.Name}).Select( "(CASE WHEN name=? THEN ? ELSE ? END) as name, age", "count-1", "main", "other", ).Count(&count7).Find(&users).Error; err != nil || count7 != 3 { - t.Fatalf(fmt.Sprintf("Count should work, but got err %v", err)) + t.Fatalf("Count should work, but got err %v", err) } expects = []User{{Name: "main", Age: 18}, {Name: "other", Age: 18}, {Name: "other", Age: 18}} diff --git a/tests/generics_test.go b/tests/generics_test.go index e6900a47af..0cf7066f07 100644 --- a/tests/generics_test.go +++ b/tests/generics_test.go @@ -667,6 +667,64 @@ func TestGenericsDistinct(t *testing.T) { } } +func TestGenericsSetCreate(t *testing.T) { + ctx := context.Background() + + name := "GenericsSetCreate" + age := uint(21) + + err := gorm.G[User](DB).Set( + clause.Assignment{Column: clause.Column{Name: "name"}, Value: name}, + clause.Assignment{Column: clause.Column{Name: "age"}, Value: age}, + ).Create(ctx) + if err != nil { + t.Fatalf("Set Create failed: %v", err) + } + + u, err := gorm.G[User](DB).Where("name = ?", name).First(ctx) + if err != nil { + t.Fatalf("failed to find created user: %v", err) + } + if u.ID == 0 || u.Name != name || u.Age != age { + t.Fatalf("created user mismatch, got %+v", u) + } +} + +func TestGenericsSetUpdate(t *testing.T) { + ctx := context.Background() + + // prepare + u := User{Name: "GenericsSetUpdate_Before", Age: 30} + if err := gorm.G[User](DB).Create(ctx, &u); err != nil { + t.Fatalf("prepare user failed: %v", err) + } + + // update with Set after chain + newName := "GenericsSetUpdate_After" + newAge := uint(31) + rows, err := gorm.G[User](DB). + Where("id = ?", u.ID). + Set( + clause.Assignment{Column: clause.Column{Name: "name"}, Value: newName}, + clause.Assignment{Column: clause.Column{Name: "age"}, Value: newAge}, + ). + Update(ctx) + if err != nil { + t.Fatalf("Set Update failed: %v", err) + } + if rows != 1 { + t.Fatalf("expected 1 row affected, got %d", rows) + } + + nu, err := gorm.G[User](DB).Where("id = ?", u.ID).First(ctx) + if err != nil { + t.Fatalf("failed to query updated user: %v", err) + } + if nu.Name != newName || nu.Age != newAge { + t.Fatalf("updated user mismatch, got %+v", nu) + } +} + func TestGenericsGroupHaving(t *testing.T) { ctx := context.Background() diff --git a/tests/go.mod b/tests/go.mod index 3434159016..6666a6df65 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -1,6 +1,6 @@ module gorm.io/gorm/tests -go 1.23.0 +go 1.24.0 require ( github.com/google/uuid v1.6.0 @@ -12,7 +12,7 @@ require ( gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlserver v1.6.1 - gorm.io/gorm v1.30.2 + gorm.io/gorm v1.30.3 ) require ( @@ -32,8 +32,8 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect golang.org/x/crypto v0.41.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/tests/lru_test.go b/tests/lru_test.go index 1c8f1afd9d..4be94ae10c 100644 --- a/tests/lru_test.go +++ b/tests/lru_test.go @@ -3,13 +3,14 @@ package tests_test import ( "crypto/rand" "fmt" - "gorm.io/gorm/internal/lru" "math" "math/big" "reflect" "sync" "testing" "time" + + "gorm.io/gorm/internal/lru" ) func TestLRU_Add_ExistingKey_UpdatesValueAndExpiresAt(t *testing.T) { diff --git a/tests/submodel_test.go b/tests/submodel_test.go index 31bfda4ed7..0593402068 100644 --- a/tests/submodel_test.go +++ b/tests/submodel_test.go @@ -2,6 +2,7 @@ package tests_test import ( "testing" + "gorm.io/gorm" ) @@ -32,8 +33,8 @@ func TestSubModel(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - var result = struct{ - ID int + var result = struct { + ID int Age int }{} if err := DB.Model(&man).Where("id = ?", man.ID).Find(&result).Error; err != nil { diff --git a/tests/transaction_test.go b/tests/transaction_test.go index 80d3a7fcb3..aff1deedf3 100644 --- a/tests/transaction_test.go +++ b/tests/transaction_test.go @@ -68,7 +68,7 @@ func TestTransaction(t *testing.T) { return tx5.First(&User{}, "name = ?", "transaction-2").Error }) }); err != nil { - t.Fatalf("prepare statement and nested transaction coexist" + err.Error()) + t.Fatalf("prepare statement and nested transaction coexist: %v", err) } }) } From 9754050f3577a0c2af206a4608ceccfad1c5dfe8 Mon Sep 17 00:00:00 2001 From: Jinzhu Date: Mon, 8 Sep 2025 19:18:54 +0800 Subject: [PATCH 10/14] Set accepts Assigner for Generics API --- clause/set.go | 11 +++++ clause/set_test.go | 6 +++ generics.go | 26 +++++++---- tests/generics_test.go | 104 ++++++++++++++++++++--------------------- tests/go.mod | 2 +- 5 files changed, 86 insertions(+), 63 deletions(-) diff --git a/clause/set.go b/clause/set.go index 75eb6bddaa..2ffadb38d5 100644 --- a/clause/set.go +++ b/clause/set.go @@ -9,6 +9,11 @@ type Assignment struct { Value interface{} } +// Assigner assignments provider interface +type Assigner interface { + Assignments() []Assignment +} + func (set Set) Name() string { return "SET" } @@ -37,6 +42,9 @@ func (set Set) MergeClause(clause *Clause) { clause.Expression = Set(copiedAssignments) } +// Assignments implements Assigner for Set. +func (set Set) Assignments() []Assignment { return []Assignment(set) } + func Assignments(values map[string]interface{}) Set { keys := make([]string, 0, len(values)) for key := range values { @@ -58,3 +66,6 @@ func AssignmentColumns(values []string) Set { } return assignments } + +// Assignments implements Assigner for a single Assignment. +func (a Assignment) Assignments() []Assignment { return []Assignment{a} } diff --git a/clause/set_test.go b/clause/set_test.go index 7a9ee895a9..c44341c804 100644 --- a/clause/set_test.go +++ b/clause/set_test.go @@ -9,6 +9,12 @@ import ( "gorm.io/gorm/clause" ) +// Compile-time assertions that types implement clause.Assigner +var ( + _ clause.Assigner = clause.Assignment{} + _ clause.Assigner = clause.Set{} +) + func TestSet(t *testing.T) { results := []struct { Clauses []clause.Interface diff --git a/generics.go b/generics.go index 0c2384e037..8c79342b1a 100644 --- a/generics.go +++ b/generics.go @@ -61,7 +61,7 @@ type CreateInterface[T any] interface { Table(name string, args ...interface{}) CreateInterface[T] Create(ctx context.Context, r *T) error CreateInBatches(ctx context.Context, r *[]T, batchSize int) error - Set(assignments ...clause.Assignment) SetCreateOrUpdateInterface[T] + Set(assignments ...clause.Assigner) SetCreateOrUpdateInterface[T] } type ChainInterface[T any] interface { @@ -81,7 +81,7 @@ type ChainInterface[T any] interface { Group(name string) ChainInterface[T] Having(query interface{}, args ...interface{}) ChainInterface[T] Order(value interface{}) ChainInterface[T] - Set(assignments ...clause.Assignment) SetUpdateOnlyInterface[T] + Set(assignments ...clause.Assigner) SetUpdateOnlyInterface[T] Build(builder clause.Builder) @@ -199,10 +199,8 @@ func (c createG[T]) Table(name string, args ...interface{}) CreateInterface[T] { })} } -func (c createG[T]) Set(assignments ...clause.Assignment) SetCreateOrUpdateInterface[T] { - assigns := make([]clause.Assignment, len(assignments)) - copy(assigns, assignments) - return setCreateOrUpdateG[T]{c: c.chainG, assigns: assigns} +func (c createG[T]) Set(assignments ...clause.Assigner) SetCreateOrUpdateInterface[T] { + return setCreateOrUpdateG[T]{c: c.chainG, assigns: toAssignments(assignments...)} } func (c createG[T]) Create(ctx context.Context, r *T) error { @@ -432,10 +430,8 @@ func (c chainG[T]) MapColumns(m map[string]string) ChainInterface[T] { }) } -func (c chainG[T]) Set(assignments ...clause.Assignment) SetUpdateOnlyInterface[T] { - assigns := make([]clause.Assignment, len(assignments)) - copy(assigns, assignments) - return setCreateOrUpdateG[T]{c: c, assigns: assigns} +func (c chainG[T]) Set(assignments ...clause.Assigner) SetUpdateOnlyInterface[T] { + return setCreateOrUpdateG[T]{c: c, assigns: toAssignments(assignments...)} } func (c chainG[T]) Distinct(args ...interface{}) ChainInterface[T] { @@ -610,6 +606,16 @@ type setCreateOrUpdateG[T any] struct { assigns []clause.Assignment } +// toAssignments converts various supported types into []clause.Assignment. +// Supported inputs implement clause.Assigner. +func toAssignments(items ...clause.Assigner) []clause.Assignment { + out := make([]clause.Assignment, 0, len(items)) + for _, it := range items { + out = append(out, it.Assignments()...) + } + return out +} + func (s setCreateOrUpdateG[T]) Update(ctx context.Context) (rowsAffected int, err error) { var r T res := s.c.g.apply(ctx).Model(r).Clauses(clause.Set(s.assigns)).Updates(map[string]interface{}{}) diff --git a/tests/generics_test.go b/tests/generics_test.go index 0cf7066f07..4be43ea839 100644 --- a/tests/generics_test.go +++ b/tests/generics_test.go @@ -668,61 +668,61 @@ func TestGenericsDistinct(t *testing.T) { } func TestGenericsSetCreate(t *testing.T) { - ctx := context.Background() - - name := "GenericsSetCreate" - age := uint(21) - - err := gorm.G[User](DB).Set( - clause.Assignment{Column: clause.Column{Name: "name"}, Value: name}, - clause.Assignment{Column: clause.Column{Name: "age"}, Value: age}, - ).Create(ctx) - if err != nil { - t.Fatalf("Set Create failed: %v", err) - } - - u, err := gorm.G[User](DB).Where("name = ?", name).First(ctx) - if err != nil { - t.Fatalf("failed to find created user: %v", err) - } - if u.ID == 0 || u.Name != name || u.Age != age { - t.Fatalf("created user mismatch, got %+v", u) - } + ctx := context.Background() + + name := "GenericsSetCreate" + age := uint(21) + + err := gorm.G[User](DB).Set( + clause.Assignment{Column: clause.Column{Name: "name"}, Value: name}, + clause.Assignment{Column: clause.Column{Name: "age"}, Value: age}, + ).Create(ctx) + if err != nil { + t.Fatalf("Set Create failed: %v", err) + } + + u, err := gorm.G[User](DB).Where("name = ?", name).First(ctx) + if err != nil { + t.Fatalf("failed to find created user: %v", err) + } + if u.ID == 0 || u.Name != name || u.Age != age { + t.Fatalf("created user mismatch, got %+v", u) + } } func TestGenericsSetUpdate(t *testing.T) { - ctx := context.Background() - - // prepare - u := User{Name: "GenericsSetUpdate_Before", Age: 30} - if err := gorm.G[User](DB).Create(ctx, &u); err != nil { - t.Fatalf("prepare user failed: %v", err) - } - - // update with Set after chain - newName := "GenericsSetUpdate_After" - newAge := uint(31) - rows, err := gorm.G[User](DB). - Where("id = ?", u.ID). - Set( - clause.Assignment{Column: clause.Column{Name: "name"}, Value: newName}, - clause.Assignment{Column: clause.Column{Name: "age"}, Value: newAge}, - ). - Update(ctx) - if err != nil { - t.Fatalf("Set Update failed: %v", err) - } - if rows != 1 { - t.Fatalf("expected 1 row affected, got %d", rows) - } - - nu, err := gorm.G[User](DB).Where("id = ?", u.ID).First(ctx) - if err != nil { - t.Fatalf("failed to query updated user: %v", err) - } - if nu.Name != newName || nu.Age != newAge { - t.Fatalf("updated user mismatch, got %+v", nu) - } + ctx := context.Background() + + // prepare + u := User{Name: "GenericsSetUpdate_Before", Age: 30} + if err := gorm.G[User](DB).Create(ctx, &u); err != nil { + t.Fatalf("prepare user failed: %v", err) + } + + // update with Set after chain + newName := "GenericsSetUpdate_After" + newAge := uint(31) + rows, err := gorm.G[User](DB). + Where("id = ?", u.ID). + Set( + clause.Assignment{Column: clause.Column{Name: "name"}, Value: newName}, + clause.Assignment{Column: clause.Column{Name: "age"}, Value: newAge}, + ). + Update(ctx) + if err != nil { + t.Fatalf("Set Update failed: %v", err) + } + if rows != 1 { + t.Fatalf("expected 1 row affected, got %d", rows) + } + + nu, err := gorm.G[User](DB).Where("id = ?", u.ID).First(ctx) + if err != nil { + t.Fatalf("failed to query updated user: %v", err) + } + if nu.Name != newName || nu.Age != newAge { + t.Fatalf("updated user mismatch, got %+v", nu) + } } func TestGenericsGroupHaving(t *testing.T) { diff --git a/tests/go.mod b/tests/go.mod index 6666a6df65..a8e4521d44 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -12,7 +12,7 @@ require ( gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlserver v1.6.1 - gorm.io/gorm v1.30.3 + gorm.io/gorm v1.30.4 ) require ( From 539e048788360d01e88b4e345fb713b1cbda8c9f Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 10 Sep 2025 10:28:53 -0600 Subject: [PATCH 11/14] Add AsOfSystemTimeNow method to retrieve the most recent data without retries This new method allows users to query data as it exists at the time of the query, specifically optimized for CockroachDB. It sets the system time to be as close to now as possible, enhancing data retrieval efficiency. --- chainable_api.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/chainable_api.go b/chainable_api.go index a9bf654c3e..ea8885550f 100644 --- a/chainable_api.go +++ b/chainable_api.go @@ -125,6 +125,17 @@ func (db *DB) AsOfSystemTime(timestamp interface{}) (tx *DB) { return tx } +// AsOfSystemTimeNow sets the system time to be as close to now as possible +// This allows us to get the most recent data without the database engine attempting retries. +// +// // Query data as it exists at the time of the query +// db.AsOfSystemTimeNow().Find(&users) +// +// Note: This feature is only supported by CockroachDB +func (db *DB) AsOfSystemTimeNow() (tx *DB) { + return db.AsOfSystemTime("-1µs") +} + // Distinct specify distinct fields that you want querying // // // Select distinct names of users From 5251385a29ed76aaf744ffcc6e5568cebc56e9c3 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 10 Sep 2025 11:08:41 -0600 Subject: [PATCH 12/14] feat: add Count method to CreateInterface for row counting functionality --- generics.go | 1 + 1 file changed, 1 insertion(+) diff --git a/generics.go b/generics.go index 8c79342b1a..971bdb66ff 100644 --- a/generics.go +++ b/generics.go @@ -57,6 +57,7 @@ type CreateInterface[T any] interface { Delete(ctx context.Context) (rowsAffected int, err error) Update(ctx context.Context, name string, value any) (rowsAffected int, err error) Updates(ctx context.Context, t T) (rowsAffected int, err error) + Count(ctx context.Context, column string) (result int64, err error) Table(name string, args ...interface{}) CreateInterface[T] Create(ctx context.Context, r *T) error From 5e266dd7d8b1b8b14f7eb05aeebcd9de99b7b67e Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 10 Sep 2025 11:27:32 -0600 Subject: [PATCH 13/14] fix: sanitize input for AsOfSystemTime clause and update test cases This commit modifies the AsOfSystemTime clause to sanitize the input by removing semicolons, ensuring safer SQL generation. Additionally, the test cases have been updated to reflect the correct formatting of the AS OF SYSTEM TIME string. --- clause/as_of_system_time.go | 4 +++- clause/as_of_system_time_test.go | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/clause/as_of_system_time.go b/clause/as_of_system_time.go index a0eae2a87a..793380076c 100644 --- a/clause/as_of_system_time.go +++ b/clause/as_of_system_time.go @@ -2,6 +2,7 @@ package clause import ( "fmt" + "strings" "time" ) @@ -20,7 +21,8 @@ func (a AsOfSystemTime) Name() string { // Build builds the "AS OF SYSTEM TIME" clause func (a AsOfSystemTime) Build(builder Builder) { if a.Raw != "" { - builder.WriteString(fmt.Sprintf("AS OF SYSTEM TIME '%s'", a.Raw)) + sanitizedRaw := strings.ReplaceAll(a.Raw, ";", "") + builder.WriteString(fmt.Sprintf("AS OF SYSTEM TIME %s", sanitizedRaw)) } else if !a.Timestamp.IsZero() { builder.WriteString(fmt.Sprintf("AS OF SYSTEM TIME '%s'", a.Timestamp.Format("2006-01-02 15:04:05.000000"))) } diff --git a/clause/as_of_system_time_test.go b/clause/as_of_system_time_test.go index 9dcb09952d..27aef7a534 100644 --- a/clause/as_of_system_time_test.go +++ b/clause/as_of_system_time_test.go @@ -30,7 +30,7 @@ func TestAsOfSystemTime(t *testing.T) { clause.Select{}, clause.From{ Tables: []clause.Table{{Name: "users"}}, - AsOfSystemTime: &clause.AsOfSystemTime{Raw: "-1h"}, + AsOfSystemTime: &clause.AsOfSystemTime{Raw: "'-1h'"}, }, }, "SELECT * FROM `users` AS OF SYSTEM TIME '-1h'", @@ -50,7 +50,7 @@ func TestAsOfSystemTime(t *testing.T) { }, }, }, - AsOfSystemTime: &clause.AsOfSystemTime{Raw: "-1h"}, + AsOfSystemTime: &clause.AsOfSystemTime{Raw: "'-1h'"}, }, }, "SELECT * FROM `users` INNER JOIN `companies` ON `companies`.`id` = `users`.`company_id` AS OF SYSTEM TIME '-1h'", From 55942c6317ebe495d8361beb1a1fdbd1dc338e52 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 10 Sep 2025 11:29:02 -0600 Subject: [PATCH 14/14] fix: correct formatting in AsOfSystemTimeNow method for SQL compatibility This commit updates the AsOfSystemTimeNow method to ensure the timestamp string is properly formatted with quotes, enhancing compatibility with SQL syntax in CockroachDB. --- chainable_api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chainable_api.go b/chainable_api.go index ea8885550f..336f73980f 100644 --- a/chainable_api.go +++ b/chainable_api.go @@ -133,7 +133,7 @@ func (db *DB) AsOfSystemTime(timestamp interface{}) (tx *DB) { // // Note: This feature is only supported by CockroachDB func (db *DB) AsOfSystemTimeNow() (tx *DB) { - return db.AsOfSystemTime("-1µs") + return db.AsOfSystemTime("'-1µs'") } // Distinct specify distinct fields that you want querying