diff --git a/validation/errors.go b/validation/errors.go index e3558e7e..6f035072 100644 --- a/validation/errors.go +++ b/validation/errors.go @@ -55,6 +55,55 @@ func (e *Errors) Add(path *walk.Path, message string) { } } +// Merge the given errors into this bag of errors at the given path. +// This can be used when a validator uses nested validation and wants +// to add the results in the higher-level validation errors. +// +// Missing path segments will be added automatically. +// Elements from the given errors are NOT cloned. Therefore there can +// be side-effects if you modify them after the call of `Merge`. +func (e *Errors) Merge(path *walk.Path, errors *Errors) { + switch path.Type { + case walk.PathTypeElement: + if len(errors.Fields) > 0 && e.Fields == nil { + e.Fields = make(FieldsErrors, len(errors.Fields)) + } + for k, v := range errors.Fields { + if fields, ok := e.Fields[k]; ok { + fields.Merge(path, v) + } else { + e.Fields[k] = v + } + } + if len(errors.Elements) > 0 && e.Elements == nil { + e.Elements = make(ArrayErrors, len(errors.Elements)) + } + for i, v := range errors.Elements { + if elements, ok := e.Elements[i]; ok { + elements.Merge(path, v) + } else { + e.Elements[i] = v + } + } + e.Errors = append(e.Errors, errors.Errors...) + case walk.PathTypeArray: + if e.Elements == nil { + e.Elements = make(ArrayErrors) + } + + index := -1 + if path.Index != nil { + index = *path.Index + } + e.Elements.Merge(path.Next, index, errors) + case walk.PathTypeObject: + if e.Fields == nil { + e.Fields = make(FieldsErrors) + } + e.Fields.Merge(path.Next, errors) + } +} + // Add an error message to the element identified by the given path. // Creates all missing elements in the path. func (e FieldsErrors) Add(path *walk.Path, message string) { @@ -66,6 +115,16 @@ func (e FieldsErrors) Add(path *walk.Path, message string) { errs.Add(path, message) } +// Merge the given errors into this bag of errors at the given path. +func (e FieldsErrors) Merge(path *walk.Path, errors *Errors) { + errs, ok := e[*path.Name] + if !ok { + errs = &Errors{} + e[*path.Name] = errs + } + errs.Merge(path, errors) +} + // Add an error message to the element identified by the given path in the array, // at the given index. "-1" index is accepted to identify non-existing elements. // Creates all missing elements in the path. @@ -77,3 +136,13 @@ func (e ArrayErrors) Add(path *walk.Path, index int, message string) { } errs.Add(path, message) } + +// Merge the given errors into this bag of errors at the given path. +func (e ArrayErrors) Merge(path *walk.Path, index int, errors *Errors) { + errs, ok := e[index] + if !ok { + errs = &Errors{} + e[index] = errs + } + errs.Merge(path, errors) +} diff --git a/validation/errors_test.go b/validation/errors_test.go index 1aa35594..a549e1df 100644 --- a/validation/errors_test.go +++ b/validation/errors_test.go @@ -148,4 +148,294 @@ func TestErrors(t *testing.T) { assert.Equal(t, expected, errs) }) + t.Run("Merge", func(t *testing.T) { + + mergeErrs := func() *Errors { + return &Errors{ + Fields: FieldsErrors{ + "mergeField": &Errors{ + Fields: FieldsErrors{ + "nested": &Errors{Errors: []string{"nested err"}}, + }, + Errors: []string{"merge mergeField err"}, + }, + "field": &Errors{ + Errors: []string{"merge field err"}, + }, + }, + Elements: ArrayErrors{ + 1: &Errors{ + Errors: []string{"element err"}, + }, + 3: &Errors{ + Fields: FieldsErrors{ + "elementField": &Errors{Errors: []string{"element merge err"}}, + }, + }, + }, + Errors: []string{"merge err 1", "merge err 2"}, + } + } + + cases := []struct { + base *Errors + mergeErrs *Errors + path *walk.Path + want *Errors + desc string + }{ + { + desc: "root", + base: &Errors{ + Fields: FieldsErrors{ + "field": &Errors{ + Errors: []string{"field err"}, + }, + }, + Elements: ArrayErrors{ + 3: &Errors{}, + }, + Errors: []string{"error 1"}, + }, + mergeErrs: mergeErrs(), + path: walk.MustParse(""), + want: &Errors{ + Fields: FieldsErrors{ + "mergeField": &Errors{ + Fields: FieldsErrors{ + "nested": &Errors{Errors: []string{"nested err"}}, + }, + Errors: []string{"merge mergeField err"}, + }, + "field": &Errors{ + Errors: []string{"field err", "merge field err"}, + }, + }, + Elements: ArrayErrors{ + 1: &Errors{ + Errors: []string{"element err"}, + }, + 3: &Errors{ + Fields: FieldsErrors{ + "elementField": &Errors{Errors: []string{"element merge err"}}, + }, + }, + }, + Errors: []string{"error 1", "merge err 1", "merge err 2"}, + }, + }, + { + desc: "in_array_element", + base: &Errors{ + Fields: FieldsErrors{ + "field": &Errors{ + Errors: []string{"field err"}, + }, + }, + Elements: ArrayErrors{ + 3: &Errors{}, + }, + Errors: []string{"error 1"}, + }, + mergeErrs: mergeErrs(), + path: &walk.Path{ + Type: walk.PathTypeArray, + Index: lo.ToPtr(3), + Next: &walk.Path{Type: walk.PathTypeElement}, + }, + want: &Errors{ + Fields: FieldsErrors{ + "field": &Errors{ + Errors: []string{"field err"}, + }, + }, + Elements: ArrayErrors{ + 3: mergeErrs(), + }, + Errors: []string{"error 1"}, + }, + }, + { + desc: "in_new_array_element", + base: &Errors{ + Fields: FieldsErrors{ + "field": &Errors{ + Errors: []string{"field err"}, + }, + }, + Elements: ArrayErrors{ + 3: &Errors{}, + }, + Errors: []string{"error 1"}, + }, + mergeErrs: mergeErrs(), + path: &walk.Path{ + Type: walk.PathTypeArray, + Index: lo.ToPtr(2), + Next: &walk.Path{Type: walk.PathTypeObject, Next: &walk.Path{Type: walk.PathTypeElement, Name: lo.ToPtr("property")}}, + }, + want: &Errors{ + Fields: FieldsErrors{ + "field": &Errors{ + Errors: []string{"field err"}, + }, + }, + Elements: ArrayErrors{ + 3: &Errors{}, + 2: &Errors{ + Fields: FieldsErrors{ + "property": mergeErrs(), + }, + }, + }, + Errors: []string{"error 1"}, + }, + }, + { + desc: "in_field", + base: &Errors{ + Fields: FieldsErrors{ + "field": &Errors{ + Errors: []string{"field err"}, + }, + }, + Elements: ArrayErrors{ + 3: &Errors{}, + }, + Errors: []string{"error 1"}, + }, + mergeErrs: mergeErrs(), + path: &walk.Path{ + Type: walk.PathTypeObject, + Next: &walk.Path{ + Type: walk.PathTypeElement, + Name: lo.ToPtr("field"), + }, + }, + want: &Errors{ + Fields: FieldsErrors{ + "field": &Errors{ + Fields: FieldsErrors{ + "mergeField": &Errors{ + Fields: FieldsErrors{ + "nested": &Errors{Errors: []string{"nested err"}}, + }, + Errors: []string{"merge mergeField err"}, + }, + "field": &Errors{ + Errors: []string{"merge field err"}, + }, + }, + Elements: ArrayErrors{ + 1: &Errors{ + Errors: []string{"element err"}, + }, + 3: &Errors{ + Fields: FieldsErrors{ + "elementField": &Errors{Errors: []string{"element merge err"}}, + }, + }, + }, + Errors: []string{"field err", "merge err 1", "merge err 2"}, + }, + }, + Elements: ArrayErrors{ + 3: &Errors{}, + }, + Errors: []string{"error 1"}, + }, + }, + { + desc: "in_new_field", + base: &Errors{ + Fields: FieldsErrors{ + "field": &Errors{ + Errors: []string{"field err"}, + }, + }, + Elements: ArrayErrors{ + 3: &Errors{}, + }, + Errors: []string{"error 1"}, + }, + mergeErrs: mergeErrs(), + path: &walk.Path{ + Type: walk.PathTypeObject, + Next: &walk.Path{ + Type: walk.PathTypeObject, + Name: lo.ToPtr("mergeObject"), + Next: &walk.Path{ + Type: walk.PathTypeElement, + Name: lo.ToPtr("mergeProp"), + }, + }, + }, + want: &Errors{ + Fields: FieldsErrors{ + "field": &Errors{ + Errors: []string{"field err"}, + }, + "mergeObject": &Errors{ + Fields: FieldsErrors{ + "mergeProp": mergeErrs(), + }, + }, + }, + Elements: ArrayErrors{ + 3: &Errors{}, + }, + Errors: []string{"error 1"}, + }, + }, + { + desc: "in_new_field_elements", + base: &Errors{ + Fields: FieldsErrors{ + "field": &Errors{ + Errors: []string{"field err"}, + }, + }, + Elements: ArrayErrors{ + 3: &Errors{}, + }, + Errors: []string{"error 1"}, + }, + mergeErrs: mergeErrs(), + path: &walk.Path{ + Type: walk.PathTypeObject, + Next: &walk.Path{ + Type: walk.PathTypeArray, + Name: lo.ToPtr("mergeArray"), + Index: lo.ToPtr(4), + Next: &walk.Path{Type: walk.PathTypeElement}, + }, + }, + want: &Errors{ + Fields: FieldsErrors{ + "field": &Errors{ + Errors: []string{"field err"}, + }, + "mergeArray": &Errors{ + Elements: ArrayErrors{ + 4: mergeErrs(), + }, + }, + }, + Elements: ArrayErrors{ + 3: &Errors{}, + }, + Errors: []string{"error 1"}, + }, + }, + } + + for _, c := range cases { + c := c + t.Run(c.desc, func(t *testing.T) { + errs := c.base + errs.Merge(c.path, c.mergeErrs) + assert.Equal(t, c.want, errs) + }) + } + }) } diff --git a/validation/validator.go b/validation/validator.go index 1f407843..922a43ef 100644 --- a/validation/validator.go +++ b/validation/validator.go @@ -134,10 +134,15 @@ type Options struct { ConvertSingleValueArrays bool } -// AddedValidationError a simple association path/message for use in `Context.AddValidationError` -type AddedValidationError struct { - Path *walk.Path - Message string +type addedValidationErrorConstraint interface { + string | *Errors +} + +// AddedValidationError a simple association path/message or path/*Errors +// for use in `Context.AddValidationError` or `Context.AddValidationErrors` +type AddedValidationError[T addedValidationErrorConstraint] struct { + Path *walk.Path + Error T } // Context is a structure unique per `Validator.Validate()` execution containing @@ -151,7 +156,8 @@ type Context struct { Parent any Field *Field arrayElementErrors []int - addedValidationErrors []AddedValidationError + addedValidationErrors []AddedValidationError[string] + mergeErrors []AddedValidationError[*Errors] fieldName string Now time.Time @@ -195,17 +201,36 @@ func (c *Context) ArrayElementErrors() []int { // This can be used when a validation rule uses nested validation or needs to add // a message on another field than the one this validator is targeted at. func (c *Context) AddValidationError(path *walk.Path, message string) { - c.addedValidationErrors = append(c.addedValidationErrors, AddedValidationError{ - Path: path, - Message: message, + c.addedValidationErrors = append(c.addedValidationErrors, AddedValidationError[string]{ + Path: path, + Error: message, }) } // AddedValidationError returns the additional errors added with `AddValidationError`. -func (c *Context) AddedValidationError() []AddedValidationError { +func (c *Context) AddedValidationError() []AddedValidationError[string] { return c.addedValidationErrors } +// AddValidationErrors add a `*Errors` to be merged into the errors bag of the current +// validation. The path is relative to the root element. +// +// This can be used when a validation rule uses nested validation needs to merge +// the results into the higher-level validation errors. +// +// See `*validation.Errors.Merge` for more details. +func (c *Context) AddValidationErrors(path *walk.Path, errors *Errors) { + c.mergeErrors = append(c.mergeErrors, AddedValidationError[*Errors]{ + Path: path, + Error: errors, + }) +} + +// AddedValidationErrors returns the additional errors added with `AddValidationErrors`. +func (c *Context) AddedValidationErrors() []AddedValidationError[*Errors] { + return c.mergeErrors +} + // Path returns the exact Path to the current element. // The path is relative to the root element. If you are compositing rule sets in your validation, // the path returned is NOT relative to the root of the current rule set. @@ -358,10 +383,7 @@ func (v *validator) validateField(fieldName string, field *Field, walkData any, continue } - for _, e := range ctx.addedValidationErrors { - v.validationErrors.Add(&walk.Path{Type: walk.PathTypeObject, Next: e.Path}, e.Message) - } - v.processArrayElementErrors(ctx, parentPath, c, validator) + v.processAddedErrors(ctx, parentPath, c, validator) value = ctx.Value } @@ -407,7 +429,13 @@ func (v *validator) isAbsent(field *Field, c *walk.Context, data any) bool { return !field.IsRequired(requiredCtx) && !(&RequiredValidator{}).Validate(requiredCtx) } -func (v *validator) processArrayElementErrors(ctx *Context, parentPath *walk.Path, c *walk.Context, validator Validator) { +func (v *validator) processAddedErrors(ctx *Context, parentPath *walk.Path, c *walk.Context, validator Validator) { + for _, e := range ctx.addedValidationErrors { + v.validationErrors.Add(&walk.Path{Type: walk.PathTypeObject, Next: e.Path}, e.Error) + } + for _, e := range ctx.mergeErrors { + v.validationErrors.Merge(&walk.Path{Type: walk.PathTypeObject, Next: e.Path}, e.Error) + } if len(ctx.arrayElementErrors) > 0 { errorPath := ctx.Field.getErrorPath(parentPath, c) message := v.options.Language.Get(v.getLangEntry(ctx, validator)+".element", v.processPlaceholders(ctx, validator)...) diff --git a/validation/validator_test.go b/validation/validator_test.go index 8da1cdd6..9ed6139f 100644 --- a/validation/validator_test.go +++ b/validation/validator_test.go @@ -510,11 +510,11 @@ func TestValidate(t *testing.T) { Language: lang.New().GetDefault(), Rules: RuleSet{ {Path: CurrentElement, Rules: List{&addErrorValidator{ - addedValidationErrors: []AddedValidationError{ - {Path: walk.MustParse("property"), Message: "added error"}, - {Path: walk.MustParse("object.addedProp"), Message: "added error"}, - {Path: &walk.Path{Type: walk.PathTypeArray, Name: lo.ToPtr("array"), Index: lo.ToPtr(3), Next: &walk.Path{Type: walk.PathTypeElement}}, Message: "added error"}, - {Path: &walk.Path{Type: walk.PathTypeArray, Name: lo.ToPtr("narray"), Index: lo.ToPtr(0), Next: &walk.Path{Type: walk.PathTypeArray, Index: lo.ToPtr(3), Next: &walk.Path{Type: walk.PathTypeElement}}}, Message: "added error"}, + addedValidationErrors: []AddedValidationError[string]{ + {Path: walk.MustParse("property"), Error: "added error"}, + {Path: walk.MustParse("object.addedProp"), Error: "added error"}, + {Path: &walk.Path{Type: walk.PathTypeArray, Name: lo.ToPtr("array"), Index: lo.ToPtr(3), Next: &walk.Path{Type: walk.PathTypeElement}}, Error: "added error"}, + {Path: &walk.Path{Type: walk.PathTypeArray, Name: lo.ToPtr("narray"), Index: lo.ToPtr(0), Next: &walk.Path{Type: walk.PathTypeArray, Index: lo.ToPtr(3), Next: &walk.Path{Type: walk.PathTypeElement}}}, Error: "added error"}, }, }}}, }, @@ -544,6 +544,100 @@ func TestValidate(t *testing.T) { }, }, }, + { + desc: "merge_errors", + options: &Options{ + Data: map[string]any{"property": "a", "object": map[string]any{"property": "c"}, "array": []any{"d"}, "narray": []any{[]any{1, "e", 3}}, "number": 0}, + Language: lang.New().GetDefault(), + Rules: RuleSet{ + {Path: "property", Rules: List{Required(), Int()}}, + {Path: "number", Rules: List{Required(), Int(), Between(1, 4)}}, + {Path: "missing", Rules: List{Required(), String()}}, + {Path: "object", Rules: List{Required(), Object()}}, + {Path: "object.property", Rules: List{Required(), Int()}}, + {Path: "array", Rules: List{Required(), Array()}}, + {Path: "array[]", Rules: List{Int()}}, + {Path: "narray", Rules: List{Required(), Array()}}, + {Path: "narray[]", Rules: List{Required(), Array()}}, + {Path: "narray[][]", Rules: List{Int()}}, + {Path: CurrentElement, Rules: List{&addErrorsValidator{ + addedValidationErrors: []AddedValidationError[*Errors]{ + {Path: walk.MustParse("object"), Error: &Errors{ + Fields: FieldsErrors{ + "mergeProp": &Errors{ + Errors: []string{"merge err"}, + }, + }, + Elements: ArrayErrors{ + 5: &Errors{ + Errors: []string{"merge err"}, + }, + }, + Errors: []string{"merge err"}, + }}, + { + Path: &walk.Path{ + Type: walk.PathTypeArray, + Name: lo.ToPtr("array"), + Index: lo.ToPtr(0), + Next: &walk.Path{Type: walk.PathTypeElement}, + }, + Error: &Errors{ + Errors: []string{"merge err"}, + }, + }, + { + Path: &walk.Path{ + Type: walk.PathTypeArray, + Name: lo.ToPtr("narray"), + Index: lo.ToPtr(0), + Next: &walk.Path{ + Type: walk.PathTypeArray, + Index: lo.ToPtr(2), + Next: &walk.Path{Type: walk.PathTypeElement}, + }, + }, + Error: &Errors{ + Errors: []string{"merge err"}, + }, + }, + }, + }}}, + }, + }, + wantValidationErrors: &Errors{ + Fields: FieldsErrors{ + "property": &Errors{Errors: []string{"The property must be an integer."}}, + "number": &Errors{Errors: []string{"The number must be between 1 and 4."}}, + "missing": &Errors{Errors: []string{"The missing is required.", "The missing must be a string."}}, + "object": &Errors{ + Fields: FieldsErrors{ + "property": &Errors{Errors: []string{"The property must be an integer."}}, + "mergeProp": &Errors{Errors: []string{"merge err"}}, + }, + Elements: ArrayErrors{ + 5: &Errors{Errors: []string{"merge err"}}, + }, + Errors: []string{"merge err"}, + }, + "array": &Errors{ + Elements: ArrayErrors{ + 0: &Errors{Errors: []string{"The array elements must be integers.", "merge err"}}, + }, + }, + "narray": &Errors{ + Elements: ArrayErrors{ + 0: &Errors{ + Elements: ArrayErrors{ + 1: &Errors{Errors: []string{"The narray[] elements must be integers."}}, + 2: &Errors{Errors: []string{"merge err"}}, + }, + }, + }, + }, + }, + }, + }, { desc: "array_elements_validation_errors", options: &Options{ @@ -696,7 +790,7 @@ func TestValidate(t *testing.T) { type addErrorValidator struct { BaseValidator - addedValidationErrors []AddedValidationError + addedValidationErrors []AddedValidationError[string] } func (addErrorValidator) Name() string { @@ -705,7 +799,23 @@ func (addErrorValidator) Name() string { func (v addErrorValidator) Validate(ctx *Context) bool { for _, e := range v.addedValidationErrors { - ctx.AddValidationError(e.Path, e.Message) + ctx.AddValidationError(e.Path, e.Error) + } + return true +} + +type addErrorsValidator struct { + BaseValidator + addedValidationErrors []AddedValidationError[*Errors] +} + +func (addErrorsValidator) Name() string { + return "addErrorsValidator" +} + +func (v addErrorsValidator) Validate(ctx *Context) bool { + for _, e := range v.addedValidationErrors { + ctx.AddValidationErrors(e.Path, e.Error) } return true }