Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions it/comparison.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -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]++
}
Expand Down
17 changes: 17 additions & 0 deletions it/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,23 @@ func ExampleHasUniqueValuesBy_skipWhen() {
// <nil>
}

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:
// <nil>
}

func ExampleIsDateTime() {
fmt.Println(
"#1 invalid:",
Expand Down
22 changes: 22 additions & 0 deletions test/validate_arguments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}

Expand Down
Loading