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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add ISO week validation with `it.IsWeek()`, `validate.Week`, and `is.Week`, inclusive min/max bounds, calendar-aware week 53 checks, and English/Russian messages. No new dependencies; document empty-value and literal early-year behavior.

- Add `it.HasMinWordCount`, `it.HasMaxWordCount`, and `it.HasWordCountBetween` with Unicode-aware counting, customizable errors/messages, and English/Russian plural forms. No new dependencies; document differences from ICU locale-aware segmentation.

- Add country, language, and locale validation with `it.IsCountry()`, `it.IsLanguage()`, `it.IsLocale()`, and matching `is`/`validate` helpers. Use existing `golang.org/x/text/language` data without new dependencies; include English/Russian messages and documentation of accepted formats, special codes, and application-support limitations.
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,41 @@ them; message parameters are `{{ count }}`, `{{ limit }}`, and `{{ value }}`
(the quoted input). Conditional validation, groups, and English/Russian plural
forms are supported.

### ISO weeks

Use `it.IsWeek()` for strings such as `2020-W53`. Optional `WithMin` and `WithMax`
set inclusive bounds, including ranges across ISO years:

```go
err := validator.Validate(context.Background(),
validation.String(week, it.IsWeek().WithMin("2020-W01").WithMax("2021-W01")),
)
```

The format is exactly `YYYY-Www`: four ASCII year digits, uppercase `W`, and a
zero-padded week from `01` to `53`. Week 53 is accepted only in ISO years that
have it. Years `0000`–`9999` are interpreted literally in the proleptic Gregorian
calendar, with no two-digit-year remapping. No whitespace is trimmed.

