diff --git a/generator/generator_test.go b/generator/generator_test.go new file mode 100644 index 0000000..6f62c65 --- /dev/null +++ b/generator/generator_test.go @@ -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) + } +} diff --git a/generator/templates/header.gotpl b/generator/templates/header.gotpl index 0ce988f..f5b7cda 100644 --- a/generator/templates/header.gotpl +++ b/generator/templates/header.gotpl @@ -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 +} + diff --git a/generator/templates/model_header.gotpl b/generator/templates/model_header.gotpl index 53e4454..ee878fa 100644 --- a/generator/templates/model_header.gotpl +++ b/generator/templates/model_header.gotpl @@ -7,6 +7,7 @@ import ( "slices" "strings" "time" + "unicode/utf8" ) var _ = time.Time{} @@ -15,3 +16,5 @@ var _ = strings.Join var _ = context.Background var _ = sql.LevelDefault var _ = slices.Contains[[]string, string] +var _ = utf8.ValidString + diff --git a/generator/templates/model_structs.gotpl b/generator/templates/model_structs.gotpl index 4275267..34ff73c 100644 --- a/generator/templates/model_structs.gotpl +++ b/generator/templates/model_structs.gotpl @@ -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 } diff --git a/integration/create_test.go b/integration/create_test.go index 76816a2..a4e0b27 100644 --- a/integration/create_test.go +++ b/integration/create_test.go @@ -141,6 +141,7 @@ func TestCreateValidation(t *testing.T) { defer cleanup() ctx := context.Background() + // basic required check _, err := db.User.Create(valkyrie.UserCreateInput{ // no email PhoneNum: "+123456789", @@ -148,10 +149,23 @@ func TestCreateValidation(t *testing.T) { 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", @@ -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) } } diff --git a/integration/validation_test.go b/integration/validation_test.go new file mode 100644 index 0000000..3b35d26 --- /dev/null +++ b/integration/validation_test.go @@ -0,0 +1,449 @@ +package main + +import ( + "context" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + "unicode/utf8" + + "integration/valkyrie" +) + +func TestCreate_DuplicateEmail_Rejected(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + input := valkyrie.UserCreateInput{Email: "dupe@example.com", PhoneNum: "+100000001"} + + if _, err := db.User.Create(input).Exec(ctx); err != nil { + t.Fatalf("first insert should succeed, got: %v", err) + } + + // Same email, different phoneNum, must still fail if email is unique + dupe := valkyrie.UserCreateInput{Email: "dupe@example.com", PhoneNum: "+100000002"} + if _, err := db.User.Create(dupe).Exec(ctx); err == nil { + t.Fatal("expected unique constraint violation on duplicate email, got nil error") + } + + var count int + if err := db.Raw().QueryRowContext(ctx, query("SELECT COUNT(*) FROM User WHERE email = ?", "SELECT COUNT(*) FROM \"User\" WHERE email = $1"), "dupe@example.com").Scan(&count); err != nil { + t.Fatalf("failed count query: %v", err) + } + if count != 1 { + t.Fatalf("expected exactly 1 row after rejected duplicate, got %d", count) + } +} + +func TestCreate_DuplicateEmail_CaseVariants(t *testing.T) { + + db, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + if _, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "Case@Example.com", PhoneNum: "+100000003", + }).Exec(ctx); err != nil { + t.Fatalf("failed initial insert: %v", err) + } + + _, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "case@example.com", PhoneNum: "+100000004", + }).Exec(ctx) + + t.Logf("case-variant email insert result: err=%v (confirm this matches intended uniqueness semantics)", err) +} + +func TestCreate_CompoundUnique_Rejected(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + if _, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "a@example.com", PhoneNum: "+199999999", + }).Exec(ctx); err != nil { + t.Fatalf("first insert failed: %v", err) + } + + // Same email + same phoneNum hits @@unique([email, phoneNum]). + if _, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "a@example.com", PhoneNum: "+199999999", + }).Exec(ctx); err == nil { + t.Fatal("expected unique constraint violation on duplicate (email, phoneNum)") + } +} + +func TestCreate_ZeroValueInput_Rejected(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + _, err := db.User.Create(valkyrie.UserCreateInput{}).Exec(ctx) + if err == nil { + t.Fatal("expected error creating user with entirely zero-value input (missing required email)") + } +} + +func TestCreate_EmptyStringEmail_Rejected(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + _, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "", PhoneNum: "+100000005", + }).Exec(ctx) + if err == nil { + t.Fatal("expected error creating user with empty-string email") + } +} + +func TestCreate_WhitespaceOnlyEmail_Rejected(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + _, err := db.User.Create(valkyrie.UserCreateInput{ + Email: " ", PhoneNum: "+100000006", + }).Exec(ctx) + + if err == nil { + t.Log("WARNING: whitespace-only email was accepted confirm this is intentional, not an oversight") + } +} + +func TestCreate_ReferredBy_NonexistentID_Rejected(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + fakeID := "clnonexistent00000000000" + _, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "orphan@example.com", + PhoneNum: "+100000007", + ReferredById: &fakeID, + }).Exec(ctx) + + if err == nil { + t.Fatal("expected FK violation when referredById points to a nonexistent user") + } +} + +func TestCreate_ReferredBy_SelfReference_Rejected(t *testing.T) { + + db, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + u, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "self@example.com", PhoneNum: "+100000008", + }).Exec(ctx) + if err != nil { + t.Fatalf("setup insert failed: %v", err) + } + + bReferrer := u.Id + b, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "referred@example.com", PhoneNum: "+100000009", ReferredById: &bReferrer, + }).Exec(ctx) + if err != nil { + t.Fatalf("valid referral chain should succeed: %v", err) + } + if b.ReferredById == nil || *b.ReferredById != u.Id { + t.Fatalf("expected ReferredById %q, got %v", u.Id, b.ReferredById) + } +} + +func TestCreate_InvalidEnumValue_BypassingTypeSystem(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + pffff := valkyrie.UserRoleType("totallyNotARole") + + _, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "pffff-role@example.com", PhoneNum: "+100000010", Role: &pffff, + }).Exec(ctx) + + if err == nil { + t.Fatal("expected rejection of an enum value outside the declared domain") + } +} + +func TestCreate_DefaultEnumAppliedWhenNil(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + u, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "noRole@example.com", PhoneNum: "+100000011", Role: nil, + }).Exec(ctx) + if err != nil { + t.Fatalf("failed to create: %v", err) + } + if u.Role != valkyrie.UserRole.Student { + t.Errorf("expected default role Student when Role is nil, got %q", u.Role) + } +} + +func TestCreate_StringEdgeCases(t *testing.T) { + cases := []struct { + name string + email string + phone string + expectError bool + }{ + {"unicode_email_local_part", "üñîçødé@example.com", "+200000001", false}, + // {"emoji_in_phone", "emoji@example.com", "+2000🎉0002", true}, // should pass when i implement validation metadata/// + // {"very_long_email", strings.Repeat("a", 300) + "@example.com", "+200000003", true}, // should pass when i implement validation metadata (or a db.VarChar() but not now to keep it valid for integration tests with sqlite)/// + {"sql_injection_shaped_email", `test' OR '1'='1@example.com`, "+200000004", false}, + {"null_byte_in_email", "nul\x00byte@example.com", "+200000005", true}, + {"leading_trailing_whitespace_email", " padded@example.com ", "+200000006", false}, + {"rtl_override_char", "\u202Eevil@example.com", "+200000007", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + u, err := db.User.Create(valkyrie.UserCreateInput{ + Email: tc.email, PhoneNum: tc.phone, + }).Exec(ctx) + + if tc.expectError && err == nil { + t.Fatalf("expected error for input %q, got success (id=%s)", tc.email, u.Id) + } + if !tc.expectError && err != nil { + t.Fatalf("expected success for input %q, got error: %v", tc.email, err) + } + if err == nil && !utf8.ValidString(u.Email) { + t.Errorf("returned email is not valid UTF-8: %q", u.Email) + } + if err == nil { + var stored string + qerr := db.Raw().QueryRowContext(ctx, query("SELECT email FROM User WHERE id = ?", "SELECT email FROM \"User\" WHERE id = $1"), u.Id).Scan(&stored) + if qerr != nil { + t.Fatalf("failed to read back: %v", qerr) + } + if stored != strings.TrimSpace(tc.email) && stored != tc.email { + t.Errorf("stored email %q does not match input %q (check for silent mutation)", stored, tc.email) + } + } + }) + } +} + +func TestCreate_Select_ForceIncludesFK_EvenWhenNotExplicitlySelected(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + referrer, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "referrer@example.com", PhoneNum: "+300000002", + }).Exec(ctx) + if err != nil { + t.Fatalf("setup failed: %v", err) + } + + rid := referrer.Id + u, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "referredfk@example.com", PhoneNum: "+300000003", ReferredById: &rid, + }).Select(valkyrie.UserSelect{ + Id: true, + ReferredBy: &valkyrie.UserSelect{Id: true}, + // ReferredById itself intenionally not selected + }).Exec(ctx) + + if err != nil { + t.Fatalf("create failed: %v", err) + } + if u.ReferredBy == nil { + t.Fatal("expected ReferredBy to be populated when its relation was selected") + } + if u.ReferredBy.Id != referrer.Id { + t.Errorf("expected ReferredBy.Id=%s, got %s", referrer.Id, u.ReferredBy.Id) + } +} + +func TestCreate_Select_EmptyStruct_ReturnsEverything(t *testing.T) { + + db, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + u, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "empty-select@example.com", PhoneNum: "+300000004", + }).Select(valkyrie.UserSelect{}).Exec(ctx) + + if err != nil { + t.Fatalf("create failed: %v", err) + } + if u.Email != "empty-select@example.com" { + t.Errorf("expected empty Select{} to select everything (select all), got Email=%q", u.Email) + } +} + +func TestCreate_ContextAlreadyCancelled(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before the call even starts + + _, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "cancelled@example.com", PhoneNum: "+400000001", + }).Exec(ctx) + + if err == nil { + t.Fatal("expected error when context is already cancelled") + } + + // Confirm nothing leaked through despite the cancelled context. + var count int + if qerr := db.Raw().QueryRowContext(context.Background(), + query("SELECT COUNT(*) FROM User WHERE email = ?", "SELECT COUNT(*) FROM \"User\" WHERE email = $1"), "cancelled@example.com").Scan(&count); qerr != nil { + t.Fatalf("count query failed: %v", qerr) + } + if count != 0 { + t.Fatalf("expected no row persisted for cancelled-context create, found %d", count) + } +} + +func TestCreate_ContextTimeout_DuringExec(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + // should fail cleanly with no partial write. + ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond) + defer cancel() + + _, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "timeout@example.com", PhoneNum: "+400000002", + }).Exec(ctx) + + if err == nil { + t.Log("create succeeded despite near-zero timeout likely fine if driver executes faster than ctx propagation, but worth a second look under load") + } +} + +func TestCreate_ConcurrentDuplicateEmail_ExactlyOneWins(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + const goroutines = 20 + var wg sync.WaitGroup + var successes int64 + var failures int64 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + _, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "race@example.com", + PhoneNum: fmt.Sprintf("+50000%04d", n), // distinct phones so only email collides + }).Exec(ctx) + if err != nil { + atomic.AddInt64(&failures, 1) + } else { + atomic.AddInt64(&successes, 1) + } + }(i) + } + wg.Wait() + + if successes != 1 { + t.Fatalf("expected exactly 1 success under concurrent duplicate-email creates, got %d successes, %d failures", + successes, failures) + } + + var count int + if err := db.Raw().QueryRowContext(ctx, query("SELECT COUNT(*) FROM User WHERE email = ?", "SELECT COUNT(*) FROM \"User\" WHERE email = $1"), "race@example.com").Scan(&count); err != nil { + t.Fatalf("count query failed: %v", err) + } + if count != 1 { + t.Fatalf("expected exactly 1 persisted row, found %d unique constraint may not be enforced at the DB level under concurrency", count) + } +} + +func TestCreate_ConcurrentUniqueIDs_NoCollision(t *testing.T) { + + db, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + const n = 200 + var wg sync.WaitGroup + ids := make([]string, n) + errs := make([]error, n) + + for i := 0; i < n; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + u, err := db.User.Create(valkyrie.UserCreateInput{ + Email: fmt.Sprintf("bulk%d@example.com", idx), + PhoneNum: fmt.Sprintf("+600%06d", idx), + }).Exec(ctx) + errs[idx] = err + if err == nil { + ids[idx] = u.Id + } + }(i) + } + wg.Wait() + + seen := make(map[string]bool, n) + for i, err := range errs { + if err != nil { + t.Fatalf("create %d failed: %v", i, err) + } + if ids[i] == "" { + t.Fatalf("create %d returned empty ID", i) + } + if seen[ids[i]] { + t.Fatalf("duplicate CUID generated: %s", ids[i]) + } + seen[ids[i]] = true + } +} + +func TestCreate_FailurePartway_LeavesNoPartialRow(t *testing.T) { + + db, cleanup := setupTestDB(t) + defer cleanup() + ctx := context.Background() + + fakeReferrer := "clDoesNotExist00000000000" + before := countAllUsers(t, ctx, db) + + _, err := db.User.Create(valkyrie.UserCreateInput{ + Email: "partial@example.com", + PhoneNum: "+700000001", + ReferredById: &fakeReferrer, + }).Exec(ctx) + + if err == nil { + t.Fatal("expected FK failure") + } + + after := countAllUsers(t, ctx, db) + if after != before { + t.Fatalf("row count changed despite failed create: before=%d after=%d", before, after) + } +} + +func countAllUsers(t *testing.T, ctx context.Context, db *valkyrie.DB) int { + t.Helper() + var count int + if err := db.Raw().QueryRowContext(ctx, query("SELECT COUNT(*) FROM User", "SELECT COUNT(*) FROM \"User\"")).Scan(&count); err != nil { + t.Fatalf("count query failed: %v", err) + } + return count +} diff --git a/integration/valkyrie/category.go b/integration/valkyrie/category.go index 425f47a..6046608 100644 --- a/integration/valkyrie/category.go +++ b/integration/valkyrie/category.go @@ -7,6 +7,7 @@ import ( "slices" "strings" "time" + "unicode/utf8" ) var _ = time.Time{} @@ -15,6 +16,7 @@ var _ = strings.Join var _ = context.Background var _ = sql.LevelDefault var _ = slices.Contains[[]string, string] +var _ = utf8.ValidString // Category represents the database model type Category struct { @@ -89,8 +91,19 @@ func (q *Queries) selectCategoryCols(selects *CategorySelect, omits *CategoryOmi } func (input CategoryCreateInput) Validate() error { + errs := &ValidationError{} if input.Name == "" { - return fmt.Errorf("field Name is required") + errs.Add("name", input.Name, "required", "field Name is required") + } + if strings.Contains(input.Name, "\x00") { + errs.Add("name", input.Name, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(input.Name) { + errs.Add("name", input.Name, "safety", "string must be valid UTF-8") + } + + if errs.HasErrors() { + return *errs } return nil } diff --git a/integration/valkyrie/categoryToPost.go b/integration/valkyrie/categoryToPost.go index 288f440..43d00db 100644 --- a/integration/valkyrie/categoryToPost.go +++ b/integration/valkyrie/categoryToPost.go @@ -7,6 +7,7 @@ import ( "slices" "strings" "time" + "unicode/utf8" ) var _ = time.Time{} @@ -15,6 +16,7 @@ var _ = strings.Join var _ = context.Background var _ = sql.LevelDefault var _ = slices.Contains[[]string, string] +var _ = utf8.ValidString // CategoryToPost represents the database model type CategoryToPost struct { @@ -92,8 +94,19 @@ func (q *Queries) selectCategoryToPostCols(selects *CategoryToPostSelect, omits } func (input CategoryToPostCreateInput) Validate() error { + errs := &ValidationError{} if input.PostId == "" { - return fmt.Errorf("field PostId is required") + errs.Add("postId", input.PostId, "required", "field PostId is required") + } + if strings.Contains(input.PostId, "\x00") { + errs.Add("postId", input.PostId, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(input.PostId) { + errs.Add("postId", input.PostId, "safety", "string must be valid UTF-8") + } + + if errs.HasErrors() { + return *errs } return nil } diff --git a/integration/valkyrie/client.go b/integration/valkyrie/client.go index 6be244b..f050ef4 100644 --- a/integration/valkyrie/client.go +++ b/integration/valkyrie/client.go @@ -44,6 +44,44 @@ 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 +} + type UserRoleType string const ( diff --git a/integration/valkyrie/comment.go b/integration/valkyrie/comment.go index 107247f..00e3133 100644 --- a/integration/valkyrie/comment.go +++ b/integration/valkyrie/comment.go @@ -7,6 +7,7 @@ import ( "slices" "strings" "time" + "unicode/utf8" ) var _ = time.Time{} @@ -15,6 +16,7 @@ var _ = strings.Join var _ = context.Background var _ = sql.LevelDefault var _ = slices.Contains[[]string, string] +var _ = utf8.ValidString // Comment represents the database model type Comment struct { @@ -132,17 +134,55 @@ func (q *Queries) selectCommentCols(selects *CommentSelect, omits *CommentOmit, } func (input CommentCreateInput) Validate() error { + errs := &ValidationError{} + if input.Id != nil { + val := *input.Id + if strings.Contains(val, "\x00") { + errs.Add("id", val, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(val) { + errs.Add("id", val, "safety", "string must be valid UTF-8") + } + } if input.Dummy3 == "" { - return fmt.Errorf("field Dummy3 is required") + errs.Add("dummy3", input.Dummy3, "required", "field Dummy3 is required") + } + if strings.Contains(input.Dummy3, "\x00") { + errs.Add("dummy3", input.Dummy3, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(input.Dummy3) { + errs.Add("dummy3", input.Dummy3, "safety", "string must be valid UTF-8") } if input.Dummy2 == "" { - return fmt.Errorf("field Dummy2 is required") + errs.Add("dummy2", input.Dummy2, "required", "field Dummy2 is required") + } + if strings.Contains(input.Dummy2, "\x00") { + errs.Add("dummy2", input.Dummy2, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(input.Dummy2) { + errs.Add("dummy2", input.Dummy2, "safety", "string must be valid UTF-8") } if input.PostId == "" { - return fmt.Errorf("field PostId is required") + errs.Add("postId", input.PostId, "required", "field PostId is required") + } + if strings.Contains(input.PostId, "\x00") { + errs.Add("postId", input.PostId, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(input.PostId) { + errs.Add("postId", input.PostId, "safety", "string must be valid UTF-8") } if input.AuthorId == "" { - return fmt.Errorf("field AuthorId is required") + errs.Add("authorId", input.AuthorId, "required", "field AuthorId is required") + } + if strings.Contains(input.AuthorId, "\x00") { + errs.Add("authorId", input.AuthorId, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(input.AuthorId) { + errs.Add("authorId", input.AuthorId, "safety", "string must be valid UTF-8") + } + + if errs.HasErrors() { + return *errs } return nil } diff --git a/integration/valkyrie/migrations/00001_init.sql b/integration/valkyrie/migrations/00001_init.sql index 32ee87c..e9ec288 100644 --- a/integration/valkyrie/migrations/00001_init.sql +++ b/integration/valkyrie/migrations/00001_init.sql @@ -10,6 +10,7 @@ CREATE TABLE `User` ( CONSTRAINT `User_role_check` CHECK ("role" IN ('ADMIN', 'student', 'TEACHER')) ); CREATE UNIQUE INDEX `User_email_key` ON `User` (`email`); +CREATE UNIQUE INDEX `User_phoneNum_key` ON `User` (`phoneNum`); CREATE UNIQUE INDEX `User_email_phoneNum_key` ON `User` (`email`, `phoneNum`); CREATE TABLE `Profile` ( `id` text NOT NULL, diff --git a/integration/valkyrie/post.go b/integration/valkyrie/post.go index ded72f1..e6a47c5 100644 --- a/integration/valkyrie/post.go +++ b/integration/valkyrie/post.go @@ -7,6 +7,7 @@ import ( "slices" "strings" "time" + "unicode/utf8" ) var _ = time.Time{} @@ -15,6 +16,7 @@ var _ = strings.Join var _ = context.Background var _ = sql.LevelDefault var _ = slices.Contains[[]string, string] +var _ = utf8.ValidString // Post represents the database model type Post struct { @@ -119,11 +121,37 @@ func (q *Queries) selectPostCols(selects *PostSelect, omits *PostOmit, forceCols } func (input PostCreateInput) Validate() error { + errs := &ValidationError{} + if input.Id != nil { + val := *input.Id + if strings.Contains(val, "\x00") { + errs.Add("id", val, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(val) { + errs.Add("id", val, "safety", "string must be valid UTF-8") + } + } if input.Title == "" { - return fmt.Errorf("field Title is required") + errs.Add("title", input.Title, "required", "field Title is required") + } + if strings.Contains(input.Title, "\x00") { + errs.Add("title", input.Title, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(input.Title) { + errs.Add("title", input.Title, "safety", "string must be valid UTF-8") } if input.AuthorId == "" { - return fmt.Errorf("field AuthorId is required") + errs.Add("authorId", input.AuthorId, "required", "field AuthorId is required") + } + if strings.Contains(input.AuthorId, "\x00") { + errs.Add("authorId", input.AuthorId, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(input.AuthorId) { + errs.Add("authorId", input.AuthorId, "safety", "string must be valid UTF-8") + } + + if errs.HasErrors() { + return *errs } return nil } diff --git a/integration/valkyrie/profile.go b/integration/valkyrie/profile.go index f5f6cc1..b6386c2 100644 --- a/integration/valkyrie/profile.go +++ b/integration/valkyrie/profile.go @@ -7,6 +7,7 @@ import ( "slices" "strings" "time" + "unicode/utf8" ) var _ = time.Time{} @@ -15,6 +16,7 @@ var _ = strings.Join var _ = context.Background var _ = sql.LevelDefault var _ = slices.Contains[[]string, string] +var _ = utf8.ValidString // Profile represents the database model type Profile struct { @@ -97,8 +99,28 @@ func (q *Queries) selectProfileCols(selects *ProfileSelect, omits *ProfileOmit, } func (input ProfileCreateInput) Validate() error { + errs := &ValidationError{} + if input.Id != nil { + val := *input.Id + if strings.Contains(val, "\x00") { + errs.Add("id", val, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(val) { + errs.Add("id", val, "safety", "string must be valid UTF-8") + } + } if input.UserId == "" { - return fmt.Errorf("field UserId is required") + errs.Add("userId", input.UserId, "required", "field UserId is required") + } + if strings.Contains(input.UserId, "\x00") { + errs.Add("userId", input.UserId, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(input.UserId) { + errs.Add("userId", input.UserId, "safety", "string must be valid UTF-8") + } + + if errs.HasErrors() { + return *errs } return nil } diff --git a/integration/valkyrie/user.go b/integration/valkyrie/user.go index 173b7fe..a7cf4ed 100644 --- a/integration/valkyrie/user.go +++ b/integration/valkyrie/user.go @@ -7,6 +7,7 @@ import ( "slices" "strings" "time" + "unicode/utf8" ) var _ = time.Time{} @@ -15,6 +16,7 @@ var _ = strings.Join var _ = context.Background var _ = sql.LevelDefault var _ = slices.Contains[[]string, string] +var _ = utf8.ValidString // User represents the database model type User struct { @@ -125,17 +127,43 @@ func (q *Queries) selectUserCols(selects *UserSelect, omits *UserOmit, forceCols } func (input UserCreateInput) Validate() error { + errs := &ValidationError{} + if input.Id != nil { + val := *input.Id + if strings.Contains(val, "\x00") { + errs.Add("id", val, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(val) { + errs.Add("id", val, "safety", "string must be valid UTF-8") + } + } if input.Email == "" { - return fmt.Errorf("field Email is required") + errs.Add("email", input.Email, "required", "field Email is required") + } + if strings.Contains(input.Email, "\x00") { + errs.Add("email", input.Email, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(input.Email) { + errs.Add("email", input.Email, "safety", "string must be valid UTF-8") } if input.PhoneNum == "" { - return fmt.Errorf("field PhoneNum is required") + errs.Add("phoneNum", input.PhoneNum, "required", "field PhoneNum is required") + } + if strings.Contains(input.PhoneNum, "\x00") { + errs.Add("phoneNum", input.PhoneNum, "safety", "string cannot contain null bytes") + } + if !utf8.ValidString(input.PhoneNum) { + errs.Add("phoneNum", input.PhoneNum, "safety", "string must be valid UTF-8") } if input.Role != nil { if !input.Role.IsValid() { - return fmt.Errorf("invalid enum value %q for field Role", *input.Role) + errs.Add("role", *input.Role, "enum", fmt.Sprintf("invalid enum value %q for field Role", *input.Role)) } } + + if errs.HasErrors() { + return *errs + } return nil } diff --git a/makefile b/makefile index e458037..723fbf6 100644 --- a/makefile +++ b/makefile @@ -1,7 +1,9 @@ .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 bi: build install - + +e2e: test integration-test + race: go test -race ./... && cd integration && go test -race ./...