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
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ The following validations will be implemented:
- 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
- keys, endkeys: after `dive` on a map, tags between `keys` and `endkeys` apply to each key and tags after `endkeys` apply to each value

### dive

Expand All @@ -110,7 +111,20 @@ type User struct {
}
```

`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.
`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. Plain `dive` validates map values.

### keys and endkeys

`keys` follows `dive` immediately and applies only to maps. Tags between `keys` and `endkeys` validate each map key. Tags after `endkeys` validate each map value.

```go
type User struct {
Labels map[string]string `valid:"dive,keys,min=2,endkeys,required"`
Scores map[uint8]string `valid:"dive,keys,gte=1,endkeys,required"`
}
```

`min=2` checks each `Labels` key. `required` checks each `Labels` value. `gte=1` checks each `Scores` key. A missing `endkeys`, an extra `endkeys`, `keys` on a non-map, `dive` inside `keys`, another `keys` block, and a non-scalar map key are rejected.

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

Expand Down
4 changes: 2 additions & 2 deletions docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ 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 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 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`. `keys` reads the key through `FieldType.MapKey`, which is `BaseType` for a Go scalar key.

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.

Expand Down Expand Up @@ -148,7 +148,7 @@ Those slice and map copies call `types.SliceOnlyContains`, `types.SliceNotContai

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.
`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. When the next tag is `keys`, `SplitKeysBlock` keeps that same list: tags between `keys` and `endkeys` use `FieldType.MapKey`, and tags after `endkeys` use the map value. The generator writes one `for` loop over the key and the value. A plain `dive` still validates map values only. 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`, a missing or extra `endkeys`, `keys` on a non-map, `dive` inside `keys`, another `keys` block, a non-scalar map key, 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`. When generated code calls `strings.EqualFold`, it also adds `strings`.

Expand Down
115 changes: 97 additions & 18 deletions internal/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,34 +89,113 @@ 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 {
op := val.Operation
if !ops.IsValid(op) {
return types.NewValidationError("unsupported operation %s", op)
}
err := checkValidations(ops, fd.FieldName, fd.Type, st.FieldsValidations[i].Validations, structsWithValidation, false, 0)
if err != nil {
return err
}
}
}

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
}
return nil
}

if err := validateOperation(ops, op, current, structsWithValidation, dived); err != nil {
return err
func checkValidations(ops *operations.Operations, fieldName string, current common.FieldType, validations []*Validation, structs map[string]bool, dived bool, keysDepth int) error {
for i, val := range validations {
op := val.Operation
if !ops.IsValid(op) {
return types.NewValidationError("unsupported operation %s", op)
}

switch op {
case "keys":
return types.NewValidationError("operation keys: field %s: keys must immediately follow dive", fieldName)
case "endkeys":
return types.NewValidationError("operation endkeys: field %s: endkeys without keys", fieldName)
case "dive":
if i+1 < len(validations) && validations[i+1].Operation == "keys" {
if keysDepth > 0 {
return types.NewValidationError("operation keys: field %s: nested keys are not supported", fieldName)
}
return checkMapKeys(ops, fieldName, current, validations[i+1:], structs)
Comment thread
leocavalcante marked this conversation as resolved.
}

next, err := current.DiveInto()
if err != nil {
return types.NewValidationError("operation dive: field %s: %s", fieldName, err.Error())
}
current = next
dived = true
continue
}

if err := validateOperation(ops, op, current, structs, dived); err != nil {
return err
}
}

return nil
}

func checkMapKeys(ops *operations.Operations, fieldName string, current common.FieldType, validations []*Validation, structs map[string]bool) error {
keyType, err := current.MapKey()
if err != nil {
return types.NewValidationError("operation keys: field %s: %s", fieldName, err.Error())
}

valueType, err := current.DiveInto()
if err != nil {
return types.NewValidationError("operation dive: field %s: %s", fieldName, err.Error())
}

keyVals, valueVals, err := SplitKeysBlock(fieldName, validations)
if err != nil {
return err
}

if err := checkValidations(ops, fieldName, keyType, keyVals, structs, true, 1); err != nil {
return err
}

return checkValidations(ops, fieldName, valueType, valueVals, structs, true, 1)
}

// SplitKeysBlock splits the validations that follow dive, starting at keys.
// Tags between keys and endkeys apply to the map key. Tags after endkeys apply to the map value.
func SplitKeysBlock(fieldName string, rest []*Validation) ([]*Validation, []*Validation, error) {
if len(rest) == 0 || rest[0].Operation != "keys" {
return nil, nil, types.NewValidationError("operation keys: field %s: keys must immediately follow dive", fieldName)
}

end := -1
for i := 1; i < len(rest); i++ {
switch rest[i].Operation {
case "endkeys":
end = i
case "dive":
return nil, nil, types.NewValidationError("operation dive: field %s: nested dive inside keys is not supported", fieldName)
case "keys":
return nil, nil, types.NewValidationError("operation keys: field %s: nested keys are not supported", fieldName)
}
if end != -1 {
break
}
}
if end == -1 {
return nil, nil, types.NewValidationError("operation keys: field %s: missing endkeys", fieldName)
}

for _, val := range rest[end+1:] {
switch val.Operation {
case "keys":
return nil, nil, types.NewValidationError("operation keys: field %s: nested keys are not supported", fieldName)
case "endkeys":
return nil, nil, types.NewValidationError("operation endkeys: field %s: extra endkeys", fieldName)
}
}

return rest[1:end], rest[end+1:], 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)
Expand Down
166 changes: 164 additions & 2 deletions internal/analyzer/dive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ func TestAnalyzeDiveRejected(t *testing.T) {
wantErr: types.NewValidationError("operation dive: field Matrix: unsupported pointer composition *[]string"),
},
{
name: "keys stays unsupported",
name: "missing endkeys",
field: parser.Field{
FieldName: "Labels",
Type: common.FieldType{
Expand All @@ -145,7 +145,7 @@ func TestAnalyzeDiveRejected(t *testing.T) {
},
Tag: `valid:"dive,keys,required"`,
},
wantErr: types.NewValidationError("parser validation keys: unsupported validation keys"),
wantErr: types.NewValidationError("operation keys: field Labels: missing endkeys"),
},
{
name: "in on a slice of pointers",
Expand Down Expand Up @@ -181,3 +181,165 @@ func TestAnalyzeDiveRejected(t *testing.T) {
})
}
}