The behavior is based on [Symfony Week](https://symfony.com/doc/current/reference/constraints/Week.html).
Unlike Symfony, empty strings are skipped, following this library's convention;
nil pointers are also skipped. Use `it.IsNotBlank()` when a value is required.
Literal early years follow Go's calendar, without PHP `mktime`'s remapping of
some years below 100. No local timezone or new dependencies are involved.

Standalone helpers are `validate.Week` and `is.Week`, with optional
`validate.WithMinWeek` and `validate.WithMaxWeek`. Empty bounds remove restrictions;
malformed, nonexistent, or reversed bounds produce `validate.ErrInvalidWeekBounds`
(or a constraint configuration error through `it`), even for empty values.

The four violation errors are `validation.ErrInvalidWeekFormat`,
`validation.ErrInvalidWeekNumber`, `validation.ErrWeekTooEarly`, and
`validation.ErrWeekTooLate`. Customize them with `WithFormatError`,
`WithWeekNumberError`, `WithMinError`, and `WithMaxError`; corresponding `Message`
methods accept `{{ value }}`, `{{ min }}`, `{{ max }}`, and custom parameters.
The constraint supports `When`, `WhenGroups`, `This`, `Each`, and English/Russian
translations. Disabled constraints and unmatched groups skip validation entirely.

## Documentation

| Topic | Description |
Expand Down
4 changes: 4 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ var (
ErrInvalidJSON = NewError("invalid JSON", message.InvalidJSON)
ErrInvalidLUHN = NewError("invalid LUHN", message.InvalidLUHN)
ErrInvalidMAC = NewError("invalid MAC address", message.InvalidMAC)
ErrInvalidWeekFormat = NewError("invalid week format", message.InvalidWeekFormat)
ErrInvalidWeekNumber = NewError("invalid week number", message.InvalidWeekNumber)
ErrWeekTooEarly = NewError("week too early", message.WeekTooEarly)
ErrWeekTooLate = NewError("week too late", message.WeekTooLate)
ErrInvalidTime = NewError("invalid time", message.InvalidTime)
ErrInvalidTimezone = NewError("invalid timezone", message.InvalidTimezone)
ErrInvalidULID = NewError("invalid ULID", message.InvalidULID)
Expand Down
9 changes: 9 additions & 0 deletions is/week.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package is

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

// Week reports whether value is a valid ISO week within optional inclusive bounds.
// See [validate.Week] for format, year range, options, and empty-value behavior.
func Week(value string, options ...func(*validate.WeekOptions)) bool {
return validate.Week(value, options...) == nil
}
28 changes: 28 additions & 0 deletions is/week_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package is_test

import (
"fmt"
"testing"

"github.com/muonsoft/validation/is"
"github.com/muonsoft/validation/validate"
"github.com/stretchr/testify/require"
)

func TestWeek(t *testing.T) {
require.True(t, is.Week(""))
require.True(t, is.Week("2020-W53"))
require.False(t, is.Week("2021-W53"))
require.False(t, is.Week("2020-W1"))
require.False(t, is.Week("2020-W53", validate.WithMinWeek("2021-W01")))
require.False(t, is.Week("2021-W01", validate.WithMaxWeek("2020-W53")))
require.False(t, is.Week("", validate.WithMinWeek("invalid")))
}

func ExampleWeek() {
fmt.Println(is.Week("2020-W53"))
fmt.Println(is.Week("2021-W53"))
// Output:
// true
// false
}
14 changes: 14 additions & 0 deletions it/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1181,3 +1181,17 @@ func ExampleHasWordCountBetween_invalid() {
// Output:
// violation: "This value should contain at least 2 words."
}

func ExampleIsWeek_valid() {
err := validator.Validate(context.Background(), validation.String("2020-W53", it.IsWeek().WithMin("2020-W01").WithMax("2021-W01")))
fmt.Println(err)
// Output:
// <nil>
}

func ExampleIsWeek_invalid() {
err := validator.Validate(context.Background(), validation.String("2021-W53", it.IsWeek()))
fmt.Println(err)
// Output:
// violation: "Week 2021-W53 does not exist in its ISO year."
}
168 changes: 168 additions & 0 deletions it/week.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package it

import (
"context"
"errors"

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

// WeekConstraint validates ISO weeks with optional inclusive bounds.
// See [validate.Week] for the format and literal year range 0000–9999.
// Nil and empty values are ignored. Use [IsNotBlank] to require a value.
type WeekConstraint struct {
isIgnored bool
groups []string
min, max string
formatErr error
formatMessage string
formatParameters validation.TemplateParameterList
numberErr error
numberMessage string
numberParameters validation.TemplateParameterList
minErr error
minMessage string
minParameters validation.TemplateParameterList
maxErr error
maxMessage string
maxParameters validation.TemplateParameterList
}

// IsWeek creates a constraint for ISO weeks in YYYY-Www format.
// WithMin and WithMax restrict the range; malformed or reversed bounds
// produce a constraint configuration error during validation.
func IsWeek() WeekConstraint {
return WeekConstraint{
formatErr: validation.ErrInvalidWeekFormat,
formatMessage: validation.ErrInvalidWeekFormat.Message(),
numberErr: validation.ErrInvalidWeekNumber,
numberMessage: validation.ErrInvalidWeekNumber.Message(),
minErr: validation.ErrWeekTooEarly,
minMessage: validation.ErrWeekTooEarly.Message(),
maxErr: validation.ErrWeekTooLate,
maxMessage: validation.ErrWeekTooLate.Message(),
}
}

// WithMin sets the inclusive minimum ISO week; empty removes the bound.
func (c WeekConstraint) WithMin(value string) WeekConstraint {
c.min = value
return c
}

// WithMax sets the inclusive maximum ISO week; empty removes the bound.
func (c WeekConstraint) WithMax(value string) WeekConstraint {
c.max = value
return c
}

// WithFormatError overrides the underlying error for the format check.
func (c WeekConstraint) WithFormatError(err error) WeekConstraint {
c.formatErr = err
return c
}

// WithFormatMessage overrides the message for the format check.
// Available parameters are {{ value }}, {{ min }}, and {{ max }}.
func (c WeekConstraint) WithFormatMessage(template string, parameters ...validation.TemplateParameter) WeekConstraint {
c.formatMessage = template
c.formatParameters = parameters
return c
}

// WithWeekNumberError overrides the underlying error for the number check.
func (c WeekConstraint) WithWeekNumberError(err error) WeekConstraint {
c.numberErr = err
return c
}

// WithWeekNumberMessage overrides the message for the number check.
// Available parameters are {{ value }}, {{ min }}, and {{ max }}.
func (c WeekConstraint) WithWeekNumberMessage(template string, parameters ...validation.TemplateParameter) WeekConstraint {
c.numberMessage = template
c.numberParameters = parameters
return c
}

// WithMinError overrides the underlying error for the min check.
func (c WeekConstraint) WithMinError(err error) WeekConstraint {
c.minErr = err
return c
}

// WithMinMessage overrides the message for the min check.
// Available parameters are {{ value }}, {{ min }}, and {{ max }}.
func (c WeekConstraint) WithMinMessage(template string, parameters ...validation.TemplateParameter) WeekConstraint {
c.minMessage = template
c.minParameters = parameters
return c
}

// WithMaxError overrides the underlying error for the max check.
func (c WeekConstraint) WithMaxError(err error) WeekConstraint {
c.maxErr = err
return c
}

// WithMaxMessage overrides the message for the max check.
// Available parameters are {{ value }}, {{ min }}, and {{ max }}.
func (c WeekConstraint) WithMaxMessage(template string, parameters ...validation.TemplateParameter) WeekConstraint {
c.maxMessage = template
c.maxParameters = parameters
return c
}

// When disables validation when condition is false.
func (c WeekConstraint) When(condition bool) WeekConstraint {
c.isIgnored = !condition
return c
}

// WhenGroups limits validation to the specified groups.
func (c WeekConstraint) WhenGroups(groups ...string) WeekConstraint {
c.groups = groups
return c
}

// ValidateString validates an optional ISO week string.
func (c WeekConstraint) ValidateString(ctx context.Context, validator *validation.Validator, value *string) error {
if c.isIgnored || validator.IsIgnoredForGroups(c.groups...) {
return nil
}
input := ""
if value != nil {
input = *value
}
err := validate.Week(input, validate.WithMinWeek(c.min), validate.WithMaxWeek(c.max))
if err == nil {
return nil
}
if errors.Is(err, validate.ErrInvalidWeekBounds) {
return validator.CreateConstraintError("WeekConstraint", "bounds must be valid ISO weeks and minimum must not exceed maximum")
}
template, parameters, cause := c.violation(err)
return validator.BuildViolation(ctx, cause, template).WithParameters(parameters.Prepend(
validation.TemplateParameter{Key: "{{ value }}", Value: input},
validation.TemplateParameter{Key: "{{ min }}", Value: c.min},
validation.TemplateParameter{Key: "{{ max }}", Value: c.max},
)...).Create()
}

func (c WeekConstraint) violation(err error) (string, validation.TemplateParameterList, error) {
switch {
case errors.Is(err, validate.ErrInvalidWeekNumber):
return c.numberMessage, c.numberParameters, c.numberErr
case errors.Is(err, validate.ErrWeekTooEarly):
return c.minMessage, c.minParameters, c.minErr
case errors.Is(err, validate.ErrWeekTooLate):
return c.maxMessage, c.maxParameters, c.maxErr
default:
return c.formatMessage, c.formatParameters, c.formatErr
}
}

// Validate implements [validation.Constraint][string] for [validation.This] and [validation.Each].
func (c WeekConstraint) Validate(ctx context.Context, validator *validation.Validator, value string) error {
return c.ValidateString(ctx, validator, &value)
}
59 changes: 59 additions & 0 deletions it/week_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package it_test

import (
"context"
"testing"

"github.com/muonsoft/language"
"github.com/muonsoft/validation"
"github.com/muonsoft/validation/it"
"github.com/muonsoft/validation/message/translations/russian"
"github.com/stretchr/testify/require"
)

func TestWeekTranslations(t *testing.T) {
v, err := validation.NewValidator(validation.Translations(russian.Messages))
require.NoError(t, err)
tests := []struct {
value string
constraint it.WeekConstraint
want string
}{
{"bad", it.IsWeek(), "Значение не является допустимой ISO-неделей в формате YYYY-Www."},
{"2021-W53", it.IsWeek(), "Неделя 2021-W53 не существует в указанном ISO-году."},
{"2020-W53", it.IsWeek().WithMin("2021-W01"), "Значение должно быть не раньше недели 2021-W01."},
{"2021-W01", it.IsWeek().WithMax("2020-W53"), "Значение должно быть не позже недели 2020-W53."},
}
for _, tt := range tests {
t.Run(tt.value+tt.want, func(t *testing.T) {
require.EqualError(t, tt.constraint.Validate(context.Background(), v.WithLanguage(language.Russian), tt.value), `violation: "`+tt.want+`"`)
})
}
}

func TestWeekGroupsAndCopies(t *testing.T) {
v, err := validation.NewValidator()
require.NoError(t, err)
base := it.IsWeek().WithMin("2020-W53").WhenGroups("schedule")
derived := base.WithMin("2021-W01")
require.NoError(t, derived.Validate(context.Background(), v, "2020-W53"))
require.NoError(t, base.Validate(context.Background(), v.WithGroups("schedule"), "2020-W53"))
require.ErrorIs(t, derived.Validate(context.Background(), v.WithGroups("schedule"), "2020-W53"), validation.ErrWeekTooEarly)
require.NoError(t, v.Validate(context.Background(), validation.This("2020-W53", it.IsWeek())))
require.ErrorIs(t, v.Validate(context.Background(), validation.Each([]string{"2020-W53", "2021-W53"}, it.IsWeek())), validation.ErrInvalidWeekNumber)
require.NoError(t, it.IsWeek().WithMin("2021-W01").WithMin("").Validate(context.Background(), v, "2020-W53"))
require.NoError(t, it.IsWeek().WithMax("2020-W53").WithMax("").Validate(context.Background(), v, "2021-W01"))
}

func TestWeekMessageParameters(t *testing.T) {
v, err := validation.NewValidator()
require.NoError(t, err)
template := "{{ value }}: {{ min }}..{{ max }} {{ custom }}"
parameter := validation.TemplateParameter{Key: "{{ custom }}", Value: "custom"}
c := it.IsWeek().WithMin("2020-W01").WithMax("2020-W53").
WithFormatMessage(template, parameter).WithWeekNumberMessage(template, parameter).
WithMinMessage(template, parameter).WithMaxMessage(template, parameter)
for _, value := range []string{"bad", "2021-W53", "2019-W52", "2021-W01"} {
require.EqualError(t, c.Validate(context.Background(), v, value), `violation: "`+value+`: 2020-W01..2020-W53 custom"`)
}
}
4 changes: 4 additions & 0 deletions message/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ const (
InvalidJSON = "This value should be valid JSON."
InvalidLUHN = "Invalid card number."
InvalidMAC = "This value is not a valid MAC address."
InvalidWeekFormat = "This value is not a valid ISO week in YYYY-Www format."
InvalidWeekNumber = "Week {{ value }} does not exist in its ISO year."
WeekTooEarly = "This value should be on or after week {{ min }}."
WeekTooLate = "This value should be on or before week {{ max }}."
InvalidTime = "This value is not a valid time."
InvalidTimezone = "This value is not a valid timezone."
InvalidULID = "This is not a valid ULID."
Expand Down
4 changes: 4 additions & 0 deletions message/translations/english/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ var Messages = map[language.Tag]map[string]catalog.Message{
message.InvalidJSON: catalog.String(message.InvalidJSON),
message.InvalidLUHN: catalog.String(message.InvalidLUHN),
message.InvalidMAC: catalog.String(message.InvalidMAC),
message.InvalidWeekFormat: catalog.String(message.InvalidWeekFormat),
message.InvalidWeekNumber: catalog.String(message.InvalidWeekNumber),
message.WeekTooEarly: catalog.String(message.WeekTooEarly),
message.WeekTooLate: catalog.String(message.WeekTooLate),
message.InvalidTime: catalog.String(message.InvalidTime),
message.InvalidTimezone: catalog.String(message.InvalidTimezone),
message.InvalidULID: catalog.String(message.InvalidULID),
Expand Down
4 changes: 4 additions & 0 deletions message/translations/russian/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ var Messages = map[language.Tag]map[string]catalog.Message{
message.InvalidJSON: catalog.String("Значение должно быть корректным JSON."),
message.InvalidLUHN: catalog.String("Недействительный номер карты."),
message.InvalidMAC: catalog.String("Значение не является допустимым MAC-адресом."),
message.InvalidWeekFormat: catalog.String("Значение не является допустимой ISO-неделей в формате YYYY-Www."),
message.InvalidWeekNumber: catalog.String("Неделя {{ value }} не существует в указанном ISO-году."),
message.WeekTooEarly: catalog.String("Значение должно быть не раньше недели {{ min }}."),
message.WeekTooLate: catalog.String("Значение должно быть не позже недели {{ max }}."),
message.InvalidTime: catalog.String("Значение времени недопустимо."),
message.InvalidTimezone: catalog.String("Значение не является допустимым часовым поясом."),
message.InvalidULID: catalog.String("Значение не соответствует формату ULID."),
Expand Down
1 change: 1 addition & 0 deletions test/constraints_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type ConstraintValidationTestCase struct {
}

var validateTestCases = mergeTestCases(
weekConstraintTestCases,
internationalConstraintTestCases,
utf8ConstraintTestCases,
barcodeConstraintsTestCases,
Expand Down
Loading
Loading