From 9702d4cf7630ed876da4699e7eebc99615189954 Mon Sep 17 00:00:00 2001 From: Clancy Date: Fri, 10 Jul 2026 16:17:35 +0300 Subject: [PATCH] Fix SQLite TIMESTAMP mapping, add catch-all type validation, refactor default func helpers - Fix critical SQLite dialect bug: TIMESTAMP columns no longer mapped to TEXT in migration/sqliteDialect.go. Previous mapping caused modernc/sqlite to treat timestamp columns as TEXT, breaking time.Time scan. Now TIMESTAMP correctly maps to "TIMESTAMP" so the driver auto-scans strings as time.Time. - Add _time_format=sqlite DSN parameter to integration/main.go SQLite connection string for consistent time serialization. - Add missing catch-all type validation in validate{Model}Create template (model_create.gotpl). Non-string/non-int scalar types (time.Time, bool, float64, json.RawMessage, []byte) now get Go type-assertion validation via the template fallback else branch using trimPrefix. - Add isKnownDefaultFunc() and defaultFuncCall() to generator.go FuncMap. ToRowMap and cols/vals builder in model_create.gotpl use these helpers instead of deep else-if chains per function. Adding a new default function only requires updating the two maps in generator.go. - Add createdAt DateTime @default(now()) to Profile schema and regenerate generated client files to reflect the new field. --- generator/generator.go | 18 ++++++ generator/templates/model_create.gotpl | 33 +++++------ integration/main.go | 28 ++++++++- integration/schema.prisma | 9 +-- integration/valk/client.go | 7 ++- integration/valk/comment.go | 3 + integration/valk/migrations/00001_init.sql | 1 + integration/valk/post.go | 6 ++ integration/valk/profile.go | 67 ++++++++++++++++------ integration/valk/profile/profile.go | 3 + integration/valk/user.go | 6 ++ migration/sqliteDialect.go | 4 +- 12 files changed, 142 insertions(+), 43 deletions(-) diff --git a/generator/generator.go b/generator/generator.go index db97d18..6b1b5c8 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -130,6 +130,24 @@ func GenerateClient(sch schema.Schema, pkgName string, parentImportPath string, return false }, "trimPrefix": strings.TrimPrefix, + "isKnownDefaultFunc": func(funcName string) bool { + switch funcName { + case "autoincrement", "cuid", "uuid", "now": + return true + } + return false + }, + "defaultFuncCall": func(funcName string) string { + switch funcName { + case "cuid": + return "generateCUID()" + case "uuid": + return "generateUUID()" + case "now": + return "time.Now()" + } + return "" + }, "hasStringField": func(m *schema.Model) bool { for _, sf := range m.ScalarFields { if sf.GoType == "string" || strings.Contains(sf.GoType, "string") { diff --git a/generator/templates/model_create.gotpl b/generator/templates/model_create.gotpl index 0f93dfe..0eef96b 100644 --- a/generator/templates/model_create.gotpl +++ b/generator/templates/model_create.gotpl @@ -82,6 +82,10 @@ func validate{{ .Model.Name }}Create(assignments []FieldAssignment) error { } {{- end }} {{- end }} + {{- else }} + if _, ok := a.Val.({{ trimPrefix $field.GoType "*" }}); !ok { + errs.Add("{{ $field.Name }}", a.Val, "type", "field {{ $field.Name }} must be of type {{ trimPrefix $field.GoType "*" }}") + } {{- end }} {{- end }} } @@ -173,17 +177,15 @@ func (s *{{ .Model.Name }}Create) ToRowMap() map[string]any { if s.{{ $fieldName }} != nil { m["{{ $col }}"] = *s.{{ $fieldName }} } - {{- else }} + {{- else if $field.Default.FuncName | isKnownDefaultFunc }} if s.{{ $fieldName }} != nil { m["{{ $col }}"] = *s.{{ $fieldName }} } else { - {{- if eq $field.Default.FuncName "cuid" }} - m["{{ $col }}"] = generateCUID() - {{- else if eq $field.Default.FuncName "uuid" }} - m["{{ $col }}"] = generateUUID() - {{- else if eq $field.Default.FuncName "now" }} - m["{{ $col }}"] = time.Now() - {{- end }} + m["{{ $col }}"] = {{ defaultFuncCall $field.Default.FuncName }} + } + {{- else }} + if s.{{ $fieldName }} != nil { + m["{{ $col }}"] = *s.{{ $fieldName }} } {{- end }} {{- else if or $field.Optional (ne $field.Default nil) }} @@ -250,19 +252,18 @@ func (q *Queries) execute{{ .Model.Name }}Create(ctx context.Context, assignment cols = append(cols, "{{ $col }}") vals = append(vals, *input.{{ $fieldName }}) } - {{- else }} + {{- else if $field.Default.FuncName | isKnownDefaultFunc }} if input.{{ $fieldName }} != nil { cols = append(cols, "{{ $col }}") vals = append(vals, *input.{{ $fieldName }}) } else { cols = append(cols, "{{ $col }}") - {{- if eq $field.Default.FuncName "cuid" }} - vals = append(vals, generateCUID()) - {{- else if eq $field.Default.FuncName "uuid" }} - vals = append(vals, generateUUID()) - {{- else if eq $field.Default.FuncName "now" }} - vals = append(vals, time.Now()) - {{- end }} + vals = append(vals, {{ defaultFuncCall $field.Default.FuncName }}) + } + {{- else }} + if input.{{ $fieldName }} != nil { + cols = append(cols, "{{ $col }}") + vals = append(vals, *input.{{ $fieldName }}) } {{- end }} {{- else if or $field.Optional (ne $field.Default nil) }} diff --git a/integration/main.go b/integration/main.go index 9e3c943..9884f43 100644 --- a/integration/main.go +++ b/integration/main.go @@ -11,6 +11,7 @@ import ( "integration/valk/post" "integration/valk/profile" "integration/valk/user" + "time" "log" @@ -101,11 +102,34 @@ func seed(db *valk.DB, ctx context.Context) *SeedData { prof, err := db.Profile.Create( profile.Bio.Set("BLEH"), profile.UserId.Set(referred.Id), + profile.CreatedAt.Set(time.Now()), ).Exec(ctx) if err != nil { log.Fatalf("failed to create profile: %v", err) } - _ = prof + fmt.Println("PROFILE:") + printJSON(prof) + + categoryTest, err := db.Category.Create( + category.Name.Set("TEST"), + ).Exec(ctx) + + if err != nil { + log.Fatalf("failed to create category: %v", err) + } + fmt.Println("CATEGORY:") + printJSON(categoryTest) + + AnothercategoryTest, err := db.Category.Create( + category.Id.Set(24), + category.Name.Set("TEST2"), + ).Exec(ctx) + + if err != nil { + log.Fatalf("failed to create category: %v", err) + } + fmt.Println("CATEGORY2:") + printJSON(AnothercategoryTest) p, err := db.Post.Create( post.Title.Set("Valkyrie ORM Deep Dive"), @@ -274,7 +298,7 @@ func main() { } func openConn() *valk.DB { - db, err := valk.Open("sqlite", "file::memory:?_pragma=foreign_keys(1)") + db, err := valk.Open("sqlite", "file::memory:?_pragma=foreign_keys(1)&_time_format=sqlite") if err != nil { log.Fatalf("failed to open db: %v", err) } diff --git a/integration/schema.prisma b/integration/schema.prisma index de0bd98..2cf8057 100644 --- a/integration/schema.prisma +++ b/integration/schema.prisma @@ -29,10 +29,11 @@ model User { } model Profile { - id String @id @default(cuid()) - bio String? - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + bio String? + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) } model Post { diff --git a/integration/valk/client.go b/integration/valk/client.go index 8816190..e2fbb29 100644 --- a/integration/valk/client.go +++ b/integration/valk/client.go @@ -167,9 +167,10 @@ type Queries struct { User *UserDelegate // Profile provides CRUD operations for Profile. // - // id string default: cuid() - // bio string optional - // userId string required + // id string default: cuid() + // bio string optional + // userId string required + // createdAt time.Time default: now() Profile *ProfileDelegate // Post provides CRUD operations for Post. // diff --git a/integration/valk/comment.go b/integration/valk/comment.go index d1da5de..153fdf4 100644 --- a/integration/valk/comment.go +++ b/integration/valk/comment.go @@ -240,6 +240,9 @@ func validateCommentCreate(assignments []FieldAssignment) error { } } case "meta": + if _, ok := a.Val.(json.RawMessage); !ok { + errs.Add("meta", a.Val, "type", "field meta must be of type json.RawMessage") + } } } if !provided["dummy3"] { diff --git a/integration/valk/migrations/00001_init.sql b/integration/valk/migrations/00001_init.sql index 6b84e8c..81cd929 100644 --- a/integration/valk/migrations/00001_init.sql +++ b/integration/valk/migrations/00001_init.sql @@ -19,6 +19,7 @@ CREATE TABLE `Profile` ( `id` text NOT NULL, `bio` text NULL, `userId` text NOT NULL, + `createdAt` timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP), PRIMARY KEY (`id`), CONSTRAINT `Profile_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User` (`id`) ON UPDATE NO ACTION ON DELETE CASCADE ); diff --git a/integration/valk/post.go b/integration/valk/post.go index fe7440d..29cb792 100644 --- a/integration/valk/post.go +++ b/integration/valk/post.go @@ -177,7 +177,13 @@ func validatePostCreate(assignments []FieldAssignment) error { } } case "content": + if _, ok := a.Val.(string); !ok { + errs.Add("content", a.Val, "type", "field content must be of type string") + } case "published": + if _, ok := a.Val.(bool); !ok { + errs.Add("published", a.Val, "type", "field published must be of type bool") + } case "authorId": if v, ok := a.Val.(string); ok { if v == "" { diff --git a/integration/valk/profile.go b/integration/valk/profile.go index 971fd58..d269368 100644 --- a/integration/valk/profile.go +++ b/integration/valk/profile.go @@ -5,38 +5,43 @@ import ( "fmt" "slices" "strings" + "time" "unicode/utf8" ) // Profile represents the database model type Profile struct { - Id string `db:"id" json:"id"` - Bio *string `db:"bio" json:"bio,omitempty"` - UserId string `db:"userId" json:"userId"` - User *User `json:"user,omitempty"` + Id string `db:"id" json:"id"` + Bio *string `db:"bio" json:"bio,omitempty"` + UserId string `db:"userId" json:"userId"` + CreatedAt time.Time `db:"createdAt" json:"createdAt"` + User *User `json:"user,omitempty"` } // ProfileCreate is used for hooks only — the Create API uses FieldAssignment type ProfileCreate struct { - Id *string `json:"id"` - Bio *string `json:"bio"` - UserId string `json:"userId"` + Id *string `json:"id"` + Bio *string `json:"bio"` + UserId string `json:"userId"` + CreatedAt *time.Time `json:"createdAt"` } // ProfileSelect specifies which fields to include type ProfileSelect struct { - Id bool `json:"id"` - Bio bool `json:"bio"` - UserId bool `json:"userId"` - User *UserSelect `json:"user,omitempty"` + Id bool `json:"id"` + Bio bool `json:"bio"` + UserId bool `json:"userId"` + CreatedAt bool `json:"createdAt"` + User *UserSelect `json:"user,omitempty"` } // ProfileOmit specifies which fields to exclude type ProfileOmit struct { - Id bool `json:"id"` - Bio bool `json:"bio"` - UserId bool `json:"userId"` - User *UserOmit `json:"user,omitempty"` + Id bool `json:"id"` + Bio bool `json:"bio"` + UserId bool `json:"userId"` + CreatedAt bool `json:"createdAt"` + User *UserOmit `json:"user,omitempty"` } type ProfileDelegate struct { @@ -68,6 +73,8 @@ func (m *Profile) ScanFields(cols []string) []any { targets[i] = &m.Bio case "userId": targets[i] = &m.UserId + case "createdAt": + targets[i] = &m.CreatedAt } } return targets @@ -77,6 +84,7 @@ var profileDefaultCols = []string{ "id", "bio", "userId", + "createdAt", } func (q *Queries) selectProfileCols(selects *ProfileSelect, omits *ProfileOmit, forceCols ...string) []string { @@ -84,12 +92,13 @@ func (q *Queries) selectProfileCols(selects *ProfileSelect, omits *ProfileOmit, return profileDefaultCols } - anySelected := selects != nil && (selects.Id || selects.Bio || selects.UserId || selects.User != nil) + anySelected := selects != nil && (selects.Id || selects.Bio || selects.UserId || selects.CreatedAt || selects.User != nil) specs := []colSpec{ {"id", selects != nil && selects.Id, omits != nil && omits.Id, selects != nil && selects.hasAnyRelation()}, {"bio", selects != nil && selects.Bio, omits != nil && omits.Bio, false}, {"userId", selects != nil && selects.UserId, omits != nil && omits.UserId, selects != nil && selects.User != nil}, + {"createdAt", selects != nil && selects.CreatedAt, omits != nil && omits.CreatedAt, false}, } cols := computeCols(specs, selects != nil, anySelected) @@ -107,6 +116,7 @@ var ProfileColOrder = []string{ "id", "bio", "userId", + "createdAt", } func (s *ProfileSelect) hasAnyRelation() bool { @@ -141,6 +151,9 @@ func validateProfileCreate(assignments []FieldAssignment) error { } } case "bio": + if _, ok := a.Val.(string); !ok { + errs.Add("bio", a.Val, "type", "field bio must be of type string") + } case "userId": if v, ok := a.Val.(string); ok { if v == "" { @@ -153,6 +166,10 @@ func validateProfileCreate(assignments []FieldAssignment) error { errs.Add("userId", v, "safety", "string must be valid UTF-8") } } + case "createdAt": + if _, ok := a.Val.(time.Time); !ok { + errs.Add("createdAt", a.Val, "type", "field createdAt must be of type time.Time") + } } } if !provided["userId"] { @@ -181,13 +198,17 @@ func assignmentsToProfileCreate(assignments []FieldAssignment) ProfileCreate { if v, ok := a.Val.(string); ok { input.UserId = v } + case "createdAt": + if v, ok := a.Val.(time.Time); ok { + input.CreatedAt = &v + } } } return input } func (s *ProfileCreate) ToRowMap() map[string]any { - m := make(map[string]any, 3) + m := make(map[string]any, 4) if s.Id != nil { m["id"] = *s.Id } else { @@ -197,6 +218,11 @@ func (s *ProfileCreate) ToRowMap() map[string]any { m["bio"] = *s.Bio } m["userId"] = s.UserId + if s.CreatedAt != nil { + m["createdAt"] = *s.CreatedAt + } else { + m["createdAt"] = time.Now() + } return m } @@ -228,6 +254,13 @@ func (q *Queries) executeProfileCreate(ctx context.Context, assignments []FieldA } cols = append(cols, "userId") vals = append(vals, input.UserId) + if input.CreatedAt != nil { + cols = append(cols, "createdAt") + vals = append(vals, *input.CreatedAt) + } else { + cols = append(cols, "createdAt") + vals = append(vals, time.Now()) + } returningCols := q.selectProfileCols(selects, omits) diff --git a/integration/valk/profile/profile.go b/integration/valk/profile/profile.go index 33e0c00..9ce7eed 100644 --- a/integration/valk/profile/profile.go +++ b/integration/valk/profile/profile.go @@ -3,6 +3,7 @@ package profile import ( "fmt" "integration/valk" + "time" ) type UniquePredicate struct { @@ -42,3 +43,5 @@ var Id = valk.StringUniqueField{Column: "id"} var Bio = valk.StringField{Column: "bio"} var UserId = valk.StringUniqueField{Column: "userId"} + +var CreatedAt = valk.Field[time.Time]{Column: "createdAt"} diff --git a/integration/valk/user.go b/integration/valk/user.go index 9d00def..d2e1824 100644 --- a/integration/valk/user.go +++ b/integration/valk/user.go @@ -213,6 +213,9 @@ func validateUserCreate(assignments []FieldAssignment) error { } } case "password": + if _, ok := a.Val.(string); !ok { + errs.Add("password", a.Val, "type", "field password must be of type string") + } case "role": if v, ok := a.Val.(UserRoleType); ok && !v.IsValid() { errs.Add("role", v, "enum", fmt.Sprintf("invalid enum value %q for field role", v)) @@ -222,6 +225,9 @@ func validateUserCreate(assignments []FieldAssignment) error { errs.Add("roleOptional", v, "enum", fmt.Sprintf("invalid enum value %q for field roleOptional", v)) } case "referredById": + if _, ok := a.Val.(string); !ok { + errs.Add("referredById", a.Val, "type", "field referredById must be of type string") + } } } if !provided["email"] { diff --git a/migration/sqliteDialect.go b/migration/sqliteDialect.go index 9693e8d..5e50a5a 100644 --- a/migration/sqliteDialect.go +++ b/migration/sqliteDialect.go @@ -19,8 +19,10 @@ func (SqliteDialect) QuoteIdent(name string) string { func (SqliteDialect) GetSQLType(sf *schema.ScalarField) string { sqlType := strings.ToUpper(sf.SQLType) switch sqlType { - case "VARCHAR", "TEXT", "UUID", "TIMESTAMP": + case "VARCHAR", "TEXT", "UUID": return "TEXT" + case "TIMESTAMP": + return "TIMESTAMP" case "INTEGER", "BIGINT", "BOOLEAN": return "INTEGER" case "DOUBLE PRECISION", "NUMERIC":