From 97f4530f589653ab80d5ff5150202746515ab34c Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Mon, 6 Apr 2026 21:47:59 +0300 Subject: [PATCH] feat: add SkipEmptyKeys option to UniqueByConstraint - Allows ignoring zero-value keys during uniqueness validation. - Includes tests for empty key skipping and duplicate key handling. --- CHANGELOG.md | 6 +----- it/comparison.go | 16 ++++++++++++++++ it/example_test.go | 17 +++++++++++++++++ test/validate_arguments_test.go | 22 ++++++++++++++++++++++ 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76f0e83..b2918fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CIDR notation validation: `it.IsCIDR()` with `IPv4Only`, `IPv6Only`, `WithVersion`, `WithNetmaskRange`, and separate invalid vs netmask-range messages; `validate.CIDR`, `validate.CIDRViolationNetmaskBounds`, `is.CIDR`; `validation.ErrInvalidCIDR` / `validation.ErrCIDRNetmaskOutOfRange` and English and Russian translations (behavior aligned with Symfony `Cidr`). Exported names use the **CIDR** initialism per [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments). - LUHN (mod 10 / Luhn) checksum validation: `it.IsLUHN()`, `validate.LUHN`, `is.LUHN`, with `validation.ErrInvalidLUHN` / `message.InvalidLUHN` and English and Russian translations (behavior aligned with Symfony `Luhn`). - ISIN (International Securities Identification Number) validation: `it.IsISIN()`, `validate.ISIN`, `is.ISIN`, with `validation.ErrInvalidISIN` / `message.InvalidISIN` and English and Russian translations (behavior aligned with Symfony `Isin`). - -### Fixed - -- **NoSuspiciousCharacters** locale restriction: decimal digits (**Unicode Nd**) with a non-Common script (e.g. Arabic-Indic digits) are now validated against allowed scripts, not skipped. -- **NoSuspiciousCharacters** locale mapping: script subtags **Hira**, **Kana**, **Hang**, and **Bopo** map to **Hiragana**, **Katakana**, **Hangul**, and **Bopomofo** (e.g. `ja-Hira` with hiragana text). +- **HasUniqueValuesBy**: `SkipEmptyKeys()` on `it.UniqueByConstraint` skips elements whose key equals the zero value for `K`, so they are not counted toward uniqueness (e.g. optional IDs). ## [0.19.0] - 2026-02-09 diff --git a/it/comparison.go b/it/comparison.go index e1c1dc2..28f92c9 100644 --- a/it/comparison.go +++ b/it/comparison.go @@ -663,6 +663,7 @@ type UniqueByConstraint[T any, K comparable] struct { messageParameters validation.TemplateParameterList getKey UniqueItemKeyFunc[T, K] skip SkipUniqueItemFunc[T] + skipEmptyKeys bool propertyPath []validation.PropertyPathElement } @@ -709,6 +710,13 @@ func (c UniqueByConstraint[T, K]) SkipWhen(skip SkipUniqueItemFunc[T]) UniqueByC return c } +// SkipEmptyKeys makes the constraint ignore elements whose key is the zero value for K. +// Such elements are excluded from the uniqueness check and do not cause violations. +func (c UniqueByConstraint[T, K]) SkipEmptyKeys() UniqueByConstraint[T, K] { + c.skipEmptyKeys = true + return c +} + // At appends elements to the property path of produced violations (e.g. for nested paths). func (c UniqueByConstraint[T, K]) At(propertyPath ...validation.PropertyPathElement) UniqueByConstraint[T, K] { c.propertyPath = propertyPath @@ -724,11 +732,15 @@ func (c UniqueByConstraint[T, K]) ValidateSlice(ctx context.Context, validator * itemsCountByKey := c.collectItemsCountByKey(items) builder := validator.BuildViolationList(ctx) + var zeroKey K for i, item := range items { if c.skip != nil && c.skip(item) { continue } key := c.getKey(item) + if c.skipEmptyKeys && key == zeroKey { + continue + } if itemsCountByKey[key] > 1 { builder.BuildViolation(c.err, c.messageTemplate). @@ -744,11 +756,15 @@ func (c UniqueByConstraint[T, K]) ValidateSlice(ctx context.Context, validator * func (c UniqueByConstraint[T, K]) collectItemsCountByKey(items []T) map[K]int { itemsCountByKey := make(map[K]int, len(items)) + var zeroKey K for _, item := range items { if c.skip != nil && c.skip(item) { continue } key := c.getKey(item) + if c.skipEmptyKeys && key == zeroKey { + continue + } itemsCountByKey[key]++ } diff --git a/it/example_test.go b/it/example_test.go index 12cecb7..a937993 100644 --- a/it/example_test.go +++ b/it/example_test.go @@ -524,6 +524,23 @@ func ExampleHasUniqueValuesBy_skipWhen() { // } +func ExampleHasUniqueValuesBy_skipEmptyKeys() { + type item struct { + ID string + } + // Elements with empty key are excluded from the uniqueness check. + items := []item{{ID: ""}, {ID: "a"}, {ID: "b"}, {ID: ""}} + + err := validator.Validate( + context.Background(), + validation.Slice(items, it.HasUniqueValuesBy(func(x item) string { return x.ID }).SkipEmptyKeys()), + ) + + fmt.Println(err) + // Output: + // +} + func ExampleIsDateTime() { fmt.Println( "#1 invalid:", diff --git a/test/validate_arguments_test.go b/test/validate_arguments_test.go index 3a8c06c..e9378bf 100644 --- a/test/validate_arguments_test.go +++ b/test/validate_arguments_test.go @@ -356,6 +356,28 @@ func TestSlice_HasUniqueValuesBy_WhenSkipWhen_ExpectSkippedItemsNotChecked(t *te assert.NoError(t, err) } +func TestSlice_HasUniqueValuesBy_WhenSkipEmptyKeys_ExpectEmptyKeysIgnored(t *testing.T) { + items := []itemWithID{{ID: ""}, {ID: "a"}, {ID: "b"}, {ID: ""}} // empty keys skipped, "a" and "b" unique + + err := validator.Validate( + context.Background(), + validation.Slice(items, it.HasUniqueValuesBy(func(x itemWithID) string { return x.ID }).SkipEmptyKeys()), + ) + + assert.NoError(t, err) +} + +func TestSlice_HasUniqueValuesBy_WhenSkipEmptyKeys_AndDuplicateNonEmptyKeys_ExpectViolations(t *testing.T) { + items := []itemWithID{{ID: ""}, {ID: "a"}, {ID: "a"}, {ID: ""}} + + err := validator.Validate( + context.Background(), + validation.Slice(items, it.HasUniqueValuesBy(func(x itemWithID) string { return x.ID }).SkipEmptyKeys()), + ) + + validationtest.Assert(t, err).IsViolationList().WithLen(2).WithErrors(validation.ErrNotUnique, validation.ErrNotUnique) +} + func TestSliceProperty_HasUniqueValuesBy_WhenDuplicateKeys_ExpectPropertyPathWithIndex(t *testing.T) { items := []itemWithID{{ID: "x"}, {ID: "x"}}