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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ Malformed JSON still returns the usual `encoding/json` decode error. Validation

## Validations

Struct tags may use `valid` or `validate`. When both keys are present, `valid` is the one that is read. The common go-playground example uses `validate`.

The following validations will be implemented:

- eq (equal): must be equal to the specified value
Expand All @@ -91,6 +93,9 @@ The following validations will be implemented:
- nin (not in): must not be one of the following values
- required (required): is required
- email (email): must be a valid email format (empty is valid for optional fields)
- oneof (one of): string or integer must be one of the space-separated values
- hexcolor, rgb, rgba, hsl, hsla: string must match that color format
- iscolor: alias for hexcolor, rgb, rgba, hsl, or hsla
- eqfield (equal field): field must be equal to another field
- neqfield (not equal field): field must not be equal to another field
- gtefield (greater than or equal field): field must be greater than or equal to another field
Expand Down Expand Up @@ -150,6 +155,13 @@ The following table shows the validations and possible types, where:
| nin | I | I | I | - | I | I | W | - | W |
| required | I | I | I | - | I | - | W | W | W |
| email | I | - | - | - | - | - | - | - | - |
| oneof | I | I | - | - | - | - | - | - | - |
| hexcolor | I | - | - | - | - | - | - | - | - |
| rgb | I | - | - | - | - | - | - | - | - |
| rgba | I | - | - | - | - | - | - | - | - |
| hsl | I | - | - | - | - | - | - | - | - |
| hsla | I | - | - | - | - | - | - | - | - |
| iscolor | I | - | - | - | - | - | - | - | - |
| eqfield | I | I | I | I | - | - | - | W | W |
| neqfield | I | I | I | I | - | - | - | W | W |
| gtefield | - | I | - | - | - | - | - | W | W |
Expand Down
8 changes: 4 additions & 4 deletions docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,11 @@ Slices, arrays, maps, and pointers keep a marker on that name, such as `[]<INT>`

### Tag parsing

`analyzeFieldValidations` copies each `parser.Struct` into an `analyzer.Struct`. A field counts as validated only when its tag text starts with `valid:`. The analyzer sets `HasValidTag` if any field matches. It unquotes the text after `valid:` and splits on commas.
`analyzeFieldValidations` copies each `parser.Struct` into an `analyzer.Struct`. A field counts as validated when its struct tag has a `valid` key, or a `validate` key if `valid` is absent. The analyzer sets `HasValidTag` if any field matches. It splits that key's value on commas and drops blank pieces, including a blank value such as `validate:""`. `reflect.StructTag` reads the key, so `json:"email" validate:"required,email"` is accepted.

`ParserValidation` splits one validation on `=`. More than two pieces is an error. The operation's `CountValues` selects the shape.
`ParserValidation` splits one validation on `=`. More than two pieces, or none, is an error. The operation's `CountValues` selects the shape.

`ZeroValue` operations `required` and `email` reject a target. `OneValue` operations require one target. `ManyValues` operations `in` and `nin` require a target list. A list that starts with a single quote is read as quoted strings. Any other list is split on commas and spaces.
`ZeroValue` operations `required`, `email`, `hexcolor`, `rgb`, `rgba`, `hsl`, `hsla`, `iscolor`, `dive`, `keys`, and `endkeys` reject a target. `OneValue` operations require one target. `ManyValues` operations `in`, `nin`, and `oneof` require a target list. Spaces and commas separate tokens. A single-quoted token keeps its interior, including spaces, and quoted tokens can be mixed with bare tokens, as in `oneof=red 'light blue'`. `iscolor` is the alias for `hexcolor|rgb|rgba|hsl|hsla` and calls `types.IsColor`. `ne` and `ne_ignore_case` are read as `neq` and `neq_ignore_case`.

The result is an `analyzer.Validation` with `Operation`, `ExpectedValues`, and `Values`.

Expand Down Expand Up @@ -144,7 +144,7 @@ errs = append(errs, types.NewValidationError("message"))

Scalar `in` rows set `concatOperator` to `||`, and the per-value copies are joined. That includes `<COMPLEX>`, `*<STRING>`, `*<INT>`, `*<FLOAT>`, and `*<BOOL>`. Scalar `nin` rows set `concatOperator` to `&&`. Complex scalar rows use the same `==` / `!=` form as numeric scalars, with the tag literal left unquoted. Slice, array, and map rows leave `concatOperator` empty, so `DefineTestElements` keeps one copy. That copy lists every target through `{{.TargetsAsStringSlice}}` or `{{.TargetsAsNumericSlice}}`.

