mirror of
https://github.com/IBM/fp-go.git
synced 2026-03-08 13:29:18 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cfb7ef659 | ||
|
|
622c87d734 | ||
|
|
2ce406a410 | ||
|
|
3743361b9f | ||
|
|
69d11f0a4b | ||
|
|
e4dd1169c4 | ||
|
|
1657569f1d | ||
|
|
545876d013 | ||
|
|
9492c5d994 | ||
|
|
94b1ea30d1 | ||
|
|
a77d61f632 |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -134,7 +134,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js ${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
|
||||
4
context7.json
Normal file
4
context7.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"url": "https://context7.com/ibm/fp-go",
|
||||
"public_key": "pk_7wJdJRn8zGHxvIYu7eh9h"
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
[](https://pkg.go.dev/github.com/IBM/fp-go/v2)
|
||||
[](https://coveralls.io/github/IBM/fp-go?branch=main)
|
||||
[](https://goreportcard.com/report/github.com/IBM/fp-go/v2)
|
||||
[](https://context7.com/ibm/fp-go)
|
||||
|
||||
**fp-go** is a comprehensive functional programming library for Go, bringing type-safe functional patterns inspired by [fp-ts](https://gcanti.github.io/fp-ts/) to the Go ecosystem. Version 2 leverages [generic type aliases](https://github.com/golang/go/issues/46477) introduced in Go 1.24, providing a more ergonomic and streamlined API.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package readerio
|
||||
|
||||
import (
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
RIO "github.com/IBM/fp-go/v2/readerio"
|
||||
)
|
||||
|
||||
@@ -73,3 +74,117 @@ func Bracket[
|
||||
) ReaderIO[B] {
|
||||
return RIO.Bracket(acquire, use, release)
|
||||
}
|
||||
|
||||
// WithResource creates a higher-order function that manages a resource lifecycle for any operation.
|
||||
// It returns a Kleisli arrow that takes a use function and automatically handles resource
|
||||
// acquisition and cleanup using the bracket pattern.
|
||||
//
|
||||
// This is a more composable alternative to Bracket, allowing you to define resource management
|
||||
// once and reuse it with different use functions. The resource is acquired when the returned
|
||||
// Kleisli arrow is invoked, used by the provided function, and then released regardless of
|
||||
// success or failure.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of the resource to be managed
|
||||
// - B: The type of the result produced by the use function
|
||||
// - ANY: The type returned by the release function (typically ignored)
|
||||
//
|
||||
// Parameters:
|
||||
// - onCreate: A ReaderIO that acquires/creates the resource
|
||||
// - onRelease: A Kleisli arrow that releases/cleans up the resource
|
||||
//
|
||||
// Returns:
|
||||
// - A Kleisli arrow that takes a use function and returns a ReaderIO managing the full lifecycle
|
||||
//
|
||||
// Example with database connection:
|
||||
//
|
||||
// // Define resource management once
|
||||
// withDB := WithResource(
|
||||
// // Acquire connection
|
||||
// func(ctx context.Context) IO[*sql.DB] {
|
||||
// return func() *sql.DB {
|
||||
// db, _ := sql.Open("postgres", "connection-string")
|
||||
// return db
|
||||
// }
|
||||
// },
|
||||
// // Release connection
|
||||
// func(db *sql.DB) ReaderIO[any] {
|
||||
// return func(ctx context.Context) IO[any] {
|
||||
// return func() any {
|
||||
// db.Close()
|
||||
// return nil
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// )
|
||||
//
|
||||
// // Reuse with different operations
|
||||
// queryUsers := withDB(func(db *sql.DB) ReaderIO[[]User] {
|
||||
// return func(ctx context.Context) IO[[]User] {
|
||||
// return func() []User {
|
||||
// // Query users from db
|
||||
// return users
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
//
|
||||
// insertUser := withDB(func(db *sql.DB) ReaderIO[int64] {
|
||||
// return func(ctx context.Context) IO[int64] {
|
||||
// return func() int64 {
|
||||
// // Insert user into db
|
||||
// return userID
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
//
|
||||
// Example with file handling:
|
||||
//
|
||||
// withFile := WithResource(
|
||||
// func(ctx context.Context) IO[*os.File] {
|
||||
// return func() *os.File {
|
||||
// f, _ := os.Open("data.txt")
|
||||
// return f
|
||||
// }
|
||||
// },
|
||||
// func(f *os.File) ReaderIO[any] {
|
||||
// return func(ctx context.Context) IO[any] {
|
||||
// return func() any {
|
||||
// f.Close()
|
||||
// return nil
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// )
|
||||
//
|
||||
// // Use for reading
|
||||
// readContent := withFile(func(f *os.File) ReaderIO[string] {
|
||||
// return func(ctx context.Context) IO[string] {
|
||||
// return func() string {
|
||||
// data, _ := io.ReadAll(f)
|
||||
// return string(data)
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
//
|
||||
// // Use for getting file info
|
||||
// getSize := withFile(func(f *os.File) ReaderIO[int64] {
|
||||
// return func(ctx context.Context) IO[int64] {
|
||||
// return func() int64 {
|
||||
// info, _ := f.Stat()
|
||||
// return info.Size()
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
//
|
||||
// Use Cases:
|
||||
// - Database connections: Acquire connection, execute queries, close connection
|
||||
// - File handles: Open file, read/write, close file
|
||||
// - Network connections: Establish connection, transfer data, close connection
|
||||
// - Locks: Acquire lock, perform critical section, release lock
|
||||
// - Temporary resources: Create temp file/directory, use it, clean up
|
||||
//
|
||||
//go:inline
|
||||
func WithResource[A, B, ANY any](
|
||||
onCreate ReaderIO[A], onRelease Kleisli[A, ANY]) Kleisli[Kleisli[A, B], B] {
|
||||
return function.Bind13of3(Bracket[A, B, ANY])(onCreate, function.Ignore2of2[B](onRelease))
|
||||
}
|
||||
|
||||
456
v2/context/readerio/bracket_test.go
Normal file
456
v2/context/readerio/bracket_test.go
Normal file
@@ -0,0 +1,456 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package readerio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// mockResource simulates a resource that tracks its lifecycle
|
||||
type mockResource struct {
|
||||
id int
|
||||
acquired bool
|
||||
released bool
|
||||
used bool
|
||||
}
|
||||
|
||||
// TestBracket_Success tests that Bracket properly manages resource lifecycle on success
|
||||
func TestBracket_Success(t *testing.T) {
|
||||
resource := &mockResource{id: 1}
|
||||
|
||||
// Acquire resource
|
||||
acquire := func(ctx context.Context) io.IO[*mockResource] {
|
||||
return func() *mockResource {
|
||||
resource.acquired = true
|
||||
return resource
|
||||
}
|
||||
}
|
||||
|
||||
// Use resource
|
||||
use := func(r *mockResource) ReaderIO[string] {
|
||||
return func(ctx context.Context) io.IO[string] {
|
||||
return func() string {
|
||||
r.used = true
|
||||
return "success"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release resource
|
||||
release := func(r *mockResource, result string) ReaderIO[any] {
|
||||
return func(ctx context.Context) io.IO[any] {
|
||||
return func() any {
|
||||
r.released = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute bracket
|
||||
operation := Bracket(acquire, use, release)
|
||||
result := operation(context.Background())()
|
||||
|
||||
// Verify lifecycle
|
||||
assert.True(t, resource.acquired, "Resource should be acquired")
|
||||
assert.True(t, resource.used, "Resource should be used")
|
||||
assert.True(t, resource.released, "Resource should be released")
|
||||
assert.Equal(t, "success", result)
|
||||
}
|
||||
|
||||
// TestBracket_MultipleResources tests managing multiple resources
|
||||
func TestBracket_MultipleResources(t *testing.T) {
|
||||
resource1 := &mockResource{id: 1}
|
||||
resource2 := &mockResource{id: 2}
|
||||
|
||||
acquire1 := func(ctx context.Context) io.IO[*mockResource] {
|
||||
return func() *mockResource {
|
||||
resource1.acquired = true
|
||||
return resource1
|
||||
}
|
||||
}
|
||||
|
||||
use1 := func(r1 *mockResource) ReaderIO[*mockResource] {
|
||||
return func(ctx context.Context) io.IO[*mockResource] {
|
||||
return func() *mockResource {
|
||||
r1.used = true
|
||||
resource2.acquired = true
|
||||
return resource2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
release1 := func(r1 *mockResource, result string) ReaderIO[any] {
|
||||
return func(ctx context.Context) io.IO[any] {
|
||||
return func() any {
|
||||
r1.released = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nested bracket for second resource
|
||||
use2 := func(r2 *mockResource) ReaderIO[string] {
|
||||
return func(ctx context.Context) io.IO[string] {
|
||||
return func() string {
|
||||
r2.used = true
|
||||
return "both used"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
release2 := func(r2 *mockResource, result string) ReaderIO[any] {
|
||||
return func(ctx context.Context) io.IO[any] {
|
||||
return func() any {
|
||||
r2.released = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compose brackets
|
||||
operation := Bracket(acquire1, func(r1 *mockResource) ReaderIO[string] {
|
||||
return func(ctx context.Context) io.IO[string] {
|
||||
r2 := use1(r1)(ctx)()
|
||||
return Bracket(
|
||||
func(ctx context.Context) io.IO[*mockResource] {
|
||||
return func() *mockResource { return r2 }
|
||||
},
|
||||
use2,
|
||||
release2,
|
||||
)(ctx)
|
||||
}
|
||||
}, release1)
|
||||
|
||||
result := operation(context.Background())()
|
||||
|
||||
assert.True(t, resource1.acquired)
|
||||
assert.True(t, resource1.used)
|
||||
assert.True(t, resource1.released)
|
||||
assert.True(t, resource2.acquired)
|
||||
assert.True(t, resource2.used)
|
||||
assert.True(t, resource2.released)
|
||||
assert.Equal(t, "both used", result)
|
||||
}
|
||||
|
||||
// TestWithResource_Success tests WithResource with successful operation
|
||||
func TestWithResource_Success(t *testing.T) {
|
||||
resource := &mockResource{id: 1}
|
||||
|
||||
// Define resource management
|
||||
withResource := WithResource[*mockResource, string, any](
|
||||
func(ctx context.Context) io.IO[*mockResource] {
|
||||
return func() *mockResource {
|
||||
resource.acquired = true
|
||||
return resource
|
||||
}
|
||||
},
|
||||
func(r *mockResource) ReaderIO[any] {
|
||||
return func(ctx context.Context) io.IO[any] {
|
||||
return func() any {
|
||||
r.released = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Use resource
|
||||
operation := withResource(func(r *mockResource) ReaderIO[string] {
|
||||
return func(ctx context.Context) io.IO[string] {
|
||||
return func() string {
|
||||
r.used = true
|
||||
return "result"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
result := operation(context.Background())()
|
||||
|
||||
assert.True(t, resource.acquired)
|
||||
assert.True(t, resource.used)
|
||||
assert.True(t, resource.released)
|
||||
assert.Equal(t, "result", result)
|
||||
}
|
||||
|
||||
// TestWithResource_Reusability tests that WithResource can be reused with different operations
|
||||
func TestWithResource_Reusability(t *testing.T) {
|
||||
callCount := 0
|
||||
|
||||
withResource := WithResource[*mockResource, int, any](
|
||||
func(ctx context.Context) io.IO[*mockResource] {
|
||||
return func() *mockResource {
|
||||
callCount++
|
||||
return &mockResource{id: callCount, acquired: true}
|
||||
}
|
||||
},
|
||||
func(r *mockResource) ReaderIO[any] {
|
||||
return func(ctx context.Context) io.IO[any] {
|
||||
return func() any {
|
||||
r.released = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// First operation
|
||||
op1 := withResource(func(r *mockResource) ReaderIO[int] {
|
||||
return func(ctx context.Context) io.IO[int] {
|
||||
return func() int {
|
||||
r.used = true
|
||||
return r.id * 2
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
result1 := op1(context.Background())()
|
||||
assert.Equal(t, 2, result1)
|
||||
assert.Equal(t, 1, callCount)
|
||||
|
||||
// Second operation (should create new resource)
|
||||
op2 := withResource(func(r *mockResource) ReaderIO[int] {
|
||||
return func(ctx context.Context) io.IO[int] {
|
||||
return func() int {
|
||||
r.used = true
|
||||
return r.id * 3
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
result2 := op2(context.Background())()
|
||||
assert.Equal(t, 6, result2)
|
||||
assert.Equal(t, 2, callCount)
|
||||
}
|
||||
|
||||
// TestWithResource_DifferentResultTypes tests WithResource with different result types
|
||||
func TestWithResource_DifferentResultTypes(t *testing.T) {
|
||||
resource := &mockResource{id: 42}
|
||||
|
||||
withResourceInt := WithResource[*mockResource, int, any](
|
||||
func(ctx context.Context) io.IO[*mockResource] {
|
||||
return func() *mockResource {
|
||||
resource.acquired = true
|
||||
return resource
|
||||
}
|
||||
},
|
||||
func(r *mockResource) ReaderIO[any] {
|
||||
return func(ctx context.Context) io.IO[any] {
|
||||
return func() any {
|
||||
r.released = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Operation returning int
|
||||
opInt := withResourceInt(func(r *mockResource) ReaderIO[int] {
|
||||
return func(ctx context.Context) io.IO[int] {
|
||||
return func() int {
|
||||
return r.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
resultInt := opInt(context.Background())()
|
||||
assert.Equal(t, 42, resultInt)
|
||||
|
||||
// Reset resource state
|
||||
resource.acquired = false
|
||||
resource.released = false
|
||||
|
||||
// Create new WithResource for string type
|
||||
withResourceString := WithResource[*mockResource, string, any](
|
||||
func(ctx context.Context) io.IO[*mockResource] {
|
||||
return func() *mockResource {
|
||||
resource.acquired = true
|
||||
return resource
|
||||
}
|
||||
},
|
||||
func(r *mockResource) ReaderIO[any] {
|
||||
return func(ctx context.Context) io.IO[any] {
|
||||
return func() any {
|
||||
r.released = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Operation returning string
|
||||
opString := withResourceString(func(r *mockResource) ReaderIO[string] {
|
||||
return func(ctx context.Context) io.IO[string] {
|
||||
return func() string {
|
||||
return "value"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
resultString := opString(context.Background())()
|
||||
assert.Equal(t, "value", resultString)
|
||||
assert.True(t, resource.released)
|
||||
}
|
||||
|
||||
// TestWithResource_ContextPropagation tests that context is properly propagated
|
||||
func TestWithResource_ContextPropagation(t *testing.T) {
|
||||
type contextKey string
|
||||
const key contextKey = "test-key"
|
||||
|
||||
withResource := WithResource[string, string, any](
|
||||
func(ctx context.Context) io.IO[string] {
|
||||
return func() string {
|
||||
value := ctx.Value(key)
|
||||
if value != nil {
|
||||
return value.(string)
|
||||
}
|
||||
return "no-value"
|
||||
}
|
||||
},
|
||||
func(r string) ReaderIO[any] {
|
||||
return func(ctx context.Context) io.IO[any] {
|
||||
return func() any {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
operation := withResource(func(r string) ReaderIO[string] {
|
||||
return func(ctx context.Context) io.IO[string] {
|
||||
return func() string {
|
||||
return r + "-processed"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ctx := context.WithValue(context.Background(), key, "test-value")
|
||||
result := operation(ctx)()
|
||||
|
||||
assert.Equal(t, "test-value-processed", result)
|
||||
}
|
||||
|
||||
// TestWithResource_ErrorInRelease tests behavior when release function encounters an error
|
||||
func TestWithResource_ErrorInRelease(t *testing.T) {
|
||||
resource := &mockResource{id: 1}
|
||||
releaseError := errors.New("release failed")
|
||||
|
||||
withResource := WithResource[*mockResource, string, error](
|
||||
func(ctx context.Context) io.IO[*mockResource] {
|
||||
return func() *mockResource {
|
||||
resource.acquired = true
|
||||
return resource
|
||||
}
|
||||
},
|
||||
func(r *mockResource) ReaderIO[error] {
|
||||
return func(ctx context.Context) io.IO[error] {
|
||||
return func() error {
|
||||
r.released = true
|
||||
return releaseError
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
operation := withResource(func(r *mockResource) ReaderIO[string] {
|
||||
return func(ctx context.Context) io.IO[string] {
|
||||
return func() string {
|
||||
r.used = true
|
||||
return "success"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
result := operation(context.Background())()
|
||||
|
||||
// Operation should succeed even if release returns error
|
||||
assert.Equal(t, "success", result)
|
||||
assert.True(t, resource.acquired)
|
||||
assert.True(t, resource.used)
|
||||
assert.True(t, resource.released)
|
||||
}
|
||||
|
||||
// BenchmarkBracket benchmarks the Bracket function
|
||||
func BenchmarkBracket(b *testing.B) {
|
||||
acquire := func(ctx context.Context) io.IO[int] {
|
||||
return func() int {
|
||||
return 42
|
||||
}
|
||||
}
|
||||
|
||||
use := func(n int) ReaderIO[int] {
|
||||
return func(ctx context.Context) io.IO[int] {
|
||||
return func() int {
|
||||
return n * 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
release := func(n int, result int) ReaderIO[any] {
|
||||
return func(ctx context.Context) io.IO[any] {
|
||||
return func() any {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
operation := Bracket(acquire, use, release)
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
operation(ctx)()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkWithResource benchmarks the WithResource function
|
||||
func BenchmarkWithResource(b *testing.B) {
|
||||
withResource := WithResource[int, int, any](
|
||||
func(ctx context.Context) io.IO[int] {
|
||||
return func() int {
|
||||
return 42
|
||||
}
|
||||
},
|
||||
func(n int) ReaderIO[any] {
|
||||
return func(ctx context.Context) io.IO[any] {
|
||||
return func() any {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
operation := withResource(func(n int) ReaderIO[int] {
|
||||
return func(ctx context.Context) io.IO[int] {
|
||||
return func() int {
|
||||
return n * 2
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
operation(ctx)()
|
||||
}
|
||||
}
|
||||
|
||||
// Made with Bob
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
)
|
||||
|
||||
// Promap is the profunctor map operation that transforms both the input and output of a context-based ReaderIO.
|
||||
@@ -44,7 +46,7 @@ import (
|
||||
// - An Operator that takes a ReaderIO[A] and returns a ReaderIO[B]
|
||||
//
|
||||
//go:inline
|
||||
func Promap[A, B any](f func(context.Context) (context.Context, context.CancelFunc), g func(A) B) Operator[A, B] {
|
||||
func Promap[A, B any](f pair.Kleisli[context.CancelFunc, context.Context, context.Context], g func(A) B) Operator[A, B] {
|
||||
return function.Flow2(
|
||||
Local[A](f),
|
||||
Map(g),
|
||||
@@ -69,6 +71,76 @@ func Promap[A, B any](f func(context.Context) (context.Context, context.CancelFu
|
||||
// - An Operator that takes a ReaderIO[A] and returns a ReaderIO[A]
|
||||
//
|
||||
//go:inline
|
||||
func Contramap[A any](f func(context.Context) (context.Context, context.CancelFunc)) Operator[A, A] {
|
||||
func Contramap[A any](f pair.Kleisli[context.CancelFunc, context.Context, context.Context]) Operator[A, A] {
|
||||
return Local[A](f)
|
||||
}
|
||||
|
||||
// LocalIOK transforms the context using an IO effect before passing it to a ReaderIO computation.
|
||||
//
|
||||
// This is similar to Local, but the context transformation itself is wrapped in an IO effect,
|
||||
// allowing for side-effectful context transformations. The transformation function receives
|
||||
// the current context and returns an IO effect that produces a new context along with a
|
||||
// cancel function. The cancel function is automatically called when the computation completes
|
||||
// (via defer), ensuring proper cleanup of resources.
|
||||
//
|
||||
// This is useful for:
|
||||
// - Context transformations that require side effects (e.g., loading configuration)
|
||||
// - Lazy initialization of context values
|
||||
// - Context transformations that may fail or need to perform I/O
|
||||
// - Composing effectful context setup with computations
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The value type of the ReaderIO
|
||||
//
|
||||
// Parameters:
|
||||
// - f: An IO Kleisli arrow that transforms the context with side effects
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that runs the computation with the effectfully transformed context
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// import (
|
||||
// "context"
|
||||
// G "github.com/IBM/fp-go/v2/io"
|
||||
// F "github.com/IBM/fp-go/v2/function"
|
||||
// )
|
||||
//
|
||||
// // Context transformation with side effects (e.g., loading config)
|
||||
// loadConfig := func(ctx context.Context) G.IO[ContextCancel] {
|
||||
// return func() ContextCancel {
|
||||
// // Simulate loading configuration
|
||||
// config := loadConfigFromFile()
|
||||
// newCtx := context.WithValue(ctx, "config", config)
|
||||
// return pair.MakePair[context.CancelFunc](func() {}, newCtx)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// getValue := readerio.FromReader(func(ctx context.Context) string {
|
||||
// if cfg := ctx.Value("config"); cfg != nil {
|
||||
// return cfg.(string)
|
||||
// }
|
||||
// return "default"
|
||||
// })
|
||||
//
|
||||
// result := F.Pipe1(
|
||||
// getValue,
|
||||
// readerio.LocalIOK[string](loadConfig),
|
||||
// )
|
||||
// value := result(t.Context())() // Loads config and uses it
|
||||
//
|
||||
// Comparison with Local:
|
||||
// - Local: Takes a pure function that transforms the context
|
||||
// - LocalIOK: Takes an IO effect that transforms the context, allowing side effects
|
||||
func LocalIOK[A any](f io.Kleisli[context.Context, ContextCancel]) Operator[A, A] {
|
||||
return func(r ReaderIO[A]) ReaderIO[A] {
|
||||
return func(ctx context.Context) IO[A] {
|
||||
p := f(ctx)
|
||||
return func() A {
|
||||
otherCancel, otherCtx := pair.Unpack(p())
|
||||
defer otherCancel()
|
||||
return r(otherCtx)()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -38,9 +39,9 @@ func TestPromapBasic(t *testing.T) {
|
||||
}
|
||||
|
||||
// Transform context and result
|
||||
addKey := func(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
addKey := func(ctx context.Context) ContextCancel {
|
||||
newCtx := context.WithValue(ctx, "key", 42)
|
||||
return newCtx, func() {}
|
||||
return pair.MakePair[context.CancelFunc](func() {}, newCtx)
|
||||
}
|
||||
toString := strconv.Itoa
|
||||
|
||||
@@ -63,9 +64,9 @@ func TestContramapBasic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
addKey := func(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
addKey := func(ctx context.Context) ContextCancel {
|
||||
newCtx := context.WithValue(ctx, "key", 100)
|
||||
return newCtx, func() {}
|
||||
return pair.MakePair[context.CancelFunc](func() {}, newCtx)
|
||||
}
|
||||
|
||||
adapted := Contramap[int](addKey)(getValue)
|
||||
@@ -85,8 +86,9 @@ func TestLocalBasic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
addTimeout := func(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(ctx, time.Second)
|
||||
addTimeout := func(ctx context.Context) ContextCancel {
|
||||
newCtx, cancelFct := context.WithTimeout(ctx, time.Second)
|
||||
return pair.MakePair(cancelFct, newCtx)
|
||||
}
|
||||
|
||||
adapted := Local[bool](addTimeout)(getValue)
|
||||
@@ -95,3 +97,81 @@ func TestLocalBasic(t *testing.T) {
|
||||
assert.True(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestLocalIOKBasic tests basic LocalIOK functionality
|
||||
func TestLocalIOKBasic(t *testing.T) {
|
||||
t.Run("context transformation with IO effect", func(t *testing.T) {
|
||||
getValue := func(ctx context.Context) IO[string] {
|
||||
return func() string {
|
||||
if v := ctx.Value("key"); v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
// Context transformation wrapped in IO effect
|
||||
addKeyIO := func(ctx context.Context) IO[ContextCancel] {
|
||||
return func() ContextCancel {
|
||||
// Simulate side effect (e.g., loading config)
|
||||
newCtx := context.WithValue(ctx, "key", "loaded-value")
|
||||
return pair.MakePair[context.CancelFunc](func() {}, newCtx)
|
||||
}
|
||||
}
|
||||
|
||||
adapted := LocalIOK[string](addKeyIO)(getValue)
|
||||
result := adapted(t.Context())()
|
||||
|
||||
assert.Equal(t, "loaded-value", result)
|
||||
})
|
||||
|
||||
t.Run("cleanup function is called", func(t *testing.T) {
|
||||
cleanupCalled := false
|
||||
|
||||
getValue := func(ctx context.Context) IO[int] {
|
||||
return func() int {
|
||||
if v := ctx.Value("value"); v != nil {
|
||||
return v.(int)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
addValueIO := func(ctx context.Context) IO[ContextCancel] {
|
||||
return func() ContextCancel {
|
||||
newCtx := context.WithValue(ctx, "value", 42)
|
||||
cleanup := context.CancelFunc(func() {
|
||||
cleanupCalled = true
|
||||
})
|
||||
return pair.MakePair(cleanup, newCtx)
|
||||
}
|
||||
}
|
||||
|
||||
adapted := LocalIOK[int](addValueIO)(getValue)
|
||||
result := adapted(t.Context())()
|
||||
|
||||
assert.Equal(t, 42, result)
|
||||
assert.True(t, cleanupCalled, "cleanup function should be called")
|
||||
})
|
||||
|
||||
t.Run("works with timeout context", func(t *testing.T) {
|
||||
getValue := func(ctx context.Context) IO[bool] {
|
||||
return func() bool {
|
||||
_, hasDeadline := ctx.Deadline()
|
||||
return hasDeadline
|
||||
}
|
||||
}
|
||||
|
||||
addTimeoutIO := func(ctx context.Context) IO[ContextCancel] {
|
||||
return func() ContextCancel {
|
||||
newCtx, cancelFct := context.WithTimeout(ctx, time.Second)
|
||||
return pair.MakePair(cancelFct, newCtx)
|
||||
}
|
||||
}
|
||||
|
||||
adapted := LocalIOK[bool](addTimeoutIO)(getValue)
|
||||
result := adapted(t.Context())()
|
||||
|
||||
assert.True(t, result, "context should have deadline")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
RIO "github.com/IBM/fp-go/v2/readerio"
|
||||
)
|
||||
@@ -677,11 +678,11 @@ func ReadIO[A any](r IO[context.Context]) func(ReaderIO[A]) IO[A] {
|
||||
// fetchData,
|
||||
// withTimeout,
|
||||
// )
|
||||
func Local[A any](f func(context.Context) (context.Context, context.CancelFunc)) Operator[A, A] {
|
||||
func Local[A any](f pair.Kleisli[context.CancelFunc, context.Context, context.Context]) Operator[A, A] {
|
||||
return func(rr ReaderIO[A]) ReaderIO[A] {
|
||||
return func(ctx context.Context) IO[A] {
|
||||
return func() A {
|
||||
otherCtx, otherCancel := f(ctx)
|
||||
otherCancel, otherCtx := pair.Unpack(f(ctx))
|
||||
defer otherCancel()
|
||||
return rr(otherCtx)()
|
||||
}
|
||||
@@ -742,8 +743,9 @@ func Local[A any](f func(context.Context) (context.Context, context.CancelFunc))
|
||||
// )
|
||||
// data := result(t.Context())() // Returns Data{Value: "quick"}
|
||||
func WithTimeout[A any](timeout time.Duration) Operator[A, A] {
|
||||
return Local[A](func(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(ctx, timeout)
|
||||
return Local[A](func(ctx context.Context) ContextCancel {
|
||||
newCtx, cancelFct := context.WithTimeout(ctx, timeout)
|
||||
return pair.MakePair(cancelFct, newCtx)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -806,8 +808,9 @@ func WithTimeout[A any](timeout time.Duration) Operator[A, A] {
|
||||
// )
|
||||
// data := result(parentCtx)() // Will use parent's 1-hour deadline
|
||||
func WithDeadline[A any](deadline time.Time) Operator[A, A] {
|
||||
return Local[A](func(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithDeadline(ctx, deadline)
|
||||
return Local[A](func(ctx context.Context) ContextCancel {
|
||||
newCtx, cancelFct := context.WithDeadline(ctx, deadline)
|
||||
return pair.MakePair(cancelFct, newCtx)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
"github.com/IBM/fp-go/v2/lazy"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
"github.com/IBM/fp-go/v2/predicate"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/IBM/fp-go/v2/readerio"
|
||||
@@ -81,4 +82,15 @@ type (
|
||||
Predicate[A any] = predicate.Predicate[A]
|
||||
|
||||
Void = function.Void
|
||||
|
||||
// Pair represents a tuple of two values of types A and B.
|
||||
// It is used to group two related values together.
|
||||
Pair[A, B any] = pair.Pair[A, B]
|
||||
|
||||
// ContextCancel represents a pair of a cancel function and a context.
|
||||
// It is used in operations that create new contexts with cancellation capabilities.
|
||||
//
|
||||
// The first element is the CancelFunc that should be called to release resources.
|
||||
// The second element is the new Context that was created.
|
||||
ContextCancel = Pair[context.CancelFunc, context.Context]
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
"github.com/IBM/fp-go/v2/logging"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
)
|
||||
@@ -90,132 +91,7 @@ func withLoggingContext(lctx loggingContext) Endomorphism[context.Context] {
|
||||
return F.Bind2nd(withLoggingContextValue, any(lctx))
|
||||
}
|
||||
|
||||
// LogEntryExitF creates a customizable operator that wraps a ReaderIOResult computation with entry/exit callbacks.
|
||||
//
|
||||
// This is a more flexible version of LogEntryExit that allows you to provide custom callbacks for
|
||||
// entry and exit events. The onEntry callback receives the current context and can return a modified
|
||||
// context (e.g., with additional logging information). The onExit callback receives the computation
|
||||
// result and can perform custom logging, metrics collection, or cleanup.
|
||||
//
|
||||
// The function uses the bracket pattern to ensure that:
|
||||
// - The onEntry callback is executed before the computation starts
|
||||
// - The computation runs with the context returned by onEntry
|
||||
// - The onExit callback is executed after the computation completes (success or failure)
|
||||
// - The original result is preserved and returned unchanged
|
||||
// - Cleanup happens even if the computation fails
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The success type of the ReaderIOResult
|
||||
// - ANY: The return type of the onExit callback (typically any)
|
||||
//
|
||||
// Parameters:
|
||||
// - onEntry: A ReaderIO that receives the current context and returns a (possibly modified) context.
|
||||
// This is executed before the computation starts. Use this for logging entry, adding context values,
|
||||
// starting timers, or initialization logic.
|
||||
// - onExit: A Kleisli function that receives the Result[A] and returns a ReaderIO[ANY].
|
||||
// This is executed after the computation completes, regardless of success or failure.
|
||||
// Use this for logging exit, recording metrics, cleanup, or finalization logic.
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that wraps the ReaderIOResult computation with the custom entry/exit callbacks
|
||||
//
|
||||
// Example with custom context modification:
|
||||
//
|
||||
// type RequestID string
|
||||
//
|
||||
// logOp := LogEntryExitF[User, any](
|
||||
// func(ctx context.Context) IO[context.Context] {
|
||||
// return func() context.Context {
|
||||
// reqID := RequestID(uuid.New().String())
|
||||
// log.Printf("[%s] Starting operation", reqID)
|
||||
// return context.WithValue(ctx, "requestID", reqID)
|
||||
// }
|
||||
// },
|
||||
// func(res Result[User]) ReaderIO[any] {
|
||||
// return func(ctx context.Context) IO[any] {
|
||||
// return func() any {
|
||||
// reqID := ctx.Value("requestID").(RequestID)
|
||||
// return F.Pipe1(
|
||||
// res,
|
||||
// result.Fold(
|
||||
// func(err error) any {
|
||||
// log.Printf("[%s] Operation failed: %v", reqID, err)
|
||||
// return nil
|
||||
// },
|
||||
// func(_ User) any {
|
||||
// log.Printf("[%s] Operation succeeded", reqID)
|
||||
// return nil
|
||||
// },
|
||||
// ),
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// )
|
||||
//
|
||||
// wrapped := logOp(fetchUser(123))
|
||||
//
|
||||
// Example with metrics collection:
|
||||
//
|
||||
// import "github.com/prometheus/client_golang/prometheus"
|
||||
//
|
||||
// metricsOp := LogEntryExitF[Response, any](
|
||||
// func(ctx context.Context) IO[context.Context] {
|
||||
// return func() context.Context {
|
||||
// requestCount.WithLabelValues("api_call", "started").Inc()
|
||||
// return context.WithValue(ctx, "startTime", time.Now())
|
||||
// }
|
||||
// },
|
||||
// func(res Result[Response]) ReaderIO[any] {
|
||||
// return func(ctx context.Context) IO[any] {
|
||||
// return func() any {
|
||||
// startTime := ctx.Value("startTime").(time.Time)
|
||||
// duration := time.Since(startTime).Seconds()
|
||||
//
|
||||
// return F.Pipe1(
|
||||
// res,
|
||||
// result.Fold(
|
||||
// func(err error) any {
|
||||
// requestCount.WithLabelValues("api_call", "error").Inc()
|
||||
// requestDuration.WithLabelValues("api_call", "error").Observe(duration)
|
||||
// return nil
|
||||
// },
|
||||
// func(_ Response) any {
|
||||
// requestCount.WithLabelValues("api_call", "success").Inc()
|
||||
// requestDuration.WithLabelValues("api_call", "success").Observe(duration)
|
||||
// return nil
|
||||
// },
|
||||
// ),
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// )
|
||||
//
|
||||
// Use Cases:
|
||||
// - Custom context modification: Adding request IDs, trace IDs, or other context values
|
||||
// - Structured logging: Integration with zap, logrus, or other structured loggers
|
||||
// - Metrics collection: Recording operation durations, success/failure rates
|
||||
// - Distributed tracing: OpenTelemetry, Jaeger integration
|
||||
// - Custom monitoring: Application-specific monitoring and alerting
|
||||
//
|
||||
// Note: LogEntryExit is implemented using LogEntryExitF with standard logging and context management.
|
||||
// Use LogEntryExitF when you need more control over the entry/exit behavior or context modification.
|
||||
func LogEntryExitF[A, ANY any](
|
||||
onEntry ReaderIO[context.Context],
|
||||
onExit readerio.Kleisli[Result[A], ANY],
|
||||
) Operator[A, A] {
|
||||
bracket := F.Bind13of3(readerio.Bracket[context.Context, Result[A], ANY])(onEntry, func(newCtx context.Context, res Result[A]) ReaderIO[ANY] {
|
||||
return readerio.FromIO(onExit(res)(newCtx)) // Get the exit callback for this result
|
||||
})
|
||||
|
||||
return func(src ReaderIOResult[A]) ReaderIOResult[A] {
|
||||
return bracket(F.Flow2(
|
||||
src,
|
||||
FromIOResult,
|
||||
))
|
||||
}
|
||||
}
|
||||
func noop() {}
|
||||
|
||||
// onEntry creates a ReaderIO that handles the entry logging for an operation.
|
||||
// It generates a unique logging ID, captures the start time, and logs the entry message.
|
||||
@@ -230,15 +106,15 @@ func LogEntryExitF[A, ANY any](
|
||||
// - A ReaderIO that prepares the context with logging information and logs the entry
|
||||
func onEntry(
|
||||
logLevel slog.Level,
|
||||
cb func(context.Context) *slog.Logger,
|
||||
cb Reader[context.Context, *slog.Logger],
|
||||
nameAttr slog.Attr,
|
||||
) ReaderIO[context.Context] {
|
||||
) ReaderIO[ContextCancel] {
|
||||
|
||||
return func(ctx context.Context) IO[context.Context] {
|
||||
return func(ctx context.Context) IO[ContextCancel] {
|
||||
// logger
|
||||
logger := cb(ctx)
|
||||
|
||||
return func() context.Context {
|
||||
return func() ContextCancel {
|
||||
// check if the logger is enabled
|
||||
if logger.Enabled(ctx, logLevel) {
|
||||
// Generate unique logging ID and capture start time
|
||||
@@ -258,19 +134,23 @@ func onEntry(
|
||||
})
|
||||
withLogger := logging.WithLogger(newLogger)
|
||||
|
||||
return withCtx(withLogger(ctx))
|
||||
return F.Pipe2(
|
||||
ctx,
|
||||
withLogger,
|
||||
pair.Map[context.CancelFunc](withCtx),
|
||||
)
|
||||
}
|
||||
// logging disabled
|
||||
withCtx := withLoggingContext(loggingContext{
|
||||
logger: logger,
|
||||
isEnabled: false,
|
||||
})
|
||||
return withCtx(ctx)
|
||||
return pair.MakePair[context.CancelFunc](noop, withCtx(ctx))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// onExitAny creates a Kleisli function that handles exit logging for an operation.
|
||||
// onExitVoid creates a Kleisli function that handles exit logging for an operation.
|
||||
// It logs either success or error based on the Result, including the operation duration.
|
||||
// Only logs if logging was enabled during entry (checked via loggingContext.isEnabled).
|
||||
//
|
||||
@@ -280,33 +160,33 @@ func onEntry(
|
||||
//
|
||||
// Returns:
|
||||
// - A Kleisli function that logs the exit/error and returns nil
|
||||
func onExitAny(
|
||||
func onExitVoid(
|
||||
logLevel slog.Level,
|
||||
nameAttr slog.Attr,
|
||||
) readerio.Kleisli[Result[any], any] {
|
||||
return func(res Result[any]) ReaderIO[any] {
|
||||
return func(ctx context.Context) IO[any] {
|
||||
) readerio.Kleisli[Result[Void], Void] {
|
||||
return func(res Result[Void]) ReaderIO[Void] {
|
||||
return func(ctx context.Context) IO[Void] {
|
||||
value := getLoggingContext(ctx)
|
||||
|
||||
if value.isEnabled {
|
||||
|
||||
return func() any {
|
||||
return func() Void {
|
||||
// Retrieve logging information from context
|
||||
durationAttr := slog.Duration("duration", time.Since(value.startTime))
|
||||
|
||||
// Log error with ID and duration
|
||||
onError := func(err error) any {
|
||||
onError := func(err error) Void {
|
||||
value.logger.LogAttrs(ctx, logLevel, "[throwing]",
|
||||
nameAttr,
|
||||
durationAttr,
|
||||
slog.Any("error", err))
|
||||
return nil
|
||||
return F.VOID
|
||||
}
|
||||
|
||||
// Log success with ID and duration
|
||||
onSuccess := func(_ any) any {
|
||||
onSuccess := func(v Void) Void {
|
||||
value.logger.LogAttrs(ctx, logLevel, "[exiting ]", nameAttr, durationAttr)
|
||||
return nil
|
||||
return v
|
||||
}
|
||||
|
||||
return F.Pipe1(
|
||||
@@ -316,7 +196,7 @@ func onExitAny(
|
||||
}
|
||||
}
|
||||
// nothing to do
|
||||
return io.Of[any](nil)
|
||||
return io.Of(F.VOID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,13 +254,21 @@ func LogEntryExitWithCallback[A any](
|
||||
|
||||
nameAttr := slog.String("name", name)
|
||||
|
||||
return LogEntryExitF(
|
||||
entry := F.Pipe1(
|
||||
onEntry(logLevel, cb, nameAttr),
|
||||
F.Flow2(
|
||||
result.MapTo[A, any](nil),
|
||||
onExitAny(logLevel, nameAttr),
|
||||
),
|
||||
readerio.LocalIOK[Result[A]],
|
||||
)
|
||||
|
||||
exit := readerio.Tap(F.Flow2(
|
||||
result.MapTo[A](F.VOID),
|
||||
onExitVoid(logLevel, nameAttr),
|
||||
))
|
||||
|
||||
return F.Flow2(
|
||||
exit,
|
||||
entry,
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
// LogEntryExit creates an operator that logs the entry and exit of a ReaderIOResult computation with timing and correlation IDs.
|
||||
|
||||
417
v2/context/readerioresult/logging_comprehensive_test.go
Normal file
417
v2/context/readerioresult/logging_comprehensive_test.go
Normal file
@@ -0,0 +1,417 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package readerioresult
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/logging"
|
||||
N "github.com/IBM/fp-go/v2/number"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestTapSLogComprehensive_Success verifies TapSLog logs successful values
|
||||
func TestTapSLogComprehensive_Success(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
oldLogger := logging.SetLogger(logger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
t.Run("logs integer success value", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
|
||||
pipeline := F.Pipe2(
|
||||
Of(42),
|
||||
TapSLog[int]("Integer value"),
|
||||
Map(N.Mul(2)),
|
||||
)
|
||||
|
||||
res := pipeline(t.Context())()
|
||||
|
||||
// Verify result is correct
|
||||
assert.Equal(t, 84, F.Pipe1(res, getOrZero))
|
||||
|
||||
// Verify logging occurred
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Integer value", "Should log the message")
|
||||
assert.Contains(t, logOutput, "value=42", "Should log the success value")
|
||||
assert.NotContains(t, logOutput, "error", "Should not contain error keyword for success")
|
||||
})
|
||||
|
||||
t.Run("logs string success value", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
|
||||
pipeline := F.Pipe1(
|
||||
Of("hello world"),
|
||||
TapSLog[string]("String value"),
|
||||
)
|
||||
|
||||
res := pipeline(t.Context())()
|
||||
|
||||
// Verify result is correct
|
||||
assert.True(t, F.Pipe1(res, isRight[string]))
|
||||
|
||||
// Verify logging occurred
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "String value")
|
||||
assert.Contains(t, logOutput, `value="hello world"`)
|
||||
})
|
||||
|
||||
t.Run("logs struct success value", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
|
||||
type User struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
|
||||
user := User{ID: 123, Name: "Alice"}
|
||||
pipeline := F.Pipe1(
|
||||
Of(user),
|
||||
TapSLog[User]("User struct"),
|
||||
)
|
||||
|
||||
res := pipeline(t.Context())()
|
||||
|
||||
// Verify result is correct
|
||||
assert.True(t, F.Pipe1(res, isRight[User]))
|
||||
|
||||
// Verify logging occurred with struct fields
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "User struct")
|
||||
assert.Contains(t, logOutput, "ID:123")
|
||||
assert.Contains(t, logOutput, "Name:Alice")
|
||||
})
|
||||
|
||||
t.Run("logs multiple success values in pipeline", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
|
||||
step1 := F.Pipe2(
|
||||
Of(10),
|
||||
TapSLog[int]("Initial value"),
|
||||
Map(N.Mul(2)),
|
||||
)
|
||||
|
||||
pipeline := F.Pipe2(
|
||||
step1,
|
||||
TapSLog[int]("After doubling"),
|
||||
Map(N.Add(5)),
|
||||
)
|
||||
|
||||
res := pipeline(t.Context())()
|
||||
|
||||
// Verify result is correct
|
||||
assert.Equal(t, 25, getOrZero(res))
|
||||
|
||||
// Verify both log entries
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Initial value")
|
||||
assert.Contains(t, logOutput, "value=10")
|
||||
assert.Contains(t, logOutput, "After doubling")
|
||||
assert.Contains(t, logOutput, "value=20")
|
||||
})
|
||||
}
|
||||
|
||||
// TestTapSLogComprehensive_Error verifies TapSLog behavior with errors
|
||||
func TestTapSLogComprehensive_Error(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
oldLogger := logging.SetLogger(logger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
t.Run("logs error values", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
|
||||
testErr := errors.New("test error")
|
||||
pipeline := F.Pipe2(
|
||||
Left[int](testErr),
|
||||
TapSLog[int]("Error case"),
|
||||
Map(N.Mul(2)),
|
||||
)
|
||||
|
||||
res := pipeline(t.Context())()
|
||||
|
||||
// Verify error is preserved
|
||||
assert.True(t, F.Pipe1(res, isLeft[int]))
|
||||
|
||||
// Verify logging occurred for error
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Error case", "Should log the message")
|
||||
assert.Contains(t, logOutput, "error", "Should contain error keyword")
|
||||
assert.Contains(t, logOutput, "test error", "Should log the error message")
|
||||
assert.NotContains(t, logOutput, "value=", "Should not log 'value=' for errors")
|
||||
})
|
||||
|
||||
t.Run("preserves error through pipeline", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
|
||||
originalErr := errors.New("original error")
|
||||
step1 := F.Pipe2(
|
||||
Left[int](originalErr),
|
||||
TapSLog[int]("First tap"),
|
||||
Map(N.Mul(2)),
|
||||
)
|
||||
|
||||
pipeline := F.Pipe2(
|
||||
step1,
|
||||
TapSLog[int]("Second tap"),
|
||||
Map(N.Add(5)),
|
||||
)
|
||||
|
||||
res := pipeline(t.Context())()
|
||||
|
||||
// Verify error is preserved
|
||||
assert.True(t, isLeft(res))
|
||||
|
||||
// Verify both taps logged the error
|
||||
logOutput := buf.String()
|
||||
errorCount := strings.Count(logOutput, "original error")
|
||||
assert.Equal(t, 2, errorCount, "Both TapSLog calls should log the error")
|
||||
assert.Contains(t, logOutput, "First tap")
|
||||
assert.Contains(t, logOutput, "Second tap")
|
||||
})
|
||||
|
||||
t.Run("logs error after successful operation", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
|
||||
pipeline := F.Pipe3(
|
||||
Of(10),
|
||||
TapSLog[int]("Before error"),
|
||||
Chain(func(n int) ReaderIOResult[int] {
|
||||
return Left[int](errors.New("chain error"))
|
||||
}),
|
||||
TapSLog[int]("After error"),
|
||||
)
|
||||
|
||||
res := pipeline(t.Context())()
|
||||
|
||||
// Verify error is present
|
||||
assert.True(t, F.Pipe1(res, isLeft[int]))
|
||||
|
||||
// Verify both logs
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Before error")
|
||||
assert.Contains(t, logOutput, "value=10")
|
||||
assert.Contains(t, logOutput, "After error")
|
||||
assert.Contains(t, logOutput, "chain error")
|
||||
})
|
||||
}
|
||||
|
||||
// TestTapSLogComprehensive_EdgeCases verifies TapSLog with edge cases
|
||||
func TestTapSLogComprehensive_EdgeCases(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
oldLogger := logging.SetLogger(logger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
t.Run("logs zero value", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
|
||||
pipeline := F.Pipe1(
|
||||
Of(0),
|
||||
TapSLog[int]("Zero value"),
|
||||
)
|
||||
|
||||
res := pipeline(t.Context())()
|
||||
|
||||
assert.Equal(t, 0, F.Pipe1(res, getOrZero))
|
||||
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Zero value")
|
||||
assert.Contains(t, logOutput, "value=0")
|
||||
})
|
||||
|
||||
t.Run("logs empty string", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
|
||||
pipeline := F.Pipe1(
|
||||
Of(""),
|
||||
TapSLog[string]("Empty string"),
|
||||
)
|
||||
|
||||
res := pipeline(t.Context())()
|
||||
|
||||
assert.True(t, F.Pipe1(res, isRight[string]))
|
||||
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Empty string")
|
||||
assert.Contains(t, logOutput, `value=""`)
|
||||
})
|
||||
|
||||
t.Run("logs nil pointer", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
|
||||
type Data struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
var nilData *Data
|
||||
pipeline := F.Pipe1(
|
||||
Of(nilData),
|
||||
TapSLog[*Data]("Nil pointer"),
|
||||
)
|
||||
|
||||
res := pipeline(t.Context())()
|
||||
|
||||
assert.True(t, F.Pipe1(res, isRight[*Data]))
|
||||
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Nil pointer")
|
||||
// Nil representation may vary, but should be logged
|
||||
assert.NotEmpty(t, logOutput)
|
||||
})
|
||||
|
||||
t.Run("respects logger level - disabled", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
|
||||
// Create logger that only logs errors
|
||||
errorLogger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelError,
|
||||
}))
|
||||
oldLogger := logging.SetLogger(errorLogger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
pipeline := F.Pipe1(
|
||||
Of(42),
|
||||
TapSLog[int]("Should not log"),
|
||||
)
|
||||
|
||||
res := pipeline(t.Context())()
|
||||
|
||||
assert.Equal(t, 42, F.Pipe1(res, getOrZero))
|
||||
|
||||
// Should have no logs since level is ERROR
|
||||
logOutput := buf.String()
|
||||
assert.Empty(t, logOutput, "Should not log when level is disabled")
|
||||
})
|
||||
}
|
||||
|
||||
// TestTapSLogComprehensive_Integration verifies TapSLog in realistic scenarios
|
||||
func TestTapSLogComprehensive_Integration(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
oldLogger := logging.SetLogger(logger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
t.Run("complex pipeline with mixed success and error", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
|
||||
// Simulate a data processing pipeline
|
||||
validatePositive := func(n int) ReaderIOResult[int] {
|
||||
if n > 0 {
|
||||
return Of(n)
|
||||
}
|
||||
return Left[int](errors.New("number must be positive"))
|
||||
}
|
||||
|
||||
step1 := F.Pipe3(
|
||||
Of(5),
|
||||
TapSLog[int]("Input received"),
|
||||
Map(N.Mul(2)),
|
||||
TapSLog[int]("After multiplication"),
|
||||
)
|
||||
|
||||
pipeline := F.Pipe2(
|
||||
step1,
|
||||
Chain(validatePositive),
|
||||
TapSLog[int]("After validation"),
|
||||
)
|
||||
|
||||
res := pipeline(t.Context())()
|
||||
|
||||
assert.Equal(t, 10, getOrZero(res))
|
||||
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Input received")
|
||||
assert.Contains(t, logOutput, "value=5")
|
||||
assert.Contains(t, logOutput, "After multiplication")
|
||||
assert.Contains(t, logOutput, "value=10")
|
||||
assert.Contains(t, logOutput, "After validation")
|
||||
assert.Contains(t, logOutput, "value=10")
|
||||
})
|
||||
|
||||
t.Run("error propagation with logging", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
|
||||
validatePositive := func(n int) ReaderIOResult[int] {
|
||||
if n > 0 {
|
||||
return Of(n)
|
||||
}
|
||||
return Left[int](errors.New("number must be positive"))
|
||||
}
|
||||
|
||||
step1 := F.Pipe3(
|
||||
Of(-5),
|
||||
TapSLog[int]("Input received"),
|
||||
Map(N.Mul(2)),
|
||||
TapSLog[int]("After multiplication"),
|
||||
)
|
||||
|
||||
pipeline := F.Pipe2(
|
||||
step1,
|
||||
Chain(validatePositive),
|
||||
TapSLog[int]("After validation"),
|
||||
)
|
||||
|
||||
res := pipeline(t.Context())()
|
||||
|
||||
assert.True(t, isLeft(res))
|
||||
|
||||
logOutput := buf.String()
|
||||
// First two taps should log success
|
||||
assert.Contains(t, logOutput, "Input received")
|
||||
assert.Contains(t, logOutput, "value=-5")
|
||||
assert.Contains(t, logOutput, "After multiplication")
|
||||
assert.Contains(t, logOutput, "value=-10")
|
||||
// Last tap should log error
|
||||
assert.Contains(t, logOutput, "After validation")
|
||||
assert.Contains(t, logOutput, "number must be positive")
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions for tests
|
||||
|
||||
func getOrZero(res Result[int]) int {
|
||||
val, err := result.Unwrap(res)
|
||||
if err == nil {
|
||||
return val
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func isRight[A any](res Result[A]) bool {
|
||||
return result.IsRight(res)
|
||||
}
|
||||
|
||||
func isLeft[A any](res Result[A]) bool {
|
||||
return result.IsLeft(res)
|
||||
}
|
||||
|
||||
// Made with Bob
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/logging"
|
||||
N "github.com/IBM/fp-go/v2/number"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
S "github.com/IBM/fp-go/v2/string"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -53,6 +54,11 @@ func TestLogEntryExitSuccess(t *testing.T) {
|
||||
assert.Contains(t, logOutput, "TestOperation")
|
||||
assert.Contains(t, logOutput, "ID=")
|
||||
assert.Contains(t, logOutput, "duration=")
|
||||
|
||||
// Verify entry log appears before exit log
|
||||
enteringIdx := strings.Index(logOutput, "[entering]")
|
||||
exitingIdx := strings.Index(logOutput, "[exiting ]")
|
||||
assert.Greater(t, exitingIdx, enteringIdx, "Exit log should appear after entry log")
|
||||
}
|
||||
|
||||
// TestLogEntryExitError tests error operation logging
|
||||
@@ -81,6 +87,11 @@ func TestLogEntryExitError(t *testing.T) {
|
||||
assert.Contains(t, logOutput, "test error")
|
||||
assert.Contains(t, logOutput, "ID=")
|
||||
assert.Contains(t, logOutput, "duration=")
|
||||
|
||||
// Verify entry log appears before error log
|
||||
enteringIdx := strings.Index(logOutput, "[entering]")
|
||||
throwingIdx := strings.Index(logOutput, "[throwing]")
|
||||
assert.Greater(t, throwingIdx, enteringIdx, "Error log should appear after entry log")
|
||||
}
|
||||
|
||||
// TestLogEntryExitNested tests nested operations with different IDs
|
||||
@@ -119,6 +130,48 @@ func TestLogEntryExitNested(t *testing.T) {
|
||||
exitCount := strings.Count(logOutput, "[exiting ]")
|
||||
assert.Equal(t, 2, enterCount, "Should have 2 entering logs")
|
||||
assert.Equal(t, 2, exitCount, "Should have 2 exiting logs")
|
||||
|
||||
// Verify log ordering: Each operation logs entry before exit
|
||||
// Note: Due to Chain semantics, OuterOp completes before InnerOp starts
|
||||
lines := strings.Split(logOutput, "\n")
|
||||
var logSequence []string
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "OuterOp") && strings.Contains(line, "[entering]") {
|
||||
logSequence = append(logSequence, "OuterOp-entering")
|
||||
} else if strings.Contains(line, "OuterOp") && strings.Contains(line, "[exiting ]") {
|
||||
logSequence = append(logSequence, "OuterOp-exiting")
|
||||
} else if strings.Contains(line, "InnerOp") && strings.Contains(line, "[entering]") {
|
||||
logSequence = append(logSequence, "InnerOp-entering")
|
||||
} else if strings.Contains(line, "InnerOp") && strings.Contains(line, "[exiting ]") {
|
||||
logSequence = append(logSequence, "InnerOp-exiting")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify each operation's entry comes before its exit
|
||||
assert.Equal(t, 4, len(logSequence), "Should have 4 log entries")
|
||||
|
||||
// Find indices
|
||||
outerEnterIdx := -1
|
||||
outerExitIdx := -1
|
||||
innerEnterIdx := -1
|
||||
innerExitIdx := -1
|
||||
|
||||
for i, log := range logSequence {
|
||||
switch log {
|
||||
case "OuterOp-entering":
|
||||
outerEnterIdx = i
|
||||
case "OuterOp-exiting":
|
||||
outerExitIdx = i
|
||||
case "InnerOp-entering":
|
||||
innerEnterIdx = i
|
||||
case "InnerOp-exiting":
|
||||
innerExitIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
// Verify entry before exit for each operation
|
||||
assert.Greater(t, outerExitIdx, outerEnterIdx, "OuterOp exit should come after OuterOp entry")
|
||||
assert.Greater(t, innerExitIdx, innerEnterIdx, "InnerOp exit should come after InnerOp entry")
|
||||
}
|
||||
|
||||
// TestLogEntryExitWithCallback tests custom log level and callback
|
||||
@@ -172,76 +225,6 @@ func TestLogEntryExitDisabled(t *testing.T) {
|
||||
assert.Empty(t, logOutput, "Should have no logs when logging is disabled")
|
||||
}
|
||||
|
||||
// TestLogEntryExitF tests custom entry/exit callbacks
|
||||
func TestLogEntryExitF(t *testing.T) {
|
||||
var entryCount, exitCount int
|
||||
|
||||
onEntry := func(ctx context.Context) IO[context.Context] {
|
||||
return func() context.Context {
|
||||
entryCount++
|
||||
return ctx
|
||||
}
|
||||
}
|
||||
|
||||
onExit := func(res Result[string]) ReaderIO[any] {
|
||||
return func(ctx context.Context) IO[any] {
|
||||
return func() any {
|
||||
exitCount++
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
operation := F.Pipe1(
|
||||
Of("test"),
|
||||
LogEntryExitF(onEntry, onExit),
|
||||
)
|
||||
|
||||
res := operation(t.Context())()
|
||||
|
||||
assert.True(t, result.IsRight(res))
|
||||
assert.Equal(t, 1, entryCount, "Entry callback should be called once")
|
||||
assert.Equal(t, 1, exitCount, "Exit callback should be called once")
|
||||
}
|
||||
|
||||
// TestLogEntryExitFWithError tests custom callbacks with error
|
||||
func TestLogEntryExitFWithError(t *testing.T) {
|
||||
var entryCount, exitCount int
|
||||
var capturedError error
|
||||
|
||||
onEntry := func(ctx context.Context) IO[context.Context] {
|
||||
return func() context.Context {
|
||||
entryCount++
|
||||
return ctx
|
||||
}
|
||||
}
|
||||
|
||||
onExit := func(res Result[string]) ReaderIO[any] {
|
||||
return func(ctx context.Context) IO[any] {
|
||||
return func() any {
|
||||
exitCount++
|
||||
if result.IsLeft(res) {
|
||||
_, capturedError = result.Unwrap(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testErr := errors.New("custom error")
|
||||
operation := F.Pipe1(
|
||||
Left[string](testErr),
|
||||
LogEntryExitF(onEntry, onExit),
|
||||
)
|
||||
|
||||
res := operation(t.Context())()
|
||||
|
||||
assert.True(t, result.IsLeft(res))
|
||||
assert.Equal(t, 1, entryCount, "Entry callback should be called once")
|
||||
assert.Equal(t, 1, exitCount, "Exit callback should be called once")
|
||||
assert.Equal(t, testErr, capturedError, "Should capture the error")
|
||||
}
|
||||
|
||||
// TestLoggingIDUniqueness tests that logging IDs are unique
|
||||
func TestLoggingIDUniqueness(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
@@ -287,7 +270,8 @@ func TestLogEntryExitWithContextLogger(t *testing.T) {
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
|
||||
ctx := logging.WithLogger(contextLogger)(t.Context())
|
||||
cancelFct, ctx := pair.Unpack(logging.WithLogger(contextLogger)(t.Context()))
|
||||
defer cancelFct()
|
||||
|
||||
operation := F.Pipe1(
|
||||
Of("context value"),
|
||||
@@ -546,7 +530,8 @@ func TestTapSLogWithContextLogger(t *testing.T) {
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
|
||||
ctx := logging.WithLogger(contextLogger)(t.Context())
|
||||
cancelFct, ctx := pair.Unpack(logging.WithLogger(contextLogger)(t.Context()))
|
||||
defer cancelFct()
|
||||
|
||||
operation := F.Pipe2(
|
||||
Of("test value"),
|
||||
@@ -660,3 +645,138 @@ func TestSLogWithCallbackLogsError(t *testing.T) {
|
||||
assert.Contains(t, logOutput, "warning error")
|
||||
assert.Contains(t, logOutput, "level=WARN")
|
||||
}
|
||||
|
||||
// TestTapSLogPreservesResult tests that TapSLog doesn't modify the result
|
||||
func TestTapSLogPreservesResult(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
oldLogger := logging.SetLogger(logger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
// Test with success value
|
||||
successOp := F.Pipe2(
|
||||
Of(42),
|
||||
TapSLog[int]("Success value"),
|
||||
Map(N.Mul(2)),
|
||||
)
|
||||
|
||||
successRes := successOp(t.Context())()
|
||||
assert.Equal(t, result.Of(84), successRes)
|
||||
|
||||
// Test with error value
|
||||
testErr := errors.New("test error")
|
||||
errorOp := F.Pipe2(
|
||||
Left[int](testErr),
|
||||
TapSLog[int]("Error value"),
|
||||
Map(N.Mul(2)),
|
||||
)
|
||||
|
||||
errorRes := errorOp(t.Context())()
|
||||
assert.True(t, result.IsLeft(errorRes))
|
||||
|
||||
// Verify the error is preserved
|
||||
_, err := result.Unwrap(errorRes)
|
||||
assert.Equal(t, testErr, err)
|
||||
}
|
||||
|
||||
// TestTapSLogChainBehavior tests that TapSLog properly chains with other operations
|
||||
func TestTapSLogChainBehavior(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
oldLogger := logging.SetLogger(logger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
// Create a pipeline with multiple TapSLog calls
|
||||
step1 := F.Pipe2(
|
||||
Of(1),
|
||||
TapSLog[int]("Step 1"),
|
||||
Map(N.Mul(2)),
|
||||
)
|
||||
|
||||
step2 := F.Pipe2(
|
||||
step1,
|
||||
TapSLog[int]("Step 2"),
|
||||
Map(N.Mul(3)),
|
||||
)
|
||||
|
||||
pipeline := F.Pipe1(
|
||||
step2,
|
||||
TapSLog[int]("Step 3"),
|
||||
)
|
||||
|
||||
res := pipeline(t.Context())()
|
||||
assert.Equal(t, result.Of(6), res)
|
||||
|
||||
logOutput := buf.String()
|
||||
|
||||
// Verify all steps were logged
|
||||
assert.Contains(t, logOutput, "Step 1")
|
||||
assert.Contains(t, logOutput, "value=1")
|
||||
assert.Contains(t, logOutput, "Step 2")
|
||||
assert.Contains(t, logOutput, "value=2")
|
||||
assert.Contains(t, logOutput, "Step 3")
|
||||
assert.Contains(t, logOutput, "value=6")
|
||||
}
|
||||
|
||||
// TestTapSLogWithNilValue tests TapSLog with nil pointer values
|
||||
func TestTapSLogWithNilValue(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
oldLogger := logging.SetLogger(logger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
type Data struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
// Test with nil pointer
|
||||
var nilData *Data
|
||||
operation := F.Pipe1(
|
||||
Of(nilData),
|
||||
TapSLog[*Data]("Nil pointer value"),
|
||||
)
|
||||
|
||||
res := operation(t.Context())()
|
||||
assert.True(t, result.IsRight(res))
|
||||
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Nil pointer value")
|
||||
// The exact representation of nil may vary, but it should be logged
|
||||
assert.NotEmpty(t, logOutput)
|
||||
}
|
||||
|
||||
// TestTapSLogLogsErrors verifies that TapSLog DOES log errors
|
||||
// TapSLog uses SLog internally, which logs both success values and errors
|
||||
func TestTapSLogLogsErrors(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
oldLogger := logging.SetLogger(logger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
testErr := errors.New("test error message")
|
||||
pipeline := F.Pipe2(
|
||||
Left[int](testErr),
|
||||
TapSLog[int]("Error logging test"),
|
||||
Map(N.Mul(2)),
|
||||
)
|
||||
|
||||
res := pipeline(t.Context())()
|
||||
|
||||
// Verify the error is preserved
|
||||
assert.True(t, result.IsLeft(res))
|
||||
|
||||
// Verify logging occurred for the error
|
||||
logOutput := buf.String()
|
||||
assert.NotEmpty(t, logOutput, "TapSLog should log when the Result is an error")
|
||||
assert.Contains(t, logOutput, "Error logging test")
|
||||
assert.Contains(t, logOutput, "error")
|
||||
assert.Contains(t, logOutput, "test error message")
|
||||
}
|
||||
|
||||
@@ -78,6 +78,26 @@ func Contramap[A any](f pair.Kleisli[context.CancelFunc, context.Context, contex
|
||||
return Local[A](f)
|
||||
}
|
||||
|
||||
// ContramapIOK changes the context during the execution of a ReaderIOResult using an IO effect.
|
||||
// This is the contravariant functor operation with IO effects.
|
||||
//
|
||||
// ContramapIOK is an alias for LocalIOK and is useful for adapting a ReaderIOResult to work with
|
||||
// a modified context when the transformation itself requires side effects.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The success type (unchanged)
|
||||
//
|
||||
// Parameters:
|
||||
// - f: An IO Kleisli arrow that transforms the context with side effects
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that takes a ReaderIOResult[A] and returns a ReaderIOResult[A]
|
||||
//
|
||||
// See Also:
|
||||
// - Contramap: For pure context transformations
|
||||
// - LocalIOK: The underlying implementation
|
||||
//
|
||||
//go:inline
|
||||
func ContramapIOK[A any](f io.Kleisli[context.Context, ContextCancel]) Operator[A, A] {
|
||||
return LocalIOK[A](f)
|
||||
}
|
||||
@@ -189,8 +209,6 @@ func LocalIOK[A any](f io.Kleisli[context.Context, ContextCancel]) Operator[A, A
|
||||
//
|
||||
// - Local: For pure context transformations
|
||||
// - LocalIOK: For context transformations with side effects that cannot fail
|
||||
//
|
||||
//go:inline
|
||||
func LocalIOResultK[A any](f ioresult.Kleisli[context.Context, ContextCancel]) Operator[A, A] {
|
||||
return func(rr ReaderIOResult[A]) ReaderIOResult[A] {
|
||||
return func(ctx context.Context) IOResult[A] {
|
||||
|
||||
@@ -77,6 +77,105 @@ func TestContramapBasic(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestContramapIOK tests ContramapIOK functionality
|
||||
func TestContramapIOK(t *testing.T) {
|
||||
t.Run("transforms context with IO effect", func(t *testing.T) {
|
||||
getValue := func(ctx context.Context) IOResult[string] {
|
||||
return func() R.Result[string] {
|
||||
if v := ctx.Value("requestID"); v != nil {
|
||||
return R.Of(v.(string))
|
||||
}
|
||||
return R.Of("no-id")
|
||||
}
|
||||
}
|
||||
|
||||
addRequestID := func(ctx context.Context) io.IO[ContextCancel] {
|
||||
return func() ContextCancel {
|
||||
// Simulate generating a request ID via side effect
|
||||
requestID := "req-12345"
|
||||
newCtx := context.WithValue(ctx, "requestID", requestID)
|
||||
return pair.MakePair(context.CancelFunc(func() {}), newCtx)
|
||||
}
|
||||
}
|
||||
|
||||
adapted := ContramapIOK[string](addRequestID)(getValue)
|
||||
result := adapted(t.Context())()
|
||||
|
||||
assert.Equal(t, R.Of("req-12345"), result)
|
||||
})
|
||||
|
||||
t.Run("preserves value type", func(t *testing.T) {
|
||||
getValue := func(ctx context.Context) IOResult[int] {
|
||||
return func() R.Result[int] {
|
||||
if v := ctx.Value("counter"); v != nil {
|
||||
return R.Of(v.(int))
|
||||
}
|
||||
return R.Of(0)
|
||||
}
|
||||
}
|
||||
|
||||
addCounter := func(ctx context.Context) io.IO[ContextCancel] {
|
||||
return func() ContextCancel {
|
||||
newCtx := context.WithValue(ctx, "counter", 999)
|
||||
return pair.MakePair(context.CancelFunc(func() {}), newCtx)
|
||||
}
|
||||
}
|
||||
|
||||
adapted := ContramapIOK[int](addCounter)(getValue)
|
||||
result := adapted(t.Context())()
|
||||
|
||||
assert.Equal(t, R.Of(999), result)
|
||||
})
|
||||
|
||||
t.Run("calls cancel function", func(t *testing.T) {
|
||||
cancelCalled := false
|
||||
|
||||
getValue := func(ctx context.Context) IOResult[string] {
|
||||
return func() R.Result[string] {
|
||||
return R.Of("test")
|
||||
}
|
||||
}
|
||||
|
||||
addData := func(ctx context.Context) io.IO[ContextCancel] {
|
||||
return func() ContextCancel {
|
||||
newCtx := context.WithValue(ctx, "data", "value")
|
||||
cancelFunc := context.CancelFunc(func() {
|
||||
cancelCalled = true
|
||||
})
|
||||
return pair.MakePair(cancelFunc, newCtx)
|
||||
}
|
||||
}
|
||||
|
||||
adapted := ContramapIOK[string](addData)(getValue)
|
||||
_ = adapted(t.Context())()
|
||||
|
||||
assert.True(t, cancelCalled, "cancel function should be called")
|
||||
})
|
||||
|
||||
t.Run("handles cancelled context", func(t *testing.T) {
|
||||
getValue := func(ctx context.Context) IOResult[string] {
|
||||
return func() R.Result[string] {
|
||||
return R.Of("should not reach here")
|
||||
}
|
||||
}
|
||||
|
||||
addData := func(ctx context.Context) io.IO[ContextCancel] {
|
||||
return func() ContextCancel {
|
||||
newCtx := context.WithValue(ctx, "data", "value")
|
||||
return pair.MakePair(context.CancelFunc(func() {}), newCtx)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
adapted := ContramapIOK[string](addData)(getValue)
|
||||
result := adapted(ctx)()
|
||||
|
||||
assert.True(t, R.IsLeft(result))
|
||||
})
|
||||
}
|
||||
|
||||
// TestLocalBasic tests basic Local functionality
|
||||
func TestLocalBasic(t *testing.T) {
|
||||
t.Run("adds value to context", func(t *testing.T) {
|
||||
|
||||
@@ -102,6 +102,29 @@ func LocalIOResultK[A, R1, R2 any](f ioresult.Kleisli[R2, R1]) func(ReaderReader
|
||||
return RRIOE.LocalIOEitherK[context.Context, A](f)
|
||||
}
|
||||
|
||||
// LocalResultK transforms the outer environment of a ReaderReaderIOResult using a Result-based Kleisli arrow.
|
||||
// It allows you to modify the outer environment through a pure computation that can fail before
|
||||
// passing it to the ReaderReaderIOResult.
|
||||
//
|
||||
// This is useful when the outer environment transformation is a pure computation that can fail,
|
||||
// such as parsing, validation, or data transformation that doesn't require IO effects.
|
||||
//
|
||||
// The transformation happens in two stages:
|
||||
// 1. The Result function f is executed with the R2 environment to produce Result[R1]
|
||||
// 2. If successful (Ok), the R1 value is passed as the outer environment to the ReaderReaderIOResult[R1, A]
|
||||
// 3. If failed (Err), the error is propagated without executing the ReaderReaderIOResult
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The success type produced by the ReaderReaderIOResult
|
||||
// - R1: The original outer environment type expected by the ReaderReaderIOResult
|
||||
// - R2: The new input outer environment type
|
||||
//
|
||||
// Parameters:
|
||||
// - f: A Result Kleisli arrow that transforms R2 to R1 with pure computation that can fail
|
||||
//
|
||||
// Returns:
|
||||
// - A function that takes a ReaderReaderIOResult[R1, A] and returns a ReaderReaderIOResult[R2, A]
|
||||
//
|
||||
//go:inline
|
||||
func LocalResultK[A, R1, R2 any](f result.Kleisli[R2, R1]) func(ReaderReaderIOResult[R1, A]) ReaderReaderIOResult[R2, A] {
|
||||
return RRIOE.LocalEitherK[context.Context, A](f)
|
||||
@@ -162,6 +185,31 @@ func LocalReaderIOResultK[A, R1, R2 any](f readerioresult.Kleisli[R2, R1]) func(
|
||||
return RRIOE.LocalReaderIOEitherK[A](f)
|
||||
}
|
||||
|
||||
// LocalReaderReaderIOEitherK transforms the outer environment of a ReaderReaderIOResult using a ReaderReaderIOResult-based Kleisli arrow.
|
||||
// It allows you to modify the outer environment through a computation that depends on both the outer environment
|
||||
// and the inner context, and can perform IO effects that may fail.
|
||||
//
|
||||
// This is the most powerful Local variant, useful when the outer environment transformation requires:
|
||||
// - Access to both the outer environment (R2) and inner context (context.Context)
|
||||
// - IO operations that can fail
|
||||
// - Complex transformations that need the full computational context
|
||||
//
|
||||
// The transformation happens in three stages:
|
||||
// 1. The ReaderReaderIOResult effect f is executed with the R2 outer environment and inner context
|
||||
// 2. If successful (Ok), the R1 value is passed as the outer environment to the ReaderReaderIOResult[R1, A]
|
||||
// 3. If failed (Err), the error is propagated without executing the ReaderReaderIOResult
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The success type produced by the ReaderReaderIOResult
|
||||
// - R1: The original outer environment type expected by the ReaderReaderIOResult
|
||||
// - R2: The new input outer environment type
|
||||
//
|
||||
// Parameters:
|
||||
// - f: A ReaderReaderIOResult Kleisli arrow that transforms R2 to R1 with full context-aware IO effects that can fail
|
||||
//
|
||||
// Returns:
|
||||
// - A function that takes a ReaderReaderIOResult[R1, A] and returns a ReaderReaderIOResult[R2, A]
|
||||
//
|
||||
//go:inline
|
||||
func LocalReaderReaderIOEitherK[A, R1, R2 any](f Kleisli[R2, R2, R1]) func(ReaderReaderIOResult[R1, A]) ReaderReaderIOResult[R2, A] {
|
||||
return RRIOE.LocalReaderReaderIOEitherK[A](f)
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"github.com/IBM/fp-go/v2/logging"
|
||||
N "github.com/IBM/fp-go/v2/number"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -104,7 +105,8 @@ func TestSLogWithContextLogger(t *testing.T) {
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
|
||||
ctx := logging.WithLogger(contextLogger)(t.Context())
|
||||
cancelFct, ctx := pair.Unpack(logging.WithLogger(contextLogger)(t.Context()))
|
||||
defer cancelFct()
|
||||
|
||||
res1 := result.Of("test value")
|
||||
logged := SLog[string]("Context logger test")(res1)(ctx)
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
RIORES "github.com/IBM/fp-go/v2/context/readerioresult"
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/internal/statet"
|
||||
RIOR "github.com/IBM/fp-go/v2/readerioresult"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
)
|
||||
|
||||
@@ -202,21 +202,38 @@ func FromResult[S, A any](ma Result[A]) StateReaderIOResult[S, A] {
|
||||
// Combinators
|
||||
|
||||
// Local runs a computation with a modified context.
|
||||
// The function f transforms the context before passing it to the computation.
|
||||
// The function f transforms the context before passing it to the computation,
|
||||
// returning both a new context and a CancelFunc that should be called to release resources.
|
||||
//
|
||||
// This is useful for:
|
||||
// - Adding values to the context
|
||||
// - Setting timeouts or deadlines
|
||||
// - Modifying context metadata
|
||||
//
|
||||
// The CancelFunc is automatically called after the computation completes to ensure proper cleanup.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - S: The state type
|
||||
// - A: The result type
|
||||
//
|
||||
// Parameters:
|
||||
// - f: Function to transform the context, returning a new context and CancelFunc
|
||||
//
|
||||
// Returns:
|
||||
// - A function that takes a StateReaderIOResult[S, A] and returns a StateReaderIOResult[S, A]
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Modify context before running computation
|
||||
// withTimeout := statereaderioresult.Local[AppState](
|
||||
// func(ctx context.Context) context.Context {
|
||||
// ctx, _ = context.WithTimeout(ctx, 60*time.Second)
|
||||
// return ctx
|
||||
// }
|
||||
// // Add a timeout to a specific operation
|
||||
// withTimeout := statereaderioresult.Local[AppState, Data](
|
||||
// func(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
// return context.WithTimeout(ctx, 60*time.Second)
|
||||
// },
|
||||
// )
|
||||
// result := withTimeout(computation)
|
||||
func Local[S, A any](f func(context.Context) context.Context) func(StateReaderIOResult[S, A]) StateReaderIOResult[S, A] {
|
||||
func Local[S, A any](f pair.Kleisli[context.CancelFunc, context.Context, context.Context]) Operator[S, A, A] {
|
||||
return func(ma StateReaderIOResult[S, A]) StateReaderIOResult[S, A] {
|
||||
return function.Flow2(ma, RIOR.Local[Pair[S, A]](f))
|
||||
return function.Flow2(ma, RIORES.Local[Pair[S, A]](f))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
IOR "github.com/IBM/fp-go/v2/ioresult"
|
||||
N "github.com/IBM/fp-go/v2/number"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
P "github.com/IBM/fp-go/v2/pair"
|
||||
RES "github.com/IBM/fp-go/v2/result"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -264,8 +265,8 @@ func TestLocal(t *testing.T) {
|
||||
|
||||
// Modify context before running computation
|
||||
result := Local[testState, string](
|
||||
func(c context.Context) context.Context {
|
||||
return context.WithValue(c, "key", "value2")
|
||||
func(c context.Context) ContextCancel {
|
||||
return pair.MakePair[context.CancelFunc](func() {}, context.WithValue(c, "key", "value2"))
|
||||
},
|
||||
)(comp)
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
package statereaderioresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
RIORES "github.com/IBM/fp-go/v2/context/readerioresult"
|
||||
"github.com/IBM/fp-go/v2/endomorphism"
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
@@ -84,4 +86,11 @@ type (
|
||||
Operator[S, A, B any] = Reader[StateReaderIOResult[S, A], StateReaderIOResult[S, B]]
|
||||
|
||||
Predicate[A any] = predicate.Predicate[A]
|
||||
|
||||
// ContextCancel represents a pair of a cancel function and a context.
|
||||
// It is used in operations that create new contexts with cancellation capabilities.
|
||||
//
|
||||
// The first element is the CancelFunc that should be called to release resources.
|
||||
// The second element is the new Context that was created.
|
||||
ContextCancel = Pair[context.CancelFunc, context.Context]
|
||||
)
|
||||
|
||||
@@ -23,6 +23,8 @@ import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
)
|
||||
|
||||
// LoggingCallbacks creates a pair of logging callback functions from the provided loggers.
|
||||
@@ -128,6 +130,7 @@ var loggerInContextKey loggerInContextType
|
||||
// logger.Info("Processing request")
|
||||
// }
|
||||
func GetLoggerFromContext(ctx context.Context) *slog.Logger {
|
||||
// using idomatic style to avoid import cycle
|
||||
value, ok := ctx.Value(loggerInContextKey).(*slog.Logger)
|
||||
if !ok {
|
||||
return globalLogger.Load()
|
||||
@@ -135,9 +138,11 @@ func GetLoggerFromContext(ctx context.Context) *slog.Logger {
|
||||
return value
|
||||
}
|
||||
|
||||
// WithLogger returns an endomorphism that adds a logger to a context.
|
||||
// An endomorphism is a function that takes a value and returns a value of the same type.
|
||||
// This function creates a context transformation that embeds the provided logger.
|
||||
func noop() {}
|
||||
|
||||
// WithLogger returns a Kleisli arrow that adds a logger to a context.
|
||||
// A Kleisli arrow transforms a context into a ContextCancel pair containing
|
||||
// a no-op cancel function and the new context with the embedded logger.
|
||||
//
|
||||
// This is particularly useful in functional programming patterns where you want to
|
||||
// compose context transformations, or when working with middleware that needs to
|
||||
@@ -147,7 +152,7 @@ func GetLoggerFromContext(ctx context.Context) *slog.Logger {
|
||||
// - l: The *slog.Logger to embed in the context
|
||||
//
|
||||
// Returns:
|
||||
// - An Endomorphism[context.Context] function that adds the logger to a context
|
||||
// - A Kleisli arrow (function from context.Context to ContextCancel) that adds the logger to a context
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
@@ -156,13 +161,14 @@ func GetLoggerFromContext(ctx context.Context) *slog.Logger {
|
||||
//
|
||||
// // Apply it to a context
|
||||
// ctx := context.Background()
|
||||
// ctxWithLogger := addLogger(ctx)
|
||||
// result := addLogger(ctx)
|
||||
// ctxWithLogger := pair.Second(result)
|
||||
//
|
||||
// // Retrieve the logger later
|
||||
// logger := GetLoggerFromContext(ctxWithLogger)
|
||||
// logger.Info("Using context logger")
|
||||
func WithLogger(l *slog.Logger) Endomorphism[context.Context] {
|
||||
return func(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, loggerInContextKey, l)
|
||||
func WithLogger(l *slog.Logger) pair.Kleisli[context.CancelFunc, context.Context, context.Context] {
|
||||
return func(ctx context.Context) ContextCancel {
|
||||
return pair.MakePair[context.CancelFunc](noop, context.WithValue(ctx, loggerInContextKey, l))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,13 @@ package logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
S "github.com/IBM/fp-go/v2/string"
|
||||
)
|
||||
|
||||
@@ -288,3 +291,355 @@ func BenchmarkLoggingCallbacks_Logging(b *testing.B) {
|
||||
infoLog("benchmark message %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetLogger_Success tests setting a new global logger and verifying it returns the old one.
|
||||
func TestSetLogger_Success(t *testing.T) {
|
||||
// Save original logger to restore later
|
||||
originalLogger := GetLogger()
|
||||
defer SetLogger(originalLogger)
|
||||
|
||||
// Create a new logger
|
||||
var buf bytes.Buffer
|
||||
handler := slog.NewTextHandler(&buf, nil)
|
||||
newLogger := slog.New(handler)
|
||||
|
||||
// Set the new logger
|
||||
oldLogger := SetLogger(newLogger)
|
||||
|
||||
// Verify old logger was returned
|
||||
if oldLogger == nil {
|
||||
t.Error("Expected SetLogger to return the previous logger")
|
||||
}
|
||||
|
||||
// Verify new logger is now active
|
||||
currentLogger := GetLogger()
|
||||
if currentLogger != newLogger {
|
||||
t.Error("Expected GetLogger to return the newly set logger")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetLogger_Multiple tests setting logger multiple times.
|
||||
func TestSetLogger_Multiple(t *testing.T) {
|
||||
// Save original logger to restore later
|
||||
originalLogger := GetLogger()
|
||||
defer SetLogger(originalLogger)
|
||||
|
||||
// Create three loggers
|
||||
logger1 := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
|
||||
logger2 := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
|
||||
logger3 := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
|
||||
|
||||
// Set first logger
|
||||
old1 := SetLogger(logger1)
|
||||
if GetLogger() != logger1 {
|
||||
t.Error("Expected logger1 to be active")
|
||||
}
|
||||
|
||||
// Set second logger
|
||||
old2 := SetLogger(logger2)
|
||||
if old2 != logger1 {
|
||||
t.Error("Expected SetLogger to return logger1")
|
||||
}
|
||||
if GetLogger() != logger2 {
|
||||
t.Error("Expected logger2 to be active")
|
||||
}
|
||||
|
||||
// Set third logger
|
||||
old3 := SetLogger(logger3)
|
||||
if old3 != logger2 {
|
||||
t.Error("Expected SetLogger to return logger2")
|
||||
}
|
||||
if GetLogger() != logger3 {
|
||||
t.Error("Expected logger3 to be active")
|
||||
}
|
||||
|
||||
// Restore to original
|
||||
restored := SetLogger(old1)
|
||||
if restored != logger3 {
|
||||
t.Error("Expected SetLogger to return logger3")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetLogger_Default tests that GetLogger returns a valid logger by default.
|
||||
func TestGetLogger_Default(t *testing.T) {
|
||||
logger := GetLogger()
|
||||
|
||||
if logger == nil {
|
||||
t.Error("Expected GetLogger to return a non-nil logger")
|
||||
}
|
||||
|
||||
// Verify it's usable
|
||||
var buf bytes.Buffer
|
||||
handler := slog.NewTextHandler(&buf, nil)
|
||||
testLogger := slog.New(handler)
|
||||
|
||||
oldLogger := SetLogger(testLogger)
|
||||
defer SetLogger(oldLogger)
|
||||
|
||||
GetLogger().Info("test message")
|
||||
if !strings.Contains(buf.String(), "test message") {
|
||||
t.Errorf("Expected logger to log message, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetLogger_AfterSet tests that GetLogger returns the logger set by SetLogger.
|
||||
func TestGetLogger_AfterSet(t *testing.T) {
|
||||
originalLogger := GetLogger()
|
||||
defer SetLogger(originalLogger)
|
||||
|
||||
var buf bytes.Buffer
|
||||
handler := slog.NewTextHandler(&buf, nil)
|
||||
customLogger := slog.New(handler)
|
||||
|
||||
SetLogger(customLogger)
|
||||
|
||||
retrievedLogger := GetLogger()
|
||||
if retrievedLogger != customLogger {
|
||||
t.Error("Expected GetLogger to return the custom logger")
|
||||
}
|
||||
|
||||
// Verify it's the same instance by logging
|
||||
retrievedLogger.Info("test")
|
||||
if !strings.Contains(buf.String(), "test") {
|
||||
t.Error("Expected retrieved logger to be the same instance")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetLoggerFromContext_WithLogger tests retrieving a logger from context.
|
||||
func TestGetLoggerFromContext_WithLogger(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
handler := slog.NewTextHandler(&buf, nil)
|
||||
contextLogger := slog.New(handler)
|
||||
|
||||
// Create context with logger using WithLogger
|
||||
ctx := context.Background()
|
||||
kleisli := WithLogger(contextLogger)
|
||||
result := kleisli(ctx)
|
||||
ctxWithLogger := pair.Second(result)
|
||||
|
||||
// Retrieve logger from context
|
||||
retrievedLogger := GetLoggerFromContext(ctxWithLogger)
|
||||
|
||||
if retrievedLogger != contextLogger {
|
||||
t.Error("Expected to retrieve the context logger")
|
||||
}
|
||||
|
||||
// Verify it's the same instance by logging
|
||||
retrievedLogger.Info("context test")
|
||||
if !strings.Contains(buf.String(), "context test") {
|
||||
t.Error("Expected retrieved logger to be the same instance")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetLoggerFromContext_WithoutLogger tests that it returns global logger when context has no logger.
|
||||
func TestGetLoggerFromContext_WithoutLogger(t *testing.T) {
|
||||
originalLogger := GetLogger()
|
||||
defer SetLogger(originalLogger)
|
||||
|
||||
var buf bytes.Buffer
|
||||
handler := slog.NewTextHandler(&buf, nil)
|
||||
globalLogger := slog.New(handler)
|
||||
SetLogger(globalLogger)
|
||||
|
||||
// Create context without logger
|
||||
ctx := context.Background()
|
||||
|
||||
// Should return global logger
|
||||
retrievedLogger := GetLoggerFromContext(ctx)
|
||||
|
||||
if retrievedLogger != globalLogger {
|
||||
t.Error("Expected to retrieve the global logger when context has no logger")
|
||||
}
|
||||
|
||||
// Verify it's the same instance
|
||||
retrievedLogger.Info("global test")
|
||||
if !strings.Contains(buf.String(), "global test") {
|
||||
t.Error("Expected retrieved logger to be the global logger")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetLoggerFromContext_NilContext tests behavior with nil context value.
|
||||
func TestGetLoggerFromContext_NilContext(t *testing.T) {
|
||||
originalLogger := GetLogger()
|
||||
defer SetLogger(originalLogger)
|
||||
|
||||
var buf bytes.Buffer
|
||||
handler := slog.NewTextHandler(&buf, nil)
|
||||
globalLogger := slog.New(handler)
|
||||
SetLogger(globalLogger)
|
||||
|
||||
// Create context with wrong type value
|
||||
ctx := context.WithValue(context.Background(), loggerInContextKey, "not a logger")
|
||||
|
||||
// Should return global logger when type assertion fails
|
||||
retrievedLogger := GetLoggerFromContext(ctx)
|
||||
|
||||
if retrievedLogger != globalLogger {
|
||||
t.Error("Expected to retrieve the global logger when context value is wrong type")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithLogger_CreatesContextWithLogger tests that WithLogger adds logger to context.
|
||||
func TestWithLogger_CreatesContextWithLogger(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
handler := slog.NewTextHandler(&buf, nil)
|
||||
testLogger := slog.New(handler)
|
||||
|
||||
// Create Kleisli arrow
|
||||
kleisli := WithLogger(testLogger)
|
||||
|
||||
// Apply to context
|
||||
ctx := context.Background()
|
||||
result := kleisli(ctx)
|
||||
|
||||
// Verify result is a ContextCancel pair
|
||||
cancelFunc := pair.First(result)
|
||||
newCtx := pair.Second(result)
|
||||
|
||||
if cancelFunc == nil {
|
||||
t.Error("Expected cancel function to be non-nil")
|
||||
}
|
||||
|
||||
if newCtx == nil {
|
||||
t.Error("Expected new context to be non-nil")
|
||||
}
|
||||
|
||||
// Verify logger is in context
|
||||
retrievedLogger := GetLoggerFromContext(newCtx)
|
||||
if retrievedLogger != testLogger {
|
||||
t.Error("Expected logger to be in the new context")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithLogger_CancelFuncIsNoop tests that the cancel function is a no-op.
|
||||
func TestWithLogger_CancelFuncIsNoop(t *testing.T) {
|
||||
testLogger := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
|
||||
kleisli := WithLogger(testLogger)
|
||||
|
||||
ctx := context.Background()
|
||||
result := kleisli(ctx)
|
||||
cancelFunc := pair.First(result)
|
||||
|
||||
// Calling cancel should not panic
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Cancel function panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
cancelFunc()
|
||||
}
|
||||
|
||||
// TestWithLogger_PreservesOriginalContext tests that original context is not modified.
|
||||
func TestWithLogger_PreservesOriginalContext(t *testing.T) {
|
||||
originalLogger := GetLogger()
|
||||
defer SetLogger(originalLogger)
|
||||
|
||||
var buf bytes.Buffer
|
||||
handler := slog.NewTextHandler(&buf, nil)
|
||||
globalLogger := slog.New(handler)
|
||||
SetLogger(globalLogger)
|
||||
|
||||
testLogger := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
|
||||
kleisli := WithLogger(testLogger)
|
||||
|
||||
// Original context without logger
|
||||
originalCtx := context.Background()
|
||||
|
||||
// Apply transformation
|
||||
result := kleisli(originalCtx)
|
||||
newCtx := pair.Second(result)
|
||||
|
||||
// Original context should still return global logger
|
||||
originalCtxLogger := GetLoggerFromContext(originalCtx)
|
||||
if originalCtxLogger != globalLogger {
|
||||
t.Error("Expected original context to still use global logger")
|
||||
}
|
||||
|
||||
// New context should have the test logger
|
||||
newCtxLogger := GetLoggerFromContext(newCtx)
|
||||
if newCtxLogger != testLogger {
|
||||
t.Error("Expected new context to have the test logger")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithLogger_Composition tests composing multiple WithLogger calls.
|
||||
func TestWithLogger_Composition(t *testing.T) {
|
||||
logger1 := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
|
||||
logger2 := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
|
||||
|
||||
kleisli1 := WithLogger(logger1)
|
||||
kleisli2 := WithLogger(logger2)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Apply first transformation
|
||||
result1 := kleisli1(ctx)
|
||||
ctx1 := pair.Second(result1)
|
||||
|
||||
// Verify first logger
|
||||
if GetLoggerFromContext(ctx1) != logger1 {
|
||||
t.Error("Expected first logger in context after first transformation")
|
||||
}
|
||||
|
||||
// Apply second transformation (should override)
|
||||
result2 := kleisli2(ctx1)
|
||||
ctx2 := pair.Second(result2)
|
||||
|
||||
// Verify second logger (should override first)
|
||||
if GetLoggerFromContext(ctx2) != logger2 {
|
||||
t.Error("Expected second logger to override first logger")
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSetLogger benchmarks setting the global logger.
|
||||
func BenchmarkSetLogger(b *testing.B) {
|
||||
logger := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
SetLogger(logger)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkGetLogger benchmarks getting the global logger.
|
||||
func BenchmarkGetLogger(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
GetLogger()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkGetLoggerFromContext_WithLogger benchmarks retrieving logger from context.
|
||||
func BenchmarkGetLoggerFromContext_WithLogger(b *testing.B) {
|
||||
logger := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
|
||||
kleisli := WithLogger(logger)
|
||||
ctx := pair.Second(kleisli(context.Background()))
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
GetLoggerFromContext(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkGetLoggerFromContext_WithoutLogger benchmarks retrieving global logger from context.
|
||||
func BenchmarkGetLoggerFromContext_WithoutLogger(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
GetLoggerFromContext(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkWithLogger benchmarks creating context with logger.
|
||||
func BenchmarkWithLogger(b *testing.B) {
|
||||
logger := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
|
||||
kleisli := WithLogger(logger)
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
kleisli(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/IBM/fp-go/v2/endomorphism"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -39,4 +42,15 @@ type (
|
||||
// ctx := context.Background()
|
||||
// newCtx := addLogger(ctx) // Both ctx and newCtx are context.Context
|
||||
Endomorphism[A any] = endomorphism.Endomorphism[A]
|
||||
|
||||
// Pair represents a tuple of two values of types A and B.
|
||||
// It is used to group two related values together.
|
||||
Pair[A, B any] = pair.Pair[A, B]
|
||||
|
||||
// ContextCancel represents a pair of a cancel function and a context.
|
||||
// It is used in operations that create new contexts with cancellation capabilities.
|
||||
//
|
||||
// The first element is the CancelFunc that should be called to release resources.
|
||||
// The second element is the new Context that was created.
|
||||
ContextCancel = Pair[context.CancelFunc, context.Context]
|
||||
)
|
||||
|
||||
@@ -26,6 +26,83 @@ import (
|
||||
"github.com/IBM/fp-go/v2/semigroup"
|
||||
)
|
||||
|
||||
// Do creates the initial empty codec to be used as the starting point for
|
||||
// do-notation style codec construction.
|
||||
//
|
||||
// This is the entry point for building up a struct codec field-by-field using
|
||||
// the applicative and monadic sequencing operators ApSL, ApSO, and Bind.
|
||||
// It wraps Empty and lifts a lazily-evaluated default Pair[O, A] into a
|
||||
// Type[A, O, I] that ignores its input and always succeeds with the default value.
|
||||
//
|
||||
// # Type Parameters
|
||||
//
|
||||
// - I: The input type for decoding (what the codec reads from)
|
||||
// - A: The target struct type being built up (what the codec decodes to)
|
||||
// - O: The output type for encoding (what the codec writes to)
|
||||
//
|
||||
// # Parameters
|
||||
//
|
||||
// - e: A Lazy[Pair[O, A]] providing the initial default values:
|
||||
// - pair.Head(e()): The default encoded output O (e.g. an empty monoid value)
|
||||
// - pair.Tail(e()): The initial zero value of the struct A (e.g. MyStruct{})
|
||||
//
|
||||
// # Returns
|
||||
//
|
||||
// - A Type[A, O, I] that always decodes to the default A and encodes to the
|
||||
// default O, regardless of input. This is then transformed by chaining
|
||||
// ApSL, ApSO, or Bind operators to add fields one by one.
|
||||
//
|
||||
// # Example Usage
|
||||
//
|
||||
// Building a struct codec using do-notation style:
|
||||
//
|
||||
// import (
|
||||
// "github.com/IBM/fp-go/v2/function"
|
||||
// "github.com/IBM/fp-go/v2/lazy"
|
||||
// "github.com/IBM/fp-go/v2/optics/codec"
|
||||
// "github.com/IBM/fp-go/v2/optics/lens"
|
||||
// "github.com/IBM/fp-go/v2/pair"
|
||||
// S "github.com/IBM/fp-go/v2/string"
|
||||
// )
|
||||
//
|
||||
// type Person struct {
|
||||
// Name string
|
||||
// Age int
|
||||
// }
|
||||
//
|
||||
// nameLens := lens.MakeLens(
|
||||
// func(p Person) string { return p.Name },
|
||||
// func(p Person, name string) Person { p.Name = name; return p },
|
||||
// )
|
||||
// ageLens := lens.MakeLens(
|
||||
// func(p Person) int { return p.Age },
|
||||
// func(p Person, age int) Person { p.Age = age; return p },
|
||||
// )
|
||||
//
|
||||
// personCodec := F.Pipe2(
|
||||
// codec.Do[any, Person, string](lazy.Of(pair.MakePair("", Person{}))),
|
||||
// codec.ApSL(S.Monoid, nameLens, codec.String()),
|
||||
// codec.ApSL(S.Monoid, ageLens, codec.Int()),
|
||||
// )
|
||||
//
|
||||
// # Notes
|
||||
//
|
||||
// - Do is typically the first call in a codec pipeline, followed by ApSL, ApSO, or Bind
|
||||
// - The lazy pair should use the monoid's empty value for O and the zero value for A
|
||||
// - For convenience, use Struct to create the initial codec for named struct types
|
||||
//
|
||||
// # See Also
|
||||
//
|
||||
// - Empty: The underlying codec constructor that Do delegates to
|
||||
// - ApSL: Applicative sequencing for required struct fields via Lens
|
||||
// - ApSO: Applicative sequencing for optional struct fields via Optional
|
||||
// - Bind: Monadic sequencing for context-dependent field codecs
|
||||
//
|
||||
//go:inline
|
||||
func Do[I, A, O any](e Lazy[Pair[O, A]]) Type[A, O, I] {
|
||||
return Empty[I](e)
|
||||
}
|
||||
|
||||
// ApSL creates an applicative sequencing operator for codecs using a lens.
|
||||
//
|
||||
// This function implements the "ApS" (Applicative Sequencing) pattern for codecs,
|
||||
@@ -297,3 +374,132 @@ func ApSO[S, T, O, I any](
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bind creates a monadic sequencing operator for codecs using a lens and a Kleisli arrow.
|
||||
//
|
||||
// This function implements the "Bind" (monadic bind / chain) pattern for codecs,
|
||||
// allowing you to build up complex codecs where the codec for a field depends on
|
||||
// the current decoded value of the struct. Unlike ApSL which uses a fixed field
|
||||
// codec, Bind accepts a Kleisli arrow — a function from the current struct value S
|
||||
// to a Type[T, O, I] — enabling context-sensitive codec construction.
|
||||
//
|
||||
// The function combines:
|
||||
// - Encoding: Evaluates the Kleisli arrow f on the current struct value s to obtain
|
||||
// the field codec, extracts the field T using the lens, encodes it with that codec,
|
||||
// and combines it with the base encoding using the monoid.
|
||||
// - Validation: Validates the base struct first (monadic sequencing), then uses the
|
||||
// Kleisli arrow to obtain the field codec for the decoded struct value, and validates
|
||||
// the field through the lens. Errors are propagated but NOT accumulated (fail-fast
|
||||
// semantics, unlike ApSL which accumulates errors).
|
||||
//
|
||||
// # Type Parameters
|
||||
//
|
||||
// - S: The source struct type (what we're building a codec for)
|
||||
// - T: The field type accessed by the lens
|
||||
// - O: The output type for encoding (must have a monoid)
|
||||
// - I: The input type for decoding
|
||||
//
|
||||
// # Parameters
|
||||
//
|
||||
// - m: A Monoid[O] for combining encoded outputs
|
||||
// - l: A Lens[S, T] that focuses on a specific field in S
|
||||
// - f: A Kleisli[S, T, O, I] — a function from S to Type[T, O, I] — that produces
|
||||
// the field codec based on the current struct value
|
||||
//
|
||||
// # Returns
|
||||
//
|
||||
// An Operator[S, S, O, I] that transforms a base codec by adding the field
|
||||
// specified by the lens, where the field codec is determined by the Kleisli arrow.
|
||||
//
|
||||
// # How It Works
|
||||
//
|
||||
// 1. **Encoding**: When encoding a value of type S:
|
||||
// - Evaluate f(s) to obtain the field codec fa
|
||||
// - Extract the field T using l.Get
|
||||
// - Encode T to O using fa.Encode
|
||||
// - Combine with the base encoding using the monoid
|
||||
//
|
||||
// 2. **Validation**: When validating input I:
|
||||
// - Run the base validation to obtain a decoded S (fail-fast: stop on base failure)
|
||||
// - For the decoded S, evaluate f(s) to obtain the field codec fa
|
||||
// - Validate the input I using fa.Validate
|
||||
// - Set the validated T into S using l.Set
|
||||
//
|
||||
// 3. **Type Checking**: Preserves the base type checker
|
||||
//
|
||||
// # Difference from ApSL
|
||||
//
|
||||
// Unlike ApSL which uses a fixed field codec:
|
||||
// - ApSL: Field codec is fixed at construction time; errors are accumulated
|
||||
// - Bind: Field codec depends on the current struct value (Kleisli arrow); validation
|
||||
// uses monadic sequencing (fail-fast on base failure)
|
||||
// - Bind is more powerful but less parallel than ApSL
|
||||
//
|
||||
// # Example
|
||||
//
|
||||
// import (
|
||||
// "github.com/IBM/fp-go/v2/optics/codec"
|
||||
// "github.com/IBM/fp-go/v2/optics/lens"
|
||||
// S "github.com/IBM/fp-go/v2/string"
|
||||
// )
|
||||
//
|
||||
// type Config struct {
|
||||
// Mode string
|
||||
// Value int
|
||||
// }
|
||||
//
|
||||
// modeLens := lens.MakeLens(
|
||||
// func(c Config) string { return c.Mode },
|
||||
// func(c Config, mode string) Config { c.Mode = mode; return c },
|
||||
// )
|
||||
//
|
||||
// // Build a Config codec where the Value codec depends on the Mode
|
||||
// configCodec := F.Pipe1(
|
||||
// codec.Struct[Config]("Config"),
|
||||
// codec.Bind(S.Monoid, modeLens, func(c Config) codec.Type[string, string, any] {
|
||||
// return codec.String()
|
||||
// }),
|
||||
// )
|
||||
//
|
||||
// # Use Cases
|
||||
//
|
||||
// - Building codecs where a field's codec depends on another field's value
|
||||
// - Implementing discriminated unions or tagged variants
|
||||
// - Context-sensitive validation (e.g., validate field B differently based on field A)
|
||||
// - Dependent type-like patterns in codec construction
|
||||
//
|
||||
// # Notes
|
||||
//
|
||||
// - The monoid determines how encoded outputs are combined
|
||||
// - The lens must be total (handle all cases safely)
|
||||
// - Validation uses monadic (fail-fast) sequencing: if the base codec fails,
|
||||
// the Kleisli arrow is never evaluated
|
||||
// - The name is automatically generated for debugging purposes
|
||||
//
|
||||
// See also:
|
||||
// - ApSL: Applicative sequencing with a fixed lens codec (error accumulation)
|
||||
// - Kleisli: The function type from S to Type[T, O, I]
|
||||
// - validate.Bind: The underlying validate-level bind combinator
|
||||
func Bind[S, T, O, I any](
|
||||
m Monoid[O],
|
||||
l Lens[S, T],
|
||||
f Kleisli[S, T, O, I],
|
||||
) Operator[S, S, O, I] {
|
||||
name := fmt.Sprintf("Bind[%s]", l)
|
||||
val := F.Curry2(Type[T, O, I].Validate)
|
||||
|
||||
return func(t Type[S, O, I]) Type[S, O, I] {
|
||||
|
||||
return MakeType(
|
||||
name,
|
||||
t.Is,
|
||||
F.Pipe1(
|
||||
t.Validate,
|
||||
validate.Bind(l.Set, F.Flow2(f, val)),
|
||||
),
|
||||
func(s S) O {
|
||||
return m.Concat(t.Encode(s), f(s).Encode(l.Get(s)))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,3 +814,588 @@ func TestApSO_ErrorAccumulation(t *testing.T) {
|
||||
assert.NotEmpty(t, errors, "Should have validation errors")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBind_EncodingCombination verifies that Bind combines the base encoding with
|
||||
// the field encoding produced by the Kleisli arrow using the monoid.
|
||||
func TestBind_EncodingCombination(t *testing.T) {
|
||||
t.Run("combines base and field encodings using monoid", func(t *testing.T) {
|
||||
// Lens for Person.Name
|
||||
nameLens := lens.MakeLens(
|
||||
func(p Person) string { return p.Name },
|
||||
func(p Person, name string) Person {
|
||||
return Person{Name: name, Age: p.Age}
|
||||
},
|
||||
)
|
||||
|
||||
// Base codec encodes to "Person:"
|
||||
baseCodec := MakeType(
|
||||
"Person",
|
||||
func(i any) validation.Result[Person] {
|
||||
if p, ok := i.(Person); ok {
|
||||
return validation.ToResult(validation.Success(p))
|
||||
}
|
||||
return validation.ToResult(validation.Failures[Person](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "expected Person"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, Person] {
|
||||
return func(ctx Context) validation.Validation[Person] {
|
||||
if p, ok := i.(Person); ok {
|
||||
return validation.Success(p)
|
||||
}
|
||||
return validation.FailureWithMessage[Person](i, "expected Person")(ctx)
|
||||
}
|
||||
},
|
||||
func(p Person) string { return "Person:" },
|
||||
)
|
||||
|
||||
// Kleisli arrow: always returns a string identity codec regardless of struct value
|
||||
kleisli := func(p Person) Type[string, string, any] {
|
||||
return MakeType(
|
||||
"Name",
|
||||
func(i any) validation.Result[string] {
|
||||
if s, ok := i.(string); ok {
|
||||
return validation.ToResult(validation.Success(s))
|
||||
}
|
||||
return validation.ToResult(validation.Failures[string](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "expected string"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, string] {
|
||||
return func(ctx Context) validation.Validation[string] {
|
||||
if s, ok := i.(string); ok {
|
||||
return validation.Success(s)
|
||||
}
|
||||
return validation.FailureWithMessage[string](i, "expected string")(ctx)
|
||||
}
|
||||
},
|
||||
F.Identity[string],
|
||||
)
|
||||
}
|
||||
|
||||
operator := Bind(S.Monoid, nameLens, kleisli)
|
||||
enhancedCodec := operator(baseCodec)
|
||||
|
||||
person := Person{Name: "Alice", Age: 30}
|
||||
encoded := enhancedCodec.Encode(person)
|
||||
|
||||
// Encoding should include both the base prefix and the field value
|
||||
assert.Contains(t, encoded, "Person:")
|
||||
assert.Contains(t, encoded, "Alice")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBind_KleisliArrowReceivesCurrentValue verifies that the Kleisli arrow f
|
||||
// receives the current struct value when producing the field codec.
|
||||
func TestBind_KleisliArrowReceivesCurrentValue(t *testing.T) {
|
||||
t.Run("kleisli arrow receives current struct value during encoding", func(t *testing.T) {
|
||||
nameLens := lens.MakeLens(
|
||||
func(p Person) string { return p.Name },
|
||||
func(p Person, name string) Person {
|
||||
return Person{Name: name, Age: p.Age}
|
||||
},
|
||||
)
|
||||
|
||||
baseCodec := MakeType(
|
||||
"Person",
|
||||
func(i any) validation.Result[Person] {
|
||||
if p, ok := i.(Person); ok {
|
||||
return validation.ToResult(validation.Success(p))
|
||||
}
|
||||
return validation.ToResult(validation.Failures[Person](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "expected Person"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, Person] {
|
||||
return func(ctx Context) validation.Validation[Person] {
|
||||
if p, ok := i.(Person); ok {
|
||||
return validation.Success(p)
|
||||
}
|
||||
return validation.FailureWithMessage[Person](i, "expected Person")(ctx)
|
||||
}
|
||||
},
|
||||
func(p Person) string { return "" },
|
||||
)
|
||||
|
||||
// Kleisli arrow that uses the struct value to produce a prefix in the encoding
|
||||
var capturedPerson Person
|
||||
kleisli := func(p Person) Type[string, string, any] {
|
||||
capturedPerson = p
|
||||
return MakeType(
|
||||
"Name",
|
||||
func(i any) validation.Result[string] {
|
||||
if s, ok := i.(string); ok {
|
||||
return validation.ToResult(validation.Success(s))
|
||||
}
|
||||
return validation.ToResult(validation.Failures[string](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "expected string"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, string] {
|
||||
return func(ctx Context) validation.Validation[string] {
|
||||
if s, ok := i.(string); ok {
|
||||
return validation.Success(s)
|
||||
}
|
||||
return validation.FailureWithMessage[string](i, "expected string")(ctx)
|
||||
}
|
||||
},
|
||||
F.Identity[string],
|
||||
)
|
||||
}
|
||||
|
||||
operator := Bind(S.Monoid, nameLens, kleisli)
|
||||
enhancedCodec := operator(baseCodec)
|
||||
|
||||
person := Person{Name: "Bob", Age: 25}
|
||||
enhancedCodec.Encode(person)
|
||||
|
||||
// The Kleisli arrow should have been called with the actual struct value
|
||||
assert.Equal(t, person, capturedPerson)
|
||||
})
|
||||
}
|
||||
|
||||
// TestBind_ValidationSuccess verifies that Bind correctly validates and decodes
|
||||
// a struct when both the base and field validations succeed.
|
||||
func TestBind_ValidationSuccess(t *testing.T) {
|
||||
t.Run("succeeds when base and field validations pass", func(t *testing.T) {
|
||||
nameLens := lens.MakeLens(
|
||||
func(p Person) string { return p.Name },
|
||||
func(p Person, name string) Person {
|
||||
return Person{Name: name, Age: p.Age}
|
||||
},
|
||||
)
|
||||
|
||||
baseCodec := MakeType(
|
||||
"Person",
|
||||
func(i any) validation.Result[Person] {
|
||||
if p, ok := i.(Person); ok {
|
||||
return validation.ToResult(validation.Success(p))
|
||||
}
|
||||
return validation.ToResult(validation.Failures[Person](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "expected Person"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, Person] {
|
||||
return func(ctx Context) validation.Validation[Person] {
|
||||
if p, ok := i.(Person); ok {
|
||||
return validation.Success(p)
|
||||
}
|
||||
return validation.FailureWithMessage[Person](i, "expected Person")(ctx)
|
||||
}
|
||||
},
|
||||
func(p Person) string { return "" },
|
||||
)
|
||||
|
||||
// The field codec receives the same input I (any = Person struct).
|
||||
// It must extract the Name field from the Person input.
|
||||
kleisli := func(p Person) Type[string, string, any] {
|
||||
return MakeType(
|
||||
"Name",
|
||||
func(i any) validation.Result[string] {
|
||||
if person, ok := i.(Person); ok {
|
||||
return validation.ToResult(validation.Success(person.Name))
|
||||
}
|
||||
return validation.ToResult(validation.Failures[string](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "expected Person"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, string] {
|
||||
return func(ctx Context) validation.Validation[string] {
|
||||
if person, ok := i.(Person); ok {
|
||||
return validation.Success(person.Name)
|
||||
}
|
||||
return validation.FailureWithMessage[string](i, "expected Person")(ctx)
|
||||
}
|
||||
},
|
||||
F.Identity[string],
|
||||
)
|
||||
}
|
||||
|
||||
operator := Bind(S.Monoid, nameLens, kleisli)
|
||||
enhancedCodec := operator(baseCodec)
|
||||
|
||||
person := Person{Name: "Carol", Age: 28}
|
||||
result := enhancedCodec.Decode(person)
|
||||
|
||||
assert.True(t, either.IsRight(result), "Should succeed when both validations pass")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBind_ValidationFailsOnBaseFailure verifies that Bind uses fail-fast (monadic)
|
||||
// semantics: if the base codec fails, the Kleisli arrow is never evaluated.
|
||||
func TestBind_ValidationFailsOnBaseFailure(t *testing.T) {
|
||||
t.Run("fails fast when base validation fails", func(t *testing.T) {
|
||||
nameLens := lens.MakeLens(
|
||||
func(p Person) string { return p.Name },
|
||||
func(p Person, name string) Person {
|
||||
return Person{Name: name, Age: p.Age}
|
||||
},
|
||||
)
|
||||
|
||||
// Base codec always fails
|
||||
baseCodec := MakeType(
|
||||
"Person",
|
||||
func(i any) validation.Result[Person] {
|
||||
return validation.ToResult(validation.Failures[Person](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "base always fails"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, Person] {
|
||||
return func(ctx Context) validation.Validation[Person] {
|
||||
return validation.FailureWithMessage[Person](i, "base always fails")(ctx)
|
||||
}
|
||||
},
|
||||
func(p Person) string { return "" },
|
||||
)
|
||||
|
||||
kleisliCalled := false
|
||||
kleisli := func(p Person) Type[string, string, any] {
|
||||
kleisliCalled = true
|
||||
return MakeType(
|
||||
"Name",
|
||||
func(i any) validation.Result[string] {
|
||||
if s, ok := i.(string); ok {
|
||||
return validation.ToResult(validation.Success(s))
|
||||
}
|
||||
return validation.ToResult(validation.Failures[string](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "expected string"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, string] {
|
||||
return func(ctx Context) validation.Validation[string] {
|
||||
if s, ok := i.(string); ok {
|
||||
return validation.Success(s)
|
||||
}
|
||||
return validation.FailureWithMessage[string](i, "expected string")(ctx)
|
||||
}
|
||||
},
|
||||
F.Identity[string],
|
||||
)
|
||||
}
|
||||
|
||||
operator := Bind(S.Monoid, nameLens, kleisli)
|
||||
enhancedCodec := operator(baseCodec)
|
||||
|
||||
person := Person{Name: "Dave", Age: 40}
|
||||
result := enhancedCodec.Decode(person)
|
||||
|
||||
assert.True(t, either.IsLeft(result), "Should fail when base validation fails")
|
||||
assert.False(t, kleisliCalled, "Kleisli arrow should NOT be called when base fails")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBind_ValidationFailsOnFieldFailure verifies that Bind propagates field
|
||||
// validation errors when the Kleisli arrow's codec fails.
|
||||
func TestBind_ValidationFailsOnFieldFailure(t *testing.T) {
|
||||
t.Run("fails when field validation from kleisli codec fails", func(t *testing.T) {
|
||||
nameLens := lens.MakeLens(
|
||||
func(p Person) string { return p.Name },
|
||||
func(p Person, name string) Person {
|
||||
return Person{Name: name, Age: p.Age}
|
||||
},
|
||||
)
|
||||
|
||||
// Base codec succeeds
|
||||
baseCodec := MakeType(
|
||||
"Person",
|
||||
func(i any) validation.Result[Person] {
|
||||
if p, ok := i.(Person); ok {
|
||||
return validation.ToResult(validation.Success(p))
|
||||
}
|
||||
return validation.ToResult(validation.Failures[Person](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "expected Person"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, Person] {
|
||||
return func(ctx Context) validation.Validation[Person] {
|
||||
if p, ok := i.(Person); ok {
|
||||
return validation.Success(p)
|
||||
}
|
||||
return validation.FailureWithMessage[Person](i, "expected Person")(ctx)
|
||||
}
|
||||
},
|
||||
func(p Person) string { return "" },
|
||||
)
|
||||
|
||||
// Kleisli arrow returns a codec that always fails regardless of input
|
||||
kleisli := func(p Person) Type[string, string, any] {
|
||||
return MakeType(
|
||||
"Name",
|
||||
func(i any) validation.Result[string] {
|
||||
return validation.ToResult(validation.Failures[string](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "field always fails"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, string] {
|
||||
return func(ctx Context) validation.Validation[string] {
|
||||
return validation.FailureWithMessage[string](i, "field always fails")(ctx)
|
||||
}
|
||||
},
|
||||
F.Identity[string],
|
||||
)
|
||||
}
|
||||
|
||||
operator := Bind(S.Monoid, nameLens, kleisli)
|
||||
enhancedCodec := operator(baseCodec)
|
||||
|
||||
// The field codec receives the same input (Person) and always fails
|
||||
person := Person{Name: "Eve", Age: 22}
|
||||
result := enhancedCodec.Decode(person)
|
||||
|
||||
assert.True(t, either.IsLeft(result), "Should fail when field validation fails")
|
||||
|
||||
errors := either.MonadFold(result,
|
||||
F.Identity[validation.Errors],
|
||||
func(Person) validation.Errors { return nil },
|
||||
)
|
||||
assert.NotEmpty(t, errors, "Should have validation errors from field codec")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBind_TypeCheckingPreserved verifies that Bind preserves the base type checker.
|
||||
func TestBind_TypeCheckingPreserved(t *testing.T) {
|
||||
t.Run("preserves base type checker", func(t *testing.T) {
|
||||
nameLens := lens.MakeLens(
|
||||
func(p Person) string { return p.Name },
|
||||
func(p Person, name string) Person {
|
||||
return Person{Name: name, Age: p.Age}
|
||||
},
|
||||
)
|
||||
|
||||
baseCodec := MakeType(
|
||||
"Person",
|
||||
func(i any) validation.Result[Person] {
|
||||
if p, ok := i.(Person); ok {
|
||||
return validation.ToResult(validation.Success(p))
|
||||
}
|
||||
return validation.ToResult(validation.Failures[Person](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "expected Person"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, Person] {
|
||||
return func(ctx Context) validation.Validation[Person] {
|
||||
if p, ok := i.(Person); ok {
|
||||
return validation.Success(p)
|
||||
}
|
||||
return validation.FailureWithMessage[Person](i, "expected Person")(ctx)
|
||||
}
|
||||
},
|
||||
func(p Person) string { return "" },
|
||||
)
|
||||
|
||||
kleisli := func(p Person) Type[string, string, any] {
|
||||
return MakeType(
|
||||
"Name",
|
||||
func(i any) validation.Result[string] {
|
||||
if s, ok := i.(string); ok {
|
||||
return validation.ToResult(validation.Success(s))
|
||||
}
|
||||
return validation.ToResult(validation.Failures[string](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "expected string"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, string] {
|
||||
return func(ctx Context) validation.Validation[string] {
|
||||
if s, ok := i.(string); ok {
|
||||
return validation.Success(s)
|
||||
}
|
||||
return validation.FailureWithMessage[string](i, "expected string")(ctx)
|
||||
}
|
||||
},
|
||||
F.Identity[string],
|
||||
)
|
||||
}
|
||||
|
||||
operator := Bind(S.Monoid, nameLens, kleisli)
|
||||
enhancedCodec := operator(baseCodec)
|
||||
|
||||
// Valid type
|
||||
person := Person{Name: "Frank", Age: 35}
|
||||
isResult := enhancedCodec.Is(person)
|
||||
assert.True(t, either.IsRight(isResult), "Should accept Person type")
|
||||
|
||||
// Invalid type
|
||||
invalidResult := enhancedCodec.Is("not a person")
|
||||
assert.True(t, either.IsLeft(invalidResult), "Should reject non-Person type")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBind_Naming verifies that Bind generates a descriptive name for the codec.
|
||||
func TestBind_Naming(t *testing.T) {
|
||||
t.Run("generates descriptive name containing Bind and lens info", func(t *testing.T) {
|
||||
nameLens := lens.MakeLens(
|
||||
func(p Person) string { return p.Name },
|
||||
func(p Person, name string) Person {
|
||||
return Person{Name: name, Age: p.Age}
|
||||
},
|
||||
)
|
||||
|
||||
baseCodec := MakeType(
|
||||
"Person",
|
||||
func(i any) validation.Result[Person] {
|
||||
if p, ok := i.(Person); ok {
|
||||
return validation.ToResult(validation.Success(p))
|
||||
}
|
||||
return validation.ToResult(validation.Failures[Person](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "expected Person"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, Person] {
|
||||
return func(ctx Context) validation.Validation[Person] {
|
||||
if p, ok := i.(Person); ok {
|
||||
return validation.Success(p)
|
||||
}
|
||||
return validation.FailureWithMessage[Person](i, "expected Person")(ctx)
|
||||
}
|
||||
},
|
||||
func(p Person) string { return "" },
|
||||
)
|
||||
|
||||
kleisli := func(p Person) Type[string, string, any] {
|
||||
return MakeType(
|
||||
"Name",
|
||||
func(i any) validation.Result[string] {
|
||||
if s, ok := i.(string); ok {
|
||||
return validation.ToResult(validation.Success(s))
|
||||
}
|
||||
return validation.ToResult(validation.Failures[string](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "expected string"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, string] {
|
||||
return func(ctx Context) validation.Validation[string] {
|
||||
if s, ok := i.(string); ok {
|
||||
return validation.Success(s)
|
||||
}
|
||||
return validation.FailureWithMessage[string](i, "expected string")(ctx)
|
||||
}
|
||||
},
|
||||
F.Identity[string],
|
||||
)
|
||||
}
|
||||
|
||||
operator := Bind(S.Monoid, nameLens, kleisli)
|
||||
enhancedCodec := operator(baseCodec)
|
||||
|
||||
name := enhancedCodec.Name()
|
||||
assert.Contains(t, name, "Bind", "Name should contain 'Bind'")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBind_DependentFieldCodec verifies that the Kleisli arrow can produce
|
||||
// different codecs based on the current struct value (the key differentiator
|
||||
// from ApSL).
|
||||
//
|
||||
// The field codec Type[T, O, I] receives the same input I as the base codec.
|
||||
// It must extract the field value from that input. The Kleisli arrow f(s)
|
||||
// produces a different codec depending on the already-decoded struct value s.
|
||||
func TestBind_DependentFieldCodec(t *testing.T) {
|
||||
t.Run("kleisli arrow produces different codecs based on struct value", func(t *testing.T) {
|
||||
// Lens for Person.Name
|
||||
nameLens := lens.MakeLens(
|
||||
func(p Person) string { return p.Name },
|
||||
func(p Person, name string) Person {
|
||||
return Person{Name: name, Age: p.Age}
|
||||
},
|
||||
)
|
||||
|
||||
// Base codec succeeds for any Person
|
||||
baseCodec := MakeType(
|
||||
"Person",
|
||||
func(i any) validation.Result[Person] {
|
||||
if p, ok := i.(Person); ok {
|
||||
return validation.ToResult(validation.Success(p))
|
||||
}
|
||||
return validation.ToResult(validation.Failures[Person](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "expected Person"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, Person] {
|
||||
return func(ctx Context) validation.Validation[Person] {
|
||||
if p, ok := i.(Person); ok {
|
||||
return validation.Success(p)
|
||||
}
|
||||
return validation.FailureWithMessage[Person](i, "expected Person")(ctx)
|
||||
}
|
||||
},
|
||||
func(p Person) string { return "" },
|
||||
)
|
||||
|
||||
// Kleisli arrow: the field codec receives the same input I (any = Person).
|
||||
// It extracts the Name from the Person input.
|
||||
// If the decoded struct's Age > 18, accept any name (including empty).
|
||||
// If Age <= 18, reject empty names.
|
||||
kleisli := func(p Person) Type[string, string, any] {
|
||||
if p.Age > 18 {
|
||||
// Adult: accept any name extracted from the Person input
|
||||
return MakeType(
|
||||
"AnyName",
|
||||
func(i any) validation.Result[string] {
|
||||
if person, ok := i.(Person); ok {
|
||||
return validation.ToResult(validation.Success(person.Name))
|
||||
}
|
||||
return validation.ToResult(validation.Failures[string](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "expected Person"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, string] {
|
||||
return func(ctx Context) validation.Validation[string] {
|
||||
if person, ok := i.(Person); ok {
|
||||
return validation.Success(person.Name)
|
||||
}
|
||||
return validation.FailureWithMessage[string](i, "expected Person")(ctx)
|
||||
}
|
||||
},
|
||||
F.Identity[string],
|
||||
)
|
||||
}
|
||||
// Minor: reject empty names
|
||||
return MakeType(
|
||||
"NonEmptyName",
|
||||
func(i any) validation.Result[string] {
|
||||
if person, ok := i.(Person); ok {
|
||||
if person.Name != "" {
|
||||
return validation.ToResult(validation.Success(person.Name))
|
||||
}
|
||||
return validation.ToResult(validation.Failures[string](validation.Errors{
|
||||
&validation.ValidationError{Value: person.Name, Messsage: "name must not be empty for minors"},
|
||||
}))
|
||||
}
|
||||
return validation.ToResult(validation.Failures[string](validation.Errors{
|
||||
&validation.ValidationError{Value: i, Messsage: "expected Person"},
|
||||
}))
|
||||
},
|
||||
func(i any) Decode[Context, string] {
|
||||
return func(ctx Context) validation.Validation[string] {
|
||||
if person, ok := i.(Person); ok {
|
||||
if person.Name != "" {
|
||||
return validation.Success(person.Name)
|
||||
}
|
||||
return validation.FailureWithMessage[string](person.Name, "name must not be empty for minors")(ctx)
|
||||
}
|
||||
return validation.FailureWithMessage[string](i, "expected Person")(ctx)
|
||||
}
|
||||
},
|
||||
F.Identity[string],
|
||||
)
|
||||
}
|
||||
|
||||
operator := Bind(S.Monoid, nameLens, kleisli)
|
||||
enhancedCodec := operator(baseCodec)
|
||||
|
||||
// Adult (Age=30) with empty name: should succeed (adult codec accepts any name)
|
||||
adultPerson := Person{Name: "", Age: 30}
|
||||
adultResult := enhancedCodec.Decode(adultPerson)
|
||||
assert.True(t, either.IsRight(adultResult), "Adult should accept empty name")
|
||||
|
||||
// Minor (Age=15) with empty name: should fail (minor codec rejects empty names)
|
||||
minorPerson := Person{Name: "", Age: 15}
|
||||
minorResult := enhancedCodec.Decode(minorPerson)
|
||||
assert.True(t, either.IsLeft(minorResult), "Minor with empty name should fail")
|
||||
|
||||
// Minor (Age=15) with non-empty name: should succeed
|
||||
minorWithName := Person{Name: "Junior", Age: 15}
|
||||
minorWithNameResult := enhancedCodec.Decode(minorWithName)
|
||||
assert.True(t, either.IsRight(minorWithNameResult), "Minor with non-empty name should succeed")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -343,6 +343,61 @@ func Int64FromString() Type[int64, string, string] {
|
||||
)
|
||||
}
|
||||
|
||||
// BoolFromString creates a bidirectional codec for parsing boolean values from strings.
|
||||
// This codec converts string representations of booleans to bool values and vice versa.
|
||||
//
|
||||
// The codec:
|
||||
// - Decodes: Parses a string to a bool using strconv.ParseBool
|
||||
// - Encodes: Converts a bool to its string representation using strconv.FormatBool
|
||||
// - Validates: Ensures the string contains a valid boolean value
|
||||
//
|
||||
// The codec accepts the following string values (case-insensitive):
|
||||
// - true: "1", "t", "T", "true", "TRUE", "True"
|
||||
// - false: "0", "f", "F", "false", "FALSE", "False"
|
||||
//
|
||||
// Returns:
|
||||
// - A Type[bool, string, string] codec that handles bool/string conversions
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// boolCodec := BoolFromString()
|
||||
//
|
||||
// // Decode valid boolean strings
|
||||
// validation := boolCodec.Decode("true")
|
||||
// // validation is Right(true)
|
||||
//
|
||||
// validation := boolCodec.Decode("1")
|
||||
// // validation is Right(true)
|
||||
//
|
||||
// validation := boolCodec.Decode("false")
|
||||
// // validation is Right(false)
|
||||
//
|
||||
// validation := boolCodec.Decode("0")
|
||||
// // validation is Right(false)
|
||||
//
|
||||
// // Encode a boolean to string
|
||||
// str := boolCodec.Encode(true)
|
||||
// // str is "true"
|
||||
//
|
||||
// str := boolCodec.Encode(false)
|
||||
// // str is "false"
|
||||
//
|
||||
// // Invalid boolean string fails validation
|
||||
// validation := boolCodec.Decode("yes")
|
||||
// // validation is Left(ValidationError{...})
|
||||
//
|
||||
// // Case variations are accepted
|
||||
// validation := boolCodec.Decode("TRUE")
|
||||
// // validation is Right(true)
|
||||
func BoolFromString() Type[bool, string, string] {
|
||||
return MakeType(
|
||||
"BoolFromString",
|
||||
Is[bool](),
|
||||
validateFromParser(strconv.ParseBool),
|
||||
strconv.FormatBool,
|
||||
)
|
||||
}
|
||||
|
||||
func decodeJSON[T any](dec json.Unmarshaler) ReaderResult[[]byte, T] {
|
||||
return func(b []byte) Result[T] {
|
||||
var t T
|
||||
|
||||
@@ -461,6 +461,233 @@ func TestInt64FromString_Name(t *testing.T) {
|
||||
assert.Equal(t, "Int64FromString", Int64FromString().Name())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BoolFromString
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBoolFromString_Decode_Success(t *testing.T) {
|
||||
t.Run("decodes 'true' string", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("true")
|
||||
assert.Equal(t, validation.Success(true), result)
|
||||
})
|
||||
|
||||
t.Run("decodes 'false' string", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("false")
|
||||
assert.Equal(t, validation.Success(false), result)
|
||||
})
|
||||
|
||||
t.Run("decodes '1' as true", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("1")
|
||||
assert.Equal(t, validation.Success(true), result)
|
||||
})
|
||||
|
||||
t.Run("decodes '0' as false", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("0")
|
||||
assert.Equal(t, validation.Success(false), result)
|
||||
})
|
||||
|
||||
t.Run("decodes 't' as true", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("t")
|
||||
assert.Equal(t, validation.Success(true), result)
|
||||
})
|
||||
|
||||
t.Run("decodes 'f' as false", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("f")
|
||||
assert.Equal(t, validation.Success(false), result)
|
||||
})
|
||||
|
||||
t.Run("decodes 'T' as true", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("T")
|
||||
assert.Equal(t, validation.Success(true), result)
|
||||
})
|
||||
|
||||
t.Run("decodes 'F' as false", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("F")
|
||||
assert.Equal(t, validation.Success(false), result)
|
||||
})
|
||||
|
||||
t.Run("decodes 'TRUE' as true", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("TRUE")
|
||||
assert.Equal(t, validation.Success(true), result)
|
||||
})
|
||||
|
||||
t.Run("decodes 'FALSE' as false", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("FALSE")
|
||||
assert.Equal(t, validation.Success(false), result)
|
||||
})
|
||||
|
||||
t.Run("decodes 'True' as true", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("True")
|
||||
assert.Equal(t, validation.Success(true), result)
|
||||
})
|
||||
|
||||
t.Run("decodes 'False' as false", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("False")
|
||||
assert.Equal(t, validation.Success(false), result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBoolFromString_Decode_Failure(t *testing.T) {
|
||||
t.Run("fails on 'yes'", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("yes")
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("fails on 'no'", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("no")
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("fails on empty string", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("")
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("fails on numeric string other than 0 or 1", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("2")
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("fails on arbitrary text", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("not a boolean")
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("fails on whitespace", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode(" ")
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
|
||||
t.Run("fails on 'true' with leading/trailing spaces", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode(" true ")
|
||||
assert.True(t, either.IsLeft(result))
|
||||
})
|
||||
}
|
||||
|
||||
func TestBoolFromString_Encode(t *testing.T) {
|
||||
t.Run("encodes true to 'true'", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
assert.Equal(t, "true", c.Encode(true))
|
||||
})
|
||||
|
||||
t.Run("encodes false to 'false'", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
assert.Equal(t, "false", c.Encode(false))
|
||||
})
|
||||
|
||||
t.Run("round-trip: decode 'true' then encode", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("true")
|
||||
require.True(t, either.IsRight(result))
|
||||
b := either.MonadFold(result, func(validation.Errors) bool { return false }, func(b bool) bool { return b })
|
||||
assert.Equal(t, "true", c.Encode(b))
|
||||
})
|
||||
|
||||
t.Run("round-trip: decode 'false' then encode", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("false")
|
||||
require.True(t, either.IsRight(result))
|
||||
b := either.MonadFold(result, func(validation.Errors) bool { return true }, func(b bool) bool { return b })
|
||||
assert.Equal(t, "false", c.Encode(b))
|
||||
})
|
||||
|
||||
t.Run("round-trip: decode '1' encodes as 'true'", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("1")
|
||||
require.True(t, either.IsRight(result))
|
||||
b := either.MonadFold(result, func(validation.Errors) bool { return false }, func(b bool) bool { return b })
|
||||
// Note: strconv.FormatBool always returns "true" or "false", not "1" or "0"
|
||||
assert.Equal(t, "true", c.Encode(b))
|
||||
})
|
||||
|
||||
t.Run("round-trip: decode '0' encodes as 'false'", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
result := c.Decode("0")
|
||||
require.True(t, either.IsRight(result))
|
||||
b := either.MonadFold(result, func(validation.Errors) bool { return true }, func(b bool) bool { return b })
|
||||
assert.Equal(t, "false", c.Encode(b))
|
||||
})
|
||||
}
|
||||
|
||||
func TestBoolFromString_EdgeCases(t *testing.T) {
|
||||
t.Run("case sensitivity variations", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
cases := []struct {
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"true", true},
|
||||
{"True", true},
|
||||
{"TRUE", true},
|
||||
{"false", false},
|
||||
{"False", false},
|
||||
{"FALSE", false},
|
||||
{"t", true},
|
||||
{"T", true},
|
||||
{"f", false},
|
||||
{"F", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
result := c.Decode(tc.input)
|
||||
require.True(t, either.IsRight(result), "expected success for %s", tc.input)
|
||||
b := either.MonadFold(result, func(validation.Errors) bool { return !tc.expected }, func(b bool) bool { return b })
|
||||
assert.Equal(t, tc.expected, b, "input: %s", tc.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBoolFromString_Name(t *testing.T) {
|
||||
assert.Equal(t, "BoolFromString", BoolFromString().Name())
|
||||
}
|
||||
|
||||
func TestBoolFromString_Integration(t *testing.T) {
|
||||
t.Run("decodes and encodes multiple boolean values", func(t *testing.T) {
|
||||
c := BoolFromString()
|
||||
cases := []struct {
|
||||
str string
|
||||
val bool
|
||||
}{
|
||||
{"true", true},
|
||||
{"false", false},
|
||||
{"1", true},
|
||||
{"0", false},
|
||||
{"T", true},
|
||||
{"F", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
result := c.Decode(tc.str)
|
||||
require.True(t, either.IsRight(result), "expected success for %s", tc.str)
|
||||
b := either.MonadFold(result, func(validation.Errors) bool { return !tc.val }, func(b bool) bool { return b })
|
||||
assert.Equal(t, tc.val, b)
|
||||
// Note: encoding always produces "true" or "false", not the original input
|
||||
if tc.val {
|
||||
assert.Equal(t, "true", c.Encode(b))
|
||||
} else {
|
||||
assert.Equal(t, "false", c.Encode(b))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MarshalJSON
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -259,5 +259,42 @@ type (
|
||||
// result := LetL(lens, double)(Success(21)) // Success(42)
|
||||
Endomorphism[A any] = endomorphism.Endomorphism[A]
|
||||
|
||||
// Lazy represents a lazily-evaluated value of type A.
|
||||
// This is an alias for lazy.Lazy[A], which defers computation until the value is needed.
|
||||
//
|
||||
// In the validation context, Lazy is used to defer expensive validation operations
|
||||
// or to break circular dependencies in validation logic.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// lazyValidation := lazy.Of(func() Validation[int] {
|
||||
// // Expensive validation logic here
|
||||
// return Success(42)
|
||||
// })
|
||||
// // Validation is not executed until lazyValidation() is called
|
||||
Lazy[A any] = lazy.Lazy[A]
|
||||
|
||||
// ErrorsProvider is an interface for types that can provide a collection of errors.
|
||||
// This interface allows validation errors to be extracted from various error types
|
||||
// in a uniform way, supporting error aggregation and reporting.
|
||||
//
|
||||
// Types implementing this interface can be unwrapped to access their underlying
|
||||
// error collection, enabling consistent error handling across different error types.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type MyErrors struct {
|
||||
// errs []error
|
||||
// }
|
||||
//
|
||||
// func (e *MyErrors) Errors() []error {
|
||||
// return e.errs
|
||||
// }
|
||||
//
|
||||
// // Usage
|
||||
// var provider ErrorsProvider = &MyErrors{errs: []error{...}}
|
||||
// allErrors := provider.Errors()
|
||||
ErrorsProvider interface {
|
||||
Errors() []error
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package validation
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
A "github.com/IBM/fp-go/v2/array"
|
||||
"github.com/IBM/fp-go/v2/either"
|
||||
@@ -12,6 +13,11 @@ import (
|
||||
// Returns a generic error message indicating this is a validation error.
|
||||
// For detailed error information, use String() or Format() methods.
|
||||
|
||||
// toError converts the validation error to the error interface
|
||||
func toError(v *ValidationError) error {
|
||||
return v
|
||||
}
|
||||
|
||||
// Error implements the error interface for ValidationError.
|
||||
// Returns a generic error message.
|
||||
func (v *ValidationError) Error() string {
|
||||
@@ -34,44 +40,45 @@ func (v *ValidationError) String() string {
|
||||
// It includes the context path, message, and optionally the cause error.
|
||||
// Supports verbs: %s, %v, %+v (with additional details)
|
||||
func (v *ValidationError) Format(s fmt.State, verb rune) {
|
||||
// Build the context path
|
||||
path := ""
|
||||
for i, entry := range v.Context {
|
||||
if i > 0 {
|
||||
path += "."
|
||||
}
|
||||
if entry.Key != "" {
|
||||
path += entry.Key
|
||||
} else {
|
||||
path += entry.Type
|
||||
}
|
||||
}
|
||||
var result strings.Builder
|
||||
|
||||
// Start with the path if available
|
||||
result := ""
|
||||
if path != "" {
|
||||
result = fmt.Sprintf("at %s: ", path)
|
||||
// Build the context path
|
||||
if len(v.Context) > 0 {
|
||||
var path strings.Builder
|
||||
for i, entry := range v.Context {
|
||||
if i > 0 {
|
||||
path.WriteString(".")
|
||||
}
|
||||
if entry.Key != "" {
|
||||
path.WriteString(entry.Key)
|
||||
} else {
|
||||
path.WriteString(entry.Type)
|
||||
}
|
||||
}
|
||||
result.WriteString("at ")
|
||||
result.WriteString(path.String())
|
||||
result.WriteString(": ")
|
||||
}
|
||||
|
||||
// Add the message
|
||||
result += v.Messsage
|
||||
result.WriteString(v.Messsage)
|
||||
|
||||
// Add the cause if present
|
||||
if v.Cause != nil {
|
||||
if s.Flag('+') && verb == 'v' {
|
||||
// Verbose format with detailed cause
|
||||
result += fmt.Sprintf("\n caused by: %+v", v.Cause)
|
||||
fmt.Fprintf(&result, "\n caused by: %+v", v.Cause)
|
||||
} else {
|
||||
result += fmt.Sprintf(" (caused by: %v)", v.Cause)
|
||||
fmt.Fprintf(&result, " (caused by: %v)", v.Cause)
|
||||
}
|
||||
}
|
||||
|
||||
// Add value information for verbose format
|
||||
if s.Flag('+') && verb == 'v' {
|
||||
result += fmt.Sprintf("\n value: %#v", v.Value)
|
||||
fmt.Fprintf(&result, "\n value: %#v", v.Value)
|
||||
}
|
||||
|
||||
fmt.Fprint(s, result)
|
||||
fmt.Fprint(s, result.String())
|
||||
}
|
||||
|
||||
// LogValue implements the slog.LogValuer interface for ValidationError.
|
||||
@@ -94,18 +101,18 @@ func (v *ValidationError) LogValue() slog.Value {
|
||||
|
||||
// Add context path if available
|
||||
if len(v.Context) > 0 {
|
||||
path := ""
|
||||
var path strings.Builder
|
||||
for i, entry := range v.Context {
|
||||
if i > 0 {
|
||||
path += "."
|
||||
path.WriteString(".")
|
||||
}
|
||||
if entry.Key != "" {
|
||||
path += entry.Key
|
||||
path.WriteString(entry.Key)
|
||||
} else {
|
||||
path += entry.Type
|
||||
path.WriteString(entry.Type)
|
||||
}
|
||||
}
|
||||
attrs = append(attrs, slog.String("path", path))
|
||||
attrs = append(attrs, slog.String("path", path.String()))
|
||||
}
|
||||
|
||||
// Add cause if present
|
||||
@@ -119,13 +126,14 @@ func (v *ValidationError) LogValue() slog.Value {
|
||||
// Error implements the error interface for ValidationErrors.
|
||||
// Returns a generic error message indicating validation errors occurred.
|
||||
func (ve *validationErrors) Error() string {
|
||||
if len(ve.errors) == 0 {
|
||||
switch len(ve.errors) {
|
||||
case 0:
|
||||
return "ValidationErrors: no errors"
|
||||
}
|
||||
if len(ve.errors) == 1 {
|
||||
case 1:
|
||||
return "ValidationErrors: 1 error"
|
||||
default:
|
||||
return fmt.Sprintf("ValidationErrors: %d errors", len(ve.errors))
|
||||
}
|
||||
return fmt.Sprintf("ValidationErrors: %d errors", len(ve.errors))
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying cause error if present.
|
||||
@@ -134,6 +142,33 @@ func (ve *validationErrors) Unwrap() error {
|
||||
return ve.cause
|
||||
}
|
||||
|
||||
// Errors implements the ErrorsProvider interface for validationErrors.
|
||||
// It converts the internal collection of ValidationError pointers to a slice of error interfaces.
|
||||
// This method enables uniform error extraction from validation error collections.
|
||||
//
|
||||
// The returned slice contains the same errors as the internal errors field,
|
||||
// but typed as error interface values for compatibility with standard Go error handling.
|
||||
//
|
||||
// Returns:
|
||||
// - A slice of error interfaces, one for each ValidationError in the collection
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ve := &validationErrors{
|
||||
// errors: Errors{
|
||||
// &ValidationError{Messsage: "invalid email"},
|
||||
// &ValidationError{Messsage: "age must be positive"},
|
||||
// },
|
||||
// }
|
||||
// errs := ve.Errors()
|
||||
// // errs is []error with 2 elements, each implementing the error interface
|
||||
// for _, err := range errs {
|
||||
// fmt.Println(err.Error()) // "ValidationError"
|
||||
// }
|
||||
func (ve *validationErrors) Errors() []error {
|
||||
return A.MonadMap(ve.errors, toError)
|
||||
}
|
||||
|
||||
// String returns a simple string representation of all validation errors.
|
||||
// Each error is listed on a separate line with its index.
|
||||
func (ve *validationErrors) String() string {
|
||||
@@ -141,16 +176,17 @@ func (ve *validationErrors) String() string {
|
||||
return "ValidationErrors: no errors"
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("ValidationErrors (%d):\n", len(ve.errors))
|
||||
var result strings.Builder
|
||||
fmt.Fprintf(&result, "ValidationErrors (%d):\n", len(ve.errors))
|
||||
for i, err := range ve.errors {
|
||||
result += fmt.Sprintf(" [%d] %s\n", i, err.String())
|
||||
fmt.Fprintf(&result, " [%d] %s\n", i, err.String())
|
||||
}
|
||||
|
||||
if ve.cause != nil {
|
||||
result += fmt.Sprintf(" caused by: %v\n", ve.cause)
|
||||
fmt.Fprintf(&result, " caused by: %v\n", ve.cause)
|
||||
}
|
||||
|
||||
return result
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// Format implements fmt.Formatter for custom formatting of ValidationErrors.
|
||||
|
||||
@@ -846,3 +846,142 @@ func TestLogValuerInterface(t *testing.T) {
|
||||
var _ slog.LogValuer = (*validationErrors)(nil)
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidationErrors_Errors tests the Errors() method implementation
|
||||
func TestValidationErrors_Errors(t *testing.T) {
|
||||
t.Run("returns empty slice for no errors", func(t *testing.T) {
|
||||
ve := &validationErrors{
|
||||
errors: Errors{},
|
||||
}
|
||||
|
||||
errs := ve.Errors()
|
||||
assert.Empty(t, errs)
|
||||
assert.NotNil(t, errs)
|
||||
})
|
||||
|
||||
t.Run("converts single ValidationError to error interface", func(t *testing.T) {
|
||||
ve := &validationErrors{
|
||||
errors: Errors{
|
||||
&ValidationError{Value: "test", Messsage: "invalid value"},
|
||||
},
|
||||
}
|
||||
|
||||
errs := ve.Errors()
|
||||
require.Len(t, errs, 1)
|
||||
assert.Equal(t, "ValidationError", errs[0].Error())
|
||||
})
|
||||
|
||||
t.Run("converts multiple ValidationErrors to error interfaces", func(t *testing.T) {
|
||||
ve := &validationErrors{
|
||||
errors: Errors{
|
||||
&ValidationError{Value: "test1", Messsage: "error 1"},
|
||||
&ValidationError{Value: "test2", Messsage: "error 2"},
|
||||
&ValidationError{Value: "test3", Messsage: "error 3"},
|
||||
},
|
||||
}
|
||||
|
||||
errs := ve.Errors()
|
||||
require.Len(t, errs, 3)
|
||||
for _, err := range errs {
|
||||
assert.Equal(t, "ValidationError", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves error details in converted errors", func(t *testing.T) {
|
||||
originalErr := &ValidationError{
|
||||
Value: "abc",
|
||||
Context: []ContextEntry{{Key: "field"}},
|
||||
Messsage: "invalid format",
|
||||
Cause: errors.New("parse error"),
|
||||
}
|
||||
ve := &validationErrors{
|
||||
errors: Errors{originalErr},
|
||||
}
|
||||
|
||||
errs := ve.Errors()
|
||||
require.Len(t, errs, 1)
|
||||
|
||||
// Verify the error can be type-asserted back to ValidationError
|
||||
validationErr, ok := errs[0].(*ValidationError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "abc", validationErr.Value)
|
||||
assert.Equal(t, "invalid format", validationErr.Messsage)
|
||||
assert.NotNil(t, validationErr.Cause)
|
||||
assert.Len(t, validationErr.Context, 1)
|
||||
})
|
||||
|
||||
t.Run("implements ErrorsProvider interface", func(t *testing.T) {
|
||||
ve := &validationErrors{
|
||||
errors: Errors{
|
||||
&ValidationError{Messsage: "error 1"},
|
||||
&ValidationError{Messsage: "error 2"},
|
||||
},
|
||||
}
|
||||
|
||||
// Verify it implements ErrorsProvider
|
||||
var provider ErrorsProvider = ve
|
||||
errs := provider.Errors()
|
||||
assert.Len(t, errs, 2)
|
||||
})
|
||||
|
||||
t.Run("returned errors are usable with standard error handling", func(t *testing.T) {
|
||||
cause := errors.New("underlying error")
|
||||
ve := &validationErrors{
|
||||
errors: Errors{
|
||||
&ValidationError{
|
||||
Value: "test",
|
||||
Messsage: "validation failed",
|
||||
Cause: cause,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
errs := ve.Errors()
|
||||
require.Len(t, errs, 1)
|
||||
|
||||
// Test with errors.Is
|
||||
assert.True(t, errors.Is(errs[0], cause))
|
||||
|
||||
// Test with errors.As
|
||||
var validationErr *ValidationError
|
||||
assert.True(t, errors.As(errs[0], &validationErr))
|
||||
assert.Equal(t, "validation failed", validationErr.Messsage)
|
||||
})
|
||||
|
||||
t.Run("does not modify original errors slice", func(t *testing.T) {
|
||||
originalErrors := Errors{
|
||||
&ValidationError{Value: "test1", Messsage: "error 1"},
|
||||
&ValidationError{Value: "test2", Messsage: "error 2"},
|
||||
}
|
||||
ve := &validationErrors{
|
||||
errors: originalErrors,
|
||||
}
|
||||
|
||||
errs := ve.Errors()
|
||||
require.Len(t, errs, 2)
|
||||
|
||||
// Original should be unchanged
|
||||
assert.Len(t, ve.errors, 2)
|
||||
assert.Equal(t, originalErrors, ve.errors)
|
||||
})
|
||||
|
||||
t.Run("each error in slice is independent", func(t *testing.T) {
|
||||
ve := &validationErrors{
|
||||
errors: Errors{
|
||||
&ValidationError{Value: "test1", Messsage: "error 1"},
|
||||
&ValidationError{Value: "test2", Messsage: "error 2"},
|
||||
},
|
||||
}
|
||||
|
||||
errs := ve.Errors()
|
||||
require.Len(t, errs, 2)
|
||||
|
||||
// Verify each error is distinct
|
||||
err1, ok1 := errs[0].(*ValidationError)
|
||||
err2, ok2 := errs[1].(*ValidationError)
|
||||
require.True(t, ok1)
|
||||
require.True(t, ok2)
|
||||
assert.NotEqual(t, err1.Messsage, err2.Messsage)
|
||||
assert.NotEqual(t, err1.Value, err2.Value)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package readerio
|
||||
|
||||
import (
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
G "github.com/IBM/fp-go/v2/internal/bracket"
|
||||
)
|
||||
|
||||
@@ -30,3 +31,10 @@ func Bracket[
|
||||
release,
|
||||
)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func WithResource[R, A, B, ANY any](
|
||||
onCreate ReaderIO[R, A], onRelease Kleisli[R, A, ANY]) Kleisli[R, Kleisli[R, A, B], B] {
|
||||
|
||||
return function.Bind13of3(Bracket[R, A, B, ANY])(onCreate, function.Ignore2of2[B](onRelease))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user