func TestAnalyzeMapKeys(t *testing.T) {
stringMap := common.FieldType{
BaseType: "string",
ComposedType: "map",
MapValue: &common.FieldType{BaseType: "string"},
}
uintMap := common.FieldType{
BaseType: "uint8",
ComposedType: "map",
MapValue: &common.FieldType{BaseType: "string"},
}
tests := []struct {
name string
field parser.Field
wantErr error
}{
{
name: "string keys and values",
field: parser.Field{
FieldName: "Labels",
Type: stringMap,
Tag: `valid:"dive,keys,min=2,endkeys,required"`,
},
},
{
name: "typed keys and values",
field: parser.Field{
FieldName: "Scores",
Type: uintMap,
Tag: `valid:"dive,keys,gte=1,endkeys,required"`,
},
},
{
name: "value validation after endkeys",
field: parser.Field{
FieldName: "Labels",
Type: stringMap,
Tag: `valid:"required,dive,keys,min=2,endkeys,email"`,
},
},
{
name: "dive into values after endkeys",
field: parser.Field{
FieldName: "Labels",
Type: common.FieldType{
BaseType: "string",
ComposedType: "map",
MapValue: &common.FieldType{BaseType: "string", ComposedType: "[]"},
},
Tag: `valid:"dive,keys,min=2,endkeys,dive,required"`,
},
},
{
name: "plain dive still validates map values",
field: parser.Field{
FieldName: "Labels",
Type: stringMap,
Tag: `valid:"dive,required"`,
},
},
{
name: "extra endkeys",
field: parser.Field{
FieldName: "Labels",
Type: stringMap,
Tag: `valid:"dive,keys,min=2,endkeys,required,endkeys"`,
},
wantErr: types.NewValidationError("operation endkeys: field Labels: extra endkeys"),
},
{
name: "endkeys without keys",
field: parser.Field{
FieldName: "Labels",
Type: stringMap,
Tag: `valid:"dive,endkeys,required"`,
},
wantErr: types.NewValidationError("operation endkeys: field Labels: endkeys without keys"),
},
{
name: "keys does not follow dive",
field: parser.Field{
FieldName: "Labels",
Type: stringMap,
Tag: `valid:"dive,required,keys,min=2,endkeys"`,
},
wantErr: types.NewValidationError("operation keys: field Labels: keys must immediately follow dive"),
},
{
name: "keys on a slice",
field: parser.Field{
FieldName: "Names",
Type: common.FieldType{BaseType: "string", ComposedType: "[]"},
Tag: `valid:"dive,keys,min=2,endkeys,required"`,
},
wantErr: types.NewValidationError("operation keys: field Names: []string is not a map"),
},
{
name: "keys on a string",
field: parser.Field{
FieldName: "Name",
Type: common.FieldType{BaseType: "string"},
Tag: `valid:"dive,keys,min=2,endkeys"`,
},
wantErr: types.NewValidationError("operation keys: field Name: string is not a map"),
},
{
name: "dive inside keys",
field: parser.Field{
FieldName: "Labels",
Type: stringMap,
Tag: `valid:"dive,keys,dive,min=2,endkeys,required"`,
},
wantErr: types.NewValidationError("operation dive: field Labels: nested dive inside keys is not supported"),
},
{
name: "nested keys",
field: parser.Field{
FieldName: "Labels",
Type: common.FieldType{
BaseType: "string",
ComposedType: "map",
MapValue: &common.FieldType{
BaseType: "string",
ComposedType: "map",
MapValue: &common.FieldType{BaseType: "string"},
},
},
Tag: `valid:"dive,keys,min=2,endkeys,dive,keys,min=2,endkeys,required"`,
},
wantErr: types.NewValidationError("operation keys: field Labels: nested keys are not supported"),
},
{
name: "array key",
field: parser.Field{
FieldName: "Labels",
Type: common.FieldType{
BaseType: "string",
ComposedType: "map[N]",
Size: "2",
MapValue: &common.FieldType{BaseType: "string"},
},
Tag: `valid:"dive,keys,min=2,endkeys,required"`,
},
wantErr: types.NewValidationError("operation keys: field Labels: nested map keys are not supported"),
},
}

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)
}
})
}
}
13 changes: 12 additions & 1 deletion internal/analyzer/operations/operations_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,21 @@ 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, keys, and endkeys are level separators.
// The analyzer checks the container, then the map key, then the element.
"dive": {
CountValues: common.ZeroValue,
IsFieldOperation: false,
ValidTypes: nil,
},
"keys": {
CountValues: common.ZeroValue,
IsFieldOperation: false,
ValidTypes: nil,
},
"endkeys": {
CountValues: common.ZeroValue,
IsFieldOperation: false,
ValidTypes: nil,
},
}
Loading
Loading