mirror of
https://github.com/IBM/fp-go.git
synced 2026-04-09 15:26:02 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21b517d388 |
@@ -13,28 +13,218 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
/*
|
||||
Package constraints defines a set of useful type constraints for generic programming in Go.
|
||||
|
||||
# Overview
|
||||
|
||||
This package provides type constraints that can be used with Go generics to restrict
|
||||
type parameters to specific categories of types. These constraints are similar to those
|
||||
in Go's standard constraints package but are defined here for consistency within the
|
||||
fp-go project.
|
||||
|
||||
# Type Constraints
|
||||
|
||||
Ordered - Types that support comparison operators:
|
||||
|
||||
type Ordered interface {
|
||||
Integer | Float | ~string
|
||||
}
|
||||
|
||||
Used for types that can be compared using <, <=, >, >= operators.
|
||||
|
||||
Integer - All integer types (signed and unsigned):
|
||||
|
||||
type Integer interface {
|
||||
Signed | Unsigned
|
||||
}
|
||||
|
||||
Signed - Signed integer types:
|
||||
|
||||
type Signed interface {
|
||||
~int | ~int8 | ~int16 | ~int32 | ~int64
|
||||
}
|
||||
|
||||
Unsigned - Unsigned integer types:
|
||||
|
||||
type Unsigned interface {
|
||||
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
|
||||
}
|
||||
|
||||
Float - Floating-point types:
|
||||
|
||||
type Float interface {
|
||||
~float32 | ~float64
|
||||
}
|
||||
|
||||
Complex - Complex number types:
|
||||
|
||||
type Complex interface {
|
||||
~complex64 | ~complex128
|
||||
}
|
||||
|
||||
# Usage Examples
|
||||
|
||||
Using Ordered constraint for comparison:
|
||||
|
||||
import C "github.com/IBM/fp-go/v2/constraints"
|
||||
|
||||
func Min[T C.Ordered](a, b T) T {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
result := Min(5, 3) // 3
|
||||
result := Min(3.14, 2.71) // 2.71
|
||||
result := Min("apple", "banana") // "apple"
|
||||
|
||||
Using Integer constraint:
|
||||
|
||||
func Abs[T C.Integer](n T) T {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
result := Abs(-42) // 42
|
||||
result := Abs(uint(10)) // 10
|
||||
|
||||
Using Float constraint:
|
||||
|
||||
func Average[T C.Float](a, b T) T {
|
||||
return (a + b) / 2
|
||||
}
|
||||
|
||||
result := Average(3.14, 2.86) // 3.0
|
||||
|
||||
Using Complex constraint:
|
||||
|
||||
func Magnitude[T C.Complex](c T) float64 {
|
||||
r, i := real(c), imag(c)
|
||||
return math.Sqrt(r*r + i*i)
|
||||
}
|
||||
|
||||
c := complex(3, 4)
|
||||
result := Magnitude(c) // 5.0
|
||||
|
||||
# Combining Constraints
|
||||
|
||||
Constraints can be combined to create more specific type restrictions:
|
||||
|
||||
type Number interface {
|
||||
C.Integer | C.Float | C.Complex
|
||||
}
|
||||
|
||||
func Add[T Number](a, b T) T {
|
||||
return a + b
|
||||
}
|
||||
|
||||
# Tilde Operator
|
||||
|
||||
The ~ operator in type constraints means "underlying type". For example, ~int
|
||||
matches not only int but also any type whose underlying type is int:
|
||||
|
||||
type MyInt int
|
||||
|
||||
func Double[T C.Integer](n T) T {
|
||||
return n * 2
|
||||
}
|
||||
|
||||
var x MyInt = 5
|
||||
result := Double(x) // Works because MyInt's underlying type is int
|
||||
|
||||
# Related Packages
|
||||
|
||||
- number: Provides algebraic structures and utilities for numeric types
|
||||
- ord: Provides ordering operations using these constraints
|
||||
- eq: Provides equality operations for comparable types
|
||||
*/
|
||||
package constraints
|
||||
|
||||
// Ordered is a constraint that permits any ordered type: any type that supports
|
||||
// the operators < <= >= >. Ordered types include integers, floats, and strings.
|
||||
//
|
||||
// This constraint is commonly used for comparison operations, sorting, and
|
||||
// finding minimum/maximum values.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func Max[T Ordered](a, b T) T {
|
||||
// if a > b {
|
||||
// return a
|
||||
// }
|
||||
// return b
|
||||
// }
|
||||
type Ordered interface {
|
||||
Integer | Float | ~string
|
||||
}
|
||||
|
||||
// Signed is a constraint that permits any signed integer type.
|
||||
// This includes int, int8, int16, int32, and int64, as well as any
|
||||
// types whose underlying type is one of these.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func Negate[T Signed](n T) T {
|
||||
// return -n
|
||||
// }
|
||||
type Signed interface {
|
||||
~int | ~int8 | ~int16 | ~int32 | ~int64
|
||||
}
|
||||
|
||||
// Unsigned is a constraint that permits any unsigned integer type.
|
||||
// This includes uint, uint8, uint16, uint32, uint64, and uintptr, as well
|
||||
// as any types whose underlying type is one of these.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func IsEven[T Unsigned](n T) bool {
|
||||
// return n%2 == 0
|
||||
// }
|
||||
type Unsigned interface {
|
||||
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
|
||||
}
|
||||
|
||||
// Integer is a constraint that permits any integer type, both signed and unsigned.
|
||||
// This is a union of the Signed and Unsigned constraints.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func Abs[T Integer](n T) T {
|
||||
// if n < 0 {
|
||||
// return -n
|
||||
// }
|
||||
// return n
|
||||
// }
|
||||
type Integer interface {
|
||||
Signed | Unsigned
|
||||
}
|
||||
|
||||
// Float is a constraint that permits any floating-point type.
|
||||
// This includes float32 and float64, as well as any types whose
|
||||
// underlying type is one of these.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func Round[T Float](f T) T {
|
||||
// return T(math.Round(float64(f)))
|
||||
// }
|
||||
type Float interface {
|
||||
~float32 | ~float64
|
||||
}
|
||||
|
||||
// Complex is a constraint that permits any complex numeric type.
|
||||
// This includes complex64 and complex128, as well as any types whose
|
||||
// underlying type is one of these.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func Conjugate[T Complex](c T) T {
|
||||
// return complex(real(c), -imag(c))
|
||||
// }
|
||||
type Complex interface {
|
||||
~complex64 | ~complex128
|
||||
}
|
||||
|
||||
@@ -522,3 +522,199 @@ func MarshalJSON[T any](
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// FromNonZero creates a bidirectional codec for non-zero values of comparable types.
|
||||
// This codec validates that values are not equal to their zero value (e.g., 0 for int,
|
||||
// "" for string, false for bool, nil for pointers).
|
||||
//
|
||||
// The codec uses a refinement (prism) that:
|
||||
// - Decodes: Validates that the input is not the zero value of type T
|
||||
// - Encodes: Returns the value unchanged (identity function)
|
||||
// - Validates: Ensures the value is non-zero/non-default
|
||||
//
|
||||
// This is useful for enforcing that required fields have meaningful values rather than
|
||||
// their default zero values, which often represent "not set" or "missing" states.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - T: A comparable type (must support == and != operators)
|
||||
//
|
||||
// Returns:
|
||||
// - A Type[T, T, T] codec that validates non-zero values
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Create a codec for non-zero integers
|
||||
// nonZeroInt := FromNonZero[int]()
|
||||
//
|
||||
// // Decode non-zero value succeeds
|
||||
// result := nonZeroInt.Decode(42)
|
||||
// // result is Right(42)
|
||||
//
|
||||
// // Decode zero value fails
|
||||
// result := nonZeroInt.Decode(0)
|
||||
// // result is Left(ValidationError{...})
|
||||
//
|
||||
// // Encode is identity
|
||||
// encoded := nonZeroInt.Encode(42)
|
||||
// // encoded is 42
|
||||
//
|
||||
// // Works with strings
|
||||
// nonEmptyStr := FromNonZero[string]()
|
||||
// result := nonEmptyStr.Decode("hello") // Right("hello")
|
||||
// result = nonEmptyStr.Decode("") // Left(ValidationError{...})
|
||||
//
|
||||
// // Works with pointers
|
||||
// nonNilPtr := FromNonZero[*int]()
|
||||
// value := 42
|
||||
// result := nonNilPtr.Decode(&value) // Right(&value)
|
||||
// result = nonNilPtr.Decode(nil) // Left(ValidationError{...})
|
||||
//
|
||||
// Common use cases:
|
||||
// - Validating required numeric fields are not zero
|
||||
// - Ensuring string fields are not empty
|
||||
// - Checking pointers are not nil
|
||||
// - Validating boolean flags are explicitly set to true
|
||||
// - Composing with other codecs for multi-stage validation
|
||||
//
|
||||
// See Also:
|
||||
// - NonEmptyString: Specialized version for strings with clearer intent
|
||||
// - FromRefinement: General function for creating codecs from prisms
|
||||
func FromNonZero[T comparable]() Type[T, T, T] {
|
||||
return FromRefinement(prism.FromNonZero[T]())
|
||||
}
|
||||
|
||||
// NonEmptyString creates a bidirectional codec for non-empty strings.
|
||||
// This codec validates that string values are not empty, providing a type-safe
|
||||
// way to work with strings that must contain at least one character.
|
||||
//
|
||||
// This is a specialized version of FromNonZero[string]() that makes the intent
|
||||
// clearer when working specifically with strings that must not be empty.
|
||||
//
|
||||
// The codec:
|
||||
// - Decodes: Validates that the input string is not empty ("")
|
||||
// - Encodes: Returns the string unchanged (identity function)
|
||||
// - Validates: Ensures the string has length > 0
|
||||
//
|
||||
// Note: This codec only checks for empty strings, not whitespace-only strings.
|
||||
// A string containing only spaces, tabs, or newlines will pass validation.
|
||||
//
|
||||
// Returns:
|
||||
// - A Type[string, string, string] codec that validates non-empty strings
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// nonEmpty := NonEmptyString()
|
||||
//
|
||||
// // Decode non-empty string succeeds
|
||||
// result := nonEmpty.Decode("hello")
|
||||
// // result is Right("hello")
|
||||
//
|
||||
// // Decode empty string fails
|
||||
// result := nonEmpty.Decode("")
|
||||
// // result is Left(ValidationError{...})
|
||||
//
|
||||
// // Whitespace-only strings pass validation
|
||||
// result := nonEmpty.Decode(" ")
|
||||
// // result is Right(" ")
|
||||
//
|
||||
// // Encode is identity
|
||||
// encoded := nonEmpty.Encode("world")
|
||||
// // encoded is "world"
|
||||
//
|
||||
// // Compose with other codecs for validation pipelines
|
||||
// intFromNonEmptyString := Pipe(IntFromString())(nonEmpty)
|
||||
// result := intFromNonEmptyString.Decode("42") // Right(42)
|
||||
// result = intFromNonEmptyString.Decode("") // Left(ValidationError{...})
|
||||
// result = intFromNonEmptyString.Decode("abc") // Left(ValidationError{...})
|
||||
//
|
||||
// Common use cases:
|
||||
// - Validating required string fields (usernames, names, IDs)
|
||||
// - Ensuring configuration values are provided
|
||||
// - Validating user input before processing
|
||||
// - Composing with parsing codecs to validate before parsing
|
||||
// - Building validation pipelines for string data
|
||||
//
|
||||
// See Also:
|
||||
// - FromNonZero: General version for any comparable type
|
||||
// - String: Basic string codec without validation
|
||||
// - IntFromString: Codec for parsing integers from strings
|
||||
func NonEmptyString() Type[string, string, string] {
|
||||
return F.Pipe1(
|
||||
FromRefinement(prism.NonEmptyString()),
|
||||
WithName[string, string, string]("NonEmptyString"),
|
||||
)
|
||||
}
|
||||
|
||||
// WithName creates an endomorphism that renames a codec without changing its behavior.
|
||||
// This function returns a higher-order function that takes a codec and returns a new codec
|
||||
// with the specified name, while preserving all validation, encoding, and type-checking logic.
|
||||
//
|
||||
// This is useful for:
|
||||
// - Providing more descriptive names for composed codecs
|
||||
// - Creating domain-specific codec names for better error messages
|
||||
// - Documenting the purpose of complex codec pipelines
|
||||
// - Improving debugging and logging output
|
||||
//
|
||||
// The renamed codec maintains the same:
|
||||
// - Type checking behavior (Is function)
|
||||
// - Validation logic (Validate function)
|
||||
// - Encoding behavior (Encode function)
|
||||
//
|
||||
// Only the name returned by the Name() method changes.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The target type (what we decode to and encode from)
|
||||
// - O: The output type (what we encode to)
|
||||
// - I: The input type (what we decode from)
|
||||
//
|
||||
// Parameters:
|
||||
// - name: The new name for the codec
|
||||
//
|
||||
// Returns:
|
||||
// - An Endomorphism[Type[A, O, I]] that renames the codec
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Create a codec with a generic name
|
||||
// positiveInt := Pipe[int, int, string, int](
|
||||
// FromRefinement(prism.FromPredicate(func(n int) bool { return n > 0 })),
|
||||
// )(IntFromString())
|
||||
// // positiveInt.Name() returns something like "Pipe(FromRefinement(...), IntFromString)"
|
||||
//
|
||||
// // Rename it for clarity
|
||||
// namedCodec := WithName[int, string, string]("PositiveIntFromString")(positiveInt)
|
||||
// // namedCodec.Name() returns "PositiveIntFromString"
|
||||
//
|
||||
// // Use in a pipeline with F.Pipe
|
||||
// userAgeCodec := F.Pipe1(
|
||||
// IntFromString(),
|
||||
// WithName[int, string, string]("UserAge"),
|
||||
// )
|
||||
//
|
||||
// // Validation errors will show the custom name
|
||||
// result := userAgeCodec.Decode("invalid")
|
||||
// // Error context will reference "UserAge" instead of "IntFromString"
|
||||
//
|
||||
// Common use cases:
|
||||
// - Naming composed codecs for better error messages
|
||||
// - Creating domain-specific codec names (e.g., "EmailAddress", "PhoneNumber")
|
||||
// - Documenting complex validation pipelines
|
||||
// - Improving debugging output in logs
|
||||
// - Making codec composition more readable
|
||||
//
|
||||
// Note: This function creates a new codec instance with the same behavior but a different
|
||||
// name. The original codec is not modified.
|
||||
//
|
||||
// See Also:
|
||||
// - MakeType: For creating codecs with custom names from scratch
|
||||
// - Pipe: For composing codecs (which generates automatic names)
|
||||
func WithName[A, O, I any](name string) Endomorphism[Type[A, O, I]] {
|
||||
return func(codec Type[A, O, I]) Type[A, O, I] {
|
||||
return MakeType(
|
||||
name,
|
||||
codec.Is,
|
||||
codec.Validate,
|
||||
codec.Encode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/IBM/fp-go/v2/either"
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/optics/codec/validation"
|
||||
"github.com/IBM/fp-go/v2/optics/prism"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
@@ -688,6 +689,596 @@ func TestBoolFromString_Integration(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FromNonZero
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFromNonZero_Decode_Success(t *testing.T) {
|
||||
t.Run("int - decodes non-zero value", func(t *testing.T) {
|
||||
c := FromNonZero[int]()
|
||||
result := c.Decode(42)
|
||||
assert.Equal(t, validation.Success(42), result)
|
||||
})
|
||||
|
||||
t.Run("int - decodes negative value", func(t *testing.T) {
|
||||
c := FromNonZero[int]()
|
||||
result := c.Decode(-5)
|
||||
assert.Equal(t, validation.Success(-5), result)
|
||||
})
|
||||
|
||||
t.Run("string - decodes non-empty string", func(t *testing.T) {
|
||||
c := FromNonZero[string]()
|
||||
result := c.Decode("hello")
|
||||
assert.Equal(t, validation.Success("hello"), result)
|
||||
})
|
||||
|
||||
t.Run("string - decodes whitespace string", func(t *testing.T) {
|
||||
c := FromNonZero[string]()
|
||||
result := c.Decode(" ")
|
||||
assert.Equal(t, validation.Success(" "), result)
|
||||
})
|
||||
|
||||
t.Run("bool - decodes true", func(t *testing.T) {
|
||||
c := FromNonZero[bool]()
|
||||
result := c.Decode(true)
|
||||
assert.Equal(t, validation.Success(true), result)
|
||||
})
|
||||
|
||||
t.Run("float64 - decodes non-zero value", func(t *testing.T) {
|
||||
c := FromNonZero[float64]()
|
||||
result := c.Decode(3.14)
|
||||
assert.Equal(t, validation.Success(3.14), result)
|
||||
})
|
||||
|
||||
t.Run("float64 - decodes negative value", func(t *testing.T) {
|
||||
c := FromNonZero[float64]()
|
||||
result := c.Decode(-2.5)
|
||||
assert.Equal(t, validation.Success(-2.5), result)
|
||||
})
|
||||
|
||||
t.Run("pointer - decodes non-nil pointer", func(t *testing.T) {
|
||||
c := FromNonZero[*int]()
|
||||
value := 42
|
||||
result := c.Decode(&value)
|
||||
assert.True(t, either.IsRight(result))
|
||||
ptr := either.MonadFold(result, func(validation.Errors) *int { return nil }, func(p *int) *int { return p })
|
||||
require.NotNil(t, ptr)
|
||||
assert.Equal(t, 42, *ptr)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFromNonZero_Decode_Failure(t *testing.T) {
|
||||
t.Run("int - fails on zero", func(t *testing.T) {
|
||||
c := FromNonZero[int]()
|
||||
result := c.Decode(0)
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("string - fails on empty string", func(t *testing.T) {
|
||||
c := FromNonZero[string]()
|
||||
result := c.Decode("")
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("bool - fails on false", func(t *testing.T) {
|
||||
c := FromNonZero[bool]()
|
||||
result := c.Decode(false)
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("float64 - fails on zero", func(t *testing.T) {
|
||||
c := FromNonZero[float64]()
|
||||
result := c.Decode(0.0)
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("pointer - fails on nil", func(t *testing.T) {
|
||||
c := FromNonZero[*int]()
|
||||
result := c.Decode(nil)
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
}
|
||||
|
||||
func TestFromNonZero_Encode(t *testing.T) {
|
||||
t.Run("int - encodes value unchanged", func(t *testing.T) {
|
||||
c := FromNonZero[int]()
|
||||
assert.Equal(t, 42, c.Encode(42))
|
||||
})
|
||||
|
||||
t.Run("string - encodes value unchanged", func(t *testing.T) {
|
||||
c := FromNonZero[string]()
|
||||
assert.Equal(t, "hello", c.Encode("hello"))
|
||||
})
|
||||
|
||||
t.Run("bool - encodes value unchanged", func(t *testing.T) {
|
||||
c := FromNonZero[bool]()
|
||||
assert.Equal(t, true, c.Encode(true))
|
||||
})
|
||||
|
||||
t.Run("float64 - encodes value unchanged", func(t *testing.T) {
|
||||
c := FromNonZero[float64]()
|
||||
assert.Equal(t, 3.14, c.Encode(3.14))
|
||||
})
|
||||
|
||||
t.Run("pointer - encodes value unchanged", func(t *testing.T) {
|
||||
c := FromNonZero[*int]()
|
||||
value := 42
|
||||
ptr := &value
|
||||
assert.Equal(t, ptr, c.Encode(ptr))
|
||||
})
|
||||
|
||||
t.Run("round-trip: decode then encode", func(t *testing.T) {
|
||||
c := FromNonZero[int]()
|
||||
original := 42
|
||||
result := c.Decode(original)
|
||||
require.True(t, either.IsRight(result))
|
||||
decoded := either.MonadFold(result, func(validation.Errors) int { return 0 }, func(n int) int { return n })
|
||||
assert.Equal(t, original, c.Encode(decoded))
|
||||
})
|
||||
}
|
||||
|
||||
func TestFromNonZero_Name(t *testing.T) {
|
||||
t.Run("int codec name", func(t *testing.T) {
|
||||
c := FromNonZero[int]()
|
||||
assert.Contains(t, c.Name(), "FromRefinement")
|
||||
assert.Contains(t, c.Name(), "PrismFromNonZero")
|
||||
})
|
||||
|
||||
t.Run("string codec name", func(t *testing.T) {
|
||||
c := FromNonZero[string]()
|
||||
assert.Contains(t, c.Name(), "FromRefinement")
|
||||
assert.Contains(t, c.Name(), "PrismFromNonZero")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFromNonZero_Integration(t *testing.T) {
|
||||
t.Run("validates multiple non-zero integers", func(t *testing.T) {
|
||||
c := FromNonZero[int]()
|
||||
values := []int{1, -1, 42, -100, 999}
|
||||
for _, v := range values {
|
||||
result := c.Decode(v)
|
||||
require.True(t, either.IsRight(result), "expected success for %d", v)
|
||||
decoded := either.MonadFold(result, func(validation.Errors) int { return 0 }, func(n int) int { return n })
|
||||
assert.Equal(t, v, decoded)
|
||||
assert.Equal(t, v, c.Encode(decoded))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects zero values", func(t *testing.T) {
|
||||
c := FromNonZero[int]()
|
||||
result := c.Decode(0)
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("works with custom comparable types", func(t *testing.T) {
|
||||
type UserID string
|
||||
c := FromNonZero[UserID]()
|
||||
|
||||
result := c.Decode(UserID("user123"))
|
||||
assert.Equal(t, validation.Success(UserID("user123")), result)
|
||||
|
||||
result = c.Decode(UserID(""))
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NonEmptyString
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNonEmptyString_Decode_Success(t *testing.T) {
|
||||
t.Run("decodes non-empty string", func(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
result := c.Decode("hello")
|
||||
assert.Equal(t, validation.Success("hello"), result)
|
||||
})
|
||||
|
||||
t.Run("decodes single character", func(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
result := c.Decode("a")
|
||||
assert.Equal(t, validation.Success("a"), result)
|
||||
})
|
||||
|
||||
t.Run("decodes whitespace string", func(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
result := c.Decode(" ")
|
||||
assert.Equal(t, validation.Success(" "), result)
|
||||
})
|
||||
|
||||
t.Run("decodes string with newlines", func(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
result := c.Decode("\n\t")
|
||||
assert.Equal(t, validation.Success("\n\t"), result)
|
||||
})
|
||||
|
||||
t.Run("decodes unicode string", func(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
result := c.Decode("你好")
|
||||
assert.Equal(t, validation.Success("你好"), result)
|
||||
})
|
||||
|
||||
t.Run("decodes emoji string", func(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
result := c.Decode("🎉")
|
||||
assert.Equal(t, validation.Success("🎉"), result)
|
||||
})
|
||||
|
||||
t.Run("decodes multiline string", func(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
multiline := "line1\nline2\nline3"
|
||||
result := c.Decode(multiline)
|
||||
assert.Equal(t, validation.Success(multiline), result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNonEmptyString_Decode_Failure(t *testing.T) {
|
||||
t.Run("fails on empty string", func(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
result := c.Decode("")
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("error contains context", func(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
result := c.Decode("")
|
||||
require.True(t, either.IsLeft(result))
|
||||
errors := either.MonadFold(result, func(e validation.Errors) validation.Errors { return e }, func(string) validation.Errors { return nil })
|
||||
require.NotEmpty(t, errors)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNonEmptyString_Encode(t *testing.T) {
|
||||
t.Run("encodes string unchanged", func(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
assert.Equal(t, "hello", c.Encode("hello"))
|
||||
})
|
||||
|
||||
t.Run("encodes unicode string unchanged", func(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
assert.Equal(t, "你好", c.Encode("你好"))
|
||||
})
|
||||
|
||||
t.Run("encodes whitespace string unchanged", func(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
assert.Equal(t, " ", c.Encode(" "))
|
||||
})
|
||||
|
||||
t.Run("round-trip: decode then encode", func(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
original := "test string"
|
||||
result := c.Decode(original)
|
||||
require.True(t, either.IsRight(result))
|
||||
decoded := either.MonadFold(result, func(validation.Errors) string { return "" }, func(s string) string { return s })
|
||||
assert.Equal(t, original, c.Encode(decoded))
|
||||
})
|
||||
}
|
||||
|
||||
func TestNonEmptyString_Name(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
assert.Equal(t, c.Name(), "NonEmptyString")
|
||||
}
|
||||
|
||||
func TestNonEmptyString_Integration(t *testing.T) {
|
||||
t.Run("validates multiple non-empty strings", func(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
strings := []string{"a", "hello", "world", "test123", " spaces ", "🎉"}
|
||||
for _, s := range strings {
|
||||
result := c.Decode(s)
|
||||
require.True(t, either.IsRight(result), "expected success for %q", s)
|
||||
decoded := either.MonadFold(result, func(validation.Errors) string { return "" }, func(str string) string { return str })
|
||||
assert.Equal(t, s, decoded)
|
||||
assert.Equal(t, s, c.Encode(decoded))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects empty string", func(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
result := c.Decode("")
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("compose with IntFromString", func(t *testing.T) {
|
||||
// Create a codec that only parses non-empty strings to integers
|
||||
nonEmptyThenInt := Pipe[string, string](IntFromString())(NonEmptyString())
|
||||
|
||||
// Valid non-empty string with integer
|
||||
result := nonEmptyThenInt.Decode("42")
|
||||
assert.Equal(t, validation.Success(42), result)
|
||||
|
||||
// Empty string fails at NonEmptyString stage
|
||||
result = nonEmptyThenInt.Decode("")
|
||||
assert.True(t, either.IsLeft(result))
|
||||
|
||||
// Non-empty but invalid integer fails at IntFromString stage
|
||||
result = nonEmptyThenInt.Decode("abc")
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("use in validation pipeline", func(t *testing.T) {
|
||||
c := NonEmptyString()
|
||||
|
||||
// Simulate validating user input
|
||||
inputs := []struct {
|
||||
value string
|
||||
expected bool
|
||||
}{
|
||||
{"john_doe", true},
|
||||
{"", false},
|
||||
{"a", true},
|
||||
{"user@example.com", true},
|
||||
}
|
||||
|
||||
for _, input := range inputs {
|
||||
result := c.Decode(input.value)
|
||||
if input.expected {
|
||||
assert.True(t, either.IsRight(result), "expected success for %q", input.value)
|
||||
} else {
|
||||
assert.True(t, either.IsLeft(result), "expected failure for %q", input.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WithName
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWithName_BasicFunctionality(t *testing.T) {
|
||||
t.Run("renames codec without changing behavior", func(t *testing.T) {
|
||||
original := IntFromString()
|
||||
renamed := WithName[int, string, string]("CustomIntCodec")(original)
|
||||
|
||||
// Name should be changed
|
||||
assert.Equal(t, "CustomIntCodec", renamed.Name())
|
||||
assert.NotEqual(t, original.Name(), renamed.Name())
|
||||
|
||||
// Behavior should be unchanged
|
||||
result := renamed.Decode("42")
|
||||
assert.Equal(t, validation.Success(42), result)
|
||||
|
||||
encoded := renamed.Encode(42)
|
||||
assert.Equal(t, "42", encoded)
|
||||
})
|
||||
|
||||
t.Run("preserves validation logic", func(t *testing.T) {
|
||||
original := IntFromString()
|
||||
renamed := WithName[int, string, string]("MyInt")(original)
|
||||
|
||||
// Valid input should succeed
|
||||
result := renamed.Decode("123")
|
||||
assert.True(t, either.IsRight(result))
|
||||
|
||||
// Invalid input should fail
|
||||
result = renamed.Decode("not a number")
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("preserves encoding logic", func(t *testing.T) {
|
||||
original := BoolFromString()
|
||||
renamed := WithName[bool, string, string]("CustomBool")(original)
|
||||
|
||||
assert.Equal(t, "true", renamed.Encode(true))
|
||||
assert.Equal(t, "false", renamed.Encode(false))
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithName_WithComposedCodecs(t *testing.T) {
|
||||
t.Run("renames composed codec", func(t *testing.T) {
|
||||
// Create a composed codec
|
||||
composed := Pipe[string, string](IntFromString())(NonEmptyString())
|
||||
|
||||
// Rename it
|
||||
renamed := WithName[int, string, string]("NonEmptyIntString")(composed)
|
||||
|
||||
assert.Equal(t, "NonEmptyIntString", renamed.Name())
|
||||
|
||||
// Behavior should be preserved
|
||||
result := renamed.Decode("42")
|
||||
assert.Equal(t, validation.Success(42), result)
|
||||
|
||||
// Empty string should fail
|
||||
result = renamed.Decode("")
|
||||
assert.True(t, either.IsLeft(result))
|
||||
|
||||
// Non-numeric should fail
|
||||
result = renamed.Decode("abc")
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("works in pipeline with F.Pipe", func(t *testing.T) {
|
||||
codec := F.Pipe1(
|
||||
IntFromString(),
|
||||
WithName[int, string, string]("UserAge"),
|
||||
)
|
||||
|
||||
assert.Equal(t, "UserAge", codec.Name())
|
||||
|
||||
result := codec.Decode("25")
|
||||
assert.Equal(t, validation.Success(25), result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithName_PreservesTypeChecking(t *testing.T) {
|
||||
t.Run("preserves Is function", func(t *testing.T) {
|
||||
original := String()
|
||||
renamed := WithName[string, string, any]("CustomString")(original)
|
||||
|
||||
// Should accept string
|
||||
result := renamed.Is("hello")
|
||||
assert.True(t, either.IsRight(result))
|
||||
|
||||
// Should reject non-string
|
||||
result = renamed.Is(42)
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("preserves complex type checking", func(t *testing.T) {
|
||||
original := Array(Int())
|
||||
renamed := WithName[[]int, []int, any]("IntArray")(original)
|
||||
|
||||
// Should accept []int
|
||||
result := renamed.Is([]int{1, 2, 3})
|
||||
assert.True(t, either.IsRight(result))
|
||||
|
||||
// Should reject []string
|
||||
result = renamed.Is([]string{"a", "b"})
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithName_RoundTrip(t *testing.T) {
|
||||
t.Run("maintains round-trip property", func(t *testing.T) {
|
||||
original := Int64FromString()
|
||||
renamed := WithName[int64, string, string]("CustomInt64")(original)
|
||||
|
||||
testValues := []string{"0", "42", "-100", "9223372036854775807"}
|
||||
for _, input := range testValues {
|
||||
result := renamed.Decode(input)
|
||||
require.True(t, either.IsRight(result), "expected success for %s", input)
|
||||
|
||||
decoded := either.MonadFold(result, func(validation.Errors) int64 { return 0 }, func(n int64) int64 { return n })
|
||||
encoded := renamed.Encode(decoded)
|
||||
assert.Equal(t, input, encoded)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithName_ErrorMessages(t *testing.T) {
|
||||
t.Run("custom name appears in validation context", func(t *testing.T) {
|
||||
codec := WithName[int, string, string]("PositiveInteger")(IntFromString())
|
||||
|
||||
result := codec.Decode("not a number")
|
||||
require.True(t, either.IsLeft(result))
|
||||
|
||||
// The error context should reference the custom name
|
||||
errors := either.MonadFold(result, func(e validation.Errors) validation.Errors { return e }, func(int) validation.Errors { return nil })
|
||||
require.NotEmpty(t, errors)
|
||||
|
||||
// Check that at least one error references our custom name
|
||||
found := false
|
||||
for _, err := range errors {
|
||||
if len(err.Context) > 0 {
|
||||
for _, ctx := range err.Context {
|
||||
if ctx.Type == "PositiveInteger" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected custom name 'PositiveInteger' in error context")
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithName_MultipleRenames(t *testing.T) {
|
||||
t.Run("can rename multiple times", func(t *testing.T) {
|
||||
codec := IntFromString()
|
||||
|
||||
renamed1 := WithName[int, string, string]("FirstName")(codec)
|
||||
assert.Equal(t, "FirstName", renamed1.Name())
|
||||
|
||||
renamed2 := WithName[int, string, string]("SecondName")(renamed1)
|
||||
assert.Equal(t, "SecondName", renamed2.Name())
|
||||
|
||||
// Behavior should still work
|
||||
result := renamed2.Decode("42")
|
||||
assert.Equal(t, validation.Success(42), result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithName_WithDifferentTypes(t *testing.T) {
|
||||
t.Run("works with string codec", func(t *testing.T) {
|
||||
codec := WithName[string, string, string]("Username")(NonEmptyString())
|
||||
assert.Equal(t, "Username", codec.Name())
|
||||
|
||||
result := codec.Decode("john_doe")
|
||||
assert.Equal(t, validation.Success("john_doe"), result)
|
||||
})
|
||||
|
||||
t.Run("works with bool codec", func(t *testing.T) {
|
||||
codec := WithName[bool, string, string]("IsActive")(BoolFromString())
|
||||
assert.Equal(t, "IsActive", codec.Name())
|
||||
|
||||
result := codec.Decode("true")
|
||||
assert.Equal(t, validation.Success(true), result)
|
||||
})
|
||||
|
||||
t.Run("works with URL codec", func(t *testing.T) {
|
||||
codec := WithName[*url.URL, string, string]("WebsiteURL")(URL())
|
||||
assert.Equal(t, "WebsiteURL", codec.Name())
|
||||
|
||||
result := codec.Decode("https://example.com")
|
||||
assert.True(t, either.IsRight(result))
|
||||
})
|
||||
|
||||
t.Run("works with array codec", func(t *testing.T) {
|
||||
codec := WithName[[]int, []int, any]("Numbers")(Array(Int()))
|
||||
assert.Equal(t, "Numbers", codec.Name())
|
||||
|
||||
result := codec.Decode([]int{1, 2, 3})
|
||||
assert.Equal(t, validation.Success([]int{1, 2, 3}), result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithName_AsDecoderEncoder(t *testing.T) {
|
||||
t.Run("AsDecoder returns decoder interface", func(t *testing.T) {
|
||||
codec := WithName[int, string, string]("MyInt")(IntFromString())
|
||||
decoder := codec.AsDecoder()
|
||||
|
||||
result := decoder.Decode("42")
|
||||
assert.Equal(t, validation.Success(42), result)
|
||||
})
|
||||
|
||||
t.Run("AsEncoder returns encoder interface", func(t *testing.T) {
|
||||
codec := WithName[int, string, string]("MyInt")(IntFromString())
|
||||
encoder := codec.AsEncoder()
|
||||
|
||||
encoded := encoder.Encode(42)
|
||||
assert.Equal(t, "42", encoded)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithName_Integration(t *testing.T) {
|
||||
t.Run("domain-specific codec names", func(t *testing.T) {
|
||||
// Create domain-specific codecs with meaningful names
|
||||
emailCodec := WithName[string, string, string]("EmailAddress")(NonEmptyString())
|
||||
phoneCodec := WithName[string, string, string]("PhoneNumber")(NonEmptyString())
|
||||
ageCodec := WithName[int, string, string]("Age")(IntFromString())
|
||||
|
||||
// Test email
|
||||
result := emailCodec.Decode("user@example.com")
|
||||
assert.True(t, either.IsRight(result))
|
||||
assert.Equal(t, "EmailAddress", emailCodec.Name())
|
||||
|
||||
// Test phone
|
||||
result = phoneCodec.Decode("+1234567890")
|
||||
assert.True(t, either.IsRight(result))
|
||||
assert.Equal(t, "PhoneNumber", phoneCodec.Name())
|
||||
|
||||
// Test age
|
||||
ageResult := ageCodec.Decode("25")
|
||||
assert.True(t, either.IsRight(ageResult))
|
||||
assert.Equal(t, "Age", ageCodec.Name())
|
||||
})
|
||||
|
||||
t.Run("naming complex validation pipelines", func(t *testing.T) {
|
||||
// Create a complex codec and give it a clear name
|
||||
positiveIntCodec := F.Pipe2(
|
||||
NonEmptyString(),
|
||||
Pipe[string, string](IntFromString()),
|
||||
WithName[int, string, string]("PositiveIntegerFromString"),
|
||||
)
|
||||
|
||||
assert.Equal(t, "PositiveIntegerFromString", positiveIntCodec.Name())
|
||||
|
||||
result := positiveIntCodec.Decode("42")
|
||||
assert.True(t, either.IsRight(result))
|
||||
|
||||
result = positiveIntCodec.Decode("")
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MarshalJSON
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -773,7 +1364,7 @@ func TestIntFromString_PipeComposition(t *testing.T) {
|
||||
func(n int) int { return n },
|
||||
"PositiveInt",
|
||||
)
|
||||
positiveIntCodec := Pipe[string, string, int, int](
|
||||
positiveIntCodec := Pipe[string, string](
|
||||
FromRefinement(positiveIntPrism),
|
||||
)(IntFromString())
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
package generic
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"sort"
|
||||
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
@@ -301,13 +302,8 @@ func unionLast[M ~map[K]V, K comparable, V any](left, right M) M {
|
||||
|
||||
result := make(M, lenLeft+lenRight)
|
||||
|
||||
for k, v := range left {
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
for k, v := range right {
|
||||
result[k] = v
|
||||
}
|
||||
maps.Copy(result, left)
|
||||
maps.Copy(result, right)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user