Those slice and map copies call `types.SliceOnlyContains`, `types.SliceNotContains`, `types.MapOnlyContains`, or `types.MapNotContains`. Non-pointer array rows pass `obj.Field[:]` into the slice helpers. Literal string comparisons quote the target. `email` calls `types.IsValidEmail`. `eq_ignore_case` and `neq_ignore_case` call `strings.EqualFold`. Field comparisons compile to `obj.Field` compared with `obj.Other` or `obj.Nested.Field`.
Those slice and map copies call `types.SliceOnlyContains`, `types.SliceNotContains`, `types.MapOnlyContains`, or `types.MapNotContains`. Non-pointer array rows pass `obj.Field[:]` into the slice helpers. Literal string comparisons quote the target. `email` calls `types.IsValidEmail`. `oneof` uses the same equality as scalar `in` for strings and integers. String targets are emitted as Go string literals. `hexcolor`, `rgb`, `rgba`, `hsl`, `hsla`, and `iscolor` call the matching `types` helpers. `eq_ignore_case` and `neq_ignore_case` call `strings.EqualFold`. Field comparisons compile to `obj.Field` compared with `obj.Other` or `obj.Nested.Field`.

When the field is a struct or a pointer to a struct, each validation on that field appends a nested call instead of a condition-table test. The call is `TypeValidate(&obj.Field)`, where `Type` is `BaseType`. If `BaseType` starts with the struct's own package name and a dot, that prefix is removed. A same-package field whose `BaseType` is `main.InnerStructType` calls `InnerStructTypeValidate`. A field whose `BaseType` is `mypkg.InnerStructType` calls `mypkg.InnerStructTypeValidate`. The call is emitted when `BaseType` is in the parsed-struct index. A missing type returns `no validator found for struct type`.

Expand Down
36 changes: 23 additions & 13 deletions internal/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package analyzer

