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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ The following validations will be implemented:
- gtfield (greater than field): field must be greater than another field
- ltefield (less than or equal field): field must be less than or equal to another field
- ltfield (less than field): field must be less than another field
- dive: tags after `dive` apply to each slice element, array element, or map value

### dive

Tags before `dive` apply to the collection. Tags after `dive` apply to each element. A struct element is validated with that struct's generated function. Without `dive`, a slice or map is checked only as a collection, including a slice of structs.

```go
type User struct {
Addresses []*Address `valid:"required,dive,required"`
Labels map[string]string `valid:"dive,required"`
}
```

`required` before `dive` checks that `Addresses` is not empty. After `dive`, each pointer must be non-nil and `Address` field tags run. Each `Labels` value must be non-empty. Map keys are not validated. `keys` and `endkeys` are not implemented.

The following table shows the validations and possible types, where:

Expand Down
8 changes: 5 additions & 3 deletions docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ An `*ast.StructType` node fills that struct and appends it. The field loop keeps

A pointer appends `*` and then walks the inner expression. A slice walks the element and then appends `[]`. An array walks the element, then sets `Size` from the length literal and appends `[N]`. A map appends `map` and then walks `ast.MapType.Key`.

The map value expression is ignored. Parser tests record `map[string]uint8` with `BaseType` `string`, and `map[uint8]string` with `BaseType` `uint8`. Later stages classify the map from that key type. `FieldType.ToType` prints a map as `map[BaseType]BaseType`.
The map value is stored on `FieldType.MapValue`. Parser tests record `map[string]uint8` with `BaseType` `string` and `MapValue` `uint8`, and `map[uint8]string` with `BaseType` `uint8` and `MapValue` `string`. Collection checks still classify the map from the key type. `FieldType.ToType` still prints a map as `map[BaseType]BaseType`. `dive` reads `MapValue`.

The same walk collapses a pointer element into the container marker. The pointer parser test records `[]*int64` as `BaseType` `int64` and `ComposedType` `*[]`, and `*map[string]bool` as `BaseType` `string` and `ComposedType` `*map`.
The same walk records a pointer on an element separately from a pointer to the container. `[]*int64` is `BaseType` `int64`, `ComposedType` `*[]`, and `ElemPointer` true. `*[]int64` is the same `ComposedType` with `ElemPointer` false. `*map[string]bool` is `BaseType` `string` and `ComposedType` `*map`. A star that remains glued to another container after one `dive`, such as the element of `[][]*string`, is rejected. Nested arrays are rejected because only the outer length is kept.

`FieldType.IsGoType` is true when `ComposedType` is `map` or `*map`. Otherwise it is true only when `BaseType` is `string`, `bool`, `float32`, `float64`, `complex64`, `complex128`, or one of the integer names listed in the analyzer section.

Expand Down Expand Up @@ -144,7 +144,9 @@ Scalar `in` rows set `concatOperator` to `||`, and the per-value copies are join

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 `types.EqualFold`. Field comparisons compile to `obj.Field` compared with `obj.Other` or `obj.Nested.Field`.

When `IsGoType` is false, 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`.
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`.

`dive` splits the tag list. Tags before `dive` use the field type. Tags after `dive` use `FieldType.DiveInto`, which is one slice element, one array element, or the map value. The generator writes a `for` loop. A struct element calls the nested validator once. A pointer element also checks `required` as non-nil and skips a nil pointer. `required`, `min`, `max`, and `len` on a slice or map whose element is not a Go type use the length check for `[]string` or `map[string]string`. The same length tags apply to `[]*T`. `in` and `nin` do not, because those helpers expect `[]T`. Field comparisons after `dive`, `keys`, `endkeys`, nested arrays, and a pointer glued to another container are rejected.

Imports kept on the generated package are the struct file's imports whose local name is a package name parsed in this run. `buildImportPath` writes each of those paths as a quoted import and always adds `github.com/opencodeco/validgen/types`. When any struct in the package has `UnmarshalJSON` source, it also adds `encoding/json` and `errors`.

