From 9891183bbe9b40d17f8060d9bbf7f02c5146974f Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Fri, 18 Jun 2021 11:57:57 +0300 Subject: [PATCH] unique-constraint --- README.md | 39 +++++++----- arguments.go | 14 +++++ code/codes.go | 1 + contraint.go | 6 ++ example_test.go | 28 ++++++++- example_validatable_struct_test.go | 9 ++- is/data.go | 18 ++++++ is/data_example_test.go | 10 +++ it/basic.go | 68 +++++++++++++++++---- it/comparison.go | 59 ++++++++++++++++++ it/{web_example_test.go => example_test.go} | 24 ++++++++ it/string_example_test.go | 24 -------- message/messages.go | 1 + message/translations/english/messages.go | 1 + message/translations/russian/messages.go | 1 + test/constraints_basic_cases_test.go | 46 ++++++++++---- test/constraints_comparison_cases_test.go | 57 +++++++++++++++++ test/constraints_test.go | 17 ++++++ validation.go | 10 +++ validator.go | 5 ++ validator/validator.go | 5 ++ violations_test.go | 7 +++ 22 files changed, 381 insertions(+), 69 deletions(-) rename it/{web_example_test.go => example_test.go} (88%) delete mode 100644 it/string_example_test.go diff --git a/README.md b/README.md index 0893e29..83feaac 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ List of common [validation arguments](https://pkg.go.dev/github.com/muonsoft/val * `validation.Bool()` - passes boolean value. * `validation.Number()` - passes any numeric value. At the moment it uses reflection for executing validation process. * `validation.String()` - passes string value. +* `validation.Strings()` - passes slice of strings value. * `validation.Iterable()` - passes array, slice or a map. At the moment it uses reflection for executing validation process. * `validation.Countable()` - you can pass result of `len()` to use easy way of iterable validation based only on count of the elements. * `validation.Time()` - passes `time.Time` value. @@ -76,6 +77,7 @@ For single value validation, you can use shorthand versions of the validation me * `validator.ValidateBool()` * `validator.ValidateNumber()` * `validator.ValidateString()` +* `validator.ValidateStrings()` * `validator.ValidateIterable()` * `validator.ValidateCountable()` * `validator.ValidateTime()` @@ -173,6 +175,7 @@ For a better experience with struct validation, you can use shorthand versions o * `validation.BoolProperty()` * `validation.NumberProperty()` * `validation.StringProperty()` +* `validation.StringsProperty()` * `validation.IterableProperty()` * `validation.CountableProperty()` * `validation.TimeProperty()` @@ -200,22 +203,25 @@ There are few ways to validate structs. The simplest one is to call the `validat ```golang document := Document{ Title: "", - Keywords: []string{""}, + Keywords: []string{"", "book", "fantasy", "book"}, } err := validator.Validate( validation.StringProperty("title", &document.Title, it.IsNotBlank()), - validation.CountableProperty("keywords", len(document.Keywords), it.HasCountBetween(2, 10)), + validation.CountableProperty("keywords", len(document.Keywords), it.HasCountBetween(5, 10)), + validation.StringsProperty("keywords", document.Keywords, it.HasUniqueValues()), validation.EachStringProperty("keywords", document.Keywords, it.IsNotBlank()), ) -violations := err.(validation.ViolationList) -for _, violation := range violations { - fmt.Println(violation.Error()) +if violations, ok := validation.UnwrapViolationList(err); ok { + for violation := violations.First(); violation != nil; violation = violation.Next() { + fmt.Println(violation) + } } // Output: // violation at 'title': This value should not be blank. -// violation at 'keywords': This collection should contain 2 elements or more. +// violation at 'keywords': This collection should contain 5 elements or more. +// violation at 'keywords': This collection should contain only unique elements. // violation at 'keywords[0]': This value should not be blank. ``` @@ -231,7 +237,9 @@ type Product struct { func (p Product) Validate(validator *validation.Validator) error { return validator.Validate( validation.StringProperty("name", &p.Name, it.IsNotBlank()), - validation.IterableProperty("tags", p.Tags, it.HasMinCount(1)), + validation.CountableProperty("tags", len(p.Tags), it.HasMinCount(5)), + validation.StringsProperty("tags", p.Tags, it.HasUniqueValues()), + validation.EachStringProperty("tags", p.Tags, it.IsNotBlank()), // this also runs validation on each of the components validation.IterableProperty("components", p.Components, it.HasMinCount(1)), ) @@ -253,6 +261,7 @@ func (c Component) Validate(validator *validation.Validator) error { func main() { p := Product{ Name: "", + Tags: []string{"device", "", "phone", "device"}, Components: []Component{ { ID: 1, @@ -269,7 +278,9 @@ func main() { } // Output: // violation at 'name': This value should not be blank. - // violation at 'tags': This collection should contain 1 element or more. + // violation at 'tags': This collection should contain 5 elements or more. + // violation at 'tags': This collection should contain only unique elements. + // violation at 'tags[1]': This value should not be blank. // violation at 'components[0].name': This value should not be blank. // violation at 'components[0].tags': This collection should contain 1 element or more. } @@ -313,13 +324,10 @@ Also, you can use helper function `validation.UnwrapViolationList()`. ```golang err := validator.Validate(/* validation arguments */) -if err != nil { - violations, ok := validation.UnwrapViolationList(err) - if ok { - // handle violations - } else { - // handle internal error - } +if violations, ok := validation.UnwrapViolationList(err); ok { + // handle violations +} else if err != nil { + // handle internal error } ``` @@ -465,6 +473,7 @@ Everything you need to create a custom constraint is to implement one of the int * `BoolConstraint` - for validating boolean values; * `NumberConstraint` - for validating numeric values; * `StringConstraint` - for validating string values; +* `StringsConstraint` - for validating slice of strings; * `IterableConstraint` - for validating iterable values: arrays, slices, or maps; * `CountableConstraint` - for validating iterable values based only on the count of elements; * `TimeConstraint` - for validating date\time values. diff --git a/arguments.go b/arguments.go index f1c3020..76e7950 100644 --- a/arguments.go +++ b/arguments.go @@ -103,6 +103,20 @@ func StringProperty(name string, value *string, options ...Option) Argument { return String(value, append([]Option{PropertyName(name)}, options...)...) } +// Strings argument is used to validate slice of strings. +func Strings(values []string, options ...Option) Argument { + return argumentFunc(func(arguments *Arguments) error { + arguments.addValidator(newStringsValidator(values, options)) + + return nil + }) +} + +// StringsProperty argument is an alias for Strings that automatically adds property name to the current scope. +func StringsProperty(name string, values []string, options ...Option) Argument { + return Strings(values, append([]Option{PropertyName(name)}, options...)...) +} + // Iterable argument is used to validate arrays, slices, or maps. At the moment it uses reflection // to iterate over values. So you can expect a performance hit using this method. For better performance // it is recommended to make a custom type that implements the Validatable interface. diff --git a/code/codes.go b/code/codes.go index 9683eba..b4dfd9a 100644 --- a/code/codes.go +++ b/code/codes.go @@ -33,6 +33,7 @@ const ( NotNil = "notNil" NotPositive = "notPositive" NotPositiveOrZero = "notPositiveOrZero" + NotUnique = "notUnique" NotValid = "notValid" ProhibitedIP = "prohibitedIP" TooEarly = "tooEarly" diff --git a/contraint.go b/contraint.go index baf0ea1..00265f8 100644 --- a/contraint.go +++ b/contraint.go @@ -43,6 +43,12 @@ type StringConstraint interface { ValidateString(value *string, scope Scope) error } +// StringsConstraint is used to build constraints to validate an array or a slice of strings. +type StringsConstraint interface { + Constraint + ValidateStrings(values []string, scope Scope) error +} + // IterableConstraint is used to build constraints for validation of iterables (arrays, slices, or maps). // // At this moment working with numbers is based on reflection. diff --git a/example_test.go b/example_test.go index d91d061..70cff04 100644 --- a/example_test.go +++ b/example_test.go @@ -98,6 +98,26 @@ func ExampleStringProperty() { // violation at 'title': This value should not be blank. } +func ExampleStrings() { + v := []string{"foo", "bar", "baz", "foo"} + err := validator.Validate( + validation.Strings(v, it.HasUniqueValues()), + ) + fmt.Println(err) + // Output: + // violation: This collection should contain only unique elements. +} + +func ExampleStringsProperty() { + v := Book{Keywords: []string{"foo", "bar", "baz", "foo"}} + err := validator.Validate( + validation.StringsProperty("keywords", v.Keywords, it.HasUniqueValues()), + ) + fmt.Println(err) + // Output: + // violation at 'keywords': This collection should contain only unique elements. +} + func ExampleIterable() { v := make([]string, 0) err := validator.Validate(validation.Iterable(v, it.IsNotBlank())) @@ -377,12 +397,13 @@ func ExampleValidator_Validate_basicStructValidation() { Keywords []string }{ Title: "", - Keywords: []string{""}, + Keywords: []string{"", "book", "fantasy", "book"}, } err := validator.Validate( validation.StringProperty("title", &document.Title, it.IsNotBlank()), - validation.CountableProperty("keywords", len(document.Keywords), it.HasCountBetween(2, 10)), + validation.CountableProperty("keywords", len(document.Keywords), it.HasCountBetween(5, 10)), + validation.StringsProperty("keywords", document.Keywords, it.HasUniqueValues()), validation.EachStringProperty("keywords", document.Keywords, it.IsNotBlank()), ) @@ -393,7 +414,8 @@ func ExampleValidator_Validate_basicStructValidation() { } // Output: // violation at 'title': This value should not be blank. - // violation at 'keywords': This collection should contain 2 elements or more. + // violation at 'keywords': This collection should contain 5 elements or more. + // violation at 'keywords': This collection should contain only unique elements. // violation at 'keywords[0]': This value should not be blank. } diff --git a/example_validatable_struct_test.go b/example_validatable_struct_test.go index 4408112..3a9309b 100644 --- a/example_validatable_struct_test.go +++ b/example_validatable_struct_test.go @@ -17,7 +17,9 @@ type Product struct { func (p Product) Validate(validator *validation.Validator) error { return validator.Validate( validation.StringProperty("name", &p.Name, it.IsNotBlank()), - validation.IterableProperty("tags", p.Tags, it.HasMinCount(1)), + validation.CountableProperty("tags", len(p.Tags), it.HasMinCount(5)), + validation.StringsProperty("tags", p.Tags, it.HasUniqueValues()), + validation.EachStringProperty("tags", p.Tags, it.IsNotBlank()), // this also runs validation on each of the components validation.IterableProperty("components", p.Components, it.HasMinCount(1)), ) @@ -39,6 +41,7 @@ func (c Component) Validate(validator *validation.Validator) error { func ExampleValidator_ValidateValidatable_validatableStruct() { p := Product{ Name: "", + Tags: []string{"device", "", "phone", "device"}, Components: []Component{ { ID: 1, @@ -56,7 +59,9 @@ func ExampleValidator_ValidateValidatable_validatableStruct() { } // Output: // violation at 'name': This value should not be blank. - // violation at 'tags': This collection should contain 1 element or more. + // violation at 'tags': This collection should contain 5 elements or more. + // violation at 'tags': This collection should contain only unique elements. + // violation at 'tags[1]': This value should not be blank. // violation at 'components[0].name': This value should not be blank. // violation at 'components[0].tags': This collection should contain 1 element or more. } diff --git a/is/data.go b/is/data.go index cc698cb..ff9062b 100644 --- a/is/data.go +++ b/is/data.go @@ -6,3 +6,21 @@ import "encoding/json" func JSON(value string) bool { return json.Valid([]byte(value)) } + +// UniqueStrings checks that slice of strings has unique values. +func UniqueStrings(values []string) bool { + if len(values) == 0 { + return true + } + + uniques := make(map[string]struct{}, len(values)) + + for _, value := range values { + if _, exists := uniques[value]; exists { + return false + } + uniques[value] = struct{}{} + } + + return true +} diff --git a/is/data_example_test.go b/is/data_example_test.go index 49894fc..c687b5e 100644 --- a/is/data_example_test.go +++ b/is/data_example_test.go @@ -13,3 +13,13 @@ func ExampleJSON() { // true // false } + +func ExampleUniqueStrings() { + fmt.Println(is.UniqueStrings([]string{})) + fmt.Println(is.UniqueStrings([]string{"one", "two", "three"})) + fmt.Println(is.UniqueStrings([]string{"one", "two", "one"})) + // Output: + // true + // true + // false +} diff --git a/it/basic.go b/it/basic.go index 163d002..f4ad9e7 100644 --- a/it/basic.go +++ b/it/basic.go @@ -119,6 +119,20 @@ func (c NotBlankConstraint) ValidateString(value *string, scope validation.Scope return c.newViolation(scope) } +func (c NotBlankConstraint) ValidateStrings(values []string, scope validation.Scope) error { + if c.isIgnored { + return nil + } + if c.allowNil && values == nil { + return nil + } + if len(values) > 0 { + return nil + } + + return c.newViolation(scope) +} + func (c NotBlankConstraint) ValidateIterable(value generic.Iterable, scope validation.Scope) error { if c.isIgnored { return nil @@ -243,6 +257,14 @@ func (c BlankConstraint) ValidateString(value *string, scope validation.Scope) e return c.newViolation(scope) } +func (c BlankConstraint) ValidateStrings(values []string, scope validation.Scope) error { + if c.isIgnored || len(values) == 0 { + return nil + } + + return c.newViolation(scope) +} + func (c BlankConstraint) ValidateIterable(value generic.Iterable, scope validation.Scope) error { if c.isIgnored || value.Count() == 0 { return nil @@ -334,11 +356,16 @@ func (c NotNilConstraint) ValidateNil(scope validation.Scope) error { return c.newViolation(scope) } -func (c NotNilConstraint) ValidateNumber(value generic.Number, scope validation.Scope) error { - if c.isIgnored { +func (c NotNilConstraint) ValidateBool(value *bool, scope validation.Scope) error { + if c.isIgnored || value != nil { return nil } - if !value.IsNil() { + + return c.newViolation(scope) +} + +func (c NotNilConstraint) ValidateNumber(value generic.Number, scope validation.Scope) error { + if c.isIgnored || !value.IsNil() { return nil } @@ -346,10 +373,15 @@ func (c NotNilConstraint) ValidateNumber(value generic.Number, scope validation. } func (c NotNilConstraint) ValidateString(value *string, scope validation.Scope) error { - if c.isIgnored { + if c.isIgnored || value != nil { return nil } - if value != nil { + + return c.newViolation(scope) +} + +func (c NotNilConstraint) ValidateStrings(values []string, scope validation.Scope) error { + if c.isIgnored || values != nil { return nil } @@ -357,10 +389,7 @@ func (c NotNilConstraint) ValidateString(value *string, scope validation.Scope) } func (c NotNilConstraint) ValidateTime(value *time.Time, scope validation.Scope) error { - if c.isIgnored { - return nil - } - if value != nil { + if c.isIgnored || value != nil { return nil } @@ -368,10 +397,7 @@ func (c NotNilConstraint) ValidateTime(value *time.Time, scope validation.Scope) } func (c NotNilConstraint) ValidateIterable(value generic.Iterable, scope validation.Scope) error { - if c.isIgnored { - return nil - } - if !value.IsNil() { + if c.isIgnored || !value.IsNil() { return nil } @@ -440,6 +466,14 @@ func (c NilConstraint) ValidateNil(scope validation.Scope) error { return nil } +func (c NilConstraint) ValidateBool(value *bool, scope validation.Scope) error { + if c.isIgnored || value == nil { + return nil + } + + return c.newViolation(scope) +} + func (c NilConstraint) ValidateNumber(value generic.Number, scope validation.Scope) error { if c.isIgnored || value.IsNil() { return nil @@ -456,6 +490,14 @@ func (c NilConstraint) ValidateString(value *string, scope validation.Scope) err return c.newViolation(scope) } +func (c NilConstraint) ValidateStrings(values []string, scope validation.Scope) error { + if c.isIgnored || values == nil { + return nil + } + + return c.newViolation(scope) +} + func (c NilConstraint) ValidateTime(value *time.Time, scope validation.Scope) error { if c.isIgnored || value == nil { return nil diff --git a/it/comparison.go b/it/comparison.go index d2713df..3c88a09 100644 --- a/it/comparison.go +++ b/it/comparison.go @@ -7,6 +7,7 @@ import ( "github.com/muonsoft/validation" "github.com/muonsoft/validation/code" "github.com/muonsoft/validation/generic" + "github.com/muonsoft/validation/is" "github.com/muonsoft/validation/message" ) @@ -828,3 +829,61 @@ func (c TimeRangeConstraint) newViolation(value *time.Time, scope validation.Sco ). CreateViolation() } + +// UniqueConstraint is used to check that all elements of the given collection are unique. +type UniqueConstraint struct { + isIgnored bool + code string + messageTemplate string + messageParameters validation.TemplateParameterList +} + +// HasUniqueValues checks that all elements of the given collection are unique +// (none of them is present more than once). +func HasUniqueValues() UniqueConstraint { + return UniqueConstraint{ + code: code.NotUnique, + messageTemplate: message.NotUnique, + } +} + +// SetUp always returns no error. +func (c UniqueConstraint) SetUp() error { + return nil +} + +// Name is the constraint name. +func (c UniqueConstraint) Name() string { + return "UniqueConstraint" +} + +// Code overrides default code for produced violation. +func (c UniqueConstraint) Code(code string) UniqueConstraint { + c.code = code + return c +} + +// Message sets the violation message template. You can set custom template parameters +// for injecting its values into the final message. +func (c UniqueConstraint) Message(template string, parameters ...validation.TemplateParameter) UniqueConstraint { + c.messageTemplate = template + c.messageParameters = parameters + return c +} + +// When enables conditional validation of this constraint. If the expression evaluates to false, +// then the constraint will be ignored. +func (c UniqueConstraint) When(condition bool) UniqueConstraint { + c.isIgnored = !condition + return c +} + +func (c UniqueConstraint) ValidateStrings(values []string, scope validation.Scope) error { + if c.isIgnored || is.UniqueStrings(values) { + return nil + } + + return scope.BuildViolation(c.code, c.messageTemplate). + SetParameters(c.messageParameters...). + CreateViolation() +} diff --git a/it/web_example_test.go b/it/example_test.go similarity index 88% rename from it/web_example_test.go rename to it/example_test.go index 49b1bde..c525c77 100644 --- a/it/web_example_test.go +++ b/it/example_test.go @@ -8,6 +8,30 @@ import ( "github.com/muonsoft/validation/validator" ) +func ExampleHasUniqueValues() { + v := []string{"foo", "bar", "baz", "foo"} + err := validator.ValidateStrings(v, it.HasUniqueValues()) + fmt.Println(err) + // Output: + // violation: This collection should contain only unique elements. +} + +func ExampleIsJSON_validJSON() { + v := `{"valid": true}` + err := validator.ValidateString(&v, it.IsJSON()) + fmt.Println(err) + // Output: + // +} + +func ExampleIsJSON_invalidJSON() { + v := `"invalid": true` + err := validator.ValidateString(&v, it.IsJSON()) + fmt.Println(err) + // Output: + // violation: This value should be valid JSON. +} + func ExampleIsEmail_validEmail() { v := "user@example.com" err := validator.ValidateString(&v, it.IsEmail()) diff --git a/it/string_example_test.go b/it/string_example_test.go deleted file mode 100644 index 34086be..0000000 --- a/it/string_example_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package it_test - -import ( - "fmt" - - "github.com/muonsoft/validation/it" - "github.com/muonsoft/validation/validator" -) - -func ExampleIsJSON_validJSON() { - v := `{"valid": true}` - err := validator.ValidateString(&v, it.IsJSON()) - fmt.Println(err) - // Output: - // -} - -func ExampleIsJSON_invalidJSON() { - v := `"invalid": true` - err := validator.ValidateString(&v, it.IsJSON()) - fmt.Println(err) - // Output: - // violation: This value should be valid JSON. -} diff --git a/message/messages.go b/message/messages.go index 79a693c..937264c 100644 --- a/message/messages.go +++ b/message/messages.go @@ -50,6 +50,7 @@ const ( NotNil = "This value should not be nil." NotPositive = "This value should be positive." NotPositiveOrZero = "This value should be either positive or zero." + NotUnique = "This collection should contain only unique elements." NotValid = "This value is not valid." ProhibitedIP = "This IP address is prohibited to use." TooEarly = "This value should be later than {{ comparedValue }}." diff --git a/message/translations/english/messages.go b/message/translations/english/messages.go index 8677101..e6484e4 100644 --- a/message/translations/english/messages.go +++ b/message/translations/english/messages.go @@ -70,6 +70,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.NotNil: catalog.String(message.NotNil), message.NotPositive: catalog.String(message.NotPositive), message.NotPositiveOrZero: catalog.String(message.NotPositiveOrZero), + message.NotUnique: catalog.String(message.NotUnique), message.NotValid: catalog.String(message.NotValid), message.ProhibitedIP: catalog.String(message.ProhibitedIP), message.TooEarly: catalog.String(message.TooEarly), diff --git a/message/translations/russian/messages.go b/message/translations/russian/messages.go index 35630ed..645e23c 100644 --- a/message/translations/russian/messages.go +++ b/message/translations/russian/messages.go @@ -76,6 +76,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.NotNil: catalog.String("Значение не должно быть nil."), message.NotPositive: catalog.String("Значение должно быть положительным."), message.NotPositiveOrZero: catalog.String("Значение должно быть положительным или равным нулю."), + message.NotUnique: catalog.String("Эта коллекция должна содержать только уникальные элементы."), message.NotValid: catalog.String("Значение недопустимо."), message.ProhibitedIP: catalog.String("Этот IP-адрес запрещено использовать."), message.TooEarly: catalog.String("Значение должно быть позже чем {{ comparedValue }}."), diff --git a/test/constraints_basic_cases_test.go b/test/constraints_basic_cases_test.go index fea662c..a1da6ee 100644 --- a/test/constraints_basic_cases_test.go +++ b/test/constraints_basic_cases_test.go @@ -23,6 +23,7 @@ var isNotBlankConstraintTestCases = []ConstraintValidationTestCase{ intValue: intValue(0), floatValue: floatValue(0), stringValue: stringValue(""), + stringsValue: []string{}, sliceValue: []string{}, mapValue: map[string]string{}, constraint: it.IsNotBlank(), @@ -35,6 +36,7 @@ var isNotBlankConstraintTestCases = []ConstraintValidationTestCase{ intValue: intValue(0), floatValue: floatValue(0), stringValue: stringValue(""), + stringsValue: []string{}, sliceValue: []string{}, mapValue: map[string]string{}, constraint: it.IsNotBlank().When(true), @@ -58,6 +60,7 @@ var isNotBlankConstraintTestCases = []ConstraintValidationTestCase{ intValue: intValue(1), floatValue: floatValue(0.1), stringValue: stringValue("a"), + stringsValue: []string{""}, timeValue: timeValue(time.Now()), sliceValue: []string{"a"}, mapValue: map[string]string{"a": "a"}, @@ -86,6 +89,7 @@ var isBlankConstraintTestCases = []ConstraintValidationTestCase{ intValue: intValue(1), floatValue: floatValue(0.1), stringValue: stringValue("a"), + stringsValue: []string{""}, timeValue: timeValue(time.Now()), sliceValue: []string{"a"}, mapValue: map[string]string{"a": "a"}, @@ -99,6 +103,7 @@ var isBlankConstraintTestCases = []ConstraintValidationTestCase{ intValue: intValue(1), floatValue: floatValue(0.1), stringValue: stringValue("a"), + stringsValue: []string{""}, timeValue: timeValue(time.Now()), sliceValue: []string{"a"}, mapValue: map[string]string{"a": "a"}, @@ -112,6 +117,7 @@ var isBlankConstraintTestCases = []ConstraintValidationTestCase{ intValue: intValue(1), floatValue: floatValue(0.1), stringValue: stringValue("a"), + stringsValue: []string{""}, timeValue: timeValue(time.Now()), sliceValue: []string{"a"}, mapValue: map[string]string{"a": "a"}, @@ -137,6 +143,7 @@ var isBlankConstraintTestCases = []ConstraintValidationTestCase{ floatValue: floatValue(0.0), stringValue: stringValue(""), timeValue: timeValue(time.Time{}), + stringsValue: []string{}, sliceValue: []string{}, mapValue: map[string]string{}, constraint: it.IsBlank(), @@ -150,6 +157,7 @@ var isBlankConstraintTestCases = []ConstraintValidationTestCase{ floatValue: floatValue(0.1), stringValue: stringValue("a"), timeValue: timeValue(time.Now()), + stringsValue: []string{""}, sliceValue: []string{"a"}, mapValue: map[string]string{"a": "a"}, constraint: it.IsBlank().When(false), @@ -160,16 +168,18 @@ var isBlankConstraintTestCases = []ConstraintValidationTestCase{ var isNotNilConstraintTestCases = []ConstraintValidationTestCase{ { name: "IsNotNil violation on nil", - isApplicableFor: specificValueTypes(intType, floatType, stringType, timeType, iterableType), + isApplicableFor: exceptValueTypes(countableType), constraint: it.IsNotNil(), assert: assertHasOneViolation(code.NotNil, message.NotNil), }, { name: "IsNotNil passes on empty value", - isApplicableFor: specificValueTypes(intType, floatType, stringType, timeType, iterableType), + isApplicableFor: exceptValueTypes(countableType), + boolValue: boolValue(false), intValue: intValue(0), floatValue: floatValue(0), stringValue: stringValue(""), + stringsValue: []string{}, timeValue: &time.Time{}, sliceValue: []string{}, mapValue: map[string]string{}, @@ -178,10 +188,12 @@ var isNotNilConstraintTestCases = []ConstraintValidationTestCase{ }, { name: "IsNotNil passes on empty value when condition is true", - isApplicableFor: specificValueTypes(intType, floatType, stringType, timeType, iterableType), + isApplicableFor: exceptValueTypes(countableType), + boolValue: boolValue(false), intValue: intValue(0), floatValue: floatValue(0), stringValue: stringValue(""), + stringsValue: []string{}, timeValue: &time.Time{}, sliceValue: []string{}, mapValue: map[string]string{}, @@ -190,7 +202,7 @@ var isNotNilConstraintTestCases = []ConstraintValidationTestCase{ }, { name: "IsNotNil violation on nil with custom message", - isApplicableFor: specificValueTypes(intType, floatType, stringType, timeType, iterableType), + isApplicableFor: exceptValueTypes(countableType), constraint: it.IsNotNil(). Code(customCode). Message( @@ -201,10 +213,12 @@ var isNotNilConstraintTestCases = []ConstraintValidationTestCase{ }, { name: "IsNotNil passes on value", - isApplicableFor: specificValueTypes(intType, floatType, stringType, timeType, iterableType), + isApplicableFor: exceptValueTypes(countableType), + boolValue: boolValue(true), intValue: intValue(1), floatValue: floatValue(0.1), stringValue: stringValue("a"), + stringsValue: []string{}, timeValue: timeValue(time.Now()), sliceValue: []string{}, mapValue: map[string]string{}, @@ -213,7 +227,7 @@ var isNotNilConstraintTestCases = []ConstraintValidationTestCase{ }, { name: "IsNotNil passes on nil when condition is false", - isApplicableFor: specificValueTypes(intType, floatType, stringType, timeType, iterableType), + isApplicableFor: exceptValueTypes(countableType), constraint: it.IsNotNil().When(false), assert: assertNoError, }, @@ -222,16 +236,18 @@ var isNotNilConstraintTestCases = []ConstraintValidationTestCase{ var isNilConstraintTestCases = []ConstraintValidationTestCase{ { name: "IsNil passes on nil", - isApplicableFor: specificValueTypes(intType, floatType, stringType, timeType, iterableType), + isApplicableFor: exceptValueTypes(countableType), constraint: it.IsNil(), assert: assertNoError, }, { name: "IsNil violation on empty value", - isApplicableFor: specificValueTypes(intType, floatType, stringType, timeType, iterableType), + isApplicableFor: exceptValueTypes(countableType), + boolValue: boolValue(false), intValue: intValue(0), floatValue: floatValue(0), stringValue: stringValue(""), + stringsValue: []string{}, timeValue: &time.Time{}, sliceValue: []string{}, mapValue: map[string]string{}, @@ -240,16 +256,18 @@ var isNilConstraintTestCases = []ConstraintValidationTestCase{ }, { name: "IsNil passes on nil when condition is true", - isApplicableFor: specificValueTypes(intType, floatType, stringType, timeType, iterableType), + isApplicableFor: exceptValueTypes(countableType), constraint: it.IsNil().When(true), assert: assertNoError, }, { name: "IsNil violation on empty value with custom message", - isApplicableFor: specificValueTypes(intType, floatType, stringType, timeType, iterableType), + isApplicableFor: exceptValueTypes(countableType), + boolValue: boolValue(false), intValue: intValue(0), floatValue: floatValue(0), stringValue: stringValue(""), + stringsValue: []string{}, timeValue: &time.Time{}, sliceValue: []string{}, mapValue: map[string]string{}, @@ -263,10 +281,12 @@ var isNilConstraintTestCases = []ConstraintValidationTestCase{ }, { name: "IsNil violation on value", - isApplicableFor: specificValueTypes(intType, floatType, stringType, timeType, iterableType), + isApplicableFor: exceptValueTypes(countableType), + boolValue: boolValue(true), intValue: intValue(1), floatValue: floatValue(0.1), stringValue: stringValue("a"), + stringsValue: []string{}, timeValue: timeValue(time.Now()), sliceValue: []string{}, mapValue: map[string]string{}, @@ -275,10 +295,12 @@ var isNilConstraintTestCases = []ConstraintValidationTestCase{ }, { name: "IsNil passes on empty value when condition is false", - isApplicableFor: specificValueTypes(intType, floatType, stringType, timeType, iterableType), + isApplicableFor: exceptValueTypes(countableType), + boolValue: boolValue(false), intValue: intValue(0), floatValue: floatValue(0), stringValue: stringValue(""), + stringsValue: []string{}, timeValue: &time.Time{}, sliceValue: []string{}, mapValue: map[string]string{}, diff --git a/test/constraints_comparison_cases_test.go b/test/constraints_comparison_cases_test.go index 2dddc3c..9e6a79f 100644 --- a/test/constraints_comparison_cases_test.go +++ b/test/constraints_comparison_cases_test.go @@ -6,6 +6,7 @@ import ( "github.com/muonsoft/validation" "github.com/muonsoft/validation/code" "github.com/muonsoft/validation/it" + "github.com/muonsoft/validation/message" ) var numberComparisonTestCases = mergeTestCases( @@ -1088,3 +1089,59 @@ var isBetweenTimeTestCases = []ConstraintValidationTestCase{ assert: assertHasOneViolation(code.NotInRange, "This value should be between 2021-04-04T12:30:00Z and 2021-04-04T12:40:00Z."), }, } + +var hasUniqueValuesTestCases = []ConstraintValidationTestCase{ + { + name: "HasUniqueValues passes on nil", + isApplicableFor: specificValueTypes(stringsType), + constraint: it.HasUniqueValues(), + assert: assertNoError, + }, + { + name: "HasUniqueValues passes on empty value", + isApplicableFor: specificValueTypes(stringsType), + constraint: it.HasUniqueValues(), + stringsValue: []string{}, + assert: assertNoError, + }, + { + name: "HasUniqueValues passes on unique values", + isApplicableFor: specificValueTypes(stringsType), + constraint: it.HasUniqueValues(), + stringsValue: []string{"one", "two", "three"}, + assert: assertNoError, + }, + { + name: "HasUniqueValues violation on duplicated values", + isApplicableFor: specificValueTypes(stringsType), + constraint: it.HasUniqueValues(), + stringsValue: []string{"one", "two", "one"}, + assert: assertHasOneViolation(code.NotUnique, message.NotUnique), + }, + { + name: "HasUniqueValues violation with custom message", + isApplicableFor: specificValueTypes(stringsType), + constraint: it.HasUniqueValues(). + Code(customCode). + Message( + `Not unique values at {{ custom }}.`, + validation.TemplateParameter{Key: "{{ custom }}", Value: "parameter"}, + ), + stringsValue: []string{"one", "two", "one"}, + assert: assertHasOneViolation(customCode, `Not unique values at parameter.`), + }, + { + name: "HasUniqueValues passes when condition is false", + isApplicableFor: specificValueTypes(stringsType), + constraint: it.HasUniqueValues().When(false), + stringsValue: []string{"one", "two", "one"}, + assert: assertNoError, + }, + { + name: "HasUniqueValues violation when condition is true", + isApplicableFor: specificValueTypes(stringsType), + constraint: it.HasUniqueValues().When(true), + stringsValue: []string{"one", "two", "one"}, + assert: assertHasOneViolation(code.NotUnique, message.NotUnique), + }, +} diff --git a/test/constraints_test.go b/test/constraints_test.go index 191a5aa..5ff7ef3 100644 --- a/test/constraints_test.go +++ b/test/constraints_test.go @@ -23,6 +23,7 @@ const ( intType = "int" floatType = "float" stringType = "string" + stringsType = "strings" iterableType = "iterable" countableType = "countable" timeType = "time" @@ -35,6 +36,7 @@ type ConstraintValidationTestCase struct { intValue *int64 floatValue *float64 stringValue *string + stringsValue []string timeValue *time.Time sliceValue []string mapValue map[string]string @@ -55,6 +57,7 @@ var validateTestCases = mergeTestCases( choiceConstraintTestCases, numberComparisonTestCases, stringComparisonTestCases, + hasUniqueValuesTestCases, customStringConstraintTestCases, timeComparisonTestCases, rangeComparisonTestCases, @@ -122,6 +125,20 @@ func TestValidateString(t *testing.T) { } } +func TestValidateStrings(t *testing.T) { + for _, test := range validateTestCases { + if !test.isApplicableFor(stringsType) { + continue + } + + t.Run(test.name, func(t *testing.T) { + err := validator.ValidateStrings(test.stringsValue, test.constraint) + + test.assert(t, err) + }) + } +} + func TestValidateIterable_AsSlice(t *testing.T) { for _, test := range validateTestCases { if !test.isApplicableFor(iterableType) { diff --git a/validation.go b/validation.go index 3e1816f..36e1b15 100644 --- a/validation.go +++ b/validation.go @@ -173,6 +173,16 @@ func newStringValidator(value *string, options []Option) validateFunc { }) } +func newStringsValidator(values []string, options []Option) validateFunc { + return newValidator(options, func(constraint Constraint, scope Scope) error { + if c, ok := constraint.(StringsConstraint); ok { + return c.ValidateStrings(values, scope) + } + + return NewInapplicableConstraintError(constraint, "strings") + }) +} + func newIterableValidator(iterable generic.Iterable, options []Option) validateFunc { return func(scope Scope) (*ViolationList, error) { err := scope.applyOptions(options...) diff --git a/validator.go b/validator.go index faf13e5..aa75fa4 100644 --- a/validator.go +++ b/validator.go @@ -160,6 +160,11 @@ func (validator *Validator) ValidateString(value *string, options ...Option) err return validator.Validate(String(value, options...)) } +// ValidateStrings is an alias for validating slice of strings. +func (validator *Validator) ValidateStrings(values []string, options ...Option) error { + return validator.Validate(Strings(values, options...)) +} + // ValidateIterable is an alias for validating a single iterable value (an array, slice, or map). func (validator *Validator) ValidateIterable(value interface{}, options ...Option) error { return validator.Validate(Iterable(value, options...)) diff --git a/validator/validator.go b/validator/validator.go index 62847df..2c8649f 100644 --- a/validator/validator.go +++ b/validator/validator.go @@ -60,6 +60,11 @@ func ValidateString(value *string, options ...validation.Option) error { return validator.ValidateString(value, options...) } +// ValidateStrings is an alias for validating slice of strings. +func ValidateStrings(values []string, options ...validation.Option) error { + return validator.ValidateStrings(values, options...) +} + // ValidateIterable is an alias for validating a single iterable value (an array, slice, or map). func ValidateIterable(value interface{}, options ...validation.Option) error { return validator.ValidateIterable(value, options...) diff --git a/violations_test.go b/violations_test.go index e687ae7..cbcc254 100644 --- a/violations_test.go +++ b/violations_test.go @@ -270,6 +270,13 @@ func TestUnwrapViolationList_WrappedViolationList_UnwrappedViolationList(t *test assert.Equal(t, wrapped, unwrapped) } +func TestUnwrapViolationList_NoError_NoListAndFalse(t *testing.T) { + unwrapped, ok := validation.UnwrapViolationList(nil) + + assert.Nil(t, unwrapped) + assert.False(t, ok) +} + func TestMarshalViolationToJSON(t *testing.T) { validator := newValidator(t)