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 }