Expand Down
56 changes: 44 additions & 12 deletions internal/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,27 +89,26 @@ func checkForInvalidOperations(structs []*Struct) error {

for _, st := range structs {
for i, fd := range st.Fields {
current := fd.Type
dived := false
for _, val := range st.FieldsValidations[i].Validations {
// Check if is a valid operation.
op := val.Operation
if !ops.IsValid(op) {
return types.NewValidationError("unsupported operation %s", op)
}

// If is a custom struct, check if it has validations.
fdType := fd.Type
if structsWithValidation[fdType.BaseType] {
if op == "dive" {
next, err := current.DiveInto()
if err != nil {
return types.NewValidationError("operation dive: field %s: %s", fd.FieldName, err.Error())
}
current = next
dived = true
continue
}

// If has a validation, must be for a go type.
if !fdType.IsGoType() {
return types.NewValidationError("unsupported operation %s with unknown go type %s", op, fdType.BaseType)
}

// Check if is a valid operation for this type.
if !ops.IsValidByType(op, fdType.ToNormalizedString()) {
return types.NewValidationError("operation %s: invalid %s(%s) type", op, fdType.BaseType, fdType.ToNormalizedString())
if err := validateOperation(ops, op, current, structsWithValidation, dived); err != nil {
return err
}
}
}
Expand All @@ -118,6 +117,39 @@ func checkForInvalidOperations(structs []*Struct) error {
return nil
}

func validateOperation(ops *operations.Operations, op string, ft common.FieldType, structs map[string]bool, dived bool) error {
if dived && ops.IsFieldOperation(op) {
return types.NewValidationError("operation %s: field comparisons are not supported after dive", op)
}
if ft.ElemPointer && !common.IsLenOperation(op) {
return types.NewValidationError("operation %s: cannot apply to a slice or array of pointers", op)
}

if ft.IsNestedStruct() {
if !structs[ft.BaseType] {
return types.NewValidationError("unsupported operation %s with unknown go type %s", op, ft.BaseType)
}
if dived && op != "required" {
return types.NewValidationError("operation %s: cannot apply to struct %s", op, ft.BaseType)
}
return nil
}

accept := func(candidate common.FieldType) bool {
return ops.IsValidByType(op, candidate.ToNormalizedString())
}
if _, ok := ft.OperationType(op, accept); ok {
return nil
}

lookup := ft.ForCatalog()
if !lookup.IsGoType() {
return types.NewValidationError("unsupported operation %s with unknown go type %s", op, ft.BaseType)
}

return types.NewValidationError("operation %s: invalid %s(%s) type", op, lookup.BaseType, lookup.ToNormalizedString())
}

func analyzeFieldOperations(structs []*Struct) error {

// Map all fields and their types.
Expand Down
183 changes: 183 additions & 0 deletions internal/analyzer/dive_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package analyzer

import (
"testing"

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

func addressStruct() *parser.Struct {
return &parser.Struct{
PackageName: "main",
StructName: "Address",
Fields: []parser.Field{
{
FieldName: "Street",
Type: common.FieldType{BaseType: "string"},
Tag: `valid:"required"`,
},
},
}
}

func TestAnalyzeDiveAccepted(t *testing.T) {
tests := []struct {
name string
field parser.Field
}{
{
name: "slice of structs",
field: parser.Field{
FieldName: "Addresses",
Type: common.FieldType{BaseType: "main.Address", ComposedType: "[]"},
Tag: `valid:"required,dive"`,
},
},
{
name: "slice of struct pointers",
field: parser.Field{
FieldName: "Addresses",
Type: common.FieldType{BaseType: "main.Address", ComposedType: "*[]", ElemPointer: true},
Tag: `valid:"required,dive,required"`,
},
},
{
name: "map value",
field: parser.Field{
FieldName: "Labels",
Type: common.FieldType{
BaseType: "string",
ComposedType: "map",
MapValue: &common.FieldType{BaseType: "string"},
},
Tag: `valid:"dive,required"`,
},
},
{
name: "slice of structs does not dive",
field: parser.Field{
FieldName: "Addresses",
Type: common.FieldType{BaseType: "main.Address", ComposedType: "[]"},
Tag: `valid:"required"`,
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user := &parser.Struct{
PackageName: "main",
StructName: "User",
Fields: []parser.Field{tt.field},
}
if _, err := AnalyzeStructs([]*parser.Struct{addressStruct(), user}); err != nil {
t.Fatalf("AnalyzeStructs() error = %v", err)
}
})
}
}

func TestAnalyzeDiveRejected(t *testing.T) {
tests := []struct {
name string
field parser.Field
wantErr error
}{
{
name: "dive on a string",
field: parser.Field{
FieldName: "Name",
Type: common.FieldType{BaseType: "string"},
Tag: `valid:"dive"`,
},
wantErr: types.NewValidationError("operation dive: field Name: string is not a slice, array, or map"),
},
{
name: "email on a map value of int",
field: parser.Field{
FieldName: "Counts",
Type: common.FieldType{
BaseType: "string",
ComposedType: "map",
MapValue: &common.FieldType{BaseType: "int"},
},
Tag: `valid:"dive,email"`,
},
wantErr: types.NewValidationError("operation email: invalid int(<INT>) type"),
},
{
name: "field comparison after dive",
field: parser.Field{
FieldName: "Names",
Type: common.FieldType{BaseType: "string", ComposedType: "[]"},
Tag: `valid:"dive,eqfield=Name"`,
},
wantErr: types.NewValidationError("operation eqfield: field comparisons are not supported after dive"),
},
{
name: "nested arrays",
field: parser.Field{
FieldName: "Matrix",
Type: common.FieldType{BaseType: "int", ComposedType: "[N][N]", Size: "2"},
Tag: `valid:"dive"`,
},
wantErr: types.NewValidationError("operation dive: field Matrix: nested arrays are not supported"),
},
{
name: "pointer composition the generator cannot express",
field: parser.Field{
FieldName: "Matrix",
Type: common.FieldType{BaseType: "string", ComposedType: "*[][]", ElemPointer: true},
Tag: `valid:"dive"`,
},
wantErr: types.NewValidationError("operation dive: field Matrix: unsupported pointer composition *[]string"),
},
{
name: "keys stays unsupported",
field: parser.Field{
FieldName: "Labels",
Type: common.FieldType{
BaseType: "string",
ComposedType: "map",
MapValue: &common.FieldType{BaseType: "string"},
},
Tag: `valid:"dive,keys,required"`,
},
wantErr: types.NewValidationError("parser validation keys: unsupported validation keys"),
},
{
name: "in on a slice of pointers",
field: parser.Field{
FieldName: "Names",
Type: common.FieldType{BaseType: "string", ComposedType: "*[]", ElemPointer: true},
Tag: `valid:"in=a b"`,
},
wantErr: types.NewValidationError("operation in: cannot apply to a slice or array of pointers"),
},
{
name: "tag other than required on a struct element",
field: parser.Field{
FieldName: "Addresses",
Type: common.FieldType{BaseType: "main.Address", ComposedType: "[]"},
Tag: `valid:"dive,email"`,
},
wantErr: types.NewValidationError("operation email: cannot apply to struct main.Address"),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user := &parser.Struct{
PackageName: "main",
StructName: "User",
Fields: []parser.Field{tt.field},
}
_, err := AnalyzeStructs([]*parser.Struct{addressStruct(), user})
if err != tt.wantErr {
t.Fatalf("AnalyzeStructs() error = %v, want %v", err, tt.wantErr)
}
})
}
}
6 changes: 6 additions & 0 deletions internal/analyzer/operations/operations_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,10 @@ var operationsList = map[string]Operation{
IsFieldOperation: true,
ValidTypes: []string{"<INT>", "<FLOAT>"},
},
// dive is a level separator. The analyzer checks the container, then the element.
"dive": {
CountValues: common.ZeroValue,
IsFieldOperation: false,
ValidTypes: nil,
},
}
3 changes: 3 additions & 0 deletions internal/analyzer/operations/operations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func TestOperationsIsValid(t *testing.T) {
{op: "gtfield", want: true},
{op: "ltefield", want: true},
{op: "ltfield", want: true},
{op: "dive", want: true},
{op: "invalid_op", want: false},
}

Expand Down Expand Up @@ -425,6 +426,7 @@ func TestOperationsIsFieldOperation(t *testing.T) {
{op: "gtfield", want: true},
{op: "ltefield", want: true},
{op: "ltfield", want: true},
{op: "dive", want: false},
{op: "invalid_op", want: false},
}

Expand Down Expand Up @@ -465,6 +467,7 @@ func TestOperationsArgsCount(t *testing.T) {
{op: "gtfield", want: common.OneValue},
{op: "ltefield", want: common.OneValue},
{op: "ltfield", want: common.OneValue},
{op: "dive", want: common.ZeroValue},
{op: "invalid_op", want: common.UndefinedValue},
}

Expand Down
19 changes: 19 additions & 0 deletions internal/analyzer/parser_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,15 @@ func TestValidParserValidation(t *testing.T) {
Values: []string{"Nested.field123"},
},
},
{
name: "dive tag",
validation: "dive",
want: &Validation{
Operation: "dive",
ExpectedValues: common.ZeroValue,
Values: []string{},
},
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -175,6 +184,16 @@ func TestParserInvalidValidation(t *testing.T) {
validation: "xpto=a",
expectedErr: types.NewValidationError("unsupported validation xpto"),
},
{
name: "keys is not implemented",
validation: "keys",
expectedErr: types.NewValidationError("unsupported validation keys"),
},
{
name: "endkeys is not implemented",
validation: "endkeys",
expectedErr: types.NewValidationError("unsupported validation endkeys"),
},
{
name: "malformed value",
validation: "in='abc",
Expand Down
Loading
Loading