Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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`).
- **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).
- IANA timezone validation: `it.IsTimezone()` with `WithZone` (`validate.TimezoneZoneAfrica`, `TimezoneZoneEurope`, etc.), `validate.Timezone` with `validate.WithTimezoneZone`, `is.Timezone`; `validation.ErrInvalidTimezone` / `message.InvalidTimezone` and English and Russian translations (behavior aligned with Symfony `Timezone`; uses [time.LoadLocation]).

## [0.19.0](https://github.com/muonsoft/validation/releases/tag/v0.19.0) - 2026-02-09

Expand Down
1 change: 1 addition & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ var (
ErrInvalidLUHN = NewError("invalid LUHN", message.InvalidLUHN)
ErrInvalidMAC = NewError("invalid MAC address", message.InvalidMAC)
ErrInvalidTime = NewError("invalid time", message.InvalidTime)
ErrInvalidTimezone = NewError("invalid timezone", message.InvalidTimezone)
ErrInvalidULID = NewError("invalid ULID", message.InvalidULID)
ErrInvalidUPCA = NewError("invalid UPC-A", message.InvalidUPCA)
ErrInvalidUPCE = NewError("invalid UPC-E", message.InvalidUPCE)
Expand Down
10 changes: 10 additions & 0 deletions is/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,16 @@ func ExampleCurrency() {
// false
}

func ExampleTimezone() {
fmt.Println(is.Timezone("Europe/Berlin"))
fmt.Println(is.Timezone("EST"))
fmt.Println(is.Timezone("America/Chicago", validate.WithTimezoneZone(validate.TimezoneZoneAmerica)))
// Output:
// true
// false
// true
}

func ExampleBIC() {
fmt.Println(is.BIC("DEUTDEFF"))
fmt.Println(is.BIC("DEUTDEF"))
Expand Down
9 changes: 9 additions & 0 deletions is/timezone.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package is

import "github.com/muonsoft/validation/validate"

// Timezone validates whether the value is a known IANA timezone identifier.
// See [github.com/muonsoft/validation/validate.Timezone] for rules and options.
func Timezone(value string, options ...func(*validate.TimezoneOptions)) bool {
return validate.Timezone(value, options...) == nil
}
84 changes: 84 additions & 0 deletions it/date_time.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"time"

"github.com/muonsoft/validation"
"github.com/muonsoft/validation/validate"
)

// DateTimeConstraint checks that the string value is a valid date and time value specified by a specific layout.
Expand Down Expand Up @@ -106,3 +107,86 @@ func (c DateTimeConstraint) ValidateString(ctx context.Context, validator *valid
func (c DateTimeConstraint) Validate(ctx context.Context, validator *validation.Validator, v string) error {
return c.ValidateString(ctx, validator, &v)
}

// TimezoneConstraint validates whether the string value is a known IANA timezone identifier,
// as in Symfony\Component\Validator\Constraints\Timezone.
// Use [TimezoneConstraint.WithZone] to restrict identifiers to a geographical region.
type TimezoneConstraint struct {
isIgnored bool
groups []string
options []func(*validate.TimezoneOptions)
err error
messageTemplate string
messageParameters validation.TemplateParameterList
}

// IsTimezone validates whether the string value is a known IANA timezone identifier.
// See [TimezoneConstraint] for configuration options.
func IsTimezone() TimezoneConstraint {
return TimezoneConstraint{
err: validation.ErrInvalidTimezone,
messageTemplate: validation.ErrInvalidTimezone.Message(),
}
}

// WithZone restricts valid timezone identifiers to the given geographical region
// (default accepts any IANA zone). Allowed values are [validate.TimezoneZoneAll],
// [validate.TimezoneZoneAfrica], [validate.TimezoneZoneAmerica], [validate.TimezoneZoneAntarctica],
// [validate.TimezoneZoneArctic], [validate.TimezoneZoneAsia], [validate.TimezoneZoneAtlantic],
// [validate.TimezoneZoneAustralia], [validate.TimezoneZoneEurope], [validate.TimezoneZoneIndian],
// and [validate.TimezoneZonePacific].
func (c TimezoneConstraint) WithZone(zone validate.TimezoneZone) TimezoneConstraint {
c.options = append(c.options, validate.WithTimezoneZone(zone))
return c
}

// WithError overrides default error for produced violation.
func (c TimezoneConstraint) WithError(err error) TimezoneConstraint {
c.err = err
return c
}

// WithMessage sets the violation message template. You can set custom template parameters
// for injecting its values into the final message. Also, you can use default parameters:
//
// {{ value }} - the current (invalid) value.
func (c TimezoneConstraint) WithMessage(template string, parameters ...validation.TemplateParameter) TimezoneConstraint {
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 TimezoneConstraint) When(condition bool) TimezoneConstraint {
c.isIgnored = !condition
return c
}

// WhenGroups enables conditional validation of the constraint by using the validation groups.
func (c TimezoneConstraint) WhenGroups(groups ...string) TimezoneConstraint {
c.groups = groups
return c
}

func (c TimezoneConstraint) ValidateString(ctx context.Context, validator *validation.Validator, value *string) error {
if c.isIgnored || validator.IsIgnoredForGroups(c.groups...) || value == nil || *value == "" {
return nil
}
if validate.Timezone(*value, c.options...) == nil {
return nil
}

return validator.BuildViolation(ctx, c.err, c.messageTemplate).
WithParameters(
c.messageParameters.Prepend(
validation.TemplateParameter{Key: "{{ value }}", Value: *value},
)...,
).
Create()
}

// Validate implements [validation.Constraint][string] so the constraint can be used with [validation.Each] and [validation.This].
func (c TimezoneConstraint) Validate(ctx context.Context, validator *validation.Validator, v string) error {
return c.ValidateString(ctx, validator, &v)
}
14 changes: 14 additions & 0 deletions it/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,20 @@ func ExampleIsDate() {
// #3 custom layout: <nil>
}

func ExampleIsTimezone_valid() {
err := validator.Validate(context.Background(), validation.String("Europe/Berlin", it.IsTimezone()))
fmt.Println(err)
// Output:
// <nil>
}

func ExampleIsTimezone_invalid() {
err := validator.Validate(context.Background(), validation.String("EST", it.IsTimezone()))
fmt.Println(err)
// Output:
// violation: "This value is not a valid timezone."
}

func ExampleHasMinCount() {
v := []int{1, 2}
err := validator.ValidateCountable(context.Background(), len(v), it.HasMinCount(3))
Expand Down
1 change: 1 addition & 0 deletions message/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const (
InvalidLUHN = "Invalid card number."
InvalidMAC = "This value is not a valid MAC address."
InvalidTime = "This value is not a valid time."
InvalidTimezone = "This value is not a valid timezone."
InvalidULID = "This is not a valid ULID."
InvalidUPCA = "This value is not a valid UPC-A."
InvalidUPCE = "This value is not a valid UPC-E."
Expand Down
1 change: 1 addition & 0 deletions message/translations/english/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{
message.InvalidLUHN: catalog.String(message.InvalidLUHN),
message.InvalidMAC: catalog.String(message.InvalidMAC),
message.InvalidTime: catalog.String(message.InvalidTime),
message.InvalidTimezone: catalog.String(message.InvalidTimezone),
message.InvalidULID: catalog.String(message.InvalidULID),
message.InvalidUPCA: catalog.String(message.InvalidUPCA),
message.InvalidUPCE: catalog.String(message.InvalidUPCE),
Expand Down
1 change: 1 addition & 0 deletions message/translations/russian/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{
message.InvalidLUHN: catalog.String("Недействительный номер карты."),
message.InvalidMAC: catalog.String("Значение не является допустимым MAC-адресом."),
message.InvalidTime: catalog.String("Значение времени недопустимо."),
message.InvalidTimezone: catalog.String("Значение не является допустимым часовым поясом."),
message.InvalidULID: catalog.String("Значение не соответствует формату ULID."),
message.InvalidUPCA: catalog.String("Значение не является допустимым UPC-A."),
message.InvalidUPCE: catalog.String("Значение не является допустимым UPC-E."),
Expand Down
91 changes: 91 additions & 0 deletions test/constraints_date_time_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package test
import (
"github.com/muonsoft/validation"
"github.com/muonsoft/validation/it"
"github.com/muonsoft/validation/message"
"github.com/muonsoft/validation/validate"
)

var dateTimeConstraintTestCases = []ConstraintValidationTestCase{
Expand Down Expand Up @@ -112,4 +114,93 @@ var dateTimeConstraintTestCases = []ConstraintValidationTestCase{
constraint: it.IsTime(),
assert: assertHasOneViolation(validation.ErrInvalidTime, "This value is not a valid time."),
},
{
name: "IsTimezone passes on empty value",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue(""),
constraint: it.IsTimezone(),
assert: assertNoError,
},
{
name: "IsTimezone passes on UTC",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("UTC"),
constraint: it.IsTimezone(),
assert: assertNoError,
},
{
name: "IsTimezone passes on valid IANA identifier",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("Europe/Berlin"),
constraint: it.IsTimezone(),
assert: assertNoError,
},
{
name: "IsTimezone violation on unknown identifier",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("Invalid/Zone"),
constraint: it.IsTimezone(),
assert: assertHasOneViolation(validation.ErrInvalidTimezone, message.InvalidTimezone),
},
{
name: "IsTimezone violation on Local",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("Local"),
constraint: it.IsTimezone(),
assert: assertHasOneViolation(validation.ErrInvalidTimezone, message.InvalidTimezone),
},
{
name: "IsTimezone violation on abbreviation",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("EST"),
constraint: it.IsTimezone(),
assert: assertHasOneViolation(validation.ErrInvalidTimezone, message.InvalidTimezone),
},
{
name: "IsTimezone passes with matching zone",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("Europe/Paris"),
constraint: it.IsTimezone().WithZone(validate.TimezoneZoneEurope),
assert: assertNoError,
},
{
name: "IsTimezone violation with non-matching zone",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("America/New_York"),
constraint: it.IsTimezone().WithZone(validate.TimezoneZoneEurope),
assert: assertHasOneViolation(validation.ErrInvalidTimezone, message.InvalidTimezone),
},
{
name: "IsTimezone violation with custom error and message",
isApplicableFor: specificValueTypes(stringType),
constraint: it.IsTimezone().
WithError(ErrCustom).
WithMessage(
`Invalid timezone "{{ value }}" for {{ custom }}.`,
validation.TemplateParameter{Key: "{{ custom }}", Value: "parameter"},
),
stringValue: stringValue("EST"),
assert: assertHasOneViolation(ErrCustom, `Invalid timezone "EST" for parameter.`),
},
{
name: "IsTimezone passes when condition is false",
isApplicableFor: specificValueTypes(stringType),
constraint: it.IsTimezone().When(false),
stringValue: stringValue("EST"),
assert: assertNoError,
},
{
name: "IsTimezone violation when condition is true",
isApplicableFor: specificValueTypes(stringType),
constraint: it.IsTimezone().When(true),
stringValue: stringValue("EST"),
assert: assertHasOneViolation(validation.ErrInvalidTimezone, message.InvalidTimezone),
},
{
name: "IsTimezone passes when groups not match",
isApplicableFor: specificValueTypes(stringType),
constraint: it.IsTimezone().WhenGroups(testGroup),
stringValue: stringValue("EST"),
assert: assertNoError,
},
}
10 changes: 10 additions & 0 deletions validate/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,16 @@ func ExampleCurrency() {
// invalid currency
}

func ExampleTimezone() {
fmt.Println(validate.Timezone("Europe/Berlin"))
fmt.Println(validate.Timezone("EST"))
fmt.Println(validate.Timezone("America/Chicago", validate.WithTimezoneZone(validate.TimezoneZoneAmerica)))
// Output:
// <nil>
// invalid timezone
// <nil>
}

func ExampleBIC() {
fmt.Println(validate.BIC("DEUTDEFF"))
fmt.Println(validate.BIC("DEUTDEF"))
Expand Down
Loading
Loading