Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions generator/generator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package generator

import (
"strings"
"testing"
"valkyrie/schema"
)

func TestGenerateClient_NativeDBConstraints(t *testing.T) {
sch := schema.Schema{
Models: []*schema.Model{
{
Name: "Item",
TableName: "items",
ScalarFields: []*schema.ScalarField{
{
Name: "id",
Type: "String",
GoType: "string",
IsID: true,
},
{
Name: "code",
Type: "String",
GoType: "string",
NativeType: &schema.NativeType{
Name: "VarChar",
Args: []string{"8"},
},
},
{
Name: "count",
Type: "Int",
GoType: "int32",
NativeType: &schema.NativeType{
Name: "SmallInt",
},
},
},
},
},
}

outputs, err := GenerateClient(sch, "valkyrie", "", "", nil)
if err != nil {
t.Fatalf("failed to generate client: %v", err)
}

itemCode, ok := outputs["item.go"]
if !ok {
t.Fatal("expected item.go in outputs")
}

// Verify length checks are generated
if !strings.Contains(itemCode, "utf8.RuneCountInString(input.Code) > 8") {
t.Errorf("expected generated code to contain VarChar limit check, got:\n%s", itemCode)
}

// Verify SmallInt range checks are generated
if !strings.Contains(itemCode, "input.Count < -32768 || input.Count > 32767") {
t.Errorf("expected generated code to contain SmallInt limit check, got:\n%s", itemCode)
}
}
39 changes: 39 additions & 0 deletions generator/templates/header.gotpl
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,42 @@ func generateCUID() string {
func generateUUID() string {
return uuid.New().String()
}

// 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
}

3 changes: 3 additions & 0 deletions generator/templates/model_header.gotpl
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"slices"
"strings"
"time"
"unicode/utf8"
)

var _ = time.Time{}
Expand All @@ -15,3 +16,5 @@ var _ = strings.Join
var _ = context.Background
var _ = sql.LevelDefault
var _ = slices.Contains[[]string, string]
var _ = utf8.ValidString

119 changes: 105 additions & 14 deletions generator/templates/model_structs.gotpl
Original file line number Diff line number Diff line change
Expand Up @@ -85,37 +85,128 @@ func (q *Queries) select{{ .Model.Name }}Cols(selects *{{ .Model.Name }}Select,
}

