diff --git a/README.md b/README.md index d6bdcf2..8a54366 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/internals.md b/docs/internals.md index b5c896e..ec864da 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -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. @@ -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`. diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 211865c..a3ddd0e 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -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 } } } @@ -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. diff --git a/internal/analyzer/dive_test.go b/internal/analyzer/dive_test.go new file mode 100644 index 0000000..54fbd7d --- /dev/null +++ b/internal/analyzer/dive_test.go @@ -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() 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) + } + }) + } +} diff --git a/internal/analyzer/operations/operations_list.go b/internal/analyzer/operations/operations_list.go index e9da1a4..55f08a3 100644 --- a/internal/analyzer/operations/operations_list.go +++ b/internal/analyzer/operations/operations_list.go @@ -130,4 +130,10 @@ var operationsList = map[string]Operation{ IsFieldOperation: true, ValidTypes: []string{"", ""}, }, + // dive is a level separator. The analyzer checks the container, then the element. + "dive": { + CountValues: common.ZeroValue, + IsFieldOperation: false, + ValidTypes: nil, + }, } diff --git a/internal/analyzer/operations/operations_test.go b/internal/analyzer/operations/operations_test.go index fe3710e..a10e742 100644 --- a/internal/analyzer/operations/operations_test.go +++ b/internal/analyzer/operations/operations_test.go @@ -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}, } @@ -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}, } @@ -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}, } diff --git a/internal/analyzer/parser_validation_test.go b/internal/analyzer/parser_validation_test.go index 606ba5c..59cdaaf 100644 --- a/internal/analyzer/parser_validation_test.go +++ b/internal/analyzer/parser_validation_test.go @@ -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 { @@ -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", diff --git a/internal/codegenerator/build_validator.go b/internal/codegenerator/build_validator.go index 144619e..8a2d492 100644 --- a/internal/codegenerator/build_validator.go +++ b/internal/codegenerator/build_validator.go @@ -66,32 +66,120 @@ func (gv *GenValidations) BuildUnmarshalJSONCode() string { } func (gv *GenValidations) BuildValidationCode(fieldName string, fieldType common.FieldType, fieldValidations []*analyzer.Validation) (string, error) { + return gv.emitValidations("obj."+fieldName, fieldName, fieldType, fieldValidations, false, 0) +} +func (gv *GenValidations) emitValidations(expr, fieldName string, fieldType common.FieldType, fieldValidations []*analyzer.Validation, dived bool, depth int) (string, error) { tests := "" - for _, fieldValidation := range fieldValidations { - var testCode = "" - var err error - - if fieldType.IsGoType() { - testCode, err = gv.buildIfCode(fieldName, fieldType, fieldValidation) - if err != nil { - return "", err - } - } else { - testCode, err = gv.buildIfNestedCode(fieldName, fieldType) + for i, fieldValidation := range fieldValidations { + if fieldValidation.Operation == "dive" { + loop, err := gv.emitDive(expr, fieldName, fieldType, fieldValidations[i+1:], depth) if err != nil { return "", err } + return tests + loop, nil } + testCode, err := gv.emitOne(expr, fieldName, fieldType, fieldValidation, dived) + if err != nil { + return "", err + } tests += testCode } + if dived && fieldType.IsNestedStruct() { + nested, err := gv.emitStructCall(expr, fieldType) + if err != nil { + return "", err + } + tests += nested + } + return tests, nil } -func (gv *GenValidations) buildIfCode(fieldName string, fieldType common.FieldType, fieldValidation *analyzer.Validation) (string, error) { - testElements, err := DefineTestElements(fieldName, fieldType, fieldValidation) +func (gv *GenValidations) emitDive(expr, fieldName string, fieldType common.FieldType, rest []*analyzer.Validation, depth int) (string, error) { + elemType, err := fieldType.DiveInto() + if err != nil { + return "", fmt.Errorf("field %s: %w", fieldName, err) + } + + depth++ + elemExpr := fmt.Sprintf("elem%d", depth) + body, err := gv.emitValidations(elemExpr, fieldName, elemType, rest, true, depth) + if err != nil { + return "", err + } + if strings.TrimSpace(body) == "" { + return "", nil + } + + rangeExpr := expr + prefix := "" + suffix := "" + if fieldType.PointerToContainer() { + rangeExpr = "*" + expr + prefix = fmt.Sprintf("if %s != nil {\n", expr) + suffix = "}\n" + } + + return prefix + fmt.Sprintf("for _, %s := range %s {\n%s}\n", elemExpr, rangeExpr, body) + suffix, nil +} + +func (gv *GenValidations) emitOne(expr, fieldName string, fieldType common.FieldType, fieldValidation *analyzer.Validation, dived bool) (string, error) { + if fieldType.IsNestedStruct() { + if !dived { + return gv.buildIfNestedCode(fieldName, fieldType) + } + if fieldValidation.Operation == "required" && fieldType.ComposedType == "*" { + return fmt.Sprintf("if !(%s != nil) {\nerrs = append(errs, types.NewValidationError(\"%s is required\"))\n}\n", expr, fieldName), nil + } + if fieldValidation.Operation == "required" { + return "", nil + } + return "", fmt.Errorf("operation %s: cannot apply to struct %s", fieldValidation.Operation, fieldType.BaseType) + } + + accept := func(candidate common.FieldType) bool { + _, err := GetConditionTable(fieldValidation.Operation, candidate) + return err == nil + } + target, ok := fieldType.OperationType(fieldValidation.Operation, accept) + if !ok { + return "", fmt.Errorf("field %s: operation %s is not supported for %s", fieldName, fieldValidation.Operation, fieldType.ToType()) + } + + return gv.buildIfCode(expr, fieldName, target, fieldValidation) +} + +func (gv *GenValidations) emitStructCall(expr string, fieldType common.FieldType) (string, error) { + _, ok := gv.StructsWithValidation[fieldType.BaseType] + if !ok { + return "", fmt.Errorf("no validator found for struct type %s", fieldType.ToType()) + } + + funcName := nestedFuncName(gv.Struct.PackageName, fieldType.BaseType) + if fieldType.ComposedType == "*" { + return fmt.Sprintf("if %s != nil {\nerrs = append(errs, %s(%s)...)\n}\n", expr, funcName, expr), nil + } + if fieldType.ComposedType != "" { + return "", fmt.Errorf("no validator found for struct type %s", fieldType.ToType()) + } + + return fmt.Sprintf("errs = append(errs, %s(&%s)...)\n", funcName, expr), nil +} + +func nestedFuncName(packageName, baseType string) string { + pkg := common.ExtractPackage(baseType) + if pkg == packageName { + baseType = strings.TrimPrefix(baseType, pkg+".") + } + + return baseType + "Validate" +} + +func (gv *GenValidations) buildIfCode(expr, fieldName string, fieldType common.FieldType, fieldValidation *analyzer.Validation) (string, error) { + testElements, err := defineTestElements(expr, fieldName, fieldType, fieldValidation) if err != nil { return "", fmt.Errorf("field %s: %w", fieldName, err) } @@ -115,7 +203,7 @@ errs = append(errs, types.NewValidationError("%s")) func (gv *GenValidations) buildIfNestedCode(fieldName string, fieldType common.FieldType) (string, error) { _, ok := gv.StructsWithValidation[fieldType.BaseType] if !ok { - return "", fmt.Errorf("no validator found for struct type %s", fieldType) + return "", fmt.Errorf("no validator found for struct type %s", fieldType.ToType()) } pkg := common.ExtractPackage(fieldType.BaseType) diff --git a/internal/codegenerator/dive_test.go b/internal/codegenerator/dive_test.go new file mode 100644 index 0000000..609c7d8 --- /dev/null +++ b/internal/codegenerator/dive_test.go @@ -0,0 +1,140 @@ +package codegenerator + +import ( + "testing" + + "github.com/opencodeco/validgen/internal/analyzer" + "github.com/opencodeco/validgen/internal/common" + "github.com/opencodeco/validgen/internal/parser" +) + +func TestBuildDiveValidationCode(t *testing.T) { + address := common.FieldType{BaseType: "main.Address"} + tests := []struct { + name string + fieldName string + fieldType common.FieldType + fieldValidation string + want string + }{ + { + name: "slice of structs", + fieldName: "Addresses", + fieldType: common.FieldType{BaseType: "main.Address", ComposedType: "[]"}, + fieldValidation: "dive", + want: `for _, elem1 := range obj.Addresses { +errs = append(errs, AddressValidate(&elem1)...) +} +`, + }, + { + name: "slice of struct pointers", + fieldName: "Addresses", + fieldType: common.FieldType{BaseType: "main.Address", ComposedType: "*[]", ElemPointer: true}, + fieldValidation: "required,dive,required", + want: `if !(len(obj.Addresses) != 0) { +errs = append(errs, types.NewValidationError("Addresses must not be empty")) +} +for _, elem1 := range obj.Addresses { +if !(elem1 != nil) { +errs = append(errs, types.NewValidationError("Addresses is required")) +} +if elem1 != nil { +errs = append(errs, AddressValidate(elem1)...) +} +} +`, + }, + { + name: "map value", + fieldName: "Labels", + fieldType: common.FieldType{ + BaseType: "string", + ComposedType: "map", + MapValue: &common.FieldType{BaseType: "string"}, + }, + fieldValidation: "dive,required", + want: `for _, elem1 := range obj.Labels { +if !(elem1 != "") { +errs = append(errs, types.NewValidationError("Labels is required")) +} +} +`, + }, + { + name: "slice of structs does not dive", + fieldName: "Addresses", + fieldType: common.FieldType{BaseType: "main.Address", ComposedType: "[]"}, + fieldValidation: "required", + want: `if !(len(obj.Addresses) != 0) { +errs = append(errs, types.NewValidationError("Addresses must not be empty")) +} +`, + }, + { + name: "pointer to a slice of structs", + fieldName: "Addresses", + fieldType: common.FieldType{BaseType: "main.Address", ComposedType: "*[]"}, + fieldValidation: "dive", + want: `if obj.Addresses != nil { +for _, elem1 := range *obj.Addresses { +errs = append(errs, AddressValidate(&elem1)...) +} +} +`, + }, + { + name: "nested string slice", + fieldName: "Matrix", + fieldType: common.FieldType{BaseType: "string", ComposedType: "[][]"}, + fieldValidation: "dive,dive,required", + want: `for _, elem1 := range obj.Matrix { +for _, elem2 := range elem1 { +if !(elem2 != "") { +errs = append(errs, types.NewValidationError("Matrix is required")) +} +} +} +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gv := GenValidations{ + Struct: &analyzer.Struct{ + Struct: parser.Struct{PackageName: "main"}, + }, + StructsWithValidation: map[string]struct{}{ + address.BaseType: {}, + }, + } + var validations []*analyzer.Validation + for _, part := range splitValidations(tt.fieldValidation) { + validations = append(validations, AssertParserValidation(t, part)) + } + got, err := gv.BuildValidationCode(tt.fieldName, tt.fieldType, validations) + if err != nil { + t.Fatalf("BuildValidationCode() error = %v", err) + } + if got != tt.want { + t.Fatalf("BuildValidationCode() = %q, want %q", got, tt.want) + } + }) + } +} + +func splitValidations(tag string) []string { + if tag == "" { + return nil + } + parts := []string{} + start := 0 + for i := 0; i <= len(tag); i++ { + if i == len(tag) || tag[i] == ',' { + parts = append(parts, tag[start:i]) + start = i + 1 + } + } + return parts +} diff --git a/internal/codegenerator/get_test_elements_all_types_test.go b/internal/codegenerator/get_test_elements_all_types_test.go index ccb2982..f750aca 100644 --- a/internal/codegenerator/get_test_elements_all_types_test.go +++ b/internal/codegenerator/get_test_elements_all_types_test.go @@ -1162,7 +1162,7 @@ func TestDefineTestElementsWithAllTypes(t *testing.T) { want.conditions[i] = strings.ReplaceAll(want.conditions[i], "{{.BaseType}}", fieldType.BaseType) } - testName := fmt.Sprintf("validation(%s) type(%s)", validation, fieldType) + testName := fmt.Sprintf("validation(%s) type(%s)", validation, fieldType.ToType()) t.Run(testName, func(t *testing.T) { validation := AssertParserValidation(t, validation) got, err := DefineTestElements("field", fieldType, validation) diff --git a/internal/codegenerator/test_elements.go b/internal/codegenerator/test_elements.go index 5a7f735..9a37b60 100644 --- a/internal/codegenerator/test_elements.go +++ b/internal/codegenerator/test_elements.go @@ -14,6 +14,10 @@ type TestElements struct { } func DefineTestElements(fieldName string, fieldType common.FieldType, fieldValidation *analyzer.Validation) (TestElements, error) { + return defineTestElements("obj."+fieldName, fieldName, fieldType, fieldValidation) +} + +func defineTestElements(expr, fieldName string, fieldType common.FieldType, fieldValidation *analyzer.Validation) (TestElements, error) { condition, err := GetConditionTable(fieldValidation.Operation, fieldType) if err != nil { @@ -27,14 +31,14 @@ func DefineTestElements(fieldName string, fieldType common.FieldType, fieldValid switch fieldValidation.ExpectedValues { case common.ZeroValue: // REFACTOR: codegenerator should inform how many values are expected - roperands = append(roperands, replaceNameAndTarget(condition.operation, fieldName, "")) + roperands = append(roperands, replaceExprNameAndTarget(condition.operation, expr, fieldName, "")) targetValue = condition.operation targetValues = "'" + condition.operation + "' " case common.OneValue, common.ManyValues: valuesAsNumericSlice, valuesAsStringSlice := normalizeSlicesAsCode(fieldType.BaseType, values) for _, value := range values { - operation := replaceNameAndTarget(condition.operation, fieldName, value) + operation := replaceExprNameAndTarget(condition.operation, expr, fieldName, value) operation = replaceSlicesTargets(operation, valuesAsStringSlice, valuesAsNumericSlice) roperands = append(roperands, operation) targetValue = value @@ -49,7 +53,7 @@ func DefineTestElements(fieldName string, fieldType common.FieldType, fieldValid targetValues = strings.TrimSpace(targetValues) errorMsg := condition.errorMessage - errorMsg = replaceNameAndTarget(errorMsg, fieldName, targetValue) + errorMsg = replaceExprNameAndTarget(errorMsg, expr, fieldName, targetValue) errorMsg = replaceTargetInErrors(errorMsg, targetValue, targetValues) return TestElements{ @@ -59,7 +63,8 @@ func DefineTestElements(fieldName string, fieldType common.FieldType, fieldValid }, nil } -func replaceNameAndTarget(text, name, target string) string { +func replaceExprNameAndTarget(text, expr, name, target string) string { + text = strings.ReplaceAll(text, "obj.{{.Name}}", expr) text = strings.ReplaceAll(text, "{{.Name}}", name) text = strings.ReplaceAll(text, "{{.Target}}", target) diff --git a/internal/common/dive.go b/internal/common/dive.go new file mode 100644 index 0000000..e2f91f4 --- /dev/null +++ b/internal/common/dive.go @@ -0,0 +1,151 @@ +package common + +import ( + "fmt" + "strings" +) + +// IsLenOperation reports tags that only check the length of a string, slice, or map. +func IsLenOperation(op string) bool { + switch op { + case "required", "min", "max", "len": + return true + default: + return false + } +} + +// IsNestedStruct reports a struct or pointer to a struct. +// Go scalars and containers are not nested structs. +func (ft FieldType) IsNestedStruct() bool { + if ft.ComposedType != "" && ft.ComposedType != "*" { + return false + } + + return ft.BaseType != "" && !ft.IsGoType() +} + +// PointerToContainer reports *[]T, *[N]T, and *map[K]V. +// []*T keeps the star on the element and returns false. +func (ft FieldType) PointerToContainer() bool { + if ft.ElemPointer || !strings.HasPrefix(ft.ComposedType, "*") { + return false + } + + ct := strings.TrimPrefix(ft.ComposedType, "*") + return isContainerBody(ct) +} + +// ForCatalog drops an element pointer so []*T is checked as a slice of T. +// *[]T is left unchanged. +func (ft FieldType) ForCatalog() FieldType { + if !ft.ElemPointer { + return ft + } + + ft.ElemPointer = false + ft.ComposedType = strings.Replace(ft.ComposedType, "*", "", 1) + return ft +} + +// LenStandIn is a slice or map of string used when a length tag does not +// depend on the element type, such as []Address or [][]string. +func (ft FieldType) LenStandIn() (FieldType, bool) { + ct := ft.ComposedType + ptr := "" + if strings.HasPrefix(ct, "*") && !ft.ElemPointer { + ptr = "*" + ct = strings.TrimPrefix(ct, "*") + } else if ft.ElemPointer { + ct = strings.Replace(ct, "*", "", 1) + } + + switch { + case strings.HasSuffix(ct, "[]"): + return FieldType{ComposedType: ptr + "[]", BaseType: "string"}, true + case ct == "map": + return FieldType{ComposedType: ptr + "map", BaseType: "string"}, true + default: + return FieldType{}, false + } +} + +// OperationType selects the field type used to look up op. +// accept reports whether that type supports the operation. +// in and nin on []*T are rejected: those helpers expect []T, not pointers. +// Length tags only call len, so they still apply to []*T. +func (ft FieldType) OperationType(op string, accept func(FieldType) bool) (FieldType, bool) { + if ft.ElemPointer && !IsLenOperation(op) { + return FieldType{}, false + } + + lookup := ft.ForCatalog() + if knownComposed(lookup.ComposedType) && lookup.IsGoType() && accept(lookup) { + return lookup, true + } + + if IsLenOperation(op) { + if standIn, ok := ft.LenStandIn(); ok && accept(standIn) { + return standIn, true + } + } + + return FieldType{}, false +} + +// DiveInto returns the type of one slice element, array element, or map value. +func (ft FieldType) DiveInto() (FieldType, error) { + ct := ft.ComposedType + if strings.HasPrefix(ct, "*") && !ft.ElemPointer { + ct = strings.TrimPrefix(ct, "*") + } + + switch { + case strings.HasSuffix(ct, "[]"): + return finishDiveElement(FieldType{ + BaseType: ft.BaseType, + ComposedType: strings.TrimSuffix(ct, "[]"), + MapValue: ft.MapValue, + }) + case strings.HasSuffix(ct, "[N]"): + elem := FieldType{ + BaseType: ft.BaseType, + ComposedType: strings.TrimSuffix(ct, "[N]"), + MapValue: ft.MapValue, + } + if strings.Contains(elem.ComposedType, "[N]") { + return FieldType{}, fmt.Errorf("nested arrays are not supported") + } + return finishDiveElement(elem) + case ct == "map": + if ft.MapValue == nil { + return FieldType{}, fmt.Errorf("map value type is not available") + } + return *ft.MapValue, nil + default: + return FieldType{}, fmt.Errorf("%s is not a slice, array, or map", ft.ToType()) + } +} + +func finishDiveElement(elem FieldType) (FieldType, error) { + // A star glued to another container (*[]T versus []*T) cannot be recovered + // once both levels share one ComposedType string. + if strings.Contains(elem.ComposedType, "*") && elem.ComposedType != "*" { + return FieldType{}, fmt.Errorf("unsupported pointer composition %s%s", elem.ComposedType, elem.BaseType) + } + + return elem, nil +} + +func knownComposed(ct string) bool { + switch ct { + case "", "*", "[]", "[N]", "map", "*[]", "*[N]", "*map": + return true + default: + return false + } +} + +func isContainerBody(ct string) bool { + return strings.HasSuffix(ct, "[]") || strings.HasSuffix(ct, "[N]") || ct == "map" || strings.HasSuffix(ct, "map") +} diff --git a/internal/common/dive_test.go b/internal/common/dive_test.go new file mode 100644 index 0000000..5b9f994 --- /dev/null +++ b/internal/common/dive_test.go @@ -0,0 +1,89 @@ +package common + +import "testing" + +func TestDiveInto(t *testing.T) { + tests := []struct { + name string + in FieldType + want FieldType + wantErr string + }{ + { + name: "slice of structs", + in: FieldType{BaseType: "main.Address", ComposedType: "[]"}, + want: FieldType{BaseType: "main.Address"}, + }, + { + name: "slice of pointers", + in: FieldType{BaseType: "main.Address", ComposedType: "*[]", ElemPointer: true}, + want: FieldType{BaseType: "main.Address", ComposedType: "*"}, + }, + { + name: "pointer to slice", + in: FieldType{BaseType: "main.Address", ComposedType: "*[]"}, + want: FieldType{BaseType: "main.Address"}, + }, + { + name: "map value", + in: FieldType{ + BaseType: "string", + ComposedType: "map", + MapValue: &FieldType{BaseType: "string"}, + }, + want: FieldType{BaseType: "string"}, + }, + { + name: "map of structs", + in: FieldType{ + BaseType: "string", + ComposedType: "map", + MapValue: &FieldType{BaseType: "main.Address"}, + }, + want: FieldType{BaseType: "main.Address"}, + }, + { + name: "nested slice", + in: FieldType{BaseType: "string", ComposedType: "[][]"}, + want: FieldType{BaseType: "string", ComposedType: "[]"}, + }, + { + name: "scalar", + in: FieldType{BaseType: "string"}, + wantErr: "string is not a slice, array, or map", + }, + { + name: "nested array", + in: FieldType{BaseType: "int", ComposedType: "[N][N]", Size: "2"}, + wantErr: "nested arrays are not supported", + }, + { + name: "pointer mixed into the next container", + in: FieldType{BaseType: "string", ComposedType: "*[][]", ElemPointer: true}, + wantErr: "unsupported pointer composition *[]string", + }, + { + name: "map without a value type", + in: FieldType{BaseType: "string", ComposedType: "map"}, + wantErr: "map value type is not available", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.in.DiveInto() + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("DiveInto() error = %v, want %s", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("DiveInto() error = %v", err) + } + if got.BaseType != tt.want.BaseType || got.ComposedType != tt.want.ComposedType || got.ElemPointer != tt.want.ElemPointer { + t.Fatalf("DiveInto() = %#v, want %#v", got, tt.want) + } + }) + } +} diff --git a/internal/common/field_type.go b/internal/common/field_type.go index 8df1d81..8b5887c 100644 --- a/internal/common/field_type.go +++ b/internal/common/field_type.go @@ -11,6 +11,11 @@ type FieldType struct { ComposedType string // array ([N]), map (map) or slice ([]) BaseType string // base type (e.g. string, int, etc.) Size string // for arrays + // MapValue is the value type of a map. Collection checks still use BaseType as the key. + MapValue *FieldType + // ElemPointer is true when a slice or array element is a pointer ([]*T, [N]*T). + // A leading * on ComposedType without ElemPointer is a pointer to the container (*[]T, *map[K]V). + ElemPointer bool } func (ft FieldType) IsGoType() bool { diff --git a/internal/parser/dive_test.go b/internal/parser/dive_test.go new file mode 100644 index 0000000..4841ecf --- /dev/null +++ b/internal/parser/dive_test.go @@ -0,0 +1,61 @@ +package parser + +import ( + "reflect" + "testing" + + "github.com/opencodeco/validgen/internal/common" +) + +func TestParseDiveFieldTypes(t *testing.T) { + src := "package main\n" + + "type User struct {\n" + + " Addresses []*Address `valid:\"required,dive,required\"`\n" + + " Labels map[string]string `valid:\"dive,required\"`\n" + + " Homes []Address `valid:\"required\"`\n" + + " Maybe *[]Address `valid:\"dive\"`\n" + + "}\n" + + "type Address struct {\n" + + " Street string `valid:\"required\"`\n" + + "}\n" + + got, err := parseStructs("example/main.go", src) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("struct count = %d, want 2", len(got)) + } + + stringValue := &common.FieldType{BaseType: "string"} + want := []Field{ + { + FieldName: "Addresses", + Type: common.FieldType{BaseType: "main.Address", ComposedType: "*[]", ElemPointer: true}, + Tag: `valid:"required,dive,required"`, + }, + { + FieldName: "Labels", + Type: common.FieldType{BaseType: "string", ComposedType: "map", MapValue: stringValue}, + Tag: `valid:"dive,required"`, + }, + { + FieldName: "Homes", + Type: common.FieldType{BaseType: "main.Address", ComposedType: "[]"}, + Tag: `valid:"required"`, + }, + { + FieldName: "Maybe", + Type: common.FieldType{BaseType: "main.Address", ComposedType: "*[]"}, + Tag: `valid:"dive"`, + }, + } + if !reflect.DeepEqual(got[0].Fields, want) { + t.Fatalf("fields mismatch\ngot:\n%swant:\n%s", formatStructs(got[:1]), formatFields(want)) + } +} + +func formatFields(fields []Field) string { + s := &Struct{Fields: fields} + return formatStructs([]*Struct{s}) +} diff --git a/internal/parser/parser.go b/internal/parser/parser.go index e0b22e1..2e772b6 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -192,11 +192,21 @@ func extractCompleteType(fType common.FieldType, expr ast.Expr, packageName stri } return fType, nil case *ast.ArrayType: - // Slice or array type + // Slice or array type. + // A star gained while parsing the element belongs to the element ([]*T), + // not to an outer pointer (*[]T). + before := fType.ComposedType + elemPointerBefore := fType.ElemPointer fType, err = extractCompleteType(fType, v.Elt, packageName) if err != nil { return common.FieldType{}, err } + gained := strings.TrimPrefix(fType.ComposedType, before) + if strings.Contains(gained, "*") { + fType.ElemPointer = true + } else { + fType.ElemPointer = elemPointerBefore + } fType.Size = "" if v.Len != nil { @@ -224,13 +234,21 @@ func extractCompleteType(fType common.FieldType, expr ast.Expr, packageName stri return fType, nil case *ast.MapType: - // Map type + // Map type. BaseType stays the key. The value is kept for dive. fType.ComposedType += "map" fType, err = extractCompleteType(fType, v.Key, packageName) if err != nil { return common.FieldType{}, err } + valueType, err := extractCompleteType(common.FieldType{}, v.Value, packageName) + if err != nil { + return common.FieldType{}, err + } + if valueType.BaseType != "" { + fType.MapValue = &valueType + } + return fType, nil case *ast.StarExpr: // Pointer type diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go index b91d1bf..975529a 100644 --- a/internal/parser/parser_test.go +++ b/internal/parser/parser_test.go @@ -1,6 +1,7 @@ package parser import ( + "fmt" "reflect" "testing" @@ -376,17 +377,17 @@ func TestParseStructsOk(t *testing.T) { Fields: []Field{ { FieldName: "MapField1", - Type: common.FieldType{BaseType: "string", ComposedType: "map", Size: ""}, + Type: common.FieldType{BaseType: "string", ComposedType: "map", Size: "", MapValue: &common.FieldType{BaseType: "string"}}, Tag: "valid:\"required\"", }, { FieldName: "MapField2", - Type: common.FieldType{BaseType: "string", ComposedType: "map", Size: ""}, + Type: common.FieldType{BaseType: "string", ComposedType: "map", Size: "", MapValue: &common.FieldType{BaseType: "uint8"}}, Tag: "valid:\"len=3\"", }, { FieldName: "MapField3", - Type: common.FieldType{BaseType: "uint8", ComposedType: "map", Size: ""}, + Type: common.FieldType{BaseType: "uint8", ComposedType: "map", Size: "", MapValue: &common.FieldType{BaseType: "string"}}, Tag: "valid:\"max=5\"", }, }, @@ -546,17 +547,17 @@ func TestParseStructsOk(t *testing.T) { }, { FieldName: "MapPointer", - Type: common.FieldType{BaseType: "string", ComposedType: "*map", Size: ""}, + Type: common.FieldType{BaseType: "string", ComposedType: "*map", Size: "", MapValue: &common.FieldType{BaseType: "bool"}}, Tag: "valid:\"min=2\"", }, { FieldName: "SliceIntPointer", - Type: common.FieldType{BaseType: "int64", ComposedType: "*[]", Size: ""}, + Type: common.FieldType{BaseType: "int64", ComposedType: "*[]", Size: "", ElemPointer: true}, Tag: "valid:\"min=2\"", }, { FieldName: "ArrayIntPointer", - Type: common.FieldType{BaseType: "int64", ComposedType: "*[5]", Size: ""}, + Type: common.FieldType{BaseType: "int64", ComposedType: "*[N]", Size: "5", ElemPointer: true}, Tag: "valid:\"max=4\"", }, }, @@ -575,6 +576,7 @@ func TestParseStructsOk(t *testing.T) { return } if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseStructs() mismatch\ngot:\n%swant:\n%s", formatStructs(got), formatStructs(tt.want)) gotStr := structsToString(got) wantStr := structsToString(tt.want) dmp := diffmatchpatch.New() @@ -587,6 +589,17 @@ func TestParseStructsOk(t *testing.T) { } } +func formatStructs(structs []*Struct) string { + var result string + for _, s := range structs { + result += fmt.Sprintf("Struct: %s package %s\n", s.StructName, s.PackageName) + for _, f := range s.Fields { + result += fmt.Sprintf(" %s %#v tag %s\n", f.FieldName, f.Type, f.Tag) + } + } + return result +} + func structsToString(structs []*Struct) string { var result string for _, s := range structs { diff --git a/tests/endtoend/dive.go b/tests/endtoend/dive.go new file mode 100644 index 0000000..7dca52c --- /dev/null +++ b/tests/endtoend/dive.go @@ -0,0 +1,65 @@ +package main + +import "log" + +type DiveUser struct { + Addresses []Address `valid:"required,dive"` + Labels map[string]string `valid:"dive,required"` +} + +type ShallowDiveUser struct { + Addresses []Address `valid:"required"` +} + +type PointerDiveUser struct { + Addresses []*Address `valid:"required,dive,required"` +} + +func diveTests() { + log.Println("starting dive tests") + + shallowEmpty := &ShallowDiveUser{} + assertExpectedErrorMsgs("shallow empty", ShallowDiveUserValidate(shallowEmpty), []string{ + "Addresses must not be empty", + }) + + shallowPresent := &ShallowDiveUser{Addresses: []Address{{}}} + assertExpectedErrorMsgs("shallow does not dive", ShallowDiveUserValidate(shallowPresent), nil) + + missing := &DiveUser{ + Addresses: []Address{{}}, + Labels: map[string]string{"home": ""}, + } + assertExpectedErrorMsgs("dive struct and map value", DiveUserValidate(missing), []string{ + "Street is required", + "City is required", + "Labels is required", + }) + + ok := &DiveUser{ + Addresses: []Address{{Street: "av 123", City: "city 123"}}, + Labels: map[string]string{"home": "earth"}, + } + assertExpectedErrorMsgs("dive valid", DiveUserValidate(ok), nil) + + nilElem := &PointerDiveUser{Addresses: []*Address{nil}} + assertExpectedErrorMsgs("nil pointer element", PointerDiveUserValidate(nilElem), []string{ + "Addresses is required", + }) + + emptyElem := &PointerDiveUser{Addresses: []*Address{{}}} + assertExpectedErrorMsgs("pointer element fields", PointerDiveUserValidate(emptyElem), []string{ + "Street is required", + "City is required", + }) + + emptySlice := &PointerDiveUser{} + assertExpectedErrorMsgs("pointer slice empty", PointerDiveUserValidate(emptySlice), []string{ + "Addresses must not be empty", + }) + + pointerOK := &PointerDiveUser{Addresses: []*Address{{Street: "av 123", City: "city 123"}}} + assertExpectedErrorMsgs("pointer dive valid", PointerDiveUserValidate(pointerOK), nil) + + log.Println("dive tests ok") +} diff --git a/tests/endtoend/main.go b/tests/endtoend/main.go index b906cce..1316043 100644 --- a/tests/endtoend/main.go +++ b/tests/endtoend/main.go @@ -35,6 +35,7 @@ func main() { structInPkgTests() nestedStructTests() + diveTests() cmpBetweenInnerFieldsTests() cmpBetweenNestedFieldsTests() boolTests() diff --git a/tests/endtoend/validator__.go b/tests/endtoend/validator__.go index 64f5f68..bf1dc94 100755 --- a/tests/endtoend/validator__.go +++ b/tests/endtoend/validator__.go @@ -271,6 +271,43 @@ func CmpNestedUint8FieldsValidate(obj *CmpNestedUint8Fields) []error { } return errs } +func DiveUserValidate(obj *DiveUser) []error { + var errs []error + if !(len(obj.Addresses) != 0) { + errs = append(errs, types.NewValidationError("Addresses must not be empty")) + } + for _, elem1 := range obj.Addresses { + errs = append(errs, AddressValidate(&elem1)...) + } + for _, elem1 := range obj.Labels { + if !(elem1 != "") { + errs = append(errs, types.NewValidationError("Labels is required")) + } + } + return errs +} +func PointerDiveUserValidate(obj *PointerDiveUser) []error { + var errs []error + if !(len(obj.Addresses) != 0) { + errs = append(errs, types.NewValidationError("Addresses must not be empty")) + } + for _, elem1 := range obj.Addresses { + if !(elem1 != nil) { + errs = append(errs, types.NewValidationError("Addresses is required")) + } + if elem1 != nil { + errs = append(errs, AddressValidate(elem1)...) + } + } + return errs +} +func ShallowDiveUserValidate(obj *ShallowDiveUser) []error { + var errs []error + if !(len(obj.Addresses) != 0) { + errs = append(errs, types.NewValidationError("Addresses must not be empty")) + } + return errs +} func UserValidate(obj *User) []error { var errs []error if !(obj.FirstName != "") {