diff --git a/db.go b/db.go index 63ee72b..21f5257 100644 --- a/db.go +++ b/db.go @@ -19,6 +19,11 @@ func New(exec Executor, compiler Compiler) *DB { // Create inserts a new model into the database. func (db *DB) Create(m Model) error { + if v, ok := m.(fmt.Validator); ok { + if err := v.Validate(); err != nil { + return err + } + } if err := validate(ActionCreate, m); err != nil { return err } @@ -54,6 +59,11 @@ func (db *DB) Create(m Model) error { // Providing zero conditions is a compile-time error — there is no variadic // fallback — preventing accidental full-table UPDATE statements. func (db *DB) Update(m Model, cond Condition, rest ...Condition) error { + if v, ok := m.(fmt.Validator); ok { + if err := v.Validate(); err != nil { + return err + } + } if err := validate(ActionUpdate, m); err != nil { return err } diff --git a/docs/ARQUITECTURE.md b/docs/ARQUITECTURE.md index b2365e0..3e0e6a8 100644 --- a/docs/ARQUITECTURE.md +++ b/docs/ARQUITECTURE.md @@ -49,8 +49,8 @@ type Fielder interface { - `Name string` — column name (snake_case) - `Type fmt.FieldType` — `FieldText`, `FieldInt`, `FieldFloat`, `FieldBool`, `FieldBlob`, `FieldStruct` - `PK bool`, `Unique bool`, `NotNull bool`, `AutoInc bool` — schema constraints -- `Input string` — hint from `form:` struct tag (read by `tinywasm/form`) -- `JSON string` — hint from `json:` struct tag (read by `tinywasm/json`) +- `OmitEmpty bool` — hint from `json:",omitempty"` struct tag (read by `tinywasm/json`) +- `Permitted Permitted` — validation rules from `validate:` struct tag (read by `fmt.ValidateFielder`) > `Pointers()` are only called by the Executor logic for the operations that require them. diff --git a/docs/PLAN.md b/docs/CHECK_PLAN.md similarity index 100% rename from docs/PLAN.md rename to docs/CHECK_PLAN.md diff --git a/docs/SKILL.md b/docs/SKILL.md index 42c6053..ee362d7 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -56,8 +56,8 @@ type Fielder interface { | `Unique bool` | `db:"unique"` | | | `NotNull bool` | `db:"not_null"` | | | `AutoInc bool` | `db:"autoincrement"` | Numeric fields only | -| `Input string` | `form:"email"` | Hint for form rendering; `form:"-"` = skip | -| `JSON string` | `json:"name"` | Hint for JSON codec; `json:"-"` = skip | +| `OmitEmpty bool` | `json:",omitempty"` | Propagated from `json` tag | +| `Permitted fmt.Permitted` | `validate:"..."` | Validation rules for characters and bounds | | FK reference | `db:"ref=table"` or `db:"ref=table:column"` | stored in `FieldExt.Ref` + `FieldExt.RefColumn` | | Ignore field | `db:"-"` | Silently excluded from `Schema()`, `Pointers()` | @@ -107,19 +107,18 @@ Use a single `//go:generate` at the project root — **not** per struct: - `ReadOneT(qb *orm.QB, model *T) (*T, error)` - `ReadAllT(qb *orm.QB) ([]*T, error)` -### `form:` and `json:` tags +### `validate:` and `json:` tags `ormc` reads struct tags and propagates them to `fmt.Field`: | Tag | Field | Generated | |---|---|---| -| `form:"email"` | `Input = "email"` | `Input: "email"` | -| `form:"-"` | `Input = "-"` | `Input: "-"` (form skips it) | -| `json:"name"` | `JSON = "name"` | `JSON: "name"` | -| `json:"bio,omitempty"` | `JSON = "bio,omitempty"` | `JSON: "bio,omitempty"` | -| `json:"-"` | `JSON = "-"` | `JSON: "-"` (json skips it) | +| `validate:"required"` | `NotNull = true` | `NotNull: true` | +| `validate:"email"` | `Format = "email"` | Injects `form.ValidateEmail` call in `Validate()` | +| `validate:"min=2"` | `Permitted.Minimum = 2` | `Permitted: {Minimum: 2}` | +| `json:"bio,omitempty"` | `OmitEmpty = true` | `OmitEmpty: true` | -No `form` or `json` dependencies are imported or generated. The downstream `form` and `json` packages read `Field.Input` / `Field.JSON` autonomously. +`ormc` generates a composite `Validate()` method that invokes `fmt.ValidateFielder(m)` plus any format validators requested by the `validate:` tag. ### `// ormc:formonly` directive diff --git a/docs/img/badges.svg b/docs/img/badges.svg index 7d0aa65..c89dd03 100644 --- a/docs/img/badges.svg +++ b/docs/img/badges.svg @@ -51,7 +51,7 @@ text-anchor="middle" font-family="sans-serif" font-size="11" fill="white">Coverage 90.6% + text-anchor="middle" font-family="sans-serif" font-size="11" fill="white">89.0% diff --git a/ormc.go b/ormc.go index 9e9886f..69be1f5 100644 --- a/ormc.go +++ b/ormc.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" . "github.com/tinywasm/fmt" ) @@ -25,8 +26,16 @@ type FieldInfo struct { RefColumn string IsPK bool GoType string - Input string - JSON string + OmitEmpty bool + // Permitted config — populated from validate:"..." tag + Letters bool + Tilde bool + Numbers bool + Spaces bool + Extra []rune + Minimum int + Maximum int + Format string // "email", "phone", etc. (triggers validator call generation) } // SliceFieldInfo records a slice-of-struct field found in a parent struct. @@ -157,18 +166,18 @@ func (o *Ormc) ParseStruct(structName string, goFile string) (StructInfo, error) } dbTag := "" - formTag := "" jsonTag := "" + validateTag := "" if field.Tag != nil { tagVal := Convert(field.Tag.Value).TrimPrefix("`").TrimSuffix("`").String() parts := Convert(tagVal).Split(" ") for _, p := range parts { if HasPrefix(p, "db:\"") { dbTag = Convert(p).TrimPrefix(`db:"`).TrimSuffix(`"`).String() - } else if HasPrefix(p, "form:\"") { - formTag = Convert(p).TrimPrefix(`form:"`).TrimSuffix(`"`).String() } else if HasPrefix(p, "json:\"") { jsonTag = Convert(p).TrimPrefix(`json:"`).TrimSuffix(`"`).String() + } else if HasPrefix(p, "validate:\"") { + validateTag = Convert(p).TrimPrefix(`validate:"`).TrimSuffix(`"`).String() } } } @@ -285,7 +294,17 @@ func (o *Ormc) ParseStruct(structName string, goFile string) (StructInfo, error) } } - info.Fields = append(info.Fields, FieldInfo{ + omitEmpty := false + if jsonTag != "" { + parts := Convert(jsonTag).Split(",") + for _, p := range parts { + if p == "omitempty" { + omitEmpty = true + } + } + } + + fi := FieldInfo{ Name: fieldName, ColumnName: colName, Type: fieldType, @@ -297,14 +316,58 @@ func (o *Ormc) ParseStruct(structName string, goFile string) (StructInfo, error) RefColumn: refCol, IsPK: fieldIsPK, GoType: typeStr, - Input: formTag, - JSON: jsonTag, - }) + OmitEmpty: omitEmpty, + } + + if validateTag != "" { + parseValidateTag(validateTag, &fi) + } + + info.Fields = append(info.Fields, fi) } return info, nil } +// parseValidateTag maps validate:"..." rules to FieldInfo Permitted fields. +func parseValidateTag(tag string, fi *FieldInfo) { + parts := Convert(tag).Split(",") + for _, v := range parts { + switch { + case v == "required": + fi.NotNull = true + case v == "email": + fi.Format = "email" + case v == "phone": + fi.Format = "phone" + case v == "ip": + fi.Format = "ip" + case v == "rut": + fi.Format = "rut" + case v == "date": + fi.Format = "date" + case v == "name": + fi.Letters = true + fi.Tilde = true + fi.Spaces = true + case v == "letters": + fi.Letters = true + case v == "numbers": + fi.Numbers = true + case v == "tilde": + fi.Tilde = true + case v == "spaces": + fi.Spaces = true + case HasPrefix(v, "min="): + n, _ := Convert(v).TrimPrefix("min=").Int64() + fi.Minimum = int(n) + case HasPrefix(v, "max="): + n, _ := Convert(v).TrimPrefix("max=").Int64() + fi.Maximum = int(n) + } + } +} + // GenerateForStruct reads the Go File and generates the ORM implementations for a given struct name. func (o *Ormc) GenerateForStruct(structName string, goFile string) error { info, err := o.ParseStruct(structName, goFile) @@ -317,6 +380,64 @@ func (o *Ormc) GenerateForStruct(structName string, goFile string) error { return o.GenerateForFile([]StructInfo{info}, goFile) } +func capitalize(s string) string { + if s == "" { + return "" + } + return strings.ToUpper(s[0:1]) + s[1:] +} + +func writePermittedFields(buf *Conv, f FieldInfo) { + // Use nested Permitted literal + hasPerm := f.Letters || f.Tilde || f.Numbers || f.Spaces || + len(f.Extra) > 0 || f.Minimum > 0 || f.Maximum > 0 + + if !hasPerm { + return + } + + buf.Write(", Permitted: fmt.Permitted{") + parts := []string{} + if f.Letters { + parts = append(parts, "Letters: true") + } + if f.Tilde { + parts = append(parts, "Tilde: true") + } + if f.Numbers { + parts = append(parts, "Numbers: true") + } + if f.Spaces { + parts = append(parts, "Spaces: true") + } + if f.Minimum > 0 { + parts = append(parts, Sprintf("Minimum: %d", f.Minimum)) + } + if f.Maximum > 0 { + parts = append(parts, Sprintf("Maximum: %d", f.Maximum)) + } + if len(f.Extra) > 0 { + buf2 := "Extra: []rune{" + for i, r := range f.Extra { + if i > 0 { + buf2 += ", " + } + buf2 += Sprintf("'%s'", string(r)) + } + buf2 += "}" + parts = append(parts, buf2) + } + + // Join parts + for i, p := range parts { + if i > 0 { + buf.Write(", ") + } + buf.Write(p) + } + buf.Write("}") +} + // GenerateForFile writes ORM implementations for all infos into one file. func (o *Ormc) GenerateForFile(infos []StructInfo, sourceFile string) error { if len(infos) == 0 { @@ -329,10 +450,15 @@ func (o *Ormc) GenerateForFile(infos []StructInfo, sourceFile string) error { buf.Write(Sprintf("package %s\n\n", infos[0].PackageName)) hasModel := false + hasFormat := false for _, info := range infos { if !info.FormOnly { hasModel = true - break + } + for _, f := range info.Fields { + if f.Format != "" { + hasFormat = true + } } } @@ -341,6 +467,9 @@ func (o *Ormc) GenerateForFile(infos []StructInfo, sourceFile string) error { if hasModel { buf.Write("\t\"github.com/tinywasm/orm\"\n") } + if hasFormat { + buf.Write("\t\"github.com/tinywasm/form\"\n") + } buf.Write(")\n\n") for _, info := range infos { @@ -386,12 +515,10 @@ func (o *Ormc) GenerateForFile(infos []StructInfo, sourceFile string) error { if f.AutoInc { buf.Write(", AutoInc: true") } - if f.Input != "" { - buf.Write(Sprintf(", Input: \"%s\"", f.Input)) - } - if f.JSON != "" { - buf.Write(Sprintf(", JSON: \"%s\"", f.JSON)) + if f.OmitEmpty { + buf.Write(", OmitEmpty: true") } + writePermittedFields(buf, f) buf.Write("},\n") } buf.Write("\t}\n\n") @@ -406,6 +533,29 @@ func (o *Ormc) GenerateForFile(infos []StructInfo, sourceFile string) error { buf.Write("\t}\n") buf.Write("}\n\n") + hasValidation := false + for _, f := range info.Fields { + if f.NotNull || f.Letters || f.Numbers || f.Tilde || f.Spaces || + len(f.Extra) > 0 || f.Minimum > 0 || f.Maximum > 0 || f.Format != "" { + hasValidation = true + break + } + } + + if hasValidation { + buf.Write(Sprintf("func (m *%s) Validate() error {\n", info.Name)) + buf.Write("\tif err := fmt.ValidateFielder(m); err != nil { return err }\n") + for _, f := range info.Fields { + if f.Format != "" { + // E.g. "email" -> "ValidateEmail" + validatorName := "form.Validate" + capitalize(f.Format) + buf.Write(Sprintf("\tif err := %s(m.%s); err != nil { return err }\n", validatorName, f.Name)) + } + } + buf.Write("\treturn nil\n") + buf.Write("}\n\n") + } + if !info.FormOnly { // Metadata Descriptors buf.Write(Sprintf("var %s_ = struct {\n", info.Name)) @@ -565,7 +715,7 @@ func (o *Ormc) Run() error { // Pass 4: sync dependencies if _, err := os.Stat(filepath.Join(o.rootDir, "go.mod")); err == nil { o.log("Syncing dependencies...") - if err := o.exec("go", "get", "github.com/tinywasm/fmt", "github.com/tinywasm/orm"); err != nil { + if err := o.exec("go", "get", "github.com/tinywasm/fmt", "github.com/tinywasm/orm", "github.com/tinywasm/form"); err != nil { return Err(err, "failed to get dependencies") } if err := o.exec("go", "mod", "tidy"); err != nil { diff --git a/tests/core_test.go b/tests/core_test.go index c43c6b8..2392289 100644 --- a/tests/core_test.go +++ b/tests/core_test.go @@ -187,16 +187,15 @@ func RunCoreTests(t *testing.T) { t.Run("Validation Error Create", func(t *testing.T) { db := orm.New(&MockExecutor{}, &MockCompiler{}) model := &MockModel{ - Table: "user", - Sch: []fmt.Field{{Name: "col1"}}, - Vals: []any{1, 2}, // Mismatch + Table: "user", + Sch: []fmt.Field{{Name: "col1"}}, + Vals: []any{1}, + ValidErr: errors.New("custom validation error"), } err := db.Create(model) - if err == nil { - t.Error("Expected error, got nil") - } else if !strings.Contains(err.Error(), orm.ErrValidation.Error()) { - t.Errorf("Expected error containing '%s', got '%v'", orm.ErrValidation.Error(), err) + if err == nil || !strings.Contains(err.Error(), "custom validation error") { + t.Errorf("Expected custom validation error, got %v", err) } }) @@ -204,16 +203,15 @@ func RunCoreTests(t *testing.T) { t.Run("Validation Error Update", func(t *testing.T) { db := orm.New(&MockExecutor{}, &MockCompiler{}) model := &MockModel{ - Table: "user", - Sch: []fmt.Field{{Name: "col1"}}, - Vals: []any{1, 2}, // Mismatch + Table: "user", + Sch: []fmt.Field{{Name: "col1"}}, + Vals: []any{1}, + ValidErr: errors.New("custom validation error"), } err := db.Update(model, orm.Eq("id", 1)) - if err == nil { - t.Error("Expected error, got nil") - } else if !strings.Contains(err.Error(), orm.ErrValidation.Error()) { - t.Errorf("Expected error containing '%s', got '%v'", orm.ErrValidation.Error(), err) + if err == nil || !strings.Contains(err.Error(), "custom validation error") { + t.Errorf("Expected custom validation error, got %v", err) } }) diff --git a/tests/mock_generator_model.go b/tests/mock_generator_model.go index bd81595..6019fe1 100644 --- a/tests/mock_generator_model.go +++ b/tests/mock_generator_model.go @@ -94,17 +94,17 @@ type MockChild struct { type UserForm struct { ID string `db:"pk"` - Name string - Email string `db:"not_null" form:"email"` - Password string `form:"password"` - Bio string `form:"textarea"` - Age int64 `form:"-"` + Name string `validate:"name,min=2,max=100"` + Email string `db:"not_null" validate:"required,email" json:"email,omitempty"` + Password string `validate:"required,min=8"` + Bio string `validate:"tilde,spaces" json:"bio,omitempty"` + Age int64 } // ormc:formonly type LoginForm struct { - Email string `form:"email"` - Password string `form:"password"` + Email string `validate:"required,email"` + Password string `validate:"required"` } type Address struct { diff --git a/tests/ormc_test.go b/tests/ormc_test.go index d2e2098..2296f77 100644 --- a/tests/ormc_test.go +++ b/tests/ormc_test.go @@ -53,7 +53,7 @@ func TestOrmc(t *testing.T) { } }) - t.Run("Form tags and FormName", func(t *testing.T) { + t.Run("Validate tags and Permitted", func(t *testing.T) { err := orm.NewOrmc().GenerateForStruct("UserForm", "mock_generator_model.go") if err != nil { t.Fatalf("Failed to generate code for UserForm: %v", err) @@ -71,10 +71,13 @@ func TestOrmc(t *testing.T) { expectedStrings := []string{ "func (m *UserForm) FormName() string {", "return \"user_form\"", - "{Name: \"email\", Type: fmt.FieldText, NotNull: true, Input: \"email\"}", - "{Name: \"password\", Type: fmt.FieldText, Input: \"password\"}", - "{Name: \"bio\", Type: fmt.FieldText, Input: \"textarea\"}", - "{Name: \"age\", Type: fmt.FieldInt, Input: \"-\"}", + "{Name: \"name\", Type: fmt.FieldText, Permitted: fmt.Permitted{Letters: true, Tilde: true, Spaces: true, Minimum: 2, Maximum: 100}}", + "{Name: \"email\", Type: fmt.FieldText, NotNull: true, OmitEmpty: true}", + "{Name: \"password\", Type: fmt.FieldText, NotNull: true, Permitted: fmt.Permitted{Minimum: 8}}", + "{Name: \"bio\", Type: fmt.FieldText, OmitEmpty: true, Permitted: fmt.Permitted{Tilde: true, Spaces: true}}", + "func (m *UserForm) Validate() error {", + "if err := fmt.ValidateFielder(m); err != nil { return err }", + "if err := form.ValidateEmail(m.Email); err != nil { return err }", } for _, expected := range expectedStrings { @@ -292,11 +295,11 @@ func TestOrmc(t *testing.T) { content := string(contentBytes) expectedStrings := []string{ - `{Name: "id", Type: fmt.FieldText, PK: true, JSON: "id"}`, - `{Name: "name", Type: fmt.FieldText, JSON: "name"}`, - `{Name: "email", Type: fmt.FieldText, Input: "email", JSON: "email"}`, - `{Name: "bio", Type: fmt.FieldText, Input: "textarea", JSON: "bio,omitempty"}`, - `{Name: "home_addr", Type: fmt.FieldStruct, JSON: "home_addr"}`, + `{Name: "id", Type: fmt.FieldText, PK: true}`, + `{Name: "name", Type: fmt.FieldText}`, + `{Name: "email", Type: fmt.FieldText}`, + `{Name: "bio", Type: fmt.FieldText, OmitEmpty: true}`, + `{Name: "home_addr", Type: fmt.FieldStruct}`, } for _, expected := range expectedStrings { @@ -332,30 +335,6 @@ func TestOrmc(t *testing.T) { } }) - t.Run("JSON tags stage 1", func(t *testing.T) { - err := orm.NewOrmc().GenerateForStruct("UserWithJSON", "mock_generator_model.go") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - outFile := "mock_generator_model_orm.go" - contentBytes, err := os.ReadFile(outFile) - if err != nil { - t.Fatalf("failed to read: %v", err) - } - defer os.Remove(outFile) - content := string(contentBytes) - expected := []string{ - `JSON: "id"`, - `JSON: "name"`, - `JSON: "email"`, - `JSON: "bio,omitempty"`, - } - for _, e := range expected { - if !strings.Contains(content, e) { - t.Errorf("missing: %s\nContent:\n%s", e, content) - } - } - }) t.Run("FieldStruct for nested struct stage 1", func(t *testing.T) { err := orm.NewOrmc().GenerateForStruct("UserWithJSON", "mock_generator_model.go") diff --git a/tests/setup_test.go b/tests/setup_test.go index 3f724cb..407e663 100644 --- a/tests/setup_test.go +++ b/tests/setup_test.go @@ -99,9 +99,14 @@ func (m *MockRows) Err() error { // MockModel is a mock implementation of the Model interface. type MockModel struct { - Table string - Sch []fmt.Field - Vals []any + Table string + Sch []fmt.Field + Vals []any + ValidErr error +} + +func (m *MockModel) Validate() error { + return m.ValidErr } func (m MockModel) TableName() string { return m.Table }