func (input {{ .Model.Name }}CreateInput) Validate() error {
errs := &ValidationError{}

{{- range $field := .Model.ScalarFields }}
{{- $fieldName := capitalize $field.Name }}
{{- if $field.EnumRef }}
{{- if $field.IsArray }}
for _, val := range input.{{ capitalize $field.Name }} {
for i, val := range input.{{ $fieldName }} {
if !val.IsValid() {
return fmt.Errorf("invalid enum value %q for field {{ capitalize $field.Name }}", val)
errs.Add(fmt.Sprintf("{{ $field.Name }}[%d]", i), val, "enum", fmt.Sprintf("invalid enum value %q for field {{ $fieldName }}", val))
}
}
{{- else if and (eq $field.Default nil) (not $field.Optional) }}
if input.{{ capitalize $field.Name }} == nil {
return fmt.Errorf("field {{ capitalize $field.Name }} is required")
}
if !input.{{ capitalize $field.Name }}.IsValid() {
return fmt.Errorf("invalid enum value %q for field {{ capitalize $field.Name }}", *input.{{ capitalize $field.Name }})
if input.{{ $fieldName }} == nil {
errs.Add("{{ $field.Name }}", nil, "required", "field {{ $fieldName }} is required")
} else if !input.{{ $fieldName }}.IsValid() {
errs.Add("{{ $field.Name }}", *input.{{ $fieldName }}, "enum", fmt.Sprintf("invalid enum value %q for field {{ $fieldName }}", *input.{{ $fieldName }}))
}
{{- else }}
if input.{{ capitalize $field.Name }} != nil {
if !input.{{ capitalize $field.Name }}.IsValid() {
return fmt.Errorf("invalid enum value %q for field {{ capitalize $field.Name }}", *input.{{ capitalize $field.Name }})
if input.{{ $fieldName }} != nil {
if !input.{{ $fieldName }}.IsValid() {
errs.Add("{{ $field.Name }}", *input.{{ $fieldName }}, "enum", fmt.Sprintf("invalid enum value %q for field {{ $fieldName }}", *input.{{ $fieldName }}))
}
}
{{- end }}
{{- else }}
{{- if and (eq $field.Default nil) (not $field.Optional) (not $field.IsArray) }}
{{- if eq $field.GoType "string" }}
if input.{{ capitalize $field.Name }} == "" {
return fmt.Errorf("field {{ capitalize $field.Name }} is required")
{{- if eq $field.GoType "string" }}
{{- if and (eq $field.Default nil) (not $field.Optional) (not $field.IsArray) }}
if input.{{ $fieldName }} == "" {
errs.Add("{{ $field.Name }}", input.{{ $fieldName }}, "required", "field {{ $fieldName }} is required")
}
{{- end }}

{{- if and (ne $field.Default nil) (not $field.Optional) }}
if input.{{ $fieldName }} != nil {
val := *input.{{ $fieldName }}
if strings.Contains(val, "\x00") {
errs.Add("{{ $field.Name }}", val, "safety", "string cannot contain null bytes")
}
if !utf8.ValidString(val) {
errs.Add("{{ $field.Name }}", val, "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(val) > {{ $limit }} {
errs.Add("{{ $field.Name }}", val, "length", "string exceeds maximum length of {{ $limit }} characters")
}
{{- end }}
{{- end }}
}
{{- else if $field.Optional }}
if input.{{ $fieldName }} != nil {
val := *input.{{ $fieldName }}
if strings.Contains(val, "\x00") {
errs.Add("{{ $field.Name }}", val, "safety", "string cannot contain null bytes")
}
if !utf8.ValidString(val) {
errs.Add("{{ $field.Name }}", val, "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(val) > {{ $limit }} {
errs.Add("{{ $field.Name }}", val, "length", "string exceeds maximum length of {{ $limit }} characters")
}
{{- end }}
{{- end }}
}
{{- else }}
if strings.Contains(input.{{ $fieldName }}, "\x00") {
errs.Add("{{ $field.Name }}", input.{{ $fieldName }}, "safety", "string cannot contain null bytes")
}
if !utf8.ValidString(input.{{ $fieldName }}) {
errs.Add("{{ $field.Name }}", input.{{ $fieldName }}, "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(input.{{ $fieldName }}) > {{ $limit }} {
errs.Add("{{ $field.Name }}", input.{{ $fieldName }}, "length", "string exceeds maximum length of {{ $limit }} characters")
}
{{- end }}
{{- end }}
{{- end }}
{{- else if or (eq $field.GoType "int32") (eq $field.GoType "int64") (eq $field.GoType "int") }}
{{- if $field.NativeType }}
{{- if eq $field.NativeType.Name "SmallInt" }}
{{- if and (ne $field.Default nil) (not $field.Optional) }}
if input.{{ $fieldName }} != nil && (*input.{{ $fieldName }} < -32768 || *input.{{ $fieldName }} > 32767) {
errs.Add("{{ $field.Name }}", *input.{{ $fieldName }}, "range", "value is out of range for SmallInt (-32768 to 32767)")
}
{{- else if $field.Optional }}
if input.{{ $fieldName }} != nil && (*input.{{ $fieldName }} < -32768 || *input.{{ $fieldName }} > 32767) {
errs.Add("{{ $field.Name }}", *input.{{ $fieldName }}, "range", "value is out of range for SmallInt (-32768 to 32767)")
}
{{- else }}
if input.{{ $fieldName }} < -32768 || input.{{ $fieldName }} > 32767 {
errs.Add("{{ $field.Name }}", input.{{ $fieldName }}, "range", "value is out of range for SmallInt (-32768 to 32767)")
}
{{- end }}
{{- else if eq $field.NativeType.Name "TinyInt" }}
{{- if and (ne $field.Default nil) (not $field.Optional) }}
if input.{{ $fieldName }} != nil && (*input.{{ $fieldName }} < -128 || *input.{{ $fieldName }} > 127) {
errs.Add("{{ $field.Name }}", *input.{{ $fieldName }}, "range", "value is out of range for TinyInt (-128 to 127)")
}
{{- else if $field.Optional }}
if input.{{ $fieldName }} != nil && (*input.{{ $fieldName }} < -128 || *input.{{ $fieldName }} > 127) {
errs.Add("{{ $field.Name }}", *input.{{ $fieldName }}, "range", "value is out of range for TinyInt (-128 to 127)")
}
{{- else }}
if input.{{ $fieldName }} < -128 || input.{{ $fieldName }} > 127 {
errs.Add("{{ $field.Name }}", input.{{ $fieldName }}, "range", "value is out of range for TinyInt (-128 to 127)")
}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

if errs.HasErrors() {
return *errs
}
return nil
}
70 changes: 66 additions & 4 deletions integration/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,17 +141,31 @@ func TestCreateValidation(t *testing.T) {
defer cleanup()
ctx := context.Background()

// basic required check
_, err := db.User.Create(valkyrie.UserCreateInput{
// no email
PhoneNum: "+123456789",
}).Exec(ctx)
if err == nil {
t.Fatal("expected error creating user with empty required email, got nil")
}
if !strings.Contains(err.Error(), "field Email is required") {
t.Errorf("expected error message to contain 'field Email is required', got: %v", err)

valErr, ok := err.(valkyrie.ValidationError)
if !ok {
t.Fatalf("expected error to be valkyrie.ValidationError, got type %T: %v", err, err)
}

foundEmailErr := false
for _, fErr := range valErr.Errors {
if fErr.Field == "email" && fErr.Rule == "required" {
foundEmailErr = true
}
}
if !foundEmailErr {
t.Errorf("expected required email error in ValidationError.Errors, got: %v", valErr.Errors)
}

// invalid enum
invalidRole := valkyrie.UserRoleType("INVALID_ROLE")
_, err = db.User.Create(valkyrie.UserCreateInput{
Email: "invalid_role@example.com",
Expand All @@ -161,7 +175,55 @@ func TestCreateValidation(t *testing.T) {
if err == nil {
t.Fatal("expected error creating user with invalid enum role, got nil")
}
if !strings.Contains(err.Error(), "invalid enum value \"INVALID_ROLE\" for field Role") {
t.Errorf("expected error message to contain 'invalid enum value \"INVALID_ROLE\" for field Role', got: %v", err)
valErr2, ok := err.(valkyrie.ValidationError)
if !ok {
t.Fatalf("expected valkyrie.ValidationError, got %T: %v", err, err)
}
foundRoleErr := false
for _, fErr := range valErr2.Errors {
if fErr.Field == "role" && fErr.Rule == "enum" {
foundRoleErr = true
}
}
if !foundRoleErr {
t.Errorf("expected enum role validation error, got: %v", valErr2.Errors)
}

// Multi-error (no email + null-byte)
_, err = db.User.Create(valkyrie.UserCreateInput{
// no email
PhoneNum: "phone\x00num",
}).Exec(ctx)
if err == nil {
t.Fatal("expected error, got nil")
}
valErr3, ok := err.(valkyrie.ValidationError)
if !ok {
t.Fatalf("expected valkyrie.ValidationError, got: %v", err)
}
if len(valErr3.Errors) < 2 {
t.Errorf("expected at least 2 errors aggregated, got %d: %v", len(valErr3.Errors), valErr3.Errors)
}

// UTF-8 validation
_, err = db.User.Create(valkyrie.UserCreateInput{
Email: "utf8@example.com",
PhoneNum: "invalid\xffutf8",
}).Exec(ctx)
if err == nil {
t.Fatal("expected error for invalid UTF-8, got nil")
}
valErr4, ok := err.(valkyrie.ValidationError)
if !ok {
t.Fatalf("expected valkyrie.ValidationError, got: %v", err)
}
foundSafetyErr := false
for _, fErr := range valErr4.Errors {
if fErr.Field == "phoneNum" && fErr.Rule == "safety" && strings.Contains(fErr.Msg, "UTF-8") {
foundSafetyErr = true
}
}
if !foundSafetyErr {
t.Errorf("expected UTF-8 safety error on phoneNum, got: %v", valErr4.Errors)
}
}
Loading
Loading