diff --git a/generator/generator.go b/generator/generator.go index 6a737a2..e1b056c 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -23,6 +23,12 @@ type templateData struct { DefaultDiskPath string Schema schema.Schema DefaultLogs []string + NeedCUID bool + NeedCUID2 bool + NeedUUID bool + NeedUUID7 bool + NeedULID bool + NeedNanoID bool } type modelTemplateData struct { @@ -114,6 +120,7 @@ func GenerateClient(sch schema.Schema, pkgName string, parentImportPath string, "hasNetField": hasNetField, "hasHstoreField": hasHstoreField, "hasHstoreAnywhere": hasHstoreAnywhere, + "hasNetAnywhere": hasNetAnywhere, "hstoreExpr": hstoreExpr, }) tmpl, err := tmpl.ParseFS(templatesFS, "templates/*.gotpl") @@ -121,6 +128,31 @@ func GenerateClient(sch schema.Schema, pkgName string, parentImportPath string, return nil, err } + var needCUID, needUUID, needUUID7, needCUID2, needULID, needNanoID bool + for _, m := range sch.Models { + for _, sf := range m.ScalarFields { + if sf.Default != nil && sf.Default.Kind == schema.DefaultFunc { + switch sf.Default.FuncName { + case "cuid", "cuid(1)": + needCUID = true + case "cuid(2)": + needCUID2 = true + case "uuid", "uuid(4)": + needUUID = true + case "uuid(7)": + needUUID7 = true + case "ulid": + needULID = true + case "nanoid": + needNanoID = true + } + } + if sf.IsID && sf.GoType == "string" && sf.Default == nil { + needCUID = true + } + } + } + var embedDir string if embedPath != "" { embedDir = filepath.ToSlash(filepath.Dir(embedPath)) @@ -133,6 +165,12 @@ func GenerateClient(sch schema.Schema, pkgName string, parentImportPath string, DefaultDiskPath: defaultDiskPath, Schema: sch, DefaultLogs: defaultLogs, + NeedCUID: needCUID, + NeedCUID2: needCUID2, + NeedUUID: needUUID, + NeedUUID7: needUUID7, + NeedULID: needULID, + NeedNanoID: needNanoID, } outputs := make(map[string]string) @@ -141,6 +179,7 @@ func GenerateClient(sch schema.Schema, pkgName string, parentImportPath string, files := []string{ "header.gotpl", "enums.gotpl", + "runtime.gotpl", "client.gotpl", "tx.gotpl", "builders_create.gotpl", diff --git a/generator/helpers.go b/generator/helpers.go index 3278d97..538ecba 100644 --- a/generator/helpers.go +++ b/generator/helpers.go @@ -1,6 +1,7 @@ package generator import ( + "slices" "strings" "github.com/voidclancy/valk/schema" @@ -114,16 +115,14 @@ func hasHstoreField(m *schema.Model) bool { return false } func hasHstoreAnywhere(sch schema.Schema) bool { - for _, m := range sch.Models { - if hasHstoreField(m) { - return true - } - } - return false + return slices.ContainsFunc(sch.Models, hasHstoreField) +} +func hasNetAnywhere(sch schema.Schema) bool { + return slices.ContainsFunc(sch.Models, hasNetField) } func hstoreExpr(goType string, expr string) string { if strings.TrimPrefix(goType, "*") == "map[string]*string" { - return "toHstore(" + expr + ")" + return "ToHstore(" + expr + ")" } return expr } diff --git a/generator/templates/client.gotpl b/generator/templates/client.gotpl index b3deb00..eaa4012 100644 --- a/generator/templates/client.gotpl +++ b/generator/templates/client.gotpl @@ -1,10 +1,3 @@ -type Dialect interface { - Quote(ident string) string - BindVar(idx int) string - SupportsReturning() bool - SupportsBulkInsert() bool -} - {{- if or (eq .Schema.Datasource.Provider "postgres") (eq .Schema.Datasource.Provider "postgresql") }} type postgresDialect struct{} func (postgresDialect) Quote(ident string) string { return `"` + ident + `"` } @@ -21,13 +14,6 @@ func (sqliteDialect) SupportsReturning() bool { return true } func (sqliteDialect) SupportsBulkInsert() bool { return false } {{- end }} -type DBTX interface { - ExecContext(context.Context, string, ...any) (sql.Result, error) - PrepareContext(context.Context, string) (*sql.Stmt, error) - QueryContext(context.Context, string, ...any) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...any) *sql.Row -} - type Queries struct { db DBTX provider string diff --git a/generator/templates/header.gotpl b/generator/templates/header.gotpl index f68ac91..7127091 100644 --- a/generator/templates/header.gotpl +++ b/generator/templates/header.gotpl @@ -2,41 +2,63 @@ package {{ .PackageName }} import ( "context" + {{- if or .NeedCUID .NeedCUID2 .NeedULID .NeedNanoID }} "crypto/rand" + {{- end }} "database/sql" "encoding/json" {{- if .EmbedPath }} "embed" {{- end }} "fmt" - {{- if hasHstoreAnywhere .Schema }} - "github.com/lib/pq/hstore" - {{- end }} {{- if hasAnyLog }} "log" {{- end }} + {{- if hasNetAnywhere .Schema }} + "net" + {{- end }} + {{- if or .NeedCUID .NeedCUID2 .NeedULID }} "strconv" + {{- end }} "strings" "time" "unicode/utf8" - "github.com/google/uuid" + {{- if hasHstoreAnywhere .Schema }} + "github.com/lib/pq/hstore" + {{- end }} "github.com/pressly/goose/v3" + + {{- if or .NeedUUID .NeedUUID7 }} + "github.com/google/uuid" + {{- end }} ) var _ = time.Time{} +{{- if hasHstoreAnywhere .Schema }} +var _ = hstore.Hstore{} +{{- end }} +{{- if hasNetAnywhere .Schema }} +var _ = net.ParseIP +{{- end }} var _ = json.RawMessage{} var _ = strings.Join +{{- if or .NeedUUID .NeedUUID7 }} var _ = uuid.New -var _ = uuid.NewV7 +{{- end }} +{{- if or .NeedCUID .NeedCUID2 .NeedULID .NeedNanoID }} var _ = rand.Read +{{- end }} +{{- if or .NeedCUID .NeedCUID2 .NeedULID }} var _ = strconv.AppendUint +{{- end }} {{- if .EmbedPath }} //go:embed {{ .EmbedPath }} var migrationsFS embed.FS {{- end }} +{{- if .NeedCUID }} func generateCUID() string { now := uint64(time.Now().UnixMilli()) b := make([]byte, 8) @@ -51,11 +73,15 @@ func generateCUID() string { } return string(buf) } +{{- end }} +{{- if .NeedUUID }} func generateUUID() string { return uuid.New().String() } +{{- end }} +{{- if .NeedUUID7 }} func generateUUID7() string { id, err := uuid.NewV7() if err != nil { @@ -63,7 +89,9 @@ func generateUUID7() string { } return id.String() } +{{- end }} +{{- if .NeedCUID2 }} func generateCUID2() string { now := uint64(time.Now().UnixMilli()) b := make([]byte, 12) @@ -77,7 +105,9 @@ func generateCUID2() string { } return string(buf) } +{{- end }} +{{- if .NeedULID }} func generateULID() string { now := uint64(time.Now().UnixMilli()) b := make([]byte, 10) @@ -105,7 +135,9 @@ func generateULID() string { } return string(buf[:]) } +{{- end }} +{{- if .NeedNanoID }} func generateNanoID() string { const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-" b := make([]byte, 21) @@ -115,91 +147,4 @@ func generateNanoID() string { } return string(b) } - -{{- if hasHstoreAnywhere .Schema }} -func toHstore(m map[string]*string) hstore.Hstore { - result := hstore.Hstore{Map: make(map[string]sql.NullString, len(m))} - for k, v := range m { - if v == nil { - result.Map[k] = sql.NullString{Valid: false} - } else { - result.Map[k] = sql.NullString{String: *v, Valid: true} - } - } - return result -} - -type hstoreScan struct { - p **map[string]*string -} - -func (s hstoreScan) Scan(src any) error { - var h hstore.Hstore - if err := h.Scan(src); err != nil { - return err - } - if h.Map == nil { - *s.p = nil - return nil - } - m := make(map[string]*string, len(h.Map)) - for k, v := range h.Map { - if v.Valid { - val := v.String - m[k] = &val - } else { - m[k] = nil - } - } - *s.p = &m - return nil -} {{- end }} - -// FieldError represents a single validation failure on a specific field. -type FieldError struct { - Field string - Value any - Rule string - Msg string -} - -func (e FieldError) Error() string { - return fmt.Sprintf("field %s: %s (value: %v, rule: %s)", e.Field, e.Msg, e.Value, e.Rule) -} - -// ValidationError collects multiple validation errors during an operation. -type ValidationError struct { - Errors []FieldError -} - -func (e ValidationError) Error() string { - var msgs []string - for _, err := range e.Errors { - msgs = append(msgs, err.Error()) - } - return fmt.Sprintf("validation failed: %s", strings.Join(msgs, "; ")) -} - -func (e *ValidationError) Add(field string, value any, rule string, msg string) { - e.Errors = append(e.Errors, FieldError{ - Field: field, - Value: value, - Rule: rule, - Msg: msg, - }) -} - -func (e *ValidationError) HasErrors() bool { - return len(e.Errors) > 0 -} - -type FieldAssignment struct { - Col string - Val any -} - -type RecordInput struct { - Assignments []FieldAssignment -} - diff --git a/generator/templates/model_create.gotpl b/generator/templates/model_create.gotpl index 6f4a5fa..0d0c427 100644 --- a/generator/templates/model_create.gotpl +++ b/generator/templates/model_create.gotpl @@ -56,74 +56,33 @@ func validate{{ .Model.Name }}Create(assignments []FieldAssignment) error { {{- end }} {{- else if eq (trimPrefix $field.GoType "*") "string" }} if v, ok := a.Val.(string); ok { - {{- if and (eq $field.Default nil) (not $field.Optional) (not $field.IsArray) }} - if v == "" { - errs.Add("{{ $field.Name }}", v, "required", "field {{ $field.Name }} is required") - } + {{- $isRequired := and (eq $field.Default nil) (not $field.Optional) (not $field.IsArray) }} + {{- $maxLen := 0 }} + {{- if and $field.NativeType (or (eq $field.NativeType.Name "VarChar") (eq $field.NativeType.Name "Char")) }} + {{- $maxLen = index $field.NativeType.Args 0 }} {{- end }} - if strings.Contains(v, "\x00") { - errs.Add("{{ $field.Name }}", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("{{ $field.Name }}", v, "safety", "string must be valid UTF-8") - } - {{- if $field.NativeType }} - {{- if or (eq $field.NativeType.Name "VarChar") (eq $field.NativeType.Name "Char") }} - {{- $limit := index $field.NativeType.Args 0 }} - if utf8.RuneCountInString(v) > {{ $limit }} { - errs.Add("{{ $field.Name }}", v, "length", "string exceeds maximum length of {{ $limit }} characters") - } - {{- else if or (eq $field.NativeType.Name "Bit") (eq $field.NativeType.Name "VarBit") }} - if strings.IndexFunc(v, func(r rune) bool { return r != '0' && r != '1' }) >= 0 { - errs.Add("{{ $field.Name }}", v, "format", "bit string must contain only '0' and '1'") - } - {{- else if eq $field.NativeType.Name "Inet" }} - if net.ParseIP(v) == nil { - if _, _, err := net.ParseCIDR(v); err != nil { - errs.Add("{{ $field.Name }}", v, "format", "field {{ $field.Name }} must be a valid IP address") - } - } + {{- $isBit := false }} + {{- if and $field.NativeType (or (eq $field.NativeType.Name "Bit") (eq $field.NativeType.Name "VarBit")) }} + {{- $isBit = true }} {{- end }} + {{- $isInet := false }} + {{- if and $field.NativeType (eq $field.NativeType.Name "Inet") }} + {{- $isInet = true }} {{- end }} + ValidateString(errs, "{{ $field.Name }}", v, {{ $isRequired }}, {{ $maxLen }}, {{ $isBit }}, {{ $isInet }}) } else { errs.Add("{{ $field.Name }}", a.Val, "type", "field {{ $field.Name }} must be of type string") } {{- else if or (eq (trimPrefix $field.GoType "*") "int32") (eq (trimPrefix $field.GoType "*") "int64") (eq (trimPrefix $field.GoType "*") "int") }} - {{- if $field.NativeType }} - {{- if eq $field.NativeType.Name "SmallInt" }} - if v, ok := a.Val.({{ trimPrefix $field.GoType "*" }}); ok { - if v < -32768 || v > 32767 { - errs.Add("{{ $field.Name }}", v, "range", "value is out of range for SmallInt (-32768 to 32767)") - } - } else { - errs.Add("{{ $field.Name }}", a.Val, "type", "field {{ $field.Name }} must be of type {{ trimPrefix $field.GoType "*" }}") - } - {{- else if eq $field.NativeType.Name "TinyInt" }} - if v, ok := a.Val.({{ trimPrefix $field.GoType "*" }}); ok { - if v < -128 || v > 127 { - errs.Add("{{ $field.Name }}", v, "range", "value is out of range for TinyInt (-128 to 127)") - } - } else { - errs.Add("{{ $field.Name }}", a.Val, "type", "field {{ $field.Name }} must be of type {{ trimPrefix $field.GoType "*" }}") - } - {{- else if eq $field.NativeType.Name "Oid" }} if v, ok := a.Val.({{ trimPrefix $field.GoType "*" }}); ok { - if v < 0 { - errs.Add("{{ $field.Name }}", v, "range", "value is out of range for Oid (must be non-negative)") - } + {{- $rule := "" }} + {{- if and $field.NativeType (or (eq $field.NativeType.Name "SmallInt") (eq $field.NativeType.Name "TinyInt") (eq $field.NativeType.Name "Oid")) }} + {{- $rule = $field.NativeType.Name }} + {{- end }} + Validate{{ capitalize (trimPrefix $field.GoType "*") }}(errs, "{{ $field.Name }}", v, "{{ $rule }}") } else { errs.Add("{{ $field.Name }}", a.Val, "type", "field {{ $field.Name }} must be of type {{ trimPrefix $field.GoType "*" }}") } - {{- 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 }} - {{- 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 }} {{- else if ne (trimPrefix $field.GoType "*") "any" }} 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 "*" }}") @@ -273,72 +232,15 @@ func (q *Queries) execute{{ .Model.Name }}Create(ctx context.Context, assignment return nil, err } + rowMap := input.ToRowMap() var cols []string var vals []any - - {{- range $field := .Model.ScalarFields }} - {{- $col := $field.EffectiveColName }} - {{- $fieldName := capitalize $field.Name }} - {{- if $field.EnumRef }} - {{- if $field.IsArray }} - if input.{{ $fieldName }} != nil { - cols = append(cols, "{{ $col }}") - vals = append(vals, input.{{ $fieldName }}) - } - {{- else }} - if input.{{ $fieldName }} != nil { - cols = append(cols, "{{ $col }}") - vals = append(vals, *input.{{ $fieldName }}) - } - {{- end }} - {{- else }} - {{- if $field.IsArray }} - if input.{{ $fieldName }} != nil { - cols = append(cols, "{{ $col }}") - vals = append(vals, input.{{ $fieldName }}) - } - {{- else }} - {{- if and $field.Default (eq $field.Default.Kind.String "Func") }} - {{- if eq $field.Default.FuncName "autoincrement" }} - if input.{{ $fieldName }} != nil { - cols = append(cols, "{{ $col }}") - vals = append(vals, {{ hstoreExpr $field.GoType (printf "*input.%s" $fieldName) }}) - } - {{- else if $field.Default.FuncName | isKnownDefaultFunc }} - if input.{{ $fieldName }} != nil { - cols = append(cols, "{{ $col }}") - vals = append(vals, {{ hstoreExpr $field.GoType (printf "*input.%s" $fieldName) }}) - } else { - cols = append(cols, "{{ $col }}") - vals = append(vals, {{ defaultFuncCall $field.Default.FuncName }}) - } - {{- else }} - if input.{{ $fieldName }} != nil { - cols = append(cols, "{{ $col }}") - vals = append(vals, {{ hstoreExpr $field.GoType (printf "*input.%s" $fieldName) }}) - } - {{- end }} - {{- else if or $field.Optional (ne $field.Default nil) }} - if input.{{ $fieldName }} != nil { - cols = append(cols, "{{ $col }}") - vals = append(vals, {{ hstoreExpr $field.GoType (printf "*input.%s" $fieldName) }}) - } - {{- else }} - {{- if and $field.IsID (eq $field.GoType "string") }} - cols = append(cols, "{{ $col }}") - if input.{{ $fieldName }} != "" { - vals = append(vals, input.{{ $fieldName }}) - } else { - vals = append(vals, generateCUID()) - } - {{- else }} - cols = append(cols, "{{ $col }}") - vals = append(vals, {{ hstoreExpr $field.GoType (printf "input.%s" $fieldName) }}) - {{- end }} - {{- end }} - {{- end }} - {{- end }} - {{- end }} + for _, col := range {{ .Model.Name }}ColOrder { + if val, ok := rowMap[col]; ok { + cols = append(cols, col) + vals = append(vals, val) + } + } returningCols := q.select{{ .Model.Name }}Cols(selects, omits) diff --git a/generator/templates/model_header.gotpl b/generator/templates/model_header.gotpl index f17ea93..71245e0 100644 --- a/generator/templates/model_header.gotpl +++ b/generator/templates/model_header.gotpl @@ -6,18 +6,12 @@ import ( "encoding/json" {{- end }} "fmt" - {{- if hasNetField .Model }} - "net" - {{- end }} "slices" - {{- if hasStringField .Model }} - "strings" - {{- end }} {{- if hasTimeField .Model }} "time" {{- end }} - {{- if hasStringField .Model }} - "unicode/utf8" + {{- if ne .PackageName .ParentPackageName }} + "{{ .ParentImportPath }}" {{- end }} ) diff --git a/generator/templates/model_predicate.gotpl b/generator/templates/model_predicate.gotpl index 7a865b8..3f0ac71 100644 --- a/generator/templates/model_predicate.gotpl +++ b/generator/templates/model_predicate.gotpl @@ -11,8 +11,6 @@ import ( "{{ .ParentImportPath }}" ) - - type UniquePredicate struct { {{ .ParentPackageName }}.StandardPredicate } diff --git a/generator/templates/model_structs.gotpl b/generator/templates/model_structs.gotpl index 6d1b030..ca520e4 100644 --- a/generator/templates/model_structs.gotpl +++ b/generator/templates/model_structs.gotpl @@ -61,7 +61,7 @@ func (m *{{ .Model.Name }}) ScanFields(cols []string) []any { {{- range $field := .Model.ScalarFields }} case "{{ $field.EffectiveColName }}": {{- if eq (trimPrefix $field.GoType "*") "map[string]*string" }} - targets[i] = hstoreScan{p: &m.{{ capitalize $field.Name }}} + targets[i] = HstoreScan{P: &m.{{ capitalize $field.Name }}} {{- else }} targets[i] = &m.{{ capitalize $field.Name }} {{- end }} diff --git a/generator/templates/runtime.gotpl b/generator/templates/runtime.gotpl new file mode 100644 index 0000000..fa9ddac --- /dev/null +++ b/generator/templates/runtime.gotpl @@ -0,0 +1,180 @@ +type Dialect interface { + Quote(ident string) string + BindVar(idx int) string + SupportsReturning() bool + SupportsBulkInsert() bool +} + +type DBTX interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +// FieldError represents a single validation failure on a specific field. +type FieldError struct { + Field string + Value any + Rule string + Msg string +} + +func (e FieldError) Error() string { + return fmt.Sprintf("field %s: %s (value: %v, rule: %s)", e.Field, e.Msg, e.Value, e.Rule) +} + +// ValidationError collects multiple validation errors during an operation. +type ValidationError struct { + Errors []FieldError +} + +func (e ValidationError) Error() string { + var msgs []string + for _, err := range e.Errors { + msgs = append(msgs, err.Error()) + } + return fmt.Sprintf("validation failed: %s", strings.Join(msgs, "; ")) +} + +func (e *ValidationError) Add(field string, value any, rule string, msg string) { + e.Errors = append(e.Errors, FieldError{ + Field: field, + Value: value, + Rule: rule, + Msg: msg, + }) +} + +func (e *ValidationError) HasErrors() bool { + return len(e.Errors) > 0 +} + +type FieldAssignment struct { + Col string + Val any +} + +type RecordInput struct { + Assignments []FieldAssignment +} + +{{- if hasHstoreAnywhere .Schema }} +func ToHstore(m map[string]*string) hstore.Hstore { + result := hstore.Hstore{Map: make(map[string]sql.NullString, len(m))} + for k, v := range m { + if v == nil { + result.Map[k] = sql.NullString{Valid: false} + } else { + result.Map[k] = sql.NullString{String: *v, Valid: true} + } + } + return result +} + +type HstoreScan struct { + P **map[string]*string +} + +func (s HstoreScan) Scan(src any) error { + var h hstore.Hstore + if err := h.Scan(src); err != nil { + return err + } + if h.Map == nil { + *s.P = nil + return nil + } + m := make(map[string]*string, len(h.Map)) + for k, v := range h.Map { + if v.Valid { + val := v.String + m[k] = &val + } else { + m[k] = nil + } + } + *s.P = &m + return nil +} +{{- end }} + +func ValidateString(errs *ValidationError, fieldName string, val string, isRequired bool, maxLen int, isBit bool, isInet bool) { + if isRequired && val == "" { + errs.Add(fieldName, val, "required", fmt.Sprintf("field %s is required", fieldName)) + } + if strings.Contains(val, "\x00") { + errs.Add(fieldName, val, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(val) { + errs.Add(fieldName, val, "safety", "string must be valid UTF-8") + } + if maxLen > 0 && utf8.RuneCountInString(val) > maxLen { + errs.Add(fieldName, val, "length", fmt.Sprintf("string exceeds maximum length of %d characters", maxLen)) + } + if isBit { + if strings.IndexFunc(val, func(r rune) bool { return r != '0' && r != '1' }) >= 0 { + errs.Add(fieldName, val, "format", "bit string must contain only '0' and '1'") + } + } + {{- if hasNetAnywhere .Schema }} + if isInet { + if net.ParseIP(val) == nil { + if _, _, err := net.ParseCIDR(val); err != nil { + errs.Add(fieldName, val, "format", fmt.Sprintf("field %s must be a valid IP address", fieldName)) + } + } + } + {{- end }} +} + +func ValidateInt32(errs *ValidationError, fieldName string, val int32, rule string) { + switch rule { + case "SmallInt": + if val < -32768 || val > 32767 { + errs.Add(fieldName, val, "range", "value is out of range for SmallInt (-32768 to 32767)") + } + case "TinyInt": + if val < -128 || val > 127 { + errs.Add(fieldName, val, "range", "value is out of range for TinyInt (-128 to 127)") + } + case "Oid": + if val < 0 { + errs.Add(fieldName, val, "range", "value is out of range for Oid (must be non-negative)") + } + } +} + +func ValidateInt64(errs *ValidationError, fieldName string, val int64, rule string) { + switch rule { + case "SmallInt": + if val < -32768 || val > 32767 { + errs.Add(fieldName, val, "range", "value is out of range for SmallInt (-32768 to 32767)") + } + case "TinyInt": + if val < -128 || val > 127 { + errs.Add(fieldName, val, "range", "value is out of range for TinyInt (-128 to 127)") + } + case "Oid": + if val < 0 { + errs.Add(fieldName, val, "range", "value is out of range for Oid (must be non-negative)") + } + } +} + +func ValidateInt(errs *ValidationError, fieldName string, val int, rule string) { + switch rule { + case "SmallInt": + if val < -32768 || val > 32767 { + errs.Add(fieldName, val, "range", "value is out of range for SmallInt (-32768 to 32767)") + } + case "TinyInt": + if val < -128 || val > 127 { + errs.Add(fieldName, val, "range", "value is out of range for TinyInt (-128 to 127)") + } + case "Oid": + if val < 0 { + errs.Add(fieldName, val, "range", "value is out of range for Oid (must be non-negative)") + } + } +} diff --git a/integration/evolution_test.go b/integration/evolution_test.go new file mode 100644 index 0000000..b40af5e --- /dev/null +++ b/integration/evolution_test.go @@ -0,0 +1,382 @@ +package main + +import ( + "bytes" + "database/sql" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + _ "github.com/lib/pq" + _ "modernc.org/sqlite" +) + +type EvolutionStep struct { + Name string + Schema string + Imports []string + Code string +} + +var evolutionSteps = []EvolutionStep{ + { + Name: "001_init", + Schema: ` +model User { + id String @id + email String @unique + age Int +} +`, + Imports: []string{ + `user "integration/sandbox/valk/user"`, + }, + Code: ` + u, err := db.User.Create( + user.Id.Set("user-1"), + user.Email.Set("user1@example.com"), + user.Age.Set(30), + ).Exec(ctx) + if err != nil { + return fmt.Errorf("failed to seed user-1: %w", err) + } + if u.Id != "user-1" { + return fmt.Errorf("expected user-1, got %q", u.Id) + } + `, + }, + { + Name: "002_optional_fields", + Schema: ` +model User { + id String @id + email String @unique + age Int? + role String? +} +`, + Imports: []string{ + `user "integration/sandbox/valk/user"`, + }, + Code: ` + // Verify user-1 is intact + u1, err := db.User.FindUnique(user.Id.EQ("user-1")).Exec(ctx) + if err != nil { + return fmt.Errorf("failed to find user-1: %w", err) + } + if u1.Age == nil || *u1.Age != 30 { + return fmt.Errorf("invalid age: expected 30, got %v", u1.Age) + } + if u1.Role != nil { + return fmt.Errorf("expected role to be nil, got %q", *u1.Role) + } + + // Create user-2 with nil age and role admin + u2, err := db.User.Create( + user.Id.Set("user-2"), + user.Email.Set("user2@example.com"), + user.Role.Set("admin"), + ).Exec(ctx) + if err != nil { + return fmt.Errorf("failed to create user-2: %w", err) + } + if u2.Age != nil { + return fmt.Errorf("expected age to be nil, got %v", *u2.Age) + } + if u2.Role == nil || *u2.Role != "admin" { + return fmt.Errorf("expected role to be admin, got %v", u2.Role) + } + `, + }, + { + Name: "003_defaults_and_uniques", + Schema: ` +model User { + id String @id + email String @unique + age Int? + role String? + phone String? @unique + status String? @default("active") +} +`, + Imports: []string{ + `user "integration/sandbox/valk/user"`, + }, + Code: ` + // Verify old records received the default values for the new status column + u1, err := db.User.FindUnique(user.Id.EQ("user-1")).Exec(ctx) + if err != nil { + return fmt.Errorf("user-1 not found: %w", err) + } + if u1.Status == nil || *u1.Status != "active" { + return fmt.Errorf("expected status 'active' for user-1, got %v", u1.Status) + } + + // Create user-3 with phone "+12345" and no status (should use default "active") + u3, err := db.User.Create( + user.Id.Set("user-3"), + user.Email.Set("user3@example.com"), + user.Phone.Set("+12345"), + ).Exec(ctx) + if err != nil { + return fmt.Errorf("failed to create user-3: %w", err) + } + if u3.Status == nil || *u3.Status != "active" { + return fmt.Errorf("expected default status 'active', got %v", u3.Status) + } + if u3.Phone == nil || *u3.Phone != "+12345" { + return fmt.Errorf("expected phone '+12345', got %v", u3.Phone) + } + + // Test unique constraint on phone + _, err = db.User.Create( + user.Id.Set("user-4"), + user.Email.Set("user4@example.com"), + user.Phone.Set("+12345"), // duplicate phone! + ).Exec(ctx) + if err == nil { + return fmt.Errorf("expected unique constraint error on phone, got nil") + } + `, + }, + { + Name: "004_relations", + Schema: ` +model User { + id String @id + email String @unique + age Int? + role String? + phone String? + status String? @default("active") + posts Post[] +} + +model Post { + id String @id + title String + authorId String + author User @relation(fields: [authorId], references: [id]) +} +`, + Imports: []string{ + `post "integration/sandbox/valk/post"`, + }, + Code: ` + // Create a post for user-1 + p, err := db.Post.Create( + post.Id.Set("post-1"), + post.Title.Set("ORM Evolution"), + post.AuthorId.Set("user-1"), + ).Exec(ctx) + if err != nil { + return fmt.Errorf("failed to create post: %w", err) + } + if p.Id != "post-1" { + return fmt.Errorf("expected post-1, got %q", p.Id) + } + + // Retrieve post with author relationship + pLoaded, err := db.Post.FindUnique(post.Id.EQ("post-1")).Select(valk.PostSelect{ + Id: true, + Title: true, + Author: &valk.UserSelect{ + Id: true, + Email: true, + }, + }).Exec(ctx) + if err != nil { + return fmt.Errorf("failed to query post with relations: %w", err) + } + if pLoaded.Author == nil { + return fmt.Errorf("expected author to be loaded, got nil") + } + if pLoaded.Author.Email != "user1@example.com" { + return fmt.Errorf("expected author email 'user1@example.com', got %q", pLoaded.Author.Email) + } + `, + }, +} + +func TestSchemaEvolution(t *testing.T) { + provider := getActiveProvider() + + // 1. Setup clean sandbox directory + sandboxDir, err := filepath.Abs("./sandbox") + if err != nil { + t.Fatalf("failed to get absolute path for sandbox: %v", err) + } + + _ = os.RemoveAll(sandboxDir) + err = os.MkdirAll(sandboxDir, 0755) + if err != nil { + t.Fatalf("failed to create sandbox dir: %v", err) + } + defer func() { + _ = os.RemoveAll(sandboxDir) + }() + + // Determine connection DSN and prepare temporary databases/schemas + var dsn string + if provider == "postgres" { + mainDsn := getPostgresDSN() + db, err := sql.Open("postgres", mainDsn) + if err != nil { + t.Fatalf("failed to connect to main pg: %v", err) + } + _, err = db.Exec("DROP SCHEMA IF EXISTS ephemeral_evolution CASCADE; CREATE SCHEMA ephemeral_evolution;") + db.Close() + if err != nil { + t.Fatalf("failed to recreate schema: %v", err) + } + + defer func() { + db, err := sql.Open("postgres", mainDsn) + if err == nil { + _, _ = db.Exec("DROP SCHEMA IF EXISTS ephemeral_evolution CASCADE;") + db.Close() + } + }() + + if strings.Contains(mainDsn, "?") { + dsn = mainDsn + "&search_path=ephemeral_evolution" + } else { + dsn = mainDsn + "?search_path=ephemeral_evolution" + } + } else { + dsn = "file:" + filepath.Join(sandboxDir, "evolution.db") + } + + // Write static valk.json configuration + valkJson := `{ + "database": { + "url_env": "DATABASE_URL" + }, + "schema": "./schema.prisma", + "output": { + "client": "./valk", + "migrations": "./valk/migrations" + } +}` + err = os.WriteFile(filepath.Join(sandboxDir, "valk.json"), []byte(valkJson), 0644) + if err != nil { + t.Fatalf("failed to write valk.json: %v", err) + } + + valkBin, err := filepath.Abs("../bin/valk") + if err != nil { + t.Fatalf("failed to get absolute path for valk: %v", err) + } + + // Helper to execute valk binary + runValk := func(args ...string) { + cmd := exec.Command(valkBin, args...) + cmd.Dir = sandboxDir + cmd.Env = append(os.Environ(), + "DATABASE_URL="+dsn, + "DATABASE_DIRECT_URL="+dsn, + ) + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + t.Fatalf("valk %v failed: %v\nstdout: %s\nstderr: %s", args, err, outBuf.String(), errBuf.String()) + } + } + + // Run through the evolution steps pipeline + for _, step := range evolutionSteps { + t.Logf("Running evolution step: %s", step.Name) + + // A. Write schema file for this step + schemaFileContent := fmt.Sprintf(` +datasource db { + provider = "%s" + url = env("DATABASE_URL") +} + +%s +`, provider, step.Schema) + + err = os.WriteFile(filepath.Join(sandboxDir, "schema.prisma"), []byte(schemaFileContent), 0644) + if err != nil { + t.Fatalf("[%s] failed to write schema: %v", step.Name, err) + } + + // Ensure migrations output folder exists + err = os.MkdirAll(filepath.Join(sandboxDir, "valk/migrations"), 0755) + if err != nil { + t.Fatalf("[%s] failed to create migrations folder: %v", step.Name, err) + } + + // B. Regenerate client and plan/apply migrations + runValk("generate") + runValk("migrate", step.Name) + + // C. Generate and compile main.go execution script for this step + importsStr := strings.Join(step.Imports, "\n\t") + goCode := fmt.Sprintf(`package main + +import ( + "context" + "fmt" + "os" + "integration/sandbox/valk" + %s + + _ "github.com/lib/pq" + _ "modernc.org/sqlite" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %%v\n", err) + os.Exit(1) + } + fmt.Println("SUCCESS") +} + +func run() error { + provider := os.Getenv("PROVIDER") + dsn := os.Getenv("DATABASE_URL") + db, err := valk.Open(provider, dsn) + if err != nil { + return fmt.Errorf("failed to open client: %%w", err) + } + defer db.Close() + ctx := context.Background() + + %s + + return nil +} +`, importsStr, step.Code) + + err = os.WriteFile(filepath.Join(sandboxDir, "main.go"), []byte(goCode), 0644) + if err != nil { + t.Fatalf("[%s] failed to write main.go script: %v", step.Name, err) + } + + // D. Execute step code and verify success + cmd := exec.Command("go", "run", "main.go") + cmd.Dir = sandboxDir + cmd.Env = append(os.Environ(), + "PROVIDER="+provider, + "DATABASE_URL="+dsn, + ) + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + t.Fatalf("[%s] go run main.go failed: %v\nstdout: %s\nstderr: %s", step.Name, err, outBuf.String(), errBuf.String()) + } + + if !strings.Contains(outBuf.String(), "SUCCESS") { + t.Fatalf("[%s] execution check failed: %s", step.Name, outBuf.String()) + } + } +} diff --git a/integration/prepareSchema.js b/integration/prepareSchema.js index 5fe56f4..1821451 100644 --- a/integration/prepareSchema.js +++ b/integration/prepareSchema.js @@ -3,6 +3,25 @@ const path = require('path'); function prepare(mode) { const schemaPath = path.join(__dirname, 'schema.prisma'); + const backupPath = path.join(__dirname, 'schema.prisma.backup'); + + if (mode === 'postgres') { + if (fs.existsSync(backupPath)) { + fs.copyFileSync(backupPath, schemaPath); + fs.unlinkSync(backupPath); + console.log('Restored schema.prisma from postgres backup.'); + } else { + console.log('No backup found, schema.prisma is already in postgres mode.'); + } + return; + } + + // mode === 'sqlite' + if (!fs.existsSync(backupPath)) { + fs.copyFileSync(schemaPath, backupPath); + console.log('Created backup of postgres schema.prisma.'); + } + const content = fs.readFileSync(schemaPath, 'utf8'); const lines = content.split(/\r?\n/); @@ -14,28 +33,23 @@ function prepare(mode) { // Swap provider string if (trimmed.startsWith('provider =')) { - if (mode === 'sqlite') { - out.push(' provider = "sqlite"'); - } else { - out.push(' provider = "postgres"'); - } + out.push(' provider = "sqlite"'); continue; } - if (mode === 'sqlite') { - // Delete any postgres-specific Unsupported fields - if (currentLine.includes('Unsupported(')) { - continue; - } - - // Strip any @db.something attributes - currentLine = currentLine.replace(/@db\.[A-Za-z0-9_]+(?:\([^)]*\))?/g, ''); + // Delete any postgres-specific Unsupported fields + if (currentLine.includes('Unsupported(')) { + continue; } + // Strip any @db.something attributes + currentLine = currentLine.replace(/@db\.[A-Za-z0-9_]+(?:\([^)]*\))?/g, ''); + out.push(currentLine); } fs.writeFileSync(schemaPath, out.join('\n')); + console.log('Prepared schema.prisma for sqlite.'); } const mode = process.argv[2]; diff --git a/integration/valk/allFieldsSoFar.go b/integration/valk/allFieldsSoFar.go index faf7757..647e634 100644 --- a/integration/valk/allFieldsSoFar.go +++ b/integration/valk/allFieldsSoFar.go @@ -4,11 +4,8 @@ import ( "context" "encoding/json" "fmt" - "net" "slices" - "strings" "time" - "unicode/utf8" ) // AllFieldsSoFar represents the database model @@ -369,7 +366,7 @@ func (m *AllFieldsSoFar) ScanFields(cols []string) []any { case "bytesOpt": targets[i] = &m.BytesOpt case "hstoreField": - targets[i] = hstoreScan{p: &m.HstoreField} + targets[i] = HstoreScan{P: &m.HstoreField} case "ltreeField": targets[i] = &m.LtreeField case "citextField": @@ -591,290 +588,171 @@ func validateAllFieldsSoFarCreate(assignments []FieldAssignment) error { provided[a.Col] = true switch a.Col { case "id": - if _, ok := a.Val.(int32); !ok { + if v, ok := a.Val.(int32); ok { + ValidateInt32(errs, "id", v, "") + } else { errs.Add("id", a.Val, "type", "field id must be of type int32") } case "stringReq": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("stringReq", v, "required", "field stringReq is required") - } - if strings.Contains(v, "\x00") { - errs.Add("stringReq", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("stringReq", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "stringReq", v, true, 0, false, false) } else { errs.Add("stringReq", a.Val, "type", "field stringReq must be of type string") } case "stringOpt": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("stringOpt", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("stringOpt", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "stringOpt", v, false, 0, false, false) } else { errs.Add("stringOpt", a.Val, "type", "field stringOpt must be of type string") } case "stringDefault": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("stringDefault", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("stringDefault", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "stringDefault", v, false, 0, false, false) } else { errs.Add("stringDefault", a.Val, "type", "field stringDefault must be of type string") } case "stringVarchar": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("stringVarchar", v, "required", "field stringVarchar is required") - } - if strings.Contains(v, "\x00") { - errs.Add("stringVarchar", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("stringVarchar", v, "safety", "string must be valid UTF-8") - } - if utf8.RuneCountInString(v) > 255 { - errs.Add("stringVarchar", v, "length", "string exceeds maximum length of 255 characters") - } + ValidateString(errs, "stringVarchar", v, true, 255, false, false) } else { errs.Add("stringVarchar", a.Val, "type", "field stringVarchar must be of type string") } case "stringChar": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("stringChar", v, "required", "field stringChar is required") - } - if strings.Contains(v, "\x00") { - errs.Add("stringChar", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("stringChar", v, "safety", "string must be valid UTF-8") - } - if utf8.RuneCountInString(v) > 10 { - errs.Add("stringChar", v, "length", "string exceeds maximum length of 10 characters") - } + ValidateString(errs, "stringChar", v, true, 10, false, false) } else { errs.Add("stringChar", a.Val, "type", "field stringChar must be of type string") } case "bitVal": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("bitVal", v, "required", "field bitVal is required") - } - if strings.Contains(v, "\x00") { - errs.Add("bitVal", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("bitVal", v, "safety", "string must be valid UTF-8") - } - if strings.IndexFunc(v, func(r rune) bool { return r != '0' && r != '1' }) >= 0 { - errs.Add("bitVal", v, "format", "bit string must contain only '0' and '1'") - } + ValidateString(errs, "bitVal", v, true, 0, true, false) } else { errs.Add("bitVal", a.Val, "type", "field bitVal must be of type string") } case "varBitVal": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("varBitVal", v, "required", "field varBitVal is required") - } - if strings.Contains(v, "\x00") { - errs.Add("varBitVal", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("varBitVal", v, "safety", "string must be valid UTF-8") - } - if strings.IndexFunc(v, func(r rune) bool { return r != '0' && r != '1' }) >= 0 { - errs.Add("varBitVal", v, "format", "bit string must contain only '0' and '1'") - } + ValidateString(errs, "varBitVal", v, true, 0, true, false) } else { errs.Add("varBitVal", a.Val, "type", "field varBitVal must be of type string") } case "inetVal": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("inetVal", v, "required", "field inetVal is required") - } - if strings.Contains(v, "\x00") { - errs.Add("inetVal", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("inetVal", v, "safety", "string must be valid UTF-8") - } - if net.ParseIP(v) == nil { - if _, _, err := net.ParseCIDR(v); err != nil { - errs.Add("inetVal", v, "format", "field inetVal must be a valid IP address") - } - } + ValidateString(errs, "inetVal", v, true, 0, false, true) } else { errs.Add("inetVal", a.Val, "type", "field inetVal must be of type string") } case "xmlVal": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("xmlVal", v, "required", "field xmlVal is required") - } - if strings.Contains(v, "\x00") { - errs.Add("xmlVal", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("xmlVal", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "xmlVal", v, true, 0, false, false) } else { errs.Add("xmlVal", a.Val, "type", "field xmlVal must be of type string") } case "cuidDefault": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("cuidDefault", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("cuidDefault", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "cuidDefault", v, false, 0, false, false) } else { errs.Add("cuidDefault", a.Val, "type", "field cuidDefault must be of type string") } case "cuid1Default": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("cuid1Default", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("cuid1Default", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "cuid1Default", v, false, 0, false, false) } else { errs.Add("cuid1Default", a.Val, "type", "field cuid1Default must be of type string") } case "cuid2Default": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("cuid2Default", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("cuid2Default", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "cuid2Default", v, false, 0, false, false) } else { errs.Add("cuid2Default", a.Val, "type", "field cuid2Default must be of type string") } case "uuidDefault": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("uuidDefault", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("uuidDefault", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "uuidDefault", v, false, 0, false, false) } else { errs.Add("uuidDefault", a.Val, "type", "field uuidDefault must be of type string") } case "uuid4Default": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("uuid4Default", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("uuid4Default", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "uuid4Default", v, false, 0, false, false) } else { errs.Add("uuid4Default", a.Val, "type", "field uuid4Default must be of type string") } case "uuid7Default": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("uuid7Default", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("uuid7Default", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "uuid7Default", v, false, 0, false, false) } else { errs.Add("uuid7Default", a.Val, "type", "field uuid7Default must be of type string") } case "ulidDefault": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("ulidDefault", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("ulidDefault", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "ulidDefault", v, false, 0, false, false) } else { errs.Add("ulidDefault", a.Val, "type", "field ulidDefault must be of type string") } case "nanoidDefault": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("nanoidDefault", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("nanoidDefault", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "nanoidDefault", v, false, 0, false, false) } else { errs.Add("nanoidDefault", a.Val, "type", "field nanoidDefault must be of type string") } case "uuidDb": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("uuidDb", v, "required", "field uuidDb is required") - } - if strings.Contains(v, "\x00") { - errs.Add("uuidDb", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("uuidDb", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "uuidDb", v, true, 0, false, false) } else { errs.Add("uuidDb", a.Val, "type", "field uuidDb must be of type string") } case "intReq": - if _, ok := a.Val.(int32); !ok { + if v, ok := a.Val.(int32); ok { + ValidateInt32(errs, "intReq", v, "") + } else { errs.Add("intReq", a.Val, "type", "field intReq must be of type int32") } case "intOpt": - if _, ok := a.Val.(int32); !ok { + if v, ok := a.Val.(int32); ok { + ValidateInt32(errs, "intOpt", v, "") + } else { errs.Add("intOpt", a.Val, "type", "field intOpt must be of type int32") } case "intDefault": - if _, ok := a.Val.(int32); !ok { + if v, ok := a.Val.(int32); ok { + ValidateInt32(errs, "intDefault", v, "") + } else { errs.Add("intDefault", a.Val, "type", "field intDefault must be of type int32") } case "integerVal": - if _, ok := a.Val.(int32); !ok { + if v, ok := a.Val.(int32); ok { + ValidateInt32(errs, "integerVal", v, "") + } else { errs.Add("integerVal", a.Val, "type", "field integerVal must be of type int32") } case "smallInt": if v, ok := a.Val.(int32); ok { - if v < -32768 || v > 32767 { - errs.Add("smallInt", v, "range", "value is out of range for SmallInt (-32768 to 32767)") - } + ValidateInt32(errs, "smallInt", v, "SmallInt") } else { errs.Add("smallInt", a.Val, "type", "field smallInt must be of type int32") } case "tinyInt": - if _, ok := a.Val.(int32); !ok { + if v, ok := a.Val.(int32); ok { + ValidateInt32(errs, "tinyInt", v, "") + } else { errs.Add("tinyInt", a.Val, "type", "field tinyInt must be of type int32") } case "oidVal": if v, ok := a.Val.(int32); ok { - if v < 0 { - errs.Add("oidVal", v, "range", "value is out of range for Oid (must be non-negative)") - } + ValidateInt32(errs, "oidVal", v, "Oid") } else { errs.Add("oidVal", a.Val, "type", "field oidVal must be of type int32") } case "bigIntReq": - if _, ok := a.Val.(int64); !ok { + if v, ok := a.Val.(int64); ok { + ValidateInt64(errs, "bigIntReq", v, "") + } else { errs.Add("bigIntReq", a.Val, "type", "field bigIntReq must be of type int64") } case "bigIntOpt": - if _, ok := a.Val.(int64); !ok { + if v, ok := a.Val.(int64); ok { + ValidateInt64(errs, "bigIntOpt", v, "") + } else { errs.Add("bigIntOpt", a.Val, "type", "field bigIntOpt must be of type int64") } case "floatReq": @@ -891,54 +769,25 @@ func validateAllFieldsSoFarCreate(assignments []FieldAssignment) error { } case "decimalReq": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("decimalReq", v, "required", "field decimalReq is required") - } - if strings.Contains(v, "\x00") { - errs.Add("decimalReq", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("decimalReq", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "decimalReq", v, true, 0, false, false) } else { errs.Add("decimalReq", a.Val, "type", "field decimalReq must be of type string") } case "decimalOpt": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("decimalOpt", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("decimalOpt", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "decimalOpt", v, false, 0, false, false) } else { errs.Add("decimalOpt", a.Val, "type", "field decimalOpt must be of type string") } case "decimalPrecise": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("decimalPrecise", v, "required", "field decimalPrecise is required") - } - if strings.Contains(v, "\x00") { - errs.Add("decimalPrecise", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("decimalPrecise", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "decimalPrecise", v, true, 0, false, false) } else { errs.Add("decimalPrecise", a.Val, "type", "field decimalPrecise must be of type string") } case "moneyVal": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("moneyVal", v, "required", "field moneyVal is required") - } - if strings.Contains(v, "\x00") { - errs.Add("moneyVal", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("moneyVal", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "moneyVal", v, true, 0, false, false) } else { errs.Add("moneyVal", a.Val, "type", "field moneyVal must be of type string") } @@ -1012,26 +861,13 @@ func validateAllFieldsSoFarCreate(assignments []FieldAssignment) error { } case "ltreeField": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("ltreeField", v, "required", "field ltreeField is required") - } - if strings.Contains(v, "\x00") { - errs.Add("ltreeField", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("ltreeField", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "ltreeField", v, true, 0, false, false) } else { errs.Add("ltreeField", a.Val, "type", "field ltreeField must be of type string") } case "citextField": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("citextField", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("citextField", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "citextField", v, false, 0, false, false) } else { errs.Add("citextField", a.Val, "type", "field citextField must be of type string") } @@ -1475,7 +1311,7 @@ func (s *AllFieldsSoFarCreate) ToRowMap() map[string]any { m["bytesOpt"] = *s.BytesOpt } if s.HstoreField != nil { - m["hstoreField"] = toHstore(*s.HstoreField) + m["hstoreField"] = ToHstore(*s.HstoreField) } m["ltreeField"] = s.LtreeField if s.CitextField != nil { @@ -1497,190 +1333,14 @@ func (q *Queries) executeAllFieldsSoFarCreate(ctx context.Context, assignments [ return nil, err } + rowMap := input.ToRowMap() var cols []string var vals []any - if input.Id != nil { - cols = append(cols, "id") - vals = append(vals, *input.Id) - } - cols = append(cols, "stringReq") - vals = append(vals, input.StringReq) - if input.StringOpt != nil { - cols = append(cols, "stringOpt") - vals = append(vals, *input.StringOpt) - } - if input.StringDefault != nil { - cols = append(cols, "stringDefault") - vals = append(vals, *input.StringDefault) - } - cols = append(cols, "stringVarchar") - vals = append(vals, input.StringVarchar) - cols = append(cols, "stringChar") - vals = append(vals, input.StringChar) - cols = append(cols, "bitVal") - vals = append(vals, input.BitVal) - cols = append(cols, "varBitVal") - vals = append(vals, input.VarBitVal) - cols = append(cols, "inetVal") - vals = append(vals, input.InetVal) - cols = append(cols, "xmlVal") - vals = append(vals, input.XmlVal) - if input.CuidDefault != nil { - cols = append(cols, "cuidDefault") - vals = append(vals, *input.CuidDefault) - } else { - cols = append(cols, "cuidDefault") - vals = append(vals, generateCUID()) - } - if input.Cuid1Default != nil { - cols = append(cols, "cuid1Default") - vals = append(vals, *input.Cuid1Default) - } else { - cols = append(cols, "cuid1Default") - vals = append(vals, generateCUID()) - } - if input.Cuid2Default != nil { - cols = append(cols, "cuid2Default") - vals = append(vals, *input.Cuid2Default) - } else { - cols = append(cols, "cuid2Default") - vals = append(vals, generateCUID2()) - } - if input.UuidDefault != nil { - cols = append(cols, "uuidDefault") - vals = append(vals, *input.UuidDefault) - } else { - cols = append(cols, "uuidDefault") - vals = append(vals, generateUUID()) - } - if input.Uuid4Default != nil { - cols = append(cols, "uuid4Default") - vals = append(vals, *input.Uuid4Default) - } else { - cols = append(cols, "uuid4Default") - vals = append(vals, generateUUID()) - } - if input.Uuid7Default != nil { - cols = append(cols, "uuid7Default") - vals = append(vals, *input.Uuid7Default) - } else { - cols = append(cols, "uuid7Default") - vals = append(vals, generateUUID7()) - } - if input.UlidDefault != nil { - cols = append(cols, "ulidDefault") - vals = append(vals, *input.UlidDefault) - } else { - cols = append(cols, "ulidDefault") - vals = append(vals, generateULID()) - } - if input.NanoidDefault != nil { - cols = append(cols, "nanoidDefault") - vals = append(vals, *input.NanoidDefault) - } else { - cols = append(cols, "nanoidDefault") - vals = append(vals, generateNanoID()) - } - cols = append(cols, "uuidDb") - vals = append(vals, input.UuidDb) - cols = append(cols, "intReq") - vals = append(vals, input.IntReq) - if input.IntOpt != nil { - cols = append(cols, "intOpt") - vals = append(vals, *input.IntOpt) - } - if input.IntDefault != nil { - cols = append(cols, "intDefault") - vals = append(vals, *input.IntDefault) - } - cols = append(cols, "integerVal") - vals = append(vals, input.IntegerVal) - cols = append(cols, "smallInt") - vals = append(vals, input.SmallInt) - cols = append(cols, "tinyInt") - vals = append(vals, input.TinyInt) - cols = append(cols, "oidVal") - vals = append(vals, input.OidVal) - cols = append(cols, "bigIntReq") - vals = append(vals, input.BigIntReq) - if input.BigIntOpt != nil { - cols = append(cols, "bigIntOpt") - vals = append(vals, *input.BigIntOpt) - } - cols = append(cols, "floatReq") - vals = append(vals, input.FloatReq) - if input.FloatOpt != nil { - cols = append(cols, "floatOpt") - vals = append(vals, *input.FloatOpt) - } - cols = append(cols, "realVal") - vals = append(vals, input.RealVal) - cols = append(cols, "decimalReq") - vals = append(vals, input.DecimalReq) - if input.DecimalOpt != nil { - cols = append(cols, "decimalOpt") - vals = append(vals, *input.DecimalOpt) - } - cols = append(cols, "decimalPrecise") - vals = append(vals, input.DecimalPrecise) - cols = append(cols, "moneyVal") - vals = append(vals, input.MoneyVal) - cols = append(cols, "boolReq") - vals = append(vals, input.BoolReq) - if input.BoolOpt != nil { - cols = append(cols, "boolOpt") - vals = append(vals, *input.BoolOpt) - } - if input.BoolDefault != nil { - cols = append(cols, "boolDefault") - vals = append(vals, *input.BoolDefault) - } - cols = append(cols, "dateTimeReq") - vals = append(vals, input.DateTimeReq) - if input.DateTimeOpt != nil { - cols = append(cols, "dateTimeOpt") - vals = append(vals, *input.DateTimeOpt) - } - if input.DateTimeDefault != nil { - cols = append(cols, "dateTimeDefault") - vals = append(vals, *input.DateTimeDefault) - } else { - cols = append(cols, "dateTimeDefault") - vals = append(vals, time.Now()) - } - cols = append(cols, "updatedAt") - vals = append(vals, input.UpdatedAt) - cols = append(cols, "dateTimeTz") - vals = append(vals, input.DateTimeTz) - cols = append(cols, "timestampVal") - vals = append(vals, input.TimestampVal) - cols = append(cols, "timeVal") - vals = append(vals, input.TimeVal) - cols = append(cols, "timetzVal") - vals = append(vals, input.TimetzVal) - cols = append(cols, "jsonReq") - vals = append(vals, input.JsonReq) - if input.JsonOpt != nil { - cols = append(cols, "jsonOpt") - vals = append(vals, *input.JsonOpt) - } - cols = append(cols, "jsonVal") - vals = append(vals, input.JsonVal) - cols = append(cols, "bytesReq") - vals = append(vals, input.BytesReq) - if input.BytesOpt != nil { - cols = append(cols, "bytesOpt") - vals = append(vals, *input.BytesOpt) - } - if input.HstoreField != nil { - cols = append(cols, "hstoreField") - vals = append(vals, toHstore(*input.HstoreField)) - } - cols = append(cols, "ltreeField") - vals = append(vals, input.LtreeField) - if input.CitextField != nil { - cols = append(cols, "citextField") - vals = append(vals, *input.CitextField) + for _, col := range AllFieldsSoFarColOrder { + if val, ok := rowMap[col]; ok { + cols = append(cols, col) + vals = append(vals, val) + } } returningCols := q.selectAllFieldsSoFarCols(selects, omits) diff --git a/integration/valk/category.go b/integration/valk/category.go index a3ff380..2c77640 100644 --- a/integration/valk/category.go +++ b/integration/valk/category.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "slices" - "strings" - "unicode/utf8" ) // Category represents the database model @@ -123,20 +121,14 @@ func validateCategoryCreate(assignments []FieldAssignment) error { provided[a.Col] = true switch a.Col { case "id": - if _, ok := a.Val.(int32); !ok { + if v, ok := a.Val.(int32); ok { + ValidateInt32(errs, "id", v, "") + } else { errs.Add("id", a.Val, "type", "field id must be of type int32") } case "name": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("name", v, "required", "field name is required") - } - if strings.Contains(v, "\x00") { - errs.Add("name", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("name", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "name", v, true, 0, false, false) } else { errs.Add("name", a.Val, "type", "field name must be of type string") } @@ -191,14 +183,15 @@ func (q *Queries) executeCategoryCreate(ctx context.Context, assignments []Field return nil, err } + rowMap := input.ToRowMap() var cols []string var vals []any - if input.Id != nil { - cols = append(cols, "id") - vals = append(vals, *input.Id) + for _, col := range CategoryColOrder { + if val, ok := rowMap[col]; ok { + cols = append(cols, col) + vals = append(vals, val) + } } - cols = append(cols, "name") - vals = append(vals, input.Name) returningCols := q.selectCategoryCols(selects, omits) diff --git a/integration/valk/categoryToPost.go b/integration/valk/categoryToPost.go index 389b07a..a7438e9 100644 --- a/integration/valk/categoryToPost.go +++ b/integration/valk/categoryToPost.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "slices" - "strings" - "unicode/utf8" ) // CategoryToPost represents the database model @@ -127,20 +125,14 @@ func validateCategoryToPostCreate(assignments []FieldAssignment) error { switch a.Col { case "postId": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("postId", v, "required", "field postId is required") - } - if strings.Contains(v, "\x00") { - errs.Add("postId", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("postId", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "postId", v, true, 0, false, false) } else { errs.Add("postId", a.Val, "type", "field postId must be of type string") } case "categoryId": - if _, ok := a.Val.(int32); !ok { + if v, ok := a.Val.(int32); ok { + ValidateInt32(errs, "categoryId", v, "") + } else { errs.Add("categoryId", a.Val, "type", "field categoryId must be of type int32") } } @@ -195,12 +187,15 @@ func (q *Queries) executeCategoryToPostCreate(ctx context.Context, assignments [ return nil, err } + rowMap := input.ToRowMap() var cols []string var vals []any - cols = append(cols, "postId") - vals = append(vals, input.PostId) - cols = append(cols, "categoryId") - vals = append(vals, input.CategoryId) + for _, col := range CategoryToPostColOrder { + if val, ok := rowMap[col]; ok { + cols = append(cols, col) + vals = append(vals, val) + } + } returningCols := q.selectCategoryToPostCols(selects, omits) diff --git a/integration/valk/client.go b/integration/valk/client.go index 4e4d74e..233edd6 100644 --- a/integration/valk/client.go +++ b/integration/valk/client.go @@ -7,21 +7,22 @@ import ( "embed" "encoding/json" "fmt" + "github.com/google/uuid" "github.com/lib/pq/hstore" + "github.com/pressly/goose/v3" + "net" "strconv" "strings" "time" "unicode/utf8" - - "github.com/google/uuid" - "github.com/pressly/goose/v3" ) var _ = time.Time{} +var _ = hstore.Hstore{} +var _ = net.ParseIP var _ = json.RawMessage{} var _ = strings.Join var _ = uuid.New -var _ = uuid.NewV7 var _ = rand.Read var _ = strconv.AppendUint @@ -42,11 +43,9 @@ func generateCUID() string { } return string(buf) } - func generateUUID() string { return uuid.New().String() } - func generateUUID7() string { id, err := uuid.NewV7() if err != nil { @@ -54,7 +53,6 @@ func generateUUID7() string { } return id.String() } - func generateCUID2() string { now := uint64(time.Now().UnixMilli()) b := make([]byte, 12) @@ -68,7 +66,6 @@ func generateCUID2() string { } return string(buf) } - func generateULID() string { now := uint64(time.Now().UnixMilli()) b := make([]byte, 10) @@ -96,7 +93,6 @@ func generateULID() string { } return string(buf[:]) } - func generateNanoID() string { const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-" b := make([]byte, 21) @@ -106,42 +102,58 @@ func generateNanoID() string { } return string(b) } -func toHstore(m map[string]*string) hstore.Hstore { - result := hstore.Hstore{Map: make(map[string]sql.NullString, len(m))} - for k, v := range m { - if v == nil { - result.Map[k] = sql.NullString{Valid: false} - } else { - result.Map[k] = sql.NullString{String: *v, Valid: true} - } - } - return result + +type UserRoleType string + +const ( + // Admin maps to "ADMIN" + UserRoleTypeAdmin UserRoleType = "ADMIN" + // Student maps to "student" + UserRoleTypeStudent UserRoleType = "student" + // Teacher maps to "TEACHER" + UserRoleTypeTeacher UserRoleType = "TEACHER" +) + +type userRoleNamespace struct { + // Admin maps to "ADMIN" + Admin UserRoleType + // Student maps to "student" + Student UserRoleType + // Teacher maps to "TEACHER" + Teacher UserRoleType } -type hstoreScan struct { - p **map[string]*string +// UserRole enum values: +// +// ADMIN ADMIN +// STUDENT student +// TEACHER TEACHER +var UserRole = userRoleNamespace{ + Admin: UserRoleTypeAdmin, + Student: UserRoleTypeStudent, + Teacher: UserRoleTypeTeacher, } -func (s hstoreScan) Scan(src any) error { - var h hstore.Hstore - if err := h.Scan(src); err != nil { - return err - } - if h.Map == nil { - *s.p = nil - return nil - } - m := make(map[string]*string, len(h.Map)) - for k, v := range h.Map { - if v.Valid { - val := v.String - m[k] = &val - } else { - m[k] = nil - } +func (e UserRoleType) IsValid() bool { + switch e { + case UserRoleTypeAdmin, UserRoleTypeStudent, UserRoleTypeTeacher: + return true } - *s.p = &m - return nil + return false +} + +type Dialect interface { + Quote(ident string) string + BindVar(idx int) string + SupportsReturning() bool + SupportsBulkInsert() bool +} + +type DBTX interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row } // FieldError represents a single validation failure on a specific field. @@ -191,51 +203,122 @@ type RecordInput struct { Assignments []FieldAssignment } -type UserRoleType string +func ToHstore(m map[string]*string) hstore.Hstore { + result := hstore.Hstore{Map: make(map[string]sql.NullString, len(m))} + for k, v := range m { + if v == nil { + result.Map[k] = sql.NullString{Valid: false} + } else { + result.Map[k] = sql.NullString{String: *v, Valid: true} + } + } + return result +} -const ( - // Admin maps to "ADMIN" - UserRoleTypeAdmin UserRoleType = "ADMIN" - // Student maps to "student" - UserRoleTypeStudent UserRoleType = "student" - // Teacher maps to "TEACHER" - UserRoleTypeTeacher UserRoleType = "TEACHER" -) +type HstoreScan struct { + P **map[string]*string +} -type userRoleNamespace struct { - // Admin maps to "ADMIN" - Admin UserRoleType - // Student maps to "student" - Student UserRoleType - // Teacher maps to "TEACHER" - Teacher UserRoleType +func (s HstoreScan) Scan(src any) error { + var h hstore.Hstore + if err := h.Scan(src); err != nil { + return err + } + if h.Map == nil { + *s.P = nil + return nil + } + m := make(map[string]*string, len(h.Map)) + for k, v := range h.Map { + if v.Valid { + val := v.String + m[k] = &val + } else { + m[k] = nil + } + } + *s.P = &m + return nil } -// UserRole enum values: -// -// ADMIN ADMIN -// STUDENT student -// TEACHER TEACHER -var UserRole = userRoleNamespace{ - Admin: UserRoleTypeAdmin, - Student: UserRoleTypeStudent, - Teacher: UserRoleTypeTeacher, +func ValidateString(errs *ValidationError, fieldName string, val string, isRequired bool, maxLen int, isBit bool, isInet bool) { + if isRequired && val == "" { + errs.Add(fieldName, val, "required", fmt.Sprintf("field %s is required", fieldName)) + } + if strings.Contains(val, "\x00") { + errs.Add(fieldName, val, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(val) { + errs.Add(fieldName, val, "safety", "string must be valid UTF-8") + } + if maxLen > 0 && utf8.RuneCountInString(val) > maxLen { + errs.Add(fieldName, val, "length", fmt.Sprintf("string exceeds maximum length of %d characters", maxLen)) + } + if isBit { + if strings.IndexFunc(val, func(r rune) bool { return r != '0' && r != '1' }) >= 0 { + errs.Add(fieldName, val, "format", "bit string must contain only '0' and '1'") + } + } + if isInet { + if net.ParseIP(val) == nil { + if _, _, err := net.ParseCIDR(val); err != nil { + errs.Add(fieldName, val, "format", fmt.Sprintf("field %s must be a valid IP address", fieldName)) + } + } + } } -func (e UserRoleType) IsValid() bool { - switch e { - case UserRoleTypeAdmin, UserRoleTypeStudent, UserRoleTypeTeacher: - return true +func ValidateInt32(errs *ValidationError, fieldName string, val int32, rule string) { + switch rule { + case "SmallInt": + if val < -32768 || val > 32767 { + errs.Add(fieldName, val, "range", "value is out of range for SmallInt (-32768 to 32767)") + } + case "TinyInt": + if val < -128 || val > 127 { + errs.Add(fieldName, val, "range", "value is out of range for TinyInt (-128 to 127)") + } + case "Oid": + if val < 0 { + errs.Add(fieldName, val, "range", "value is out of range for Oid (must be non-negative)") + } } - return false } -type Dialect interface { - Quote(ident string) string - BindVar(idx int) string - SupportsReturning() bool - SupportsBulkInsert() bool +func ValidateInt64(errs *ValidationError, fieldName string, val int64, rule string) { + switch rule { + case "SmallInt": + if val < -32768 || val > 32767 { + errs.Add(fieldName, val, "range", "value is out of range for SmallInt (-32768 to 32767)") + } + case "TinyInt": + if val < -128 || val > 127 { + errs.Add(fieldName, val, "range", "value is out of range for TinyInt (-128 to 127)") + } + case "Oid": + if val < 0 { + errs.Add(fieldName, val, "range", "value is out of range for Oid (must be non-negative)") + } + } } + +func ValidateInt(errs *ValidationError, fieldName string, val int, rule string) { + switch rule { + case "SmallInt": + if val < -32768 || val > 32767 { + errs.Add(fieldName, val, "range", "value is out of range for SmallInt (-32768 to 32767)") + } + case "TinyInt": + if val < -128 || val > 127 { + errs.Add(fieldName, val, "range", "value is out of range for TinyInt (-128 to 127)") + } + case "Oid": + if val < 0 { + errs.Add(fieldName, val, "range", "value is out of range for Oid (must be non-negative)") + } + } +} + type postgresDialect struct{} func (postgresDialect) Quote(ident string) string { return `"` + ident + `"` } @@ -243,13 +326,6 @@ func (postgresDialect) BindVar(idx int) string { return fmt.Sprintf("$%d", id func (postgresDialect) SupportsReturning() bool { return true } func (postgresDialect) SupportsBulkInsert() bool { return true } -type DBTX interface { - ExecContext(context.Context, string, ...any) (sql.Result, error) - PrepareContext(context.Context, string) (*sql.Stmt, error) - QueryContext(context.Context, string, ...any) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...any) *sql.Row -} - type Queries struct { db DBTX provider string diff --git a/integration/valk/comment.go b/integration/valk/comment.go index 2ff11ac..c2efa4b 100644 --- a/integration/valk/comment.go +++ b/integration/valk/comment.go @@ -5,8 +5,6 @@ import ( "encoding/json" "fmt" "slices" - "strings" - "unicode/utf8" ) // Comment represents the database model @@ -182,76 +180,43 @@ func validateCommentCreate(assignments []FieldAssignment) error { switch a.Col { case "id": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("id", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("id", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "id", v, false, 0, false, false) } else { errs.Add("id", a.Val, "type", "field id must be of type string") } case "textify": - if _, ok := a.Val.(int32); !ok { + if v, ok := a.Val.(int32); ok { + ValidateInt32(errs, "textify", v, "") + } else { errs.Add("textify", a.Val, "type", "field textify must be of type int32") } case "dummy3": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("dummy3", v, "required", "field dummy3 is required") - } - if strings.Contains(v, "\x00") { - errs.Add("dummy3", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("dummy3", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "dummy3", v, true, 0, false, false) } else { errs.Add("dummy3", a.Val, "type", "field dummy3 must be of type string") } case "dummy1": - if _, ok := a.Val.(int32); !ok { + if v, ok := a.Val.(int32); ok { + ValidateInt32(errs, "dummy1", v, "") + } else { errs.Add("dummy1", a.Val, "type", "field dummy1 must be of type int32") } case "dummy2": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("dummy2", v, "required", "field dummy2 is required") - } - if strings.Contains(v, "\x00") { - errs.Add("dummy2", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("dummy2", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "dummy2", v, true, 0, false, false) } else { errs.Add("dummy2", a.Val, "type", "field dummy2 must be of type string") } case "postId": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("postId", v, "required", "field postId is required") - } - if strings.Contains(v, "\x00") { - errs.Add("postId", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("postId", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "postId", v, true, 0, false, false) } else { errs.Add("postId", a.Val, "type", "field postId must be of type string") } case "authorId": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("authorId", v, "required", "field authorId is required") - } - if strings.Contains(v, "\x00") { - errs.Add("authorId", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("authorId", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "authorId", v, true, 0, false, false) } else { errs.Add("authorId", a.Val, "type", "field authorId must be of type string") } @@ -359,30 +324,14 @@ func (q *Queries) executeCommentCreate(ctx context.Context, assignments []FieldA return nil, err } + rowMap := input.ToRowMap() var cols []string var vals []any - if input.Id != nil { - cols = append(cols, "id") - vals = append(vals, *input.Id) - } else { - cols = append(cols, "id") - vals = append(vals, generateCUID()) - } - cols = append(cols, "textify") - vals = append(vals, input.Textify) - cols = append(cols, "dummy3") - vals = append(vals, input.Dummy3) - cols = append(cols, "dummy1") - vals = append(vals, input.Dummy1) - cols = append(cols, "dummy2") - vals = append(vals, input.Dummy2) - cols = append(cols, "postId") - vals = append(vals, input.PostId) - cols = append(cols, "authorId") - vals = append(vals, input.AuthorId) - if input.Meta != nil { - cols = append(cols, "meta") - vals = append(vals, *input.Meta) + for _, col := range CommentColOrder { + if val, ok := rowMap[col]; ok { + cols = append(cols, col) + vals = append(vals, val) + } } returningCols := q.selectCommentCols(selects, omits) diff --git a/integration/valk/defaultsTest.go b/integration/valk/defaultsTest.go index df2cc27..def5060 100644 --- a/integration/valk/defaultsTest.go +++ b/integration/valk/defaultsTest.go @@ -4,9 +4,7 @@ import ( "context" "fmt" "slices" - "strings" "time" - "unicode/utf8" ) // DefaultsTest represents the database model @@ -185,89 +183,49 @@ func validateDefaultsTestCreate(assignments []FieldAssignment) error { switch a.Col { case "uuid4": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("uuid4", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("uuid4", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "uuid4", v, false, 0, false, false) } else { errs.Add("uuid4", a.Val, "type", "field uuid4 must be of type string") } case "uuid7": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("uuid7", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("uuid7", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "uuid7", v, false, 0, false, false) } else { errs.Add("uuid7", a.Val, "type", "field uuid7 must be of type string") } case "uuidNoArgs": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("uuidNoArgs", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("uuidNoArgs", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "uuidNoArgs", v, false, 0, false, false) } else { errs.Add("uuidNoArgs", a.Val, "type", "field uuidNoArgs must be of type string") } case "cuid1": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("cuid1", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("cuid1", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "cuid1", v, false, 0, false, false) } else { errs.Add("cuid1", a.Val, "type", "field cuid1 must be of type string") } case "cuid2": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("cuid2", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("cuid2", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "cuid2", v, false, 0, false, false) } else { errs.Add("cuid2", a.Val, "type", "field cuid2 must be of type string") } case "cuidNoArgs": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("cuidNoArgs", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("cuidNoArgs", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "cuidNoArgs", v, false, 0, false, false) } else { errs.Add("cuidNoArgs", a.Val, "type", "field cuidNoArgs must be of type string") } case "ulid": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("ulid", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("ulid", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "ulid", v, false, 0, false, false) } else { errs.Add("ulid", a.Val, "type", "field ulid must be of type string") } case "nanoid": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("nanoid", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("nanoid", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "nanoid", v, false, 0, false, false) } else { errs.Add("nanoid", a.Val, "type", "field nanoid must be of type string") } @@ -392,70 +350,14 @@ func (q *Queries) executeDefaultsTestCreate(ctx context.Context, assignments []F return nil, err } + rowMap := input.ToRowMap() var cols []string var vals []any - if input.Uuid4 != nil { - cols = append(cols, "uuid4") - vals = append(vals, *input.Uuid4) - } else { - cols = append(cols, "uuid4") - vals = append(vals, generateUUID()) - } - if input.Uuid7 != nil { - cols = append(cols, "uuid7") - vals = append(vals, *input.Uuid7) - } else { - cols = append(cols, "uuid7") - vals = append(vals, generateUUID7()) - } - if input.UuidNoArgs != nil { - cols = append(cols, "uuidNoArgs") - vals = append(vals, *input.UuidNoArgs) - } else { - cols = append(cols, "uuidNoArgs") - vals = append(vals, generateUUID()) - } - if input.Cuid1 != nil { - cols = append(cols, "cuid1") - vals = append(vals, *input.Cuid1) - } else { - cols = append(cols, "cuid1") - vals = append(vals, generateCUID()) - } - if input.Cuid2 != nil { - cols = append(cols, "cuid2") - vals = append(vals, *input.Cuid2) - } else { - cols = append(cols, "cuid2") - vals = append(vals, generateCUID2()) - } - if input.CuidNoArgs != nil { - cols = append(cols, "cuidNoArgs") - vals = append(vals, *input.CuidNoArgs) - } else { - cols = append(cols, "cuidNoArgs") - vals = append(vals, generateCUID()) - } - if input.Ulid != nil { - cols = append(cols, "ulid") - vals = append(vals, *input.Ulid) - } else { - cols = append(cols, "ulid") - vals = append(vals, generateULID()) - } - if input.Nanoid != nil { - cols = append(cols, "nanoid") - vals = append(vals, *input.Nanoid) - } else { - cols = append(cols, "nanoid") - vals = append(vals, generateNanoID()) - } - if input.Now != nil { - cols = append(cols, "now") - vals = append(vals, *input.Now) - } else { - cols = append(cols, "now") - vals = append(vals, time.Now()) + for _, col := range DefaultsTestColOrder { + if val, ok := rowMap[col]; ok { + cols = append(cols, col) + vals = append(vals, val) + } } returningCols := q.selectDefaultsTestCols(selects, omits) diff --git a/integration/valk/post.go b/integration/valk/post.go index 496f964..f6ed35d 100644 --- a/integration/valk/post.go +++ b/integration/valk/post.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "slices" - "strings" - "unicode/utf8" ) // Post represents the database model @@ -157,37 +155,19 @@ func validatePostCreate(assignments []FieldAssignment) error { switch a.Col { case "id": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("id", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("id", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "id", v, false, 0, false, false) } else { errs.Add("id", a.Val, "type", "field id must be of type string") } case "title": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("title", v, "required", "field title is required") - } - if strings.Contains(v, "\x00") { - errs.Add("title", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("title", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "title", v, true, 0, false, false) } else { errs.Add("title", a.Val, "type", "field title must be of type string") } case "content": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("content", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("content", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "content", v, false, 0, false, false) } else { errs.Add("content", a.Val, "type", "field content must be of type string") } @@ -197,15 +177,7 @@ func validatePostCreate(assignments []FieldAssignment) error { } case "authorId": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("authorId", v, "required", "field authorId is required") - } - if strings.Contains(v, "\x00") { - errs.Add("authorId", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("authorId", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "authorId", v, true, 0, false, false) } else { errs.Add("authorId", a.Val, "type", "field authorId must be of type string") } @@ -284,27 +256,15 @@ func (q *Queries) executePostCreate(ctx context.Context, assignments []FieldAssi return nil, err } + rowMap := input.ToRowMap() var cols []string var vals []any - if input.Id != nil { - cols = append(cols, "id") - vals = append(vals, *input.Id) - } else { - cols = append(cols, "id") - vals = append(vals, generateCUID()) - } - cols = append(cols, "title") - vals = append(vals, input.Title) - if input.Content != nil { - cols = append(cols, "content") - vals = append(vals, *input.Content) - } - if input.Published != nil { - cols = append(cols, "published") - vals = append(vals, *input.Published) + for _, col := range PostColOrder { + if val, ok := rowMap[col]; ok { + cols = append(cols, col) + vals = append(vals, val) + } } - cols = append(cols, "authorId") - vals = append(vals, input.AuthorId) returningCols := q.selectPostCols(selects, omits) diff --git a/integration/valk/profile.go b/integration/valk/profile.go index 3ac8641..ef70060 100644 --- a/integration/valk/profile.go +++ b/integration/valk/profile.go @@ -4,9 +4,7 @@ import ( "context" "fmt" "slices" - "strings" "time" - "unicode/utf8" ) // Profile represents the database model @@ -143,37 +141,19 @@ func validateProfileCreate(assignments []FieldAssignment) error { switch a.Col { case "id": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("id", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("id", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "id", v, false, 0, false, false) } else { errs.Add("id", a.Val, "type", "field id must be of type string") } case "bio": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("bio", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("bio", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "bio", v, false, 0, false, false) } else { errs.Add("bio", a.Val, "type", "field bio must be of type string") } case "userId": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("userId", v, "required", "field userId is required") - } - if strings.Contains(v, "\x00") { - errs.Add("userId", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("userId", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "userId", v, true, 0, false, false) } else { errs.Add("userId", a.Val, "type", "field userId must be of type string") } @@ -250,27 +230,14 @@ func (q *Queries) executeProfileCreate(ctx context.Context, assignments []FieldA return nil, err } + rowMap := input.ToRowMap() var cols []string var vals []any - if input.Id != nil { - cols = append(cols, "id") - vals = append(vals, *input.Id) - } else { - cols = append(cols, "id") - vals = append(vals, generateCUID()) - } - if input.Bio != nil { - cols = append(cols, "bio") - vals = append(vals, *input.Bio) - } - 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()) + for _, col := range ProfileColOrder { + if val, ok := rowMap[col]; ok { + cols = append(cols, col) + vals = append(vals, val) + } } returningCols := q.selectProfileCols(selects, omits) diff --git a/integration/valk/user.go b/integration/valk/user.go index 29237fe..eb86976 100644 --- a/integration/valk/user.go +++ b/integration/valk/user.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "slices" - "strings" - "unicode/utf8" ) // User represents the database model @@ -181,51 +179,25 @@ func validateUserCreate(assignments []FieldAssignment) error { switch a.Col { case "id": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("id", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("id", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "id", v, false, 0, false, false) } else { errs.Add("id", a.Val, "type", "field id must be of type string") } case "email": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("email", v, "required", "field email is required") - } - if strings.Contains(v, "\x00") { - errs.Add("email", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("email", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "email", v, true, 0, false, false) } else { errs.Add("email", a.Val, "type", "field email must be of type string") } case "phoneNum": if v, ok := a.Val.(string); ok { - if v == "" { - errs.Add("phoneNum", v, "required", "field phoneNum is required") - } - if strings.Contains(v, "\x00") { - errs.Add("phoneNum", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("phoneNum", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "phoneNum", v, true, 0, false, false) } else { errs.Add("phoneNum", a.Val, "type", "field phoneNum must be of type string") } case "password": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("password", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("password", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "password", v, false, 0, false, false) } else { errs.Add("password", a.Val, "type", "field password must be of type string") } @@ -247,12 +219,7 @@ func validateUserCreate(assignments []FieldAssignment) error { } case "referredById": if v, ok := a.Val.(string); ok { - if strings.Contains(v, "\x00") { - errs.Add("referredById", v, "safety", "string cannot contain null bytes") - } - if !utf8.ValidString(v) { - errs.Add("referredById", v, "safety", "string must be valid UTF-8") - } + ValidateString(errs, "referredById", v, false, 0, false, false) } else { errs.Add("referredById", a.Val, "type", "field referredById must be of type string") } @@ -345,34 +312,14 @@ func (q *Queries) executeUserCreate(ctx context.Context, assignments []FieldAssi return nil, err } + rowMap := input.ToRowMap() var cols []string var vals []any - if input.Id != nil { - cols = append(cols, "id") - vals = append(vals, *input.Id) - } else { - cols = append(cols, "id") - vals = append(vals, generateCUID()) - } - cols = append(cols, "email") - vals = append(vals, input.Email) - cols = append(cols, "phoneNum") - vals = append(vals, input.PhoneNum) - if input.Password != nil { - cols = append(cols, "password") - vals = append(vals, *input.Password) - } - if input.Role != nil { - cols = append(cols, "role") - vals = append(vals, *input.Role) - } - if input.RoleOptional != nil { - cols = append(cols, "roleOptional") - vals = append(vals, *input.RoleOptional) - } - if input.ReferredById != nil { - cols = append(cols, "referredById") - vals = append(vals, *input.ReferredById) + for _, col := range UserColOrder { + if val, ok := rowMap[col]; ok { + cols = append(cols, col) + vals = append(vals, val) + } } returningCols := q.selectUserCols(selects, omits) diff --git a/makefile b/makefile index 7c7d988..540c03e 100644 --- a/makefile +++ b/makefile @@ -1,4 +1,4 @@ -.PHONY: build build-prod run test install db-up db-down db-clean bi fmt fmt-check vet integration-gen integration-test bench race lint +.PHONY: build build-prod run test install db-up db-down db-clean bi fmt fmt-check vet integration-gen integration-test bench race lint test-sqlite test-pg test-dbs bi: build install @@ -60,4 +60,21 @@ db-clean: docker compose down -v db-reset: - docker compose exec db psql -U postgres -d postgres -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" \ No newline at end of file + docker compose exec db psql -U postgres -d postgres -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" + +test-sqlite: bi test + node integration/prepareSchema.js sqlite + cd integration && ../bin/valk -g + rm -f integration/valk/migrations/*.sql + rm -f integration/dev.db + cd integration && DATABASE_URL="file:./dev.db" DATABASE_DIRECT_URL="file:./dev.db" ../bin/valk -m init + cd integration && go test -tags sqlite -v ./... + +test-pg: bi db-reset test + node integration/prepareSchema.js postgres + cd integration && ../bin/valk -g + rm -f integration/valk/migrations/*.sql + cd integration && DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" DATABASE_DIRECT_URL="postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" ../bin/valk -m init + cd integration && go test -v ./... + +test-dbs: test-sqlite test-pg \ No newline at end of file diff --git a/migration/diff.go b/migration/diff.go index fc9b6db..772cd65 100644 --- a/migration/diff.go +++ b/migration/diff.go @@ -146,18 +146,18 @@ func injectRequiredExtensions(upSQL string, targetSchema *vs.Schema) string { needed := make(map[string]string) for _, model := range targetSchema.Models { for _, sf := range model.ScalarFields { - switch { - case sf.SQLType == "hstore": - needed["hstore"] = "hstore" - case sf.SQLType == "ltree": - needed["ltree"] = "ltree" - case sf.SQLType == "citext": - needed["citext"] = "citext" - case sf.NativeType != nil && sf.NativeType.Name == "Citext": - needed["citext"] = "citext" - case strings.HasPrefix(sf.SQLType, "geometry") || strings.HasPrefix(sf.SQLType, "geography"): + for _, spec := range vs.NativeTypes { + if (sf.NativeType != nil && strings.EqualFold(sf.NativeType.Name, spec.PrismaName)) || + strings.EqualFold(sf.SQLType, spec.SQLType) { + if spec.Extension != "" { + needed[spec.Extension] = spec.Extension + } + } + } + lowerSql := strings.ToLower(sf.SQLType) + if strings.HasPrefix(lowerSql, "geometry") || strings.HasPrefix(lowerSql, "geography") { needed["postgis"] = "postgis" - case strings.HasPrefix(sf.SQLType, "vector"): + } else if strings.HasPrefix(lowerSql, "vector") { needed["vector"] = "vector" } } diff --git a/schema/native_types.go b/schema/native_types.go new file mode 100644 index 0000000..51dc6c8 --- /dev/null +++ b/schema/native_types.go @@ -0,0 +1,35 @@ +package schema + +type NativeTypeSpec struct { + PrismaName string + SQLType string + GoType string + Extension string +} + +var NativeTypes = []NativeTypeSpec{ + {PrismaName: "VarChar", SQLType: "VARCHAR", GoType: "string"}, + {PrismaName: "Char", SQLType: "CHAR", GoType: "string"}, + {PrismaName: "Text", SQLType: "TEXT", GoType: "string"}, + {PrismaName: "Decimal", SQLType: "NUMERIC", GoType: "string"}, + {PrismaName: "Numeric", SQLType: "NUMERIC", GoType: "string"}, + {PrismaName: "Uuid", SQLType: "UUID", GoType: "string"}, + {PrismaName: "Timestamptz", SQLType: "TIMESTAMPTZ", GoType: "time.Time"}, + {PrismaName: "Date", SQLType: "DATE", GoType: "time.Time"}, + {PrismaName: "SmallInt", SQLType: "SMALLINT", GoType: "int32"}, + {PrismaName: "Oid", SQLType: "OID", GoType: "int32"}, + {PrismaName: "Bit", SQLType: "BIT", GoType: "string"}, + {PrismaName: "VarBit", SQLType: "BIT VARYING", GoType: "string"}, + {PrismaName: "Inet", SQLType: "INET", GoType: "string"}, + {PrismaName: "Xml", SQLType: "XML", GoType: "string"}, + {PrismaName: "Real", SQLType: "REAL", GoType: "float64"}, + {PrismaName: "Money", SQLType: "MONEY", GoType: "string"}, + {PrismaName: "Json", SQLType: "JSON", GoType: "json.RawMessage"}, + {PrismaName: "Time", SQLType: "TIME", GoType: "time.Time"}, + {PrismaName: "Timetz", SQLType: "TIMETZ", GoType: "time.Time"}, + + // Extension-backed types + {PrismaName: "Citext", SQLType: "citext", GoType: "string", Extension: "citext"}, + {PrismaName: "Ltree", SQLType: "ltree", GoType: "string", Extension: "ltree"}, + {PrismaName: "Hstore", SQLType: "hstore", GoType: "map[string]*string", Extension: "hstore"}, +} diff --git a/schema/parser.go b/schema/parser.go index 228c3f3..1fe39fc 100644 --- a/schema/parser.go +++ b/schema/parser.go @@ -230,26 +230,6 @@ func (p *Parser) expect(t TokenType) Token { return tok } -func (p *Parser) skipBlock() { - for !p.eof() && p.current().Type != LBRACE && p.current().Type != EOF { - p.advance() - } - if p.eof() || p.current().Type == EOF { - return - } - p.expect(LBRACE) - braceCount := 1 - for !p.eof() && braceCount > 0 && p.current().Type != EOF { - if p.current().Type == LBRACE { - braceCount++ - } else if p.current().Type == RBRACE { - braceCount-- - } - p.advance() - } - p.popDelim(RBRACE) -} - func (p *Parser) parseDatasourceDecl() astDatasourceDecl { startTok := p.expect(IDENT) name := p.expect(IDENT).Value diff --git a/schema/resolver.go b/schema/resolver.go index 7c77b08..996653f 100644 --- a/schema/resolver.go +++ b/schema/resolver.go @@ -246,59 +246,24 @@ func stringifyArgs(args []Argument) []string { } func nativeTypeToSQL(name string) string { - switch name { - case "VarChar": - return "VARCHAR" - case "Char": - return "CHAR" - case "Text": - return "TEXT" - case "Decimal", "Numeric": - return "NUMERIC" - case "Uuid": - return "UUID" - case "Timestamptz": - return "TIMESTAMPTZ" - case "Date": - return "DATE" - case "SmallInt": - return "SMALLINT" - case "Oid": - return "OID" - case "Bit": - return "BIT" - case "VarBit": - return "BIT VARYING" - case "Inet": - return "INET" - case "Xml": - return "XML" - case "Citext": - return "CITEXT" - case "Real": - return "REAL" - case "Money": - return "MONEY" - case "Json": - return "JSON" - case "Time": - return "TIME" - case "Timetz": - return "TIMETZ" - default: - return "" + for _, spec := range NativeTypes { + if strings.EqualFold(spec.PrismaName, name) { + return spec.SQLType + } } + return "" } func unsupportedToGoType(sqlType string) string { lower := strings.ToLower(sqlType) + for _, spec := range NativeTypes { + if strings.ToLower(spec.SQLType) == lower { + return spec.GoType + } + } switch { - case lower == "citext": - return "string" - case lower == "ltree": - return "string" - case strings.HasPrefix(lower, "hstore"): - return "map[string]*string" + case strings.HasPrefix(lower, "geometry") || strings.HasPrefix(lower, "geography"): + return "any" default: return "any" }