import (
"fmt"
"strconv"
"reflect"
"strings"

"github.com/opencodeco/validgen/internal/analyzer/operations"
Expand All @@ -11,8 +11,6 @@ import (
"github.com/opencodeco/validgen/types"
)

const validTag = "valid"

func AnalyzeStructs(structs []*parser.Struct) ([]*Struct, error) {
result, err := analyzeFieldValidations(structs)
if err != nil {
Expand Down Expand Up @@ -63,18 +61,30 @@ func analyzeFieldValidations(structs []*parser.Struct) ([]*Struct, error) {
}

func parseFieldValidations(fieldTag string) ([]string, bool) {
fieldValidations := []string{}
hasValidTag := false
prefixToSearch := validTag + ":"

if strings.HasPrefix(fieldTag, prefixToSearch) {
hasValidTag = true
tagWithoutPrefix, _ := strings.CutPrefix(fieldTag, prefixToSearch)
tagWithoutQuotes, _ := strconv.Unquote(tagWithoutPrefix)
fieldValidations = strings.Split(tagWithoutQuotes, ",")
if fieldTag == "" {
return nil, false
}

tag := reflect.StructTag(fieldTag)
value, ok := tag.Lookup("valid")
if !ok {
value, ok = tag.Lookup("validate")
}
if !ok {
return nil, false
}

parts := strings.Split(value, ",")
fieldValidations := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
fieldValidations = append(fieldValidations, part)
}

return fieldValidations, hasValidTag
return fieldValidations, true
}

func checkForInvalidOperations(structs []*Struct) error {
Expand Down
159 changes: 159 additions & 0 deletions internal/analyzer/common_example_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package analyzer

import (
"reflect"
"testing"

"github.com/opencodeco/validgen/internal/common"
"github.com/opencodeco/validgen/internal/parser"
)

func TestAnalyzeCommonValidatorExample(t *testing.T) {
structs := commonExampleStructs()
got, err := AnalyzeStructs(structs)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("struct count = %d, want 2", len(got))
}
if !got[0].HasValidTag || !got[1].HasValidTag {
t.Fatal("example structs should be validated")
}

wantUser := [][]string{
{"required"},
{"required"},
{"gte", "lte"},
{"required", "email"},
{"oneof"},
{"iscolor"},
{"required", "dive", "required"},
}
if ops := operationNames(got[0]); !reflect.DeepEqual(ops, wantUser) {
t.Fatalf("User operations = %#v, want %#v", ops, wantUser)
}

gender := got[0].FieldsValidations[4].Validations[0]
wantValues := []string{"male", "female", "prefer_not_to"}
if !reflect.DeepEqual(gender.Values, wantValues) {
t.Fatalf("oneof values = %#v, want %#v", gender.Values, wantValues)
}

wantAddress := [][]string{
{"required"},
{"required"},
{"required"},
{"required"},
}
if ops := operationNames(got[1]); !reflect.DeepEqual(ops, wantAddress) {
t.Fatalf("Address operations = %#v, want %#v", ops, wantAddress)
}
}

func TestValidationTagKey(t *testing.T) {
stringField := common.FieldType{BaseType: "string"}
tests := []struct {
name string
tag string
want []string
}{
{
name: "valid tag",
tag: `valid:"required,email"`,
want: []string{"required", "email"},
},
{
name: "validate tag after json",
tag: `json:"email" validate:"required,email"`,
want: []string{"required", "email"},
},
{
name: "valid wins when both keys are present",
tag: `valid:"required" validate:"email"`,
want: []string{"required"},
},
{
name: "unrelated tag",
tag: `json:"email"`,
want: nil,
},
{
name: "empty validate value",
tag: `validate:""`,
want: nil,
},
{
name: "blank piece between commas",
tag: `valid:"required, ,email"`,
want: []string{"required", "email"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := AnalyzeStructs([]*parser.Struct{{
PackageName: "main",
StructName: "User",
Fields: []parser.Field{{
FieldName: "Email",
Type: stringField,
Tag: tt.tag,
}},
}})
if err != nil {
t.Fatal(err)
}
ops := operationNames(got[0])
var flat []string
if len(ops) == 1 {
flat = ops[0]
}
if !reflect.DeepEqual(flat, tt.want) {
t.Fatalf("operations = %#v, want %#v", flat, tt.want)
}
})
}
}

func commonExampleStructs() []*parser.Struct {
return []*parser.Struct{
{
PackageName: "main",
StructName: "User",
Fields: []parser.Field{
{FieldName: "FirstName", Type: common.FieldType{BaseType: "string"}, Tag: `validate:"required"`},
{FieldName: "LastName", Type: common.FieldType{BaseType: "string"}, Tag: `validate:"required"`},
{FieldName: "Age", Type: common.FieldType{BaseType: "uint8"}, Tag: `validate:"gte=0,lte=130"`},
{FieldName: "Email", Type: common.FieldType{BaseType: "string"}, Tag: `validate:"required,email"`},
{FieldName: "Gender", Type: common.FieldType{BaseType: "string"}, Tag: `validate:"oneof=male female prefer_not_to"`},
{FieldName: "FavouriteColor", Type: common.FieldType{BaseType: "string"}, Tag: `validate:"iscolor"`},
{
FieldName: "Addresses",
Type: common.FieldType{BaseType: "main.Address", ComposedType: "*[]", ElemPointer: true},
Tag: `validate:"required,dive,required"`,
},
},
},
{
PackageName: "main",
StructName: "Address",
Fields: []parser.Field{
{FieldName: "Street", Type: common.FieldType{BaseType: "string"}, Tag: `validate:"required"`},
{FieldName: "City", Type: common.FieldType{BaseType: "string"}, Tag: `validate:"required"`},
{FieldName: "Planet", Type: common.FieldType{BaseType: "string"}, Tag: `validate:"required"`},
{FieldName: "Phone", Type: common.FieldType{BaseType: "string"}, Tag: `validate:"required"`},
},
},
}
}

func operationNames(st *Struct) [][]string {
names := make([][]string, len(st.FieldsValidations))
for i, field := range st.FieldsValidations {
for _, validation := range field.Validations {
names[i] = append(names[i], validation.Operation)
}
}
return names
}
20 changes: 20 additions & 0 deletions internal/analyzer/operations/operations_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,18 @@ var operationsList = map[string]Operation{
IsFieldOperation: false,
ValidTypes: []string{"<STRING>"},
},
"oneof": {
CountValues: common.ManyValues,
Comment on lines +103 to +104
IsFieldOperation: false,
ValidTypes: []string{"<STRING>", "<INT>"},
},
// iscolor is go-playground/validator's alias for hexcolor|rgb|rgba|hsl|hsla.
"hexcolor": zeroStringOp(),
"rgb": zeroStringOp(),
"rgba": zeroStringOp(),
"hsl": zeroStringOp(),
"hsla": zeroStringOp(),
"iscolor": zeroStringOp(),
"eqfield": {
CountValues: common.OneValue,
IsFieldOperation: true,
Expand Down Expand Up @@ -148,3 +160,11 @@ var operationsList = map[string]Operation{
ValidTypes: nil,
},
}

func zeroStringOp() Operation {
return Operation{
CountValues: common.ZeroValue,
IsFieldOperation: false,
ValidTypes: []string{"<STRING>"},
}
}
Loading
Loading