mirror of
https://github.com/IBM/fp-go.git
synced 2026-03-08 13:29:18 +02:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd21bdeabf | ||
|
|
6834f72856 | ||
|
|
8cfb7ef659 | ||
|
|
622c87d734 | ||
|
|
2ce406a410 | ||
|
|
3743361b9f | ||
|
|
69d11f0a4b | ||
|
|
e4dd1169c4 | ||
|
|
1657569f1d | ||
|
|
545876d013 | ||
|
|
9492c5d994 | ||
|
|
94b1ea30d1 | ||
|
|
a77d61f632 | ||
|
|
66b2f57d73 | ||
|
|
92eb2a50a2 | ||
|
|
3df1dca146 | ||
|
|
a0132e2e92 | ||
|
|
c6b342908d | ||
|
|
962237492f | ||
|
|
168a6e1072 |
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"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Functional I/O in Go: Context, Errors, and the Reader Pattern
|
||||
|
||||
This document explores how functional programming principles apply to I/O operations in Go, comparing traditional imperative approaches with functional patterns using the `context/readerioresult` and `idiomatic/context/readerresult` packages.
|
||||
This document explores how functional programming principles apply to I/O operations in Go, comparing traditional imperative approaches with functional patterns using the `context/readerioresult`, `idiomatic/context/readerresult`, and `effect` packages.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -10,6 +10,7 @@ This document explores how functional programming principles apply to I/O operat
|
||||
- [Benefits of the Functional Approach](#benefits-of-the-functional-approach)
|
||||
- [Side-by-Side Comparison](#side-by-side-comparison)
|
||||
- [Advanced Patterns](#advanced-patterns)
|
||||
- [The Effect Package: Higher-Level Abstraction](#the-effect-package-higher-level-abstraction)
|
||||
- [When to Use Each Approach](#when-to-use-each-approach)
|
||||
|
||||
## Why Context in I/O Operations
|
||||
@@ -775,6 +776,191 @@ func FetchWithRetry(url string, maxRetries int) RIO.ReaderIOResult[[]byte] {
|
||||
}
|
||||
```
|
||||
|
||||
## The Effect Package: Higher-Level Abstraction
|
||||
|
||||
### What is Effect?
|
||||
|
||||
The `effect` package provides a higher-level abstraction over `ReaderReaderIOResult`, offering a complete effect system for managing dependencies, errors, and side effects in a composable way. It's inspired by [effect-ts](https://effect.website/) and provides a cleaner API for complex workflows.
|
||||
|
||||
### Core Type
|
||||
|
||||
```go
|
||||
// Effect represents an effectful computation that:
|
||||
// - Requires a context of type C (dependency injection)
|
||||
// - Can perform I/O operations
|
||||
// - Can fail with an error
|
||||
// - Produces a value of type A on success
|
||||
type Effect[C, A any] = readerreaderioresult.ReaderReaderIOResult[C, A]
|
||||
```
|
||||
|
||||
**Key difference from ReaderIOResult**: Effect adds an additional layer of dependency injection (the `C` type parameter) on top of the runtime `context.Context`, enabling type-safe dependency management.
|
||||
|
||||
### When to Use Effect
|
||||
|
||||
Use the Effect package when you need:
|
||||
|
||||
1. **Type-Safe Dependency Injection**: Your application has typed dependencies (config, services, repositories) that need to be threaded through operations
|
||||
2. **Complex Workflows**: Multiple services and dependencies need to be composed
|
||||
3. **Testability**: You want to easily mock dependencies by providing different contexts
|
||||
4. **Separation of Concerns**: Clear separation between business logic, dependencies, and I/O
|
||||
|
||||
### Effect vs ReaderIOResult
|
||||
|
||||
```go
|
||||
// ReaderIOResult - depends only on runtime context
|
||||
type ReaderIOResult[A any] = func(context.Context) (A, error)
|
||||
|
||||
// Effect - depends on typed context C AND runtime context
|
||||
type Effect[C, A any] = func(C) func(context.Context) (A, error)
|
||||
```
|
||||
|
||||
**ReaderIOResult** is simpler and suitable when you only need runtime context (cancellation, deadlines, request-scoped values).
|
||||
|
||||
**Effect** adds typed dependency injection, making it ideal for applications with complex service dependencies.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
#### Creating Effects
|
||||
|
||||
```go
|
||||
type AppConfig struct {
|
||||
DatabaseURL string
|
||||
APIKey string
|
||||
}
|
||||
|
||||
// Create a successful effect
|
||||
successEffect := effect.Succeed[AppConfig, string]("hello")
|
||||
|
||||
// Create a failed effect
|
||||
failEffect := effect.Fail[AppConfig, string](errors.New("failed"))
|
||||
|
||||
// Lift a pure value
|
||||
pureEffect := effect.Of[AppConfig, int](42)
|
||||
```
|
||||
|
||||
#### Integrating Standard Go Functions
|
||||
|
||||
The `Eitherize` function makes it easy to integrate standard Go functions that return `(value, error)`:
|
||||
|
||||
```go
|
||||
type Database struct {
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
// Convert a standard Go function to an Effect using Eitherize
|
||||
func fetchUser(id int) effect.Effect[Database, User] {
|
||||
return effect.Eitherize(func(db Database, ctx context.Context) (User, error) {
|
||||
var user User
|
||||
err := db.conn.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", id).Scan(&user)
|
||||
return user, err
|
||||
})
|
||||
}
|
||||
|
||||
// Use Eitherize1 for Kleisli arrows (functions with an additional parameter)
|
||||
fetchUserKleisli := effect.Eitherize1(func(db Database, ctx context.Context, id int) (User, error) {
|
||||
var user User
|
||||
err := db.conn.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", id).Scan(&user)
|
||||
return user, err
|
||||
})
|
||||
// fetchUserKleisli has type: func(int) Effect[Database, User]
|
||||
```
|
||||
|
||||
#### Composing Effects
|
||||
|
||||
```go
|
||||
type Services struct {
|
||||
UserRepo UserRepository
|
||||
EmailSvc EmailService
|
||||
}
|
||||
|
||||
// Compose multiple effects with typed dependencies
|
||||
func processUser(id int, newEmail string) effect.Effect[Services, User] {
|
||||
return F.Pipe3(
|
||||
// Fetch user from repository
|
||||
effect.Eitherize(func(svc Services, ctx context.Context) (User, error) {
|
||||
return svc.UserRepo.GetUser(ctx, id)
|
||||
}),
|
||||
// Validate user (pure function lifted into Effect)
|
||||
effect.ChainEitherK[Services](validateUser),
|
||||
// Update email
|
||||
effect.Chain[Services](func(user User) effect.Effect[Services, User] {
|
||||
return effect.Eitherize(func(svc Services, ctx context.Context) (User, error) {
|
||||
if err := svc.EmailSvc.SendVerification(ctx, newEmail); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return svc.UserRepo.UpdateEmail(ctx, user.ID, newEmail)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### Running Effects
|
||||
|
||||
```go
|
||||
func main() {
|
||||
// Set up typed dependencies once
|
||||
services := Services{
|
||||
UserRepo: &PostgresUserRepo{db: db},
|
||||
EmailSvc: &SMTPEmailService{host: "smtp.example.com"},
|
||||
}
|
||||
|
||||
// Build the effect pipeline (no execution yet)
|
||||
userEffect := processUser(42, "new@email.com")
|
||||
|
||||
// Provide dependencies - returns a Thunk (ReaderIOResult)
|
||||
thunk := effect.Provide(services)(userEffect)
|
||||
|
||||
// Run synchronously - returns a func(context.Context) (User, error)
|
||||
readerResult := effect.RunSync(thunk)
|
||||
|
||||
// Execute with runtime context
|
||||
user, err := readerResult(context.Background())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Updated user: %+v\n", user)
|
||||
}
|
||||
```
|
||||
|
||||
### Comparison: Traditional vs ReaderIOResult vs Effect
|
||||
|
||||
| Aspect | Traditional | ReaderIOResult | Effect |
|
||||
|---|---|---|---|
|
||||
| Error propagation | Manual | Automatic | Automatic |
|
||||
| Dependency injection | Function parameters | Closure / `context.Context` | Typed `C` parameter |
|
||||
| Testability | Requires mocking | Mock `ReaderIOResult` | Provide mock `C` |
|
||||
| Composability | Low | High | High |
|
||||
| Type-safe dependencies | No | No | Yes |
|
||||
| Complexity | Low | Medium | Medium-High |
|
||||
|
||||
### Testing with Effect
|
||||
|
||||
One of the key benefits of Effect is easy testing through dependency substitution:
|
||||
|
||||
```go
|
||||
func TestProcessUser(t *testing.T) {
|
||||
// Create mock services
|
||||
mockServices := Services{
|
||||
UserRepo: &MockUserRepo{
|
||||
users: map[int]User{42: {ID: 42, Age: 25, Email: "old@email.com"}},
|
||||
},
|
||||
EmailSvc: &MockEmailService{},
|
||||
}
|
||||
|
||||
// Run the effect with mock dependencies
|
||||
user, err := effect.RunSync(
|
||||
effect.Provide(mockServices)(processUser(42, "new@email.com")),
|
||||
)(context.Background())
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "new@email.com", user.Email)
|
||||
}
|
||||
```
|
||||
|
||||
No database or SMTP server needed — just provide mock implementations of the `Services` struct.
|
||||
|
||||
## When to Use Each Approach
|
||||
|
||||
### Use Traditional Go Style When:
|
||||
@@ -794,6 +980,17 @@ func FetchWithRetry(url string, maxRetries int) RIO.ReaderIOResult[[]byte] {
|
||||
5. **Resource management**: Need guaranteed cleanup (Bracket)
|
||||
6. **Parallel execution**: Need to parallelize operations easily
|
||||
7. **Type safety**: Want the type system to track I/O effects
|
||||
8. **Simple dependencies**: Only need runtime `context.Context`, no typed dependencies
|
||||
|
||||
### Use Effect When:
|
||||
|
||||
1. **Type-safe dependency injection**: Application has typed dependencies (config, services, repositories)
|
||||
2. **Complex service architectures**: Multiple services need to be composed with clear dependency management
|
||||
3. **Testability with mocks**: Want to easily substitute dependencies for testing
|
||||
4. **Separation of concerns**: Need clear separation between business logic, dependencies, and I/O
|
||||
5. **Large applications**: Building applications where dependency management is critical
|
||||
6. **Team experience**: Team is comfortable with functional programming and effect systems
|
||||
7. **Integration with standard Go**: Need to integrate many standard `(value, error)` functions using `Eitherize`
|
||||
|
||||
### Use Idiomatic Functional Style (idiomatic/context/readerresult) When:
|
||||
|
||||
@@ -803,6 +1000,7 @@ func FetchWithRetry(url string, maxRetries int) RIO.ReaderIOResult[[]byte] {
|
||||
4. **Go integration**: Want seamless integration with existing Go code
|
||||
5. **Production services**: Building high-throughput services
|
||||
6. **Best of both worlds**: Want functional composition with Go's native patterns
|
||||
7. **Simple dependencies**: Only need runtime `context.Context`, no typed dependencies
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -814,11 +1012,18 @@ The functional approach to I/O in Go offers significant advantages:
|
||||
4. **Type Safety**: I/O effects visible in the type system
|
||||
5. **Lazy Evaluation**: Build pipelines, execute when ready
|
||||
6. **Context Propagation**: Automatic threading of context
|
||||
7. **Performance**: Idiomatic version offers 2-10x speedup
|
||||
7. **Dependency Injection**: Type-safe dependency management with Effect
|
||||
8. **Performance**: Idiomatic version offers 2-10x speedup
|
||||
|
||||
The key insight is that **I/O operations return descriptions of effects** (ReaderIOResult) rather than performing effects immediately. This enables powerful composition patterns while maintaining Go's idiomatic error handling through the `(value, error)` tuple pattern.
|
||||
The key insight is that **I/O operations return descriptions of effects** rather than performing effects immediately. This enables powerful composition patterns while maintaining Go's idiomatic error handling through the `(value, error)` tuple pattern.
|
||||
|
||||
For production Go services, the **idiomatic/context/readerresult** package provides the best balance: full functional programming capabilities with native Go performance and familiar error handling patterns.
|
||||
### Choosing the Right Abstraction
|
||||
|
||||
- **ReaderIOResult**: Best for simple I/O pipelines that only need runtime `context.Context`
|
||||
- **Effect**: Best for complex applications with typed dependencies and service architectures
|
||||
- **idiomatic/context/readerresult**: Best for production services needing high performance with functional patterns
|
||||
|
||||
For production Go services, the **idiomatic/context/readerresult** package provides the best balance of performance and functional capabilities. For applications with complex dependency management, the **effect** package provides type-safe dependency injection with a clean, composable API.
|
||||
|
||||
## Further Reading
|
||||
|
||||
@@ -826,4 +1031,6 @@ For production Go services, the **idiomatic/context/readerresult** package provi
|
||||
- [IDIOMATIC_COMPARISON.md](./IDIOMATIC_COMPARISON.md) - Performance comparison
|
||||
- [idiomatic/doc.go](./idiomatic/doc.go) - Idiomatic package overview
|
||||
- [context/readerioresult](./context/readerioresult/) - ReaderIOResult package
|
||||
- [idiomatic/context/readerresult](./idiomatic/context/readerresult/) - Idiomatic ReaderResult package
|
||||
- [idiomatic/context/readerresult](./idiomatic/context/readerresult/) - Idiomatic ReaderResult package
|
||||
- [effect](./effect/) - Effect package for type-safe dependency injection
|
||||
- [effect-ts](https://effect.website/) - TypeScript effect system that inspired this package
|
||||
@@ -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.
|
||||
|
||||
@@ -461,7 +462,8 @@ func process() IOResult[string] {
|
||||
- **Result** - Simplified Either with error as left type (recommended for error handling)
|
||||
- **IO** - Lazy evaluation and side effect management
|
||||
- **IOOption** - Combine IO with Option for optional values with side effects
|
||||
- **IOResult** - Combine IO with Result for error handling (recommended over IOEither)
|
||||
- **IOResult** - Combine IO with Result for error handling (recommended over IOEither when using standard `error` type)
|
||||
- **Effect** - Composable effects with dependency injection and error handling
|
||||
- **Reader** - Dependency injection pattern
|
||||
- **ReaderOption** - Combine Reader with Option for optional values with dependency injection
|
||||
- **ReaderIOOption** - Combine Reader, IO, and Option for optional values with dependency injection and side effects
|
||||
|
||||
@@ -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,9 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
RIO "github.com/IBM/fp-go/v2/readerio"
|
||||
)
|
||||
|
||||
// Promap is the profunctor map operation that transforms both the input and output of a context-based ReaderIO.
|
||||
@@ -33,21 +36,24 @@ import (
|
||||
// The function f returns both a new context and a CancelFunc that should be called to release resources.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - R: The input environment type that f transforms into context.Context
|
||||
// - A: The original result type produced by the ReaderIO
|
||||
// - B: The new output result type
|
||||
//
|
||||
// Parameters:
|
||||
// - f: Function to transform the input context (contravariant)
|
||||
// - f: Function to transform the input environment R into context.Context (contravariant)
|
||||
// - g: Function to transform the output value from A to B (covariant)
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that takes a ReaderIO[A] and returns a ReaderIO[B]
|
||||
// - A Kleisli arrow that takes a ReaderIO[A] and returns a function from R to B
|
||||
//
|
||||
// Note: When R is context.Context, this simplifies to an Operator[A, 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[R, A, B any](f pair.Kleisli[context.CancelFunc, R, context.Context], g func(A) B) RIO.Kleisli[R, ReaderIO[A], B] {
|
||||
return function.Flow2(
|
||||
Local[A](f),
|
||||
Map(g),
|
||||
RIO.Map[R](g),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,14 +67,87 @@ func Promap[A, B any](f func(context.Context) (context.Context, context.CancelFu
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The result type (unchanged)
|
||||
// - R: The input environment type that f transforms into context.Context
|
||||
//
|
||||
// Parameters:
|
||||
// - f: Function to transform the context, returning a new context and CancelFunc
|
||||
// - f: Function to transform the input environment R into context.Context, returning a new context and CancelFunc
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that takes a ReaderIO[A] and returns a ReaderIO[A]
|
||||
// - A Kleisli arrow that takes a ReaderIO[A] and returns a function from R to A
|
||||
//
|
||||
// Note: When R is context.Context, this simplifies to an Operator[A, A]
|
||||
//
|
||||
//go:inline
|
||||
func Contramap[A any](f func(context.Context) (context.Context, context.CancelFunc)) Operator[A, A] {
|
||||
func Contramap[A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) RIO.Kleisli[R, ReaderIO[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"
|
||||
)
|
||||
@@ -633,12 +634,15 @@ func ReadIO[A any](r IO[context.Context]) func(ReaderIO[A]) IO[A] {
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The value type of the ReaderIO
|
||||
// - R: The input environment type that f transforms into context.Context
|
||||
//
|
||||
// Parameters:
|
||||
// - f: A function that transforms the context and returns a cancel function
|
||||
// - f: A function that transforms the input environment R into context.Context and returns a cancel function
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that runs the computation with the transformed context
|
||||
// - A Kleisli arrow that runs the computation with the transformed context
|
||||
//
|
||||
// Note: When R is context.Context, this simplifies to an Operator[A, A]
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
@@ -648,9 +652,9 @@ func ReadIO[A any](r IO[context.Context]) func(ReaderIO[A]) IO[A] {
|
||||
// type key int
|
||||
// const userKey key = 0
|
||||
//
|
||||
// addUser := readerio.Local[string](func(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
// addUser := readerio.Local[string, context.Context](func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
|
||||
// newCtx := context.WithValue(ctx, userKey, "Alice")
|
||||
// return newCtx, func() {} // No-op cancel
|
||||
// return pair.MakePair(func() {}, newCtx) // No-op cancel
|
||||
// })
|
||||
//
|
||||
// getUser := readerio.FromReader(func(ctx context.Context) string {
|
||||
@@ -669,19 +673,20 @@ func ReadIO[A any](r IO[context.Context]) func(ReaderIO[A]) IO[A] {
|
||||
// Timeout Example:
|
||||
//
|
||||
// // Add a 5-second timeout to a specific operation
|
||||
// withTimeout := readerio.Local[Data](func(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
// return context.WithTimeout(ctx, 5*time.Second)
|
||||
// withTimeout := readerio.Local[Data, context.Context](func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
|
||||
// newCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
// return pair.MakePair(cancel, newCtx)
|
||||
// })
|
||||
//
|
||||
// result := F.Pipe1(
|
||||
// fetchData,
|
||||
// withTimeout,
|
||||
// )
|
||||
func Local[A any](f func(context.Context) (context.Context, context.CancelFunc)) Operator[A, A] {
|
||||
return func(rr ReaderIO[A]) ReaderIO[A] {
|
||||
return func(ctx context.Context) IO[A] {
|
||||
func Local[A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) RIO.Kleisli[R, ReaderIO[A], A] {
|
||||
return func(rr ReaderIO[A]) RIO.ReaderIO[R, A] {
|
||||
return func(r R) IO[A] {
|
||||
return func() A {
|
||||
otherCtx, otherCancel := f(ctx)
|
||||
otherCancel, otherCtx := pair.Unpack(f(r))
|
||||
defer otherCancel()
|
||||
return rr(otherCtx)()
|
||||
}
|
||||
@@ -742,8 +747,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 +812,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -369,18 +249,26 @@ func onExitAny(
|
||||
// )
|
||||
func LogEntryExitWithCallback[A any](
|
||||
logLevel slog.Level,
|
||||
cb func(context.Context) *slog.Logger,
|
||||
cb Reader[context.Context, *slog.Logger],
|
||||
name string) Operator[A, A] {
|
||||
|
||||
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.
|
||||
@@ -499,12 +387,12 @@ func LogEntryExit[A any](name string) Operator[A, A] {
|
||||
func curriedLog(
|
||||
logLevel slog.Level,
|
||||
cb func(context.Context) *slog.Logger,
|
||||
message string) func(slog.Attr) func(context.Context) func() struct{} {
|
||||
return F.Curry2(func(a slog.Attr, ctx context.Context) func() struct{} {
|
||||
message string) func(slog.Attr) ReaderIO[Void] {
|
||||
return F.Curry2(func(a slog.Attr, ctx context.Context) IO[Void] {
|
||||
logger := cb(ctx)
|
||||
return func() struct{} {
|
||||
return func() Void {
|
||||
logger.LogAttrs(ctx, logLevel, message, a)
|
||||
return struct{}{}
|
||||
return F.VOID
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -571,7 +459,7 @@ func curriedLog(
|
||||
// - Conditional logging: Enable/disable logging based on logger configuration
|
||||
func SLogWithCallback[A any](
|
||||
logLevel slog.Level,
|
||||
cb func(context.Context) *slog.Logger,
|
||||
cb Reader[context.Context, *slog.Logger],
|
||||
message string) Kleisli[Result[A], A] {
|
||||
|
||||
return F.Pipe1(
|
||||
@@ -582,18 +470,23 @@ func SLogWithCallback[A any](
|
||||
curriedLog(logLevel, cb, message),
|
||||
),
|
||||
// preserve the original context
|
||||
reader.Chain(reader.Sequence(readerio.MapTo[struct{}, Result[A]])),
|
||||
reader.Chain(reader.Sequence(readerio.MapTo[Void, Result[A]])),
|
||||
)
|
||||
}
|
||||
|
||||
// SLog creates a Kleisli arrow that logs a Result value (success or error) with a message.
|
||||
//
|
||||
// This function logs both successful values and errors at Info level using the logger from the context.
|
||||
// This function logs both successful values and errors at Info level. It retrieves the logger
|
||||
// using logging.GetLoggerFromContext, which returns either:
|
||||
// - The logger stored in the context via logging.WithLogger, or
|
||||
// - The global logger (set via logging.SetLogger or slog.Default())
|
||||
//
|
||||
// It's a convenience wrapper around SLogWithCallback with standard settings.
|
||||
//
|
||||
// The logged output includes:
|
||||
// - For success: The message with the value as a structured "value" attribute
|
||||
// - For error: The message with the error as a structured "error" attribute
|
||||
// The message parameter becomes the main log message text, and the Result value or error
|
||||
// is attached as a structured logging attribute:
|
||||
// - For success: Logs message with attribute value=<the actual value>
|
||||
// - For error: Logs message with attribute error=<the error>
|
||||
//
|
||||
// The Result is passed through unchanged after logging, making this function transparent in the
|
||||
// computation pipeline.
|
||||
@@ -647,25 +540,47 @@ func SLog[A any](message string) Kleisli[Result[A], A] {
|
||||
return SLogWithCallback[A](slog.LevelInfo, logging.GetLoggerFromContext, message)
|
||||
}
|
||||
|
||||
// TapSLog creates an operator that logs only successful values with a message and passes them through unchanged.
|
||||
// TapSLog creates an operator that logs both successful values and errors with a message,
|
||||
// and passes the ReaderIOResult through unchanged.
|
||||
//
|
||||
// This function is useful for debugging and monitoring values as they flow through a ReaderIOResult
|
||||
// computation chain. Unlike SLog which logs both successes and errors, TapSLog only logs when the
|
||||
// computation is successful. If the computation contains an error, no logging occurs and the error
|
||||
// is propagated unchanged.
|
||||
// computation chain. It logs both successful values and errors at Info level. It retrieves the logger
|
||||
// using logging.GetLoggerFromContext, which returns either:
|
||||
// - The logger stored in the context via logging.WithLogger, or
|
||||
// - The global logger (set via logging.SetLogger or slog.Default())
|
||||
//
|
||||
// The logged output includes:
|
||||
// - The provided message
|
||||
// - The value being passed through (as a structured "value" attribute)
|
||||
// The ReaderIOResult is returned unchanged after logging.
|
||||
//
|
||||
// The difference between TapSLog and SLog is their position in the pipeline:
|
||||
// - SLog is a Kleisli[Result[A], A] used with Chain to intercept the raw Result
|
||||
// - TapSLog is an Operator[A, A] used directly in a pipe on a ReaderIOResult[A]
|
||||
//
|
||||
// Both log the same information (success value or error), but TapSLog is more ergonomic
|
||||
// when composing ReaderIOResult pipelines with F.Pipe.
|
||||
//
|
||||
// The message parameter becomes the main log message text, and the Result value or error
|
||||
// is attached as a structured logging attribute:
|
||||
// - For success: Logs message with attribute value=<the actual value>
|
||||
// - For error: Logs message with attribute error=<the error>
|
||||
//
|
||||
// For example, TapSLog[User]("Fetched user") with a successful result produces:
|
||||
//
|
||||
// Log message: "Fetched user"
|
||||
// Structured attribute: value={ID:123 Name:"Alice"}
|
||||
//
|
||||
// With an error result, it produces:
|
||||
//
|
||||
// Log message: "Fetched user"
|
||||
// Structured attribute: error="user not found"
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of the value to log and pass through
|
||||
// - A: The success type of the ReaderIOResult to log and pass through
|
||||
//
|
||||
// Parameters:
|
||||
// - message: A descriptive message to include in the log entry
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that logs successful values and returns them unchanged
|
||||
// - An Operator that logs the Result (value or error) and returns the ReaderIOResult unchanged
|
||||
//
|
||||
// Example with simple value logging:
|
||||
//
|
||||
@@ -680,7 +595,7 @@ func SLog[A any](message string) Kleisli[Result[A], A] {
|
||||
// )
|
||||
//
|
||||
// result := pipeline(t.Context())()
|
||||
// // Logs: "Fetched user" value={ID:123 Name:"Alice"}
|
||||
// // If successful, logs: "Fetched user" value={ID:123 Name:"Alice"}
|
||||
// // Returns: result.Of("Alice")
|
||||
//
|
||||
// Example in a processing pipeline:
|
||||
@@ -695,36 +610,36 @@ func SLog[A any](message string) Kleisli[Result[A], A] {
|
||||
// )
|
||||
//
|
||||
// result := processOrder(t.Context())()
|
||||
// // Logs each successful step with the intermediate values
|
||||
// // If any step fails, subsequent TapSLog calls don't log
|
||||
// // Logs each step with its value or error
|
||||
//
|
||||
// Example with error handling:
|
||||
//
|
||||
// pipeline := F.Pipe3(
|
||||
// fetchData(id),
|
||||
// TapSLog[Data]("Data fetched"),
|
||||
// Chain(func(d Data) ReaderIOResult[Result] {
|
||||
// Chain(func(d Data) ReaderIOResult[Data] {
|
||||
// if d.IsValid() {
|
||||
// return Of(processData(d))
|
||||
// }
|
||||
// return Left[Result](errors.New("invalid data"))
|
||||
// return Left[Data](errors.New("invalid data"))
|
||||
// }),
|
||||
// TapSLog[Result]("Data processed"),
|
||||
// TapSLog[Data]("Data processed"),
|
||||
// )
|
||||
//
|
||||
// // If fetchData succeeds: logs "Data fetched" with the data
|
||||
// // If processing succeeds: logs "Data processed" with the result
|
||||
// // If processing fails: "Data processed" is NOT logged (error propagates)
|
||||
// // If fetchData succeeds: logs "Data fetched" value={...}
|
||||
// // If fetchData fails: logs "Data fetched" error="..."
|
||||
// // If processing succeeds: logs "Data processed" value={...}
|
||||
// // If processing fails: logs "Data processed" error="invalid data"
|
||||
//
|
||||
// Use Cases:
|
||||
// - Debugging: Inspect intermediate successful values in a computation pipeline
|
||||
// - Monitoring: Track successful data flow through complex operations
|
||||
// - Troubleshooting: Identify where successful computations stop (last logged value before error)
|
||||
// - Auditing: Log important successful values for compliance or security
|
||||
// - Development: Understand data transformations during development
|
||||
// - Debugging: Inspect intermediate values and errors in a computation pipeline
|
||||
// - Monitoring: Track both successful and failed data flow through complex operations
|
||||
// - Troubleshooting: Identify where errors are introduced or propagated
|
||||
// - Auditing: Log important values and failures for compliance or security
|
||||
// - Development: Understand data transformations and error paths during development
|
||||
//
|
||||
// Note: This function only logs successful values. Errors are silently propagated without logging.
|
||||
// For logging both successes and errors, use SLog instead.
|
||||
// Note: This function logs both successful values and errors. It is equivalent to SLog
|
||||
// but expressed as an Operator for direct use in F.Pipe pipelines on ReaderIOResult values.
|
||||
//
|
||||
//go:inline
|
||||
func TapSLog[A any](message string) Operator[A, A] {
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
"github.com/IBM/fp-go/v2/ioresult"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
RIOR "github.com/IBM/fp-go/v2/readerioresult"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
)
|
||||
|
||||
@@ -38,21 +39,24 @@ import (
|
||||
// The error type is fixed as error and remains unchanged through the transformation.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - R: The input environment type that f transforms into context.Context
|
||||
// - A: The original success type produced by the ReaderIOResult
|
||||
// - B: The new output success type
|
||||
//
|
||||
// Parameters:
|
||||
// - f: Function to transform the input context (contravariant)
|
||||
// - f: Function to transform the input environment R into context.Context (contravariant)
|
||||
// - g: Function to transform the output success value from A to B (covariant)
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that takes a ReaderIOResult[A] and returns a ReaderIOResult[B]
|
||||
// - A Kleisli arrow that takes a ReaderIOResult[A] and returns a function from R to B
|
||||
//
|
||||
// Note: When R is context.Context, this simplifies to an Operator[A, B]
|
||||
//
|
||||
//go:inline
|
||||
func Promap[A, B any](f pair.Kleisli[context.CancelFunc, context.Context, context.Context], g func(A) B) Operator[A, B] {
|
||||
func Promap[R, A, B any](f pair.Kleisli[context.CancelFunc, R, context.Context], g func(A) B) RIOR.Kleisli[R, ReaderIOResult[A], B] {
|
||||
return function.Flow2(
|
||||
Local[A](f),
|
||||
Map(g),
|
||||
RIOR.Map[R](g),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,18 +70,41 @@ func Promap[A, B any](f pair.Kleisli[context.CancelFunc, context.Context, contex
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The success type (unchanged)
|
||||
// - R: The input environment type that f transforms into context.Context
|
||||
//
|
||||
// Parameters:
|
||||
// - f: Function to transform the context, returning a new context and CancelFunc
|
||||
// - f: Function to transform the input environment R into context.Context, returning a new context and CancelFunc
|
||||
//
|
||||
// Returns:
|
||||
// - A Kleisli arrow that takes a ReaderIOResult[A] and returns a function from R to A
|
||||
//
|
||||
// Note: When R is context.Context, this simplifies to an Operator[A, A]
|
||||
//
|
||||
//go:inline
|
||||
func Contramap[A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) RIOR.Kleisli[R, ReaderIOResult[A], A] {
|
||||
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 Contramap[A any](f pair.Kleisli[context.CancelFunc, context.Context, context.Context]) Operator[A, A] {
|
||||
return Local[A](f)
|
||||
}
|
||||
|
||||
func ContramapIOK[A any](f io.Kleisli[context.Context, ContextCancel]) Operator[A, A] {
|
||||
return LocalIOK[A](f)
|
||||
}
|
||||
@@ -189,8 +216,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) {
|
||||
|
||||
@@ -32,7 +32,6 @@ import (
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
RIOR "github.com/IBM/fp-go/v2/readerioresult"
|
||||
"github.com/IBM/fp-go/v2/readeroption"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -1011,12 +1010,15 @@ func TapLeftIOK[A, B any](f io.Kleisli[error, B]) Operator[A, A] {
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The value type of the ReaderIOResult
|
||||
// - R: The input environment type that f transforms into context.Context
|
||||
//
|
||||
// Parameters:
|
||||
// - f: A function that transforms the context and returns a cancel function
|
||||
// - f: A function that transforms the input environment R into context.Context and returns a cancel function
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that runs the computation with the transformed context
|
||||
// - A Kleisli arrow that runs the computation with the transformed context
|
||||
//
|
||||
// Note: When R is context.Context, this simplifies to an Operator[A, A]
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
@@ -1026,9 +1028,9 @@ func TapLeftIOK[A, B any](f io.Kleisli[error, B]) Operator[A, A] {
|
||||
// type key int
|
||||
// const userKey key = 0
|
||||
//
|
||||
// addUser := readerioresult.Local[string](func(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
// addUser := readerioresult.Local[string, context.Context](func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
|
||||
// newCtx := context.WithValue(ctx, userKey, "Alice")
|
||||
// return newCtx, func() {} // No-op cancel
|
||||
// return pair.MakePair(func() {}, newCtx) // No-op cancel
|
||||
// })
|
||||
//
|
||||
// getUser := readerioresult.FromReader(func(ctx context.Context) string {
|
||||
@@ -1047,27 +1049,19 @@ func TapLeftIOK[A, B any](f io.Kleisli[error, B]) Operator[A, A] {
|
||||
// Timeout Example:
|
||||
//
|
||||
// // Add a 5-second timeout to a specific operation
|
||||
// withTimeout := readerioresult.Local[Data](func(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
// return context.WithTimeout(ctx, 5*time.Second)
|
||||
// withTimeout := readerioresult.Local[Data, context.Context](func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
|
||||
// newCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
// return pair.MakePair(cancel, newCtx)
|
||||
// })
|
||||
//
|
||||
// result := F.Pipe1(
|
||||
// fetchData,
|
||||
// withTimeout,
|
||||
// )
|
||||
func Local[A any](f pair.Kleisli[context.CancelFunc, context.Context, context.Context]) Operator[A, A] {
|
||||
return func(rr ReaderIOResult[A]) ReaderIOResult[A] {
|
||||
return func(ctx context.Context) IOResult[A] {
|
||||
return func() Result[A] {
|
||||
if ctx.Err() != nil {
|
||||
return result.Left[A](context.Cause(ctx))
|
||||
}
|
||||
otherCancel, otherCtx := pair.Unpack(f(ctx))
|
||||
defer otherCancel()
|
||||
return rr(otherCtx)()
|
||||
}
|
||||
}
|
||||
}
|
||||
//
|
||||
//go:inline
|
||||
func Local[A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) RIOR.Kleisli[R, ReaderIOResult[A], A] {
|
||||
return readerio.Local[Result[A]](f)
|
||||
}
|
||||
|
||||
// WithTimeout adds a timeout to the context for a ReaderIOResult computation.
|
||||
|
||||
213
v2/context/readerreaderioresult/eitherize.go
Normal file
213
v2/context/readerreaderioresult/eitherize.go
Normal file
@@ -0,0 +1,213 @@
|
||||
// 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 readerreaderioresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/ioresult"
|
||||
)
|
||||
|
||||
// Eitherize converts a function that returns a value and error into a ReaderReaderIOResult.
|
||||
//
|
||||
// This function takes a function that accepts an outer context R and context.Context,
|
||||
// returning a value T and an error, and converts it into a ReaderReaderIOResult[R, T].
|
||||
// The error is automatically converted into the Left case of the Result, while successful
|
||||
// values become the Right case.
|
||||
//
|
||||
// This is particularly useful for integrating standard Go error-handling patterns into
|
||||
// the functional programming style of ReaderReaderIOResult. It is especially helpful
|
||||
// for adapting interface member functions that accept a context. When you have an
|
||||
// interface method with signature (receiver, context.Context) (T, error), you can
|
||||
// use Eitherize to convert it into a ReaderReaderIOResult where the receiver becomes
|
||||
// the outer reader context R.
|
||||
//
|
||||
// # Type Parameters
|
||||
//
|
||||
// - R: The outer reader context type (e.g., application configuration)
|
||||
// - T: The success value type
|
||||
//
|
||||
// # Parameters
|
||||
//
|
||||
// - f: A function that takes R and context.Context and returns (T, error)
|
||||
//
|
||||
// # Returns
|
||||
//
|
||||
// - ReaderReaderIOResult[R, T]: A computation that depends on R and context.Context,
|
||||
// performs IO, and produces a Result[T]
|
||||
//
|
||||
// # Example Usage
|
||||
//
|
||||
// type AppConfig struct {
|
||||
// DatabaseURL string
|
||||
// }
|
||||
//
|
||||
// // A function using standard Go error handling
|
||||
// func fetchUser(cfg AppConfig, ctx context.Context) (*User, error) {
|
||||
// // Implementation that may return an error
|
||||
// return &User{ID: 1, Name: "Alice"}, nil
|
||||
// }
|
||||
//
|
||||
// // Convert to ReaderReaderIOResult
|
||||
// fetchUserRR := Eitherize(fetchUser)
|
||||
//
|
||||
// // Use in functional composition
|
||||
// result := F.Pipe1(
|
||||
// fetchUserRR,
|
||||
// Map[AppConfig](func(u *User) string { return u.Name }),
|
||||
// )
|
||||
//
|
||||
// // Execute with config and context
|
||||
// cfg := AppConfig{DatabaseURL: "postgres://localhost"}
|
||||
// outcome := result(cfg)(context.Background())()
|
||||
//
|
||||
// # Adapting Interface Methods
|
||||
//
|
||||
// Eitherize is particularly useful for adapting interface member functions:
|
||||
//
|
||||
// type UserRepository interface {
|
||||
// GetUser(ctx context.Context, id int) (*User, error)
|
||||
// }
|
||||
//
|
||||
// type UserRepo struct {
|
||||
// db *sql.DB
|
||||
// }
|
||||
//
|
||||
// func (r *UserRepo) GetUser(ctx context.Context, id int) (*User, error) {
|
||||
// // Implementation
|
||||
// return &User{ID: id}, nil
|
||||
// }
|
||||
//
|
||||
// // Adapt the method by binding the first parameter (receiver)
|
||||
// repo := &UserRepo{db: db}
|
||||
// getUserRR := Eitherize(func(id int, ctx context.Context) (*User, error) {
|
||||
// return repo.GetUser(ctx, id)
|
||||
// })
|
||||
//
|
||||
// // Now getUserRR has type: ReaderReaderIOResult[int, *User]
|
||||
// // The receiver (repo) is captured in the closure
|
||||
// // The id becomes the outer reader context R
|
||||
//
|
||||
// # See Also
|
||||
//
|
||||
// - Eitherize1: For functions that take an additional parameter
|
||||
// - ioresult.Eitherize2: The underlying conversion function
|
||||
func Eitherize[R, T any](f func(R, context.Context) (T, error)) ReaderReaderIOResult[R, T] {
|
||||
return F.Pipe1(
|
||||
ioresult.Eitherize2(f),
|
||||
F.Curry2,
|
||||
)
|
||||
}
|
||||
|
||||
// Eitherize1 converts a function that takes an additional parameter and returns a value
|
||||
// and error into a Kleisli arrow.
|
||||
//
|
||||
// This function takes a function that accepts an outer context R, context.Context, and
|
||||
// an additional parameter A, returning a value T and an error, and converts it into a
|
||||
// Kleisli arrow (A -> ReaderReaderIOResult[R, T]). The error is automatically converted
|
||||
// into the Left case of the Result, while successful values become the Right case.
|
||||
//
|
||||
// This is useful for creating composable operations that depend on both contexts and
|
||||
// an input value, following standard Go error-handling patterns. It is especially helpful
|
||||
// for adapting interface member functions that accept a context and additional parameters.
|
||||
// When you have an interface method with signature (receiver, context.Context, A) (T, error),
|
||||
// you can use Eitherize1 to convert it into a Kleisli arrow where the receiver becomes
|
||||
// the outer reader context R and A becomes the input parameter.
|
||||
//
|
||||
// # Type Parameters
|
||||
//
|
||||
// - R: The outer reader context type (e.g., application configuration)
|
||||
// - A: The input parameter type
|
||||
// - T: The success value type
|
||||
//
|
||||
// # Parameters
|
||||
//
|
||||
// - f: A function that takes R, context.Context, and A, returning (T, error)
|
||||
//
|
||||
// # Returns
|
||||
//
|
||||
// - Kleisli[R, A, T]: A function from A to ReaderReaderIOResult[R, T]
|
||||
//
|
||||
// # Example Usage
|
||||
//
|
||||
// type AppConfig struct {
|
||||
// DatabaseURL string
|
||||
// }
|
||||
//
|
||||
// // A function using standard Go error handling
|
||||
// func fetchUserByID(cfg AppConfig, ctx context.Context, id int) (*User, error) {
|
||||
// // Implementation that may return an error
|
||||
// return &User{ID: id, Name: "Alice"}, nil
|
||||
// }
|
||||
//
|
||||
// // Convert to Kleisli arrow
|
||||
// fetchUserKleisli := Eitherize1(fetchUserByID)
|
||||
//
|
||||
// // Use in functional composition with Chain
|
||||
// pipeline := F.Pipe1(
|
||||
// Of[AppConfig](123),
|
||||
// Chain[AppConfig](fetchUserKleisli),
|
||||
// )
|
||||
//
|
||||
// // Execute with config and context
|
||||
// cfg := AppConfig{DatabaseURL: "postgres://localhost"}
|
||||
// outcome := pipeline(cfg)(context.Background())()
|
||||
//
|
||||
// # Adapting Interface Methods
|
||||
//
|
||||
// Eitherize1 is particularly useful for adapting interface member functions with parameters:
|
||||
//
|
||||
// type UserRepository interface {
|
||||
// GetUserByID(ctx context.Context, id int) (*User, error)
|
||||
// UpdateUser(ctx context.Context, user *User) error
|
||||
// }
|
||||
//
|
||||
// type UserRepo struct {
|
||||
// db *sql.DB
|
||||
// }
|
||||
//
|
||||
// func (r *UserRepo) GetUserByID(ctx context.Context, id int) (*User, error) {
|
||||
// // Implementation
|
||||
// return &User{ID: id}, nil
|
||||
// }
|
||||
//
|
||||
// // Adapt the method - receiver becomes R, id becomes A
|
||||
// repo := &UserRepo{db: db}
|
||||
// getUserKleisli := Eitherize1(func(r *UserRepo, ctx context.Context, id int) (*User, error) {
|
||||
// return r.GetUserByID(ctx, id)
|
||||
// })
|
||||
//
|
||||
// // Now getUserKleisli has type: Kleisli[*UserRepo, int, *User]
|
||||
// // Which is: func(int) ReaderReaderIOResult[*UserRepo, *User]
|
||||
// // Use it in composition:
|
||||
// pipeline := F.Pipe1(
|
||||
// Of[*UserRepo](123),
|
||||
// Chain[*UserRepo](getUserKleisli),
|
||||
// )
|
||||
// result := pipeline(repo)(context.Background())()
|
||||
//
|
||||
// # See Also
|
||||
//
|
||||
// - Eitherize: For functions without an additional parameter
|
||||
// - Chain: For composing Kleisli arrows
|
||||
// - ioresult.Eitherize3: The underlying conversion function
|
||||
func Eitherize1[R, A, T any](f func(R, context.Context, A) (T, error)) Kleisli[R, A, T] {
|
||||
return F.Flow2(
|
||||
F.Bind3of3(ioresult.Eitherize3(f)),
|
||||
F.Curry2,
|
||||
)
|
||||
}
|
||||
507
v2/context/readerreaderioresult/eitherize_test.go
Normal file
507
v2/context/readerreaderioresult/eitherize_test.go
Normal file
@@ -0,0 +1,507 @@
|
||||
// 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 readerreaderioresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type TestConfig struct {
|
||||
Prefix string
|
||||
MaxLen int
|
||||
}
|
||||
|
||||
var testConfig = TestConfig{
|
||||
Prefix: "test",
|
||||
MaxLen: 100,
|
||||
}
|
||||
|
||||
// TestEitherize_Success tests successful conversion with Eitherize
|
||||
func TestEitherize_Success(t *testing.T) {
|
||||
t.Run("converts successful function to ReaderReaderIOResult", func(t *testing.T) {
|
||||
// Arrange
|
||||
successFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
|
||||
return cfg.Prefix + "-success", nil
|
||||
}
|
||||
rr := Eitherize(successFunc)
|
||||
|
||||
// Act
|
||||
outcome := rr(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, result.Of("test-success"), outcome)
|
||||
})
|
||||
|
||||
t.Run("preserves context values", func(t *testing.T) {
|
||||
// Arrange
|
||||
type ctxKey string
|
||||
key := ctxKey("testKey")
|
||||
expectedValue := "contextValue"
|
||||
|
||||
contextFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
|
||||
value := ctx.Value(key)
|
||||
if value == nil {
|
||||
return "", errors.New("context value not found")
|
||||
}
|
||||
return value.(string), nil
|
||||
}
|
||||
rr := Eitherize(contextFunc)
|
||||
|
||||
ctx := context.WithValue(context.Background(), key, expectedValue)
|
||||
|
||||
// Act
|
||||
outcome := rr(testConfig)(ctx)()
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, result.Of(expectedValue), outcome)
|
||||
})
|
||||
|
||||
t.Run("works with different types", func(t *testing.T) {
|
||||
// Arrange
|
||||
intFunc := func(cfg TestConfig, ctx context.Context) (int, error) {
|
||||
return cfg.MaxLen, nil
|
||||
}
|
||||
rr := Eitherize(intFunc)
|
||||
|
||||
// Act
|
||||
outcome := rr(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, result.Of(100), outcome)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherize_Failure tests error handling with Eitherize
|
||||
func TestEitherize_Failure(t *testing.T) {
|
||||
t.Run("converts error to Left", func(t *testing.T) {
|
||||
// Arrange
|
||||
expectedErr := errors.New("operation failed")
|
||||
failFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
|
||||
return "", expectedErr
|
||||
}
|
||||
rr := Eitherize(failFunc)
|
||||
|
||||
// Act
|
||||
outcome := rr(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.True(t, result.IsLeft(outcome))
|
||||
assert.Equal(t, result.Left[string](expectedErr), outcome)
|
||||
})
|
||||
|
||||
t.Run("preserves error message", func(t *testing.T) {
|
||||
// Arrange
|
||||
expectedErr := fmt.Errorf("validation error: field is required")
|
||||
failFunc := func(cfg TestConfig, ctx context.Context) (int, error) {
|
||||
return 0, expectedErr
|
||||
}
|
||||
rr := Eitherize(failFunc)
|
||||
|
||||
// Act
|
||||
outcome := rr(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.True(t, result.IsLeft(outcome))
|
||||
leftValue := result.MonadFold(outcome,
|
||||
F.Identity[error],
|
||||
func(int) error { return nil },
|
||||
)
|
||||
assert.Equal(t, expectedErr, leftValue)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherize_EdgeCases tests edge cases for Eitherize
|
||||
func TestEitherize_EdgeCases(t *testing.T) {
|
||||
t.Run("handles nil context", func(t *testing.T) {
|
||||
// Arrange
|
||||
nilCtxFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
|
||||
if ctx == nil {
|
||||
return "nil-context", nil
|
||||
}
|
||||
return "non-nil-context", nil
|
||||
}
|
||||
rr := Eitherize(nilCtxFunc)
|
||||
|
||||
// Act
|
||||
outcome := rr(testConfig)(nil)()
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, result.Of("nil-context"), outcome)
|
||||
})
|
||||
|
||||
t.Run("handles zero value config", func(t *testing.T) {
|
||||
// Arrange
|
||||
zeroFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
|
||||
return cfg.Prefix, nil
|
||||
}
|
||||
rr := Eitherize(zeroFunc)
|
||||
|
||||
// Act
|
||||
outcome := rr(TestConfig{})(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, result.Of(""), outcome)
|
||||
})
|
||||
|
||||
t.Run("handles pointer types", func(t *testing.T) {
|
||||
// Arrange
|
||||
type User struct {
|
||||
Name string
|
||||
}
|
||||
ptrFunc := func(cfg TestConfig, ctx context.Context) (*User, error) {
|
||||
return &User{Name: "Alice"}, nil
|
||||
}
|
||||
rr := Eitherize(ptrFunc)
|
||||
|
||||
// Act
|
||||
outcome := rr(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.True(t, result.IsRight(outcome))
|
||||
user := result.MonadFold(outcome,
|
||||
func(error) *User { return nil },
|
||||
F.Identity[*User],
|
||||
)
|
||||
assert.NotNil(t, user)
|
||||
assert.Equal(t, "Alice", user.Name)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherize_Integration tests integration with other operations
|
||||
func TestEitherize_Integration(t *testing.T) {
|
||||
t.Run("composes with Map", func(t *testing.T) {
|
||||
// Arrange
|
||||
baseFunc := func(cfg TestConfig, ctx context.Context) (int, error) {
|
||||
return 42, nil
|
||||
}
|
||||
rr := Eitherize(baseFunc)
|
||||
|
||||
// Act
|
||||
pipeline := F.Pipe1(
|
||||
rr,
|
||||
Map[TestConfig](func(n int) string { return strconv.Itoa(n) }),
|
||||
)
|
||||
outcome := pipeline(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, result.Of("42"), outcome)
|
||||
})
|
||||
|
||||
t.Run("composes with Chain", func(t *testing.T) {
|
||||
// Arrange
|
||||
firstFunc := func(cfg TestConfig, ctx context.Context) (int, error) {
|
||||
return 10, nil
|
||||
}
|
||||
secondFunc := func(n int) ReaderReaderIOResult[TestConfig, string] {
|
||||
return Of[TestConfig](fmt.Sprintf("value: %d", n))
|
||||
}
|
||||
|
||||
// Act
|
||||
pipeline := F.Pipe1(
|
||||
Eitherize(firstFunc),
|
||||
Chain[TestConfig](secondFunc),
|
||||
)
|
||||
outcome := pipeline(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, result.Of("value: 10"), outcome)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherize1_Success tests successful conversion with Eitherize1
|
||||
func TestEitherize1_Success(t *testing.T) {
|
||||
t.Run("converts successful function to Kleisli", func(t *testing.T) {
|
||||
// Arrange
|
||||
addFunc := func(cfg TestConfig, ctx context.Context, n int) (int, error) {
|
||||
return n + cfg.MaxLen, nil
|
||||
}
|
||||
kleisli := Eitherize1(addFunc)
|
||||
|
||||
// Act
|
||||
outcome := kleisli(10)(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, result.Of(110), outcome)
|
||||
})
|
||||
|
||||
t.Run("works with string input", func(t *testing.T) {
|
||||
// Arrange
|
||||
concatFunc := func(cfg TestConfig, ctx context.Context, s string) (string, error) {
|
||||
return cfg.Prefix + "-" + s, nil
|
||||
}
|
||||
kleisli := Eitherize1(concatFunc)
|
||||
|
||||
// Act
|
||||
outcome := kleisli("input")(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, result.Of("test-input"), outcome)
|
||||
})
|
||||
|
||||
t.Run("preserves context in Kleisli", func(t *testing.T) {
|
||||
// Arrange
|
||||
type ctxKey string
|
||||
key := ctxKey("multiplier")
|
||||
|
||||
multiplyFunc := func(cfg TestConfig, ctx context.Context, n int) (int, error) {
|
||||
multiplier := ctx.Value(key)
|
||||
if multiplier == nil {
|
||||
return n, nil
|
||||
}
|
||||
return n * multiplier.(int), nil
|
||||
}
|
||||
kleisli := Eitherize1(multiplyFunc)
|
||||
|
||||
ctx := context.WithValue(context.Background(), key, 3)
|
||||
|
||||
// Act
|
||||
outcome := kleisli(5)(testConfig)(ctx)()
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, result.Of(15), outcome)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherize1_Failure tests error handling with Eitherize1
|
||||
func TestEitherize1_Failure(t *testing.T) {
|
||||
t.Run("converts error to Left in Kleisli", func(t *testing.T) {
|
||||
// Arrange
|
||||
expectedErr := errors.New("division by zero")
|
||||
divideFunc := func(cfg TestConfig, ctx context.Context, n int) (int, error) {
|
||||
if n == 0 {
|
||||
return 0, expectedErr
|
||||
}
|
||||
return 100 / n, nil
|
||||
}
|
||||
kleisli := Eitherize1(divideFunc)
|
||||
|
||||
// Act
|
||||
outcome := kleisli(0)(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.True(t, result.IsLeft(outcome))
|
||||
assert.Equal(t, result.Left[int](expectedErr), outcome)
|
||||
})
|
||||
|
||||
t.Run("preserves error context", func(t *testing.T) {
|
||||
// Arrange
|
||||
validateFunc := func(cfg TestConfig, ctx context.Context, s string) (string, error) {
|
||||
if len(s) > cfg.MaxLen {
|
||||
return "", fmt.Errorf("string too long: %d > %d", len(s), cfg.MaxLen)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
kleisli := Eitherize1(validateFunc)
|
||||
|
||||
longString := string(make([]byte, 200))
|
||||
|
||||
// Act
|
||||
outcome := kleisli(longString)(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.True(t, result.IsLeft(outcome))
|
||||
leftValue := result.MonadFold(outcome,
|
||||
F.Identity[error],
|
||||
func(string) error { return nil },
|
||||
)
|
||||
assert.Contains(t, leftValue.Error(), "string too long")
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherize1_EdgeCases tests edge cases for Eitherize1
|
||||
func TestEitherize1_EdgeCases(t *testing.T) {
|
||||
t.Run("handles zero value input", func(t *testing.T) {
|
||||
// Arrange
|
||||
zeroFunc := func(cfg TestConfig, ctx context.Context, n int) (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
kleisli := Eitherize1(zeroFunc)
|
||||
|
||||
// Act
|
||||
outcome := kleisli(0)(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, result.Of(0), outcome)
|
||||
})
|
||||
|
||||
t.Run("handles pointer input", func(t *testing.T) {
|
||||
// Arrange
|
||||
type Input struct {
|
||||
Value int
|
||||
}
|
||||
ptrFunc := func(cfg TestConfig, ctx context.Context, in *Input) (int, error) {
|
||||
if in == nil {
|
||||
return 0, errors.New("nil input")
|
||||
}
|
||||
return in.Value, nil
|
||||
}
|
||||
kleisli := Eitherize1(ptrFunc)
|
||||
|
||||
// Act
|
||||
outcome := kleisli(&Input{Value: 42})(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, result.Of(42), outcome)
|
||||
})
|
||||
|
||||
t.Run("handles nil pointer input", func(t *testing.T) {
|
||||
// Arrange
|
||||
type Input struct {
|
||||
Value int
|
||||
}
|
||||
ptrFunc := func(cfg TestConfig, ctx context.Context, in *Input) (int, error) {
|
||||
if in == nil {
|
||||
return 0, errors.New("nil input")
|
||||
}
|
||||
return in.Value, nil
|
||||
}
|
||||
kleisli := Eitherize1(ptrFunc)
|
||||
|
||||
// Act
|
||||
outcome := kleisli((*Input)(nil))(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.True(t, result.IsLeft(outcome))
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherize1_Integration tests integration with other operations
|
||||
func TestEitherize1_Integration(t *testing.T) {
|
||||
t.Run("composes with Chain", func(t *testing.T) {
|
||||
// Arrange
|
||||
parseFunc := func(cfg TestConfig, ctx context.Context, s string) (int, error) {
|
||||
return strconv.Atoi(s)
|
||||
}
|
||||
doubleFunc := func(n int) ReaderReaderIOResult[TestConfig, int] {
|
||||
return Of[TestConfig](n * 2)
|
||||
}
|
||||
|
||||
parseKleisli := Eitherize1(parseFunc)
|
||||
|
||||
// Act
|
||||
pipeline := F.Pipe2(
|
||||
Of[TestConfig]("42"),
|
||||
Chain[TestConfig](parseKleisli),
|
||||
Chain[TestConfig](doubleFunc),
|
||||
)
|
||||
outcome := pipeline(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, result.Of(84), outcome)
|
||||
})
|
||||
|
||||
t.Run("handles error in chain", func(t *testing.T) {
|
||||
// Arrange
|
||||
parseFunc := func(cfg TestConfig, ctx context.Context, s string) (int, error) {
|
||||
return strconv.Atoi(s)
|
||||
}
|
||||
parseKleisli := Eitherize1(parseFunc)
|
||||
|
||||
// Act
|
||||
pipeline := F.Pipe1(
|
||||
Of[TestConfig]("not-a-number"),
|
||||
Chain(parseKleisli),
|
||||
)
|
||||
outcome := pipeline(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.True(t, result.IsLeft(outcome))
|
||||
})
|
||||
|
||||
t.Run("composes multiple Kleisli arrows", func(t *testing.T) {
|
||||
// Arrange
|
||||
parseFunc := func(cfg TestConfig, ctx context.Context, s string) (int, error) {
|
||||
return strconv.Atoi(s)
|
||||
}
|
||||
formatFunc := func(cfg TestConfig, ctx context.Context, n int) (string, error) {
|
||||
return fmt.Sprintf("%s-%d", cfg.Prefix, n), nil
|
||||
}
|
||||
|
||||
parseKleisli := Eitherize1(parseFunc)
|
||||
formatKleisli := Eitherize1(formatFunc)
|
||||
|
||||
// Act
|
||||
pipeline := F.Pipe2(
|
||||
Of[TestConfig]("123"),
|
||||
Chain[TestConfig](parseKleisli),
|
||||
Chain[TestConfig](formatKleisli),
|
||||
)
|
||||
outcome := pipeline(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, result.Of("test-123"), outcome)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherize_TypeSafety tests type safety across different scenarios
|
||||
func TestEitherize_TypeSafety(t *testing.T) {
|
||||
t.Run("Eitherize with complex types", func(t *testing.T) {
|
||||
// Arrange
|
||||
type ComplexResult struct {
|
||||
Data map[string]int
|
||||
Count int
|
||||
}
|
||||
|
||||
complexFunc := func(cfg TestConfig, ctx context.Context) (ComplexResult, error) {
|
||||
return ComplexResult{
|
||||
Data: map[string]int{"key": 42},
|
||||
Count: 1,
|
||||
}, nil
|
||||
}
|
||||
rr := Eitherize(complexFunc)
|
||||
|
||||
// Act
|
||||
outcome := rr(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.True(t, result.IsRight(outcome))
|
||||
value := result.MonadFold(outcome,
|
||||
func(error) ComplexResult { return ComplexResult{} },
|
||||
F.Identity[ComplexResult],
|
||||
)
|
||||
assert.Equal(t, 42, value.Data["key"])
|
||||
assert.Equal(t, 1, value.Count)
|
||||
})
|
||||
|
||||
t.Run("Eitherize1 with different input and output types", func(t *testing.T) {
|
||||
// Arrange
|
||||
type Input struct {
|
||||
ID int
|
||||
}
|
||||
type Output struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
convertFunc := func(cfg TestConfig, ctx context.Context, in Input) (Output, error) {
|
||||
return Output{Name: fmt.Sprintf("%s-%d", cfg.Prefix, in.ID)}, nil
|
||||
}
|
||||
kleisli := Eitherize1(convertFunc)
|
||||
|
||||
// Act
|
||||
outcome := kleisli(Input{ID: 99})(testConfig)(context.Background())()
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, result.Of(Output{Name: "test-99"}), outcome)
|
||||
})
|
||||
}
|
||||
@@ -13,6 +13,17 @@ import (
|
||||
// Local modifies the outer environment before passing it to a computation.
|
||||
// Useful for providing different configurations to sub-computations.
|
||||
//
|
||||
// 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 function that transforms R2 to R1
|
||||
//
|
||||
// Returns:
|
||||
// - A function that takes a ReaderReaderIOResult[R1, A] and returns a ReaderReaderIOResult[R2, A]
|
||||
//
|
||||
//go:inline
|
||||
func Local[A, R1, R2 any](f func(R2) R1) func(ReaderReaderIOResult[R1, A]) ReaderReaderIOResult[R2, A] {
|
||||
return RRIOE.Local[context.Context, error, A](f)
|
||||
@@ -102,6 +113,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 +196,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)
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
RR "github.com/IBM/fp-go/v2/readerresult"
|
||||
)
|
||||
|
||||
// Promap is the profunctor map operation that transforms both the input and output of a context-based ReaderResult.
|
||||
@@ -34,21 +36,24 @@ import (
|
||||
// The error type is fixed as error and remains unchanged through the transformation.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - R: The input environment type that f transforms into context.Context
|
||||
// - A: The original success type produced by the ReaderResult
|
||||
// - B: The new output success type
|
||||
//
|
||||
// Parameters:
|
||||
// - f: Function to transform the input context (contravariant)
|
||||
// - f: Function to transform the input environment R into context.Context (contravariant)
|
||||
// - g: Function to transform the output success value from A to B (covariant)
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that takes a ReaderResult[A] and returns a ReaderResult[B]
|
||||
// - A Kleisli arrow that takes a ReaderResult[A] and returns a function from R to B
|
||||
//
|
||||
// Note: When R is context.Context, this simplifies to an Operator[A, 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[R, A, B any](f pair.Kleisli[context.CancelFunc, R, context.Context], g func(A) B) RR.Kleisli[R, ReaderResult[A], B] {
|
||||
return function.Flow2(
|
||||
Local[A](f),
|
||||
Map(g),
|
||||
RR.Map[R](g),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,15 +67,18 @@ func Promap[A, B any](f func(context.Context) (context.Context, context.CancelFu
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The success type (unchanged)
|
||||
// - R: The input environment type that f transforms into context.Context
|
||||
//
|
||||
// Parameters:
|
||||
// - f: Function to transform the context, returning a new context and CancelFunc
|
||||
// - f: Function to transform the input environment R into context.Context, returning a new context and CancelFunc
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that takes a ReaderResult[A] and returns a ReaderResult[A]
|
||||
// - A Kleisli arrow that takes a ReaderResult[A] and returns a function from R to A
|
||||
//
|
||||
// Note: When R is context.Context, this simplifies to an Operator[A, A]
|
||||
//
|
||||
//go:inline
|
||||
func Contramap[A any](f func(context.Context) (context.Context, context.CancelFunc)) Operator[A, A] {
|
||||
func Contramap[A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) RR.Kleisli[R, ReaderResult[A], A] {
|
||||
return Local[A](f)
|
||||
}
|
||||
|
||||
@@ -89,16 +97,19 @@ func Contramap[A any](f func(context.Context) (context.Context, context.CancelFu
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The result type (unchanged)
|
||||
// - R: The input environment type that f transforms into context.Context
|
||||
//
|
||||
// Parameters:
|
||||
// - f: Function to transform the context, returning a new context and CancelFunc
|
||||
// - f: Function to transform the input environment R into context.Context, returning a new context and CancelFunc
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that takes a ReaderResult[A] and returns a ReaderResult[A]
|
||||
func Local[A any](f func(context.Context) (context.Context, context.CancelFunc)) Operator[A, A] {
|
||||
return func(rr ReaderResult[A]) ReaderResult[A] {
|
||||
return func(ctx context.Context) Result[A] {
|
||||
otherCtx, otherCancel := f(ctx)
|
||||
// - A Kleisli arrow that takes a ReaderResult[A] and returns a function from R to A
|
||||
//
|
||||
// Note: When R is context.Context, this simplifies to an Operator[A, A]
|
||||
func Local[A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) RR.Kleisli[R, ReaderResult[A], A] {
|
||||
return func(rr ReaderResult[A]) RR.ReaderResult[R, A] {
|
||||
return func(r R) Result[A] {
|
||||
otherCancel, otherCtx := pair.Unpack(f(r))
|
||||
defer otherCancel()
|
||||
return rr(otherCtx)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
R "github.com/IBM/fp-go/v2/result"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -34,9 +35,9 @@ func TestPromapBasic(t *testing.T) {
|
||||
return R.Of(0)
|
||||
}
|
||||
|
||||
addKey := func(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
addKey := func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
|
||||
newCtx := context.WithValue(ctx, "key", 42)
|
||||
return newCtx, func() {}
|
||||
return pair.MakePair[context.CancelFunc](func() {}, newCtx)
|
||||
}
|
||||
toString := strconv.Itoa
|
||||
|
||||
@@ -57,9 +58,9 @@ func TestContramapBasic(t *testing.T) {
|
||||
return R.Of(0)
|
||||
}
|
||||
|
||||
addKey := func(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
addKey := func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
|
||||
newCtx := context.WithValue(ctx, "key", 100)
|
||||
return newCtx, func() {}
|
||||
return pair.MakePair[context.CancelFunc](func() {}, newCtx)
|
||||
}
|
||||
|
||||
adapted := Contramap[int](addKey)(getValue)
|
||||
@@ -79,9 +80,9 @@ func TestLocalBasic(t *testing.T) {
|
||||
return R.Of("unknown")
|
||||
}
|
||||
|
||||
addUser := func(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
addUser := func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
|
||||
newCtx := context.WithValue(ctx, "user", "Alice")
|
||||
return newCtx, func() {}
|
||||
return pair.MakePair[context.CancelFunc](func() {}, newCtx)
|
||||
}
|
||||
|
||||
adapted := Local[string](addUser)(getValue)
|
||||
|
||||
@@ -21,8 +21,9 @@ 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"
|
||||
SRIOE "github.com/IBM/fp-go/v2/statereaderioeither"
|
||||
)
|
||||
|
||||
// Left creates a StateReaderIOResult that represents a failed computation with the given error.
|
||||
@@ -202,21 +203,42 @@ 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
|
||||
// - R: The input environment type that f transforms into context.Context
|
||||
//
|
||||
// Parameters:
|
||||
// - f: Function to transform the input environment R into context.Context, returning a new context and CancelFunc
|
||||
//
|
||||
// Returns:
|
||||
// - A Kleisli arrow that takes a StateReaderIOResult[S, A] and returns a StateReaderIOEither[S, R, error, A]
|
||||
//
|
||||
// Note: When R is context.Context, the return type simplifies to func(StateReaderIOResult[S, 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, context.Context](
|
||||
// func(ctx context.Context) pair.Pair[context.CancelFunc, context.Context] {
|
||||
// newCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
// return pair.MakePair(cancel, newCtx)
|
||||
// },
|
||||
// )
|
||||
// result := withTimeout(computation)
|
||||
func Local[S, A any](f func(context.Context) context.Context) func(StateReaderIOResult[S, A]) StateReaderIOResult[S, A] {
|
||||
return func(ma StateReaderIOResult[S, A]) StateReaderIOResult[S, A] {
|
||||
return function.Flow2(ma, RIOR.Local[Pair[S, A]](f))
|
||||
func Local[S, A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) SRIOE.Kleisli[S, R, error, StateReaderIOResult[S, A], A] {
|
||||
return func(ma StateReaderIOResult[S, A]) SRIOE.StateReaderIOEither[S, R, error, A] {
|
||||
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]
|
||||
)
|
||||
|
||||
208
v2/effect/eitherize.go
Normal file
208
v2/effect/eitherize.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// 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 effect
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/IBM/fp-go/v2/context/readerreaderioresult"
|
||||
)
|
||||
|
||||
// Eitherize converts a function that returns a value and error into an Effect.
|
||||
//
|
||||
// This function takes a function that accepts a context C and context.Context,
|
||||
// returning a value T and an error, and converts it into an Effect[C, T].
|
||||
// The error is automatically converted into a failure, while successful
|
||||
// values become successes.
|
||||
//
|
||||
// This is particularly useful for integrating standard Go error-handling patterns into
|
||||
// the effect system. It is especially helpful for adapting interface member functions
|
||||
// that accept a context. When you have an interface method with signature
|
||||
// (receiver, context.Context) (T, error), you can use Eitherize to convert it into
|
||||
// an Effect where the receiver becomes the context C.
|
||||
//
|
||||
// # Type Parameters
|
||||
//
|
||||
// - C: The context type required by the effect
|
||||
// - T: The success value type
|
||||
//
|
||||
// # Parameters
|
||||
//
|
||||
// - f: A function that takes C and context.Context and returns (T, error)
|
||||
//
|
||||
// # Returns
|
||||
//
|
||||
// - Effect[C, T]: An effect that depends on C, performs IO, and produces T
|
||||
//
|
||||
// # Example Usage
|
||||
//
|
||||
// type AppConfig struct {
|
||||
// DatabaseURL string
|
||||
// }
|
||||
//
|
||||
// // A function using standard Go error handling
|
||||
// func fetchUser(cfg AppConfig, ctx context.Context) (*User, error) {
|
||||
// // Implementation that may return an error
|
||||
// return &User{ID: 1, Name: "Alice"}, nil
|
||||
// }
|
||||
//
|
||||
// // Convert to Effect
|
||||
// fetchUserEffect := effect.Eitherize(fetchUser)
|
||||
//
|
||||
// // Use in functional composition
|
||||
// pipeline := F.Pipe1(
|
||||
// fetchUserEffect,
|
||||
// effect.Map[AppConfig](func(u *User) string { return u.Name }),
|
||||
// )
|
||||
//
|
||||
// // Execute with config
|
||||
// cfg := AppConfig{DatabaseURL: "postgres://localhost"}
|
||||
// result, err := effect.RunSync(effect.Provide[*User](cfg)(pipeline))(context.Background())
|
||||
//
|
||||
// # Adapting Interface Methods
|
||||
//
|
||||
// Eitherize is particularly useful for adapting interface member functions:
|
||||
//
|
||||
// type UserRepository interface {
|
||||
// GetUser(ctx context.Context, id int) (*User, error)
|
||||
// }
|
||||
//
|
||||
// type UserRepo struct {
|
||||
// db *sql.DB
|
||||
// }
|
||||
//
|
||||
// func (r *UserRepo) GetUser(ctx context.Context, id int) (*User, error) {
|
||||
// // Implementation
|
||||
// return &User{ID: id}, nil
|
||||
// }
|
||||
//
|
||||
// // Adapt the method by binding the first parameter (receiver)
|
||||
// repo := &UserRepo{db: db}
|
||||
// getUserEffect := effect.Eitherize(func(id int, ctx context.Context) (*User, error) {
|
||||
// return repo.GetUser(ctx, id)
|
||||
// })
|
||||
//
|
||||
// // Now getUserEffect has type: Effect[int, *User]
|
||||
// // The receiver (repo) is captured in the closure
|
||||
// // The id becomes the context C
|
||||
//
|
||||
// # See Also
|
||||
//
|
||||
// - Eitherize1: For functions that take an additional parameter
|
||||
// - readerreaderioresult.Eitherize: The underlying implementation
|
||||
//
|
||||
//go:inline
|
||||
func Eitherize[C, T any](f func(C, context.Context) (T, error)) Effect[C, T] {
|
||||
return readerreaderioresult.Eitherize(f)
|
||||
}
|
||||
|
||||
// Eitherize1 converts a function that takes an additional parameter and returns a value
|
||||
// and error into a Kleisli arrow.
|
||||
//
|
||||
// This function takes a function that accepts a context C, context.Context, and
|
||||
// an additional parameter A, returning a value T and an error, and converts it into a
|
||||
// Kleisli arrow (A -> Effect[C, T]). The error is automatically converted into a failure,
|
||||
// while successful values become successes.
|
||||
//
|
||||
// This is useful for creating composable operations that depend on context and
|
||||
// an input value, following standard Go error-handling patterns. It is especially helpful
|
||||
// for adapting interface member functions that accept a context and additional parameters.
|
||||
// When you have an interface method with signature (receiver, context.Context, A) (T, error),
|
||||
// you can use Eitherize1 to convert it into a Kleisli arrow where the receiver becomes
|
||||
// the context C and A becomes the input parameter.
|
||||
//
|
||||
// # Type Parameters
|
||||
//
|
||||
// - C: The context type required by the effect
|
||||
// - A: The input parameter type
|
||||
// - T: The success value type
|
||||
//
|
||||
// # Parameters
|
||||
//
|
||||
// - f: A function that takes C, context.Context, and A, returning (T, error)
|
||||
//
|
||||
// # Returns
|
||||
//
|
||||
// - Kleisli[C, A, T]: A function from A to Effect[C, T]
|
||||
//
|
||||
// # Example Usage
|
||||
//
|
||||
// type AppConfig struct {
|
||||
// DatabaseURL string
|
||||
// }
|
||||
//
|
||||
// // A function using standard Go error handling
|
||||
// func fetchUserByID(cfg AppConfig, ctx context.Context, id int) (*User, error) {
|
||||
// // Implementation that may return an error
|
||||
// return &User{ID: id, Name: "Alice"}, nil
|
||||
// }
|
||||
//
|
||||
// // Convert to Kleisli arrow
|
||||
// fetchUserKleisli := effect.Eitherize1(fetchUserByID)
|
||||
//
|
||||
// // Use in functional composition with Chain
|
||||
// pipeline := F.Pipe1(
|
||||
// effect.Succeed[AppConfig](123),
|
||||
// effect.Chain[AppConfig](fetchUserKleisli),
|
||||
// )
|
||||
//
|
||||
// // Execute with config
|
||||
// cfg := AppConfig{DatabaseURL: "postgres://localhost"}
|
||||
// result, err := effect.RunSync(effect.Provide[*User](cfg)(pipeline))(context.Background())
|
||||
//
|
||||
// # Adapting Interface Methods
|
||||
//
|
||||
// Eitherize1 is particularly useful for adapting interface member functions with parameters:
|
||||
//
|
||||
// type UserRepository interface {
|
||||
// GetUserByID(ctx context.Context, id int) (*User, error)
|
||||
// UpdateUser(ctx context.Context, user *User) error
|
||||
// }
|
||||
//
|
||||
// type UserRepo struct {
|
||||
// db *sql.DB
|
||||
// }
|
||||
//
|
||||
// func (r *UserRepo) GetUserByID(ctx context.Context, id int) (*User, error) {
|
||||
// // Implementation
|
||||
// return &User{ID: id}, nil
|
||||
// }
|
||||
//
|
||||
// // Adapt the method - receiver becomes C, id becomes A
|
||||
// repo := &UserRepo{db: db}
|
||||
// getUserKleisli := effect.Eitherize1(func(r *UserRepo, ctx context.Context, id int) (*User, error) {
|
||||
// return r.GetUserByID(ctx, id)
|
||||
// })
|
||||
//
|
||||
// // Now getUserKleisli has type: Kleisli[*UserRepo, int, *User]
|
||||
// // Which is: func(int) Effect[*UserRepo, *User]
|
||||
// // Use it in composition:
|
||||
// pipeline := F.Pipe1(
|
||||
// effect.Succeed[*UserRepo](123),
|
||||
// effect.Chain[*UserRepo](getUserKleisli),
|
||||
// )
|
||||
// result, err := effect.RunSync(effect.Provide[*User](repo)(pipeline))(context.Background())
|
||||
//
|
||||
// # See Also
|
||||
//
|
||||
// - Eitherize: For functions without an additional parameter
|
||||
// - Chain: For composing Kleisli arrows
|
||||
// - readerreaderioresult.Eitherize1: The underlying implementation
|
||||
//
|
||||
//go:inline
|
||||
func Eitherize1[C, A, T any](f func(C, context.Context, A) (T, error)) Kleisli[C, A, T] {
|
||||
return readerreaderioresult.Eitherize1(f)
|
||||
}
|
||||
507
v2/effect/eitherize_test.go
Normal file
507
v2/effect/eitherize_test.go
Normal file
@@ -0,0 +1,507 @@
|
||||
// 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 effect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestEitherize_Success tests successful conversion with Eitherize
|
||||
func TestEitherize_Success(t *testing.T) {
|
||||
t.Run("converts successful function to Effect", func(t *testing.T) {
|
||||
// Arrange
|
||||
successFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
|
||||
return cfg.Prefix + "-success", nil
|
||||
}
|
||||
eff := Eitherize(successFunc)
|
||||
|
||||
// Act
|
||||
result, err := runEffect(eff, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "LOG-success", result)
|
||||
})
|
||||
|
||||
t.Run("preserves context values", func(t *testing.T) {
|
||||
// Arrange
|
||||
type ctxKey string
|
||||
key := ctxKey("testKey")
|
||||
expectedValue := "contextValue"
|
||||
|
||||
contextFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
|
||||
value := ctx.Value(key)
|
||||
if value == nil {
|
||||
return "", errors.New("context value not found")
|
||||
}
|
||||
return value.(string), nil
|
||||
}
|
||||
eff := Eitherize(contextFunc)
|
||||
|
||||
// Act
|
||||
ioResult := Provide[string](testConfig)(eff)
|
||||
readerResult := RunSync(ioResult)
|
||||
ctx := context.WithValue(context.Background(), key, expectedValue)
|
||||
result, err := readerResult(ctx)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedValue, result)
|
||||
})
|
||||
|
||||
t.Run("works with different types", func(t *testing.T) {
|
||||
// Arrange
|
||||
intFunc := func(cfg TestConfig, ctx context.Context) (int, error) {
|
||||
return cfg.Multiplier, nil
|
||||
}
|
||||
eff := Eitherize(intFunc)
|
||||
|
||||
// Act
|
||||
result, err := runEffect(eff, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherize_Failure tests error handling with Eitherize
|
||||
func TestEitherize_Failure(t *testing.T) {
|
||||
t.Run("converts error to failure", func(t *testing.T) {
|
||||
// Arrange
|
||||
expectedErr := errors.New("operation failed")
|
||||
failFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
|
||||
return "", expectedErr
|
||||
}
|
||||
eff := Eitherize(failFunc)
|
||||
|
||||
// Act
|
||||
_, err := runEffect(eff, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
|
||||
t.Run("preserves error message", func(t *testing.T) {
|
||||
// Arrange
|
||||
expectedErr := fmt.Errorf("validation error: field is required")
|
||||
failFunc := func(cfg TestConfig, ctx context.Context) (int, error) {
|
||||
return 0, expectedErr
|
||||
}
|
||||
eff := Eitherize(failFunc)
|
||||
|
||||
// Act
|
||||
_, err := runEffect(eff, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherize_EdgeCases tests edge cases for Eitherize
|
||||
func TestEitherize_EdgeCases(t *testing.T) {
|
||||
t.Run("handles nil context", func(t *testing.T) {
|
||||
// Arrange
|
||||
nilCtxFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
|
||||
if ctx == nil {
|
||||
return "nil-context", nil
|
||||
}
|
||||
return "non-nil-context", nil
|
||||
}
|
||||
eff := Eitherize(nilCtxFunc)
|
||||
|
||||
// Act
|
||||
ioResult := Provide[string](testConfig)(eff)
|
||||
readerResult := RunSync(ioResult)
|
||||
result, err := readerResult(nil)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "nil-context", result)
|
||||
})
|
||||
|
||||
t.Run("handles zero value config", func(t *testing.T) {
|
||||
// Arrange
|
||||
zeroFunc := func(cfg TestConfig, ctx context.Context) (string, error) {
|
||||
return cfg.Prefix, nil
|
||||
}
|
||||
eff := Eitherize(zeroFunc)
|
||||
|
||||
// Act
|
||||
result, err := runEffect(eff, TestConfig{})
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", result)
|
||||
})
|
||||
|
||||
t.Run("handles pointer types", func(t *testing.T) {
|
||||
// Arrange
|
||||
type User struct {
|
||||
Name string
|
||||
}
|
||||
ptrFunc := func(cfg TestConfig, ctx context.Context) (*User, error) {
|
||||
return &User{Name: cfg.Prefix}, nil
|
||||
}
|
||||
eff := Eitherize(ptrFunc)
|
||||
|
||||
// Act
|
||||
result, err := runEffect(eff, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "LOG", result.Name)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherize_Integration tests integration with other operations
|
||||
func TestEitherize_Integration(t *testing.T) {
|
||||
t.Run("composes with Map", func(t *testing.T) {
|
||||
// Arrange
|
||||
baseFunc := func(cfg TestConfig, ctx context.Context) (int, error) {
|
||||
return cfg.Multiplier, nil
|
||||
}
|
||||
eff := Eitherize(baseFunc)
|
||||
|
||||
// Act
|
||||
pipeline := F.Pipe1(
|
||||
eff,
|
||||
Map[TestConfig](func(n int) string { return strconv.Itoa(n) }),
|
||||
)
|
||||
result, err := runEffect(pipeline, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "3", result)
|
||||
})
|
||||
|
||||
t.Run("composes with Chain", func(t *testing.T) {
|
||||
// Arrange
|
||||
firstFunc := func(cfg TestConfig, ctx context.Context) (int, error) {
|
||||
return cfg.Multiplier, nil
|
||||
}
|
||||
secondFunc := func(n int) Effect[TestConfig, string] {
|
||||
return Succeed[TestConfig](fmt.Sprintf("value: %d", n))
|
||||
}
|
||||
|
||||
// Act
|
||||
pipeline := F.Pipe1(
|
||||
Eitherize(firstFunc),
|
||||
Chain[TestConfig](secondFunc),
|
||||
)
|
||||
result, err := runEffect(pipeline, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "value: 3", result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherize1_Success tests successful conversion with Eitherize1
|
||||
func TestEitherize1_Success(t *testing.T) {
|
||||
t.Run("converts successful function to Kleisli", func(t *testing.T) {
|
||||
// Arrange
|
||||
multiplyFunc := func(cfg TestConfig, ctx context.Context, n int) (int, error) {
|
||||
return n * cfg.Multiplier, nil
|
||||
}
|
||||
kleisli := Eitherize1(multiplyFunc)
|
||||
|
||||
// Act
|
||||
eff := kleisli(10)
|
||||
result, err := runEffect(eff, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 30, result)
|
||||
})
|
||||
|
||||
t.Run("works with string input", func(t *testing.T) {
|
||||
// Arrange
|
||||
concatFunc := func(cfg TestConfig, ctx context.Context, s string) (string, error) {
|
||||
return cfg.Prefix + "-" + s, nil
|
||||
}
|
||||
kleisli := Eitherize1(concatFunc)
|
||||
|
||||
// Act
|
||||
eff := kleisli("input")
|
||||
result, err := runEffect(eff, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "LOG-input", result)
|
||||
})
|
||||
|
||||
t.Run("preserves context in Kleisli", func(t *testing.T) {
|
||||
// Arrange
|
||||
type ctxKey string
|
||||
key := ctxKey("factor")
|
||||
|
||||
scaleFunc := func(cfg TestConfig, ctx context.Context, n int) (int, error) {
|
||||
factor := ctx.Value(key)
|
||||
if factor == nil {
|
||||
return n * cfg.Multiplier, nil
|
||||
}
|
||||
return n * factor.(int), nil
|
||||
}
|
||||
kleisli := Eitherize1(scaleFunc)
|
||||
|
||||
// Act
|
||||
eff := kleisli(5)
|
||||
ioResult := Provide[int](testConfig)(eff)
|
||||
readerResult := RunSync(ioResult)
|
||||
ctx := context.WithValue(context.Background(), key, 7)
|
||||
result, err := readerResult(ctx)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 35, result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherize1_Failure tests error handling with Eitherize1
|
||||
func TestEitherize1_Failure(t *testing.T) {
|
||||
t.Run("converts error to failure in Kleisli", func(t *testing.T) {
|
||||
// Arrange
|
||||
expectedErr := errors.New("division by zero")
|
||||
divideFunc := func(cfg TestConfig, ctx context.Context, n int) (int, error) {
|
||||
if n == 0 {
|
||||
return 0, expectedErr
|
||||
}
|
||||
return 100 / n, nil
|
||||
}
|
||||
kleisli := Eitherize1(divideFunc)
|
||||
|
||||
// Act
|
||||
eff := kleisli(0)
|
||||
_, err := runEffect(eff, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
})
|
||||
|
||||
t.Run("preserves error context", func(t *testing.T) {
|
||||
// Arrange
|
||||
validateFunc := func(cfg TestConfig, ctx context.Context, s string) (string, error) {
|
||||
if len(s) > 10 {
|
||||
return "", fmt.Errorf("string too long: %d > 10", len(s))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
kleisli := Eitherize1(validateFunc)
|
||||
|
||||
// Act
|
||||
eff := kleisli("this-string-is-too-long")
|
||||
_, err := runEffect(eff, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "string too long")
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherize1_EdgeCases tests edge cases for Eitherize1
|
||||
func TestEitherize1_EdgeCases(t *testing.T) {
|
||||
t.Run("handles zero value input", func(t *testing.T) {
|
||||
// Arrange
|
||||
zeroFunc := func(cfg TestConfig, ctx context.Context, n int) (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
kleisli := Eitherize1(zeroFunc)
|
||||
|
||||
// Act
|
||||
eff := kleisli(0)
|
||||
result, err := runEffect(eff, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, result)
|
||||
})
|
||||
|
||||
t.Run("handles pointer input", func(t *testing.T) {
|
||||
// Arrange
|
||||
type Input struct {
|
||||
Value int
|
||||
}
|
||||
ptrFunc := func(cfg TestConfig, ctx context.Context, in *Input) (int, error) {
|
||||
if in == nil {
|
||||
return 0, errors.New("nil input")
|
||||
}
|
||||
return in.Value * cfg.Multiplier, nil
|
||||
}
|
||||
kleisli := Eitherize1(ptrFunc)
|
||||
|
||||
// Act
|
||||
eff := kleisli(&Input{Value: 7})
|
||||
result, err := runEffect(eff, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 21, result)
|
||||
})
|
||||
|
||||
t.Run("handles nil pointer input", func(t *testing.T) {
|
||||
// Arrange
|
||||
type Input struct {
|
||||
Value int
|
||||
}
|
||||
ptrFunc := func(cfg TestConfig, ctx context.Context, in *Input) (int, error) {
|
||||
if in == nil {
|
||||
return 0, errors.New("nil input")
|
||||
}
|
||||
return in.Value, nil
|
||||
}
|
||||
kleisli := Eitherize1(ptrFunc)
|
||||
|
||||
// Act
|
||||
eff := kleisli((*Input)(nil))
|
||||
_, err := runEffect(eff, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "nil input")
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherize1_Integration tests integration with other operations
|
||||
func TestEitherize1_Integration(t *testing.T) {
|
||||
t.Run("composes with Chain", func(t *testing.T) {
|
||||
// Arrange
|
||||
parseFunc := func(cfg TestConfig, ctx context.Context, s string) (int, error) {
|
||||
return strconv.Atoi(s)
|
||||
}
|
||||
doubleFunc := func(n int) Effect[TestConfig, int] {
|
||||
return Succeed[TestConfig](n * 2)
|
||||
}
|
||||
|
||||
parseKleisli := Eitherize1(parseFunc)
|
||||
|
||||
// Act
|
||||
pipeline := F.Pipe2(
|
||||
Succeed[TestConfig]("42"),
|
||||
Chain[TestConfig](parseKleisli),
|
||||
Chain[TestConfig](doubleFunc),
|
||||
)
|
||||
result, err := runEffect(pipeline, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 84, result)
|
||||
})
|
||||
|
||||
t.Run("handles error in chain", func(t *testing.T) {
|
||||
// Arrange
|
||||
parseFunc := func(cfg TestConfig, ctx context.Context, s string) (int, error) {
|
||||
return strconv.Atoi(s)
|
||||
}
|
||||
parseKleisli := Eitherize1(parseFunc)
|
||||
|
||||
// Act
|
||||
pipeline := F.Pipe1(
|
||||
Succeed[TestConfig]("not-a-number"),
|
||||
Chain[TestConfig](parseKleisli),
|
||||
)
|
||||
_, err := runEffect(pipeline, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("composes multiple Kleisli arrows", func(t *testing.T) {
|
||||
// Arrange
|
||||
parseFunc := func(cfg TestConfig, ctx context.Context, s string) (int, error) {
|
||||
return strconv.Atoi(s)
|
||||
}
|
||||
formatFunc := func(cfg TestConfig, ctx context.Context, n int) (string, error) {
|
||||
return fmt.Sprintf("%s-%d", cfg.Prefix, n), nil
|
||||
}
|
||||
|
||||
parseKleisli := Eitherize1(parseFunc)
|
||||
formatKleisli := Eitherize1(formatFunc)
|
||||
|
||||
// Act
|
||||
pipeline := F.Pipe2(
|
||||
Succeed[TestConfig]("123"),
|
||||
Chain[TestConfig](parseKleisli),
|
||||
Chain[TestConfig](formatKleisli),
|
||||
)
|
||||
result, err := runEffect(pipeline, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "LOG-123", result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEitherize_TypeSafety tests type safety across different scenarios
|
||||
func TestEitherize_TypeSafety(t *testing.T) {
|
||||
t.Run("Eitherize with complex types", func(t *testing.T) {
|
||||
// Arrange
|
||||
type ComplexResult struct {
|
||||
Data map[string]int
|
||||
Count int
|
||||
}
|
||||
|
||||
complexFunc := func(cfg TestConfig, ctx context.Context) (ComplexResult, error) {
|
||||
return ComplexResult{
|
||||
Data: map[string]int{cfg.Prefix: cfg.Multiplier},
|
||||
Count: cfg.Multiplier,
|
||||
}, nil
|
||||
}
|
||||
eff := Eitherize(complexFunc)
|
||||
|
||||
// Act
|
||||
result, err := runEffect(eff, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, result.Data["LOG"])
|
||||
assert.Equal(t, 3, result.Count)
|
||||
})
|
||||
|
||||
t.Run("Eitherize1 with different input and output types", func(t *testing.T) {
|
||||
// Arrange
|
||||
type Input struct {
|
||||
ID int
|
||||
}
|
||||
type Output struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
convertFunc := func(cfg TestConfig, ctx context.Context, in Input) (Output, error) {
|
||||
return Output{Name: fmt.Sprintf("%s-%d", cfg.Prefix, in.ID)}, nil
|
||||
}
|
||||
kleisli := Eitherize1(convertFunc)
|
||||
|
||||
// Act
|
||||
eff := kleisli(Input{ID: 99})
|
||||
result, err := runEffect(eff, testConfig)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "LOG-99", result.Name)
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,7 @@ go 1.24
|
||||
|
||||
require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v3 v3.6.2
|
||||
github.com/urfave/cli/v3 v3.7.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -6,6 +6,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8=
|
||||
github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U=
|
||||
github.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -288,5 +288,3 @@ func BenchmarkVoidMonoid_Empty(b *testing.B) {
|
||||
_ = m.Empty()
|
||||
}
|
||||
}
|
||||
|
||||
// Made with Bob
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func (t *typeImpl[A, O, I]) Is(i any) Result[A] {
|
||||
// stringToInt := codec.MakeType(...) // Type[int, string, string]
|
||||
// intToPositive := codec.MakeType(...) // Type[PositiveInt, int, int]
|
||||
// composed := codec.Pipe(intToPositive)(stringToInt) // Type[PositiveInt, string, string]
|
||||
func Pipe[O, I, A, B any](ab Type[B, A, A]) func(Type[A, O, I]) Type[B, O, I] {
|
||||
func Pipe[O, I, A, B any](ab Type[B, A, A]) Operator[A, B, O, I] {
|
||||
return func(this Type[A, O, I]) Type[B, O, I] {
|
||||
return MakeType(
|
||||
fmt.Sprintf("Pipe(%s, %s)", this.Name(), ab.Name()),
|
||||
@@ -851,7 +851,7 @@ func FromRefinement[A, B any](refinement Refinement[A, B]) Type[B, A, A] {
|
||||
// See also:
|
||||
// - Id: For identity codecs that preserve values
|
||||
// - MakeType: For creating custom codecs with validation logic
|
||||
func Empty[A, O, I any](e Lazy[Pair[O, A]]) Type[A, O, I] {
|
||||
func Empty[I, A, O any](e Lazy[Pair[O, A]]) Type[A, O, I] {
|
||||
return MakeType(
|
||||
"Empty",
|
||||
Is[A](),
|
||||
|
||||
@@ -1742,7 +1742,7 @@ func TestFromRefinementValidationContext(t *testing.T) {
|
||||
// TestEmpty_Success tests that Empty always succeeds during decoding
|
||||
func TestEmpty_Success(t *testing.T) {
|
||||
t.Run("decodes any input to default value", func(t *testing.T) {
|
||||
defaultCodec := Empty[int, string, any](lazy.Of(pair.MakePair("default", 42)))
|
||||
defaultCodec := Empty[any, int, string](lazy.Of(pair.MakePair("default", 42)))
|
||||
|
||||
// Test with various input types
|
||||
testCases := []struct {
|
||||
@@ -1766,7 +1766,7 @@ func TestEmpty_Success(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("always returns same default value", func(t *testing.T) {
|
||||
defaultCodec := Empty[string, string, any](lazy.Of(pair.MakePair("output", "default")))
|
||||
defaultCodec := Empty[any, string, string](lazy.Of(pair.MakePair("output", "default")))
|
||||
|
||||
result1 := defaultCodec.Decode(123)
|
||||
result2 := defaultCodec.Decode("different")
|
||||
@@ -1789,7 +1789,7 @@ func TestEmpty_Success(t *testing.T) {
|
||||
// TestEmpty_Encoding tests that Empty always uses default output during encoding
|
||||
func TestEmpty_Encoding(t *testing.T) {
|
||||
t.Run("encodes any value to default output", func(t *testing.T) {
|
||||
defaultCodec := Empty[int, string, any](lazy.Of(pair.MakePair("default", 42)))
|
||||
defaultCodec := Empty[any, int, string](lazy.Of(pair.MakePair("default", 42)))
|
||||
|
||||
// Test with various input values
|
||||
testCases := []struct {
|
||||
@@ -1811,7 +1811,7 @@ func TestEmpty_Encoding(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("always returns same default output", func(t *testing.T) {
|
||||
defaultCodec := Empty[string, int, any](lazy.Of(pair.MakePair(999, "ignored")))
|
||||
defaultCodec := Empty[any, string, int](lazy.Of(pair.MakePair(999, "ignored")))
|
||||
|
||||
encoded1 := defaultCodec.Encode("value1")
|
||||
encoded2 := defaultCodec.Encode("value2")
|
||||
@@ -1826,7 +1826,7 @@ func TestEmpty_Encoding(t *testing.T) {
|
||||
// TestEmpty_Name tests that Empty has correct name
|
||||
func TestEmpty_Name(t *testing.T) {
|
||||
t.Run("has name 'Empty'", func(t *testing.T) {
|
||||
defaultCodec := Empty[int, int, any](lazy.Of(pair.MakePair(0, 0)))
|
||||
defaultCodec := Empty[any, int, int](lazy.Of(pair.MakePair(0, 0)))
|
||||
assert.Equal(t, "Empty", defaultCodec.Name())
|
||||
})
|
||||
}
|
||||
@@ -1834,7 +1834,7 @@ func TestEmpty_Name(t *testing.T) {
|
||||
// TestEmpty_TypeChecking tests that Empty performs standard type checking
|
||||
func TestEmpty_TypeChecking(t *testing.T) {
|
||||
t.Run("Is checks for correct type", func(t *testing.T) {
|
||||
defaultCodec := Empty[int, string, any](lazy.Of(pair.MakePair("default", 42)))
|
||||
defaultCodec := Empty[any, int, string](lazy.Of(pair.MakePair("default", 42)))
|
||||
|
||||
// Should succeed for int
|
||||
result := defaultCodec.Is(100)
|
||||
@@ -1846,7 +1846,7 @@ func TestEmpty_TypeChecking(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Is checks for string type", func(t *testing.T) {
|
||||
defaultCodec := Empty[string, string, any](lazy.Of(pair.MakePair("out", "in")))
|
||||
defaultCodec := Empty[any, string, string](lazy.Of(pair.MakePair("out", "in")))
|
||||
|
||||
// Should succeed for string
|
||||
result := defaultCodec.Is("hello")
|
||||
@@ -1867,7 +1867,7 @@ func TestEmpty_LazyEvaluation(t *testing.T) {
|
||||
return pair.MakePair(counter, counter*10)
|
||||
}
|
||||
|
||||
defaultCodec := Empty[int, int, any](lazyPair)
|
||||
defaultCodec := Empty[any, int, int](lazyPair)
|
||||
|
||||
// Each decode can get a different value if the lazy function is dynamic
|
||||
result1 := defaultCodec.Decode("input1")
|
||||
@@ -1897,7 +1897,7 @@ func TestEmpty_WithStructs(t *testing.T) {
|
||||
|
||||
t.Run("provides default struct value", func(t *testing.T) {
|
||||
defaultConfig := Config{Timeout: 30, Retries: 3}
|
||||
defaultCodec := Empty[Config, Config, any](lazy.Of(pair.MakePair(defaultConfig, defaultConfig)))
|
||||
defaultCodec := Empty[any, Config, Config](lazy.Of(pair.MakePair(defaultConfig, defaultConfig)))
|
||||
|
||||
result := defaultCodec.Decode("anything")
|
||||
assert.True(t, either.IsRight(result))
|
||||
@@ -1914,7 +1914,7 @@ func TestEmpty_WithStructs(t *testing.T) {
|
||||
defaultConfig := Config{Timeout: 30, Retries: 3}
|
||||
inputConfig := Config{Timeout: 60, Retries: 5}
|
||||
|
||||
defaultCodec := Empty[Config, Config, any](lazy.Of(pair.MakePair(defaultConfig, defaultConfig)))
|
||||
defaultCodec := Empty[any, Config, Config](lazy.Of(pair.MakePair(defaultConfig, defaultConfig)))
|
||||
|
||||
encoded := defaultCodec.Encode(inputConfig)
|
||||
assert.Equal(t, 30, encoded.Timeout)
|
||||
@@ -1926,7 +1926,7 @@ func TestEmpty_WithStructs(t *testing.T) {
|
||||
func TestEmpty_WithPointers(t *testing.T) {
|
||||
t.Run("provides default pointer value", func(t *testing.T) {
|
||||
defaultValue := 42
|
||||
defaultCodec := Empty[*int, *int, any](lazy.Of(pair.MakePair(&defaultValue, &defaultValue)))
|
||||
defaultCodec := Empty[any, *int, *int](lazy.Of(pair.MakePair(&defaultValue, &defaultValue)))
|
||||
|
||||
result := defaultCodec.Decode("anything")
|
||||
assert.True(t, either.IsRight(result))
|
||||
@@ -1941,7 +1941,7 @@ func TestEmpty_WithPointers(t *testing.T) {
|
||||
|
||||
t.Run("provides nil pointer as default", func(t *testing.T) {
|
||||
var nilPtr *int
|
||||
defaultCodec := Empty[*int, *int, any](lazy.Of(pair.MakePair(nilPtr, nilPtr)))
|
||||
defaultCodec := Empty[any, *int, *int](lazy.Of(pair.MakePair(nilPtr, nilPtr)))
|
||||
|
||||
result := defaultCodec.Decode("anything")
|
||||
assert.True(t, either.IsRight(result))
|
||||
@@ -1958,7 +1958,7 @@ func TestEmpty_WithPointers(t *testing.T) {
|
||||
func TestEmpty_WithSlices(t *testing.T) {
|
||||
t.Run("provides default slice value", func(t *testing.T) {
|
||||
defaultSlice := []int{1, 2, 3}
|
||||
defaultCodec := Empty[[]int, []int, any](lazy.Of(pair.MakePair(defaultSlice, defaultSlice)))
|
||||
defaultCodec := Empty[any, []int, []int](lazy.Of(pair.MakePair(defaultSlice, defaultSlice)))
|
||||
|
||||
result := defaultCodec.Decode("anything")
|
||||
assert.True(t, either.IsRight(result))
|
||||
@@ -1972,7 +1972,7 @@ func TestEmpty_WithSlices(t *testing.T) {
|
||||
|
||||
t.Run("provides empty slice as default", func(t *testing.T) {
|
||||
emptySlice := []int{}
|
||||
defaultCodec := Empty[[]int, []int, any](lazy.Of(pair.MakePair(emptySlice, emptySlice)))
|
||||
defaultCodec := Empty[any, []int, []int](lazy.Of(pair.MakePair(emptySlice, emptySlice)))
|
||||
|
||||
result := defaultCodec.Decode("anything")
|
||||
assert.True(t, either.IsRight(result))
|
||||
@@ -1988,7 +1988,7 @@ func TestEmpty_WithSlices(t *testing.T) {
|
||||
// TestEmpty_DifferentInputOutput tests Empty with different input and output types
|
||||
func TestEmpty_DifferentInputOutput(t *testing.T) {
|
||||
t.Run("decodes to int, encodes to string", func(t *testing.T) {
|
||||
defaultCodec := Empty[int, string, any](lazy.Of(pair.MakePair("default-output", 42)))
|
||||
defaultCodec := Empty[any, int, string](lazy.Of(pair.MakePair("default-output", 42)))
|
||||
|
||||
// Decode always returns 42
|
||||
result := defaultCodec.Decode("any input")
|
||||
@@ -2000,7 +2000,7 @@ func TestEmpty_DifferentInputOutput(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("decodes to string, encodes to int", func(t *testing.T) {
|
||||
defaultCodec := Empty[string, int, any](lazy.Of(pair.MakePair(999, "default-value")))
|
||||
defaultCodec := Empty[any, string, int](lazy.Of(pair.MakePair(999, "default-value")))
|
||||
|
||||
// Decode always returns "default-value"
|
||||
result := defaultCodec.Decode(123)
|
||||
@@ -2020,7 +2020,7 @@ func TestEmpty_DifferentInputOutput(t *testing.T) {
|
||||
// TestEmpty_EdgeCases tests edge cases for Empty
|
||||
func TestEmpty_EdgeCases(t *testing.T) {
|
||||
t.Run("with zero values", func(t *testing.T) {
|
||||
defaultCodec := Empty[int, int, any](lazy.Of(pair.MakePair(0, 0)))
|
||||
defaultCodec := Empty[any, int, int](lazy.Of(pair.MakePair(0, 0)))
|
||||
|
||||
result := defaultCodec.Decode("anything")
|
||||
assert.True(t, either.IsRight(result))
|
||||
@@ -2035,7 +2035,7 @@ func TestEmpty_EdgeCases(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("with empty string", func(t *testing.T) {
|
||||
defaultCodec := Empty[string, string, any](lazy.Of(pair.MakePair("", "")))
|
||||
defaultCodec := Empty[any, string, string](lazy.Of(pair.MakePair("", "")))
|
||||
|
||||
result := defaultCodec.Decode("non-empty")
|
||||
assert.True(t, either.IsRight(result))
|
||||
@@ -2050,7 +2050,7 @@ func TestEmpty_EdgeCases(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("with false boolean", func(t *testing.T) {
|
||||
defaultCodec := Empty[bool, bool, any](lazy.Of(pair.MakePair(false, false)))
|
||||
defaultCodec := Empty[any, bool, bool](lazy.Of(pair.MakePair(false, false)))
|
||||
|
||||
result := defaultCodec.Decode(true)
|
||||
assert.Equal(t, validation.Of(false), result)
|
||||
@@ -2064,7 +2064,7 @@ func TestEmpty_EdgeCases(t *testing.T) {
|
||||
func TestEmpty_Integration(t *testing.T) {
|
||||
t.Run("composes with other codecs using Pipe", func(t *testing.T) {
|
||||
// Create a codec that always provides a default int
|
||||
defaultIntCodec := Empty[int, int, any](lazy.Of(pair.MakePair(42, 42)))
|
||||
defaultIntCodec := Empty[any, int, int](lazy.Of(pair.MakePair(42, 42)))
|
||||
|
||||
// Create a refinement that only accepts positive integers
|
||||
positiveIntPrism := prism.MakePrismWithName(
|
||||
@@ -2090,7 +2090,7 @@ func TestEmpty_Integration(t *testing.T) {
|
||||
|
||||
t.Run("used as placeholder in generic contexts", func(t *testing.T) {
|
||||
// Empty can be used where a codec is required but not actually used
|
||||
unitCodec := Empty[Void, Void, any](
|
||||
unitCodec := Empty[any, Void, Void](
|
||||
lazy.Of(pair.MakePair(F.VOID, F.VOID)),
|
||||
)
|
||||
|
||||
@@ -2105,7 +2105,7 @@ func TestEmpty_Integration(t *testing.T) {
|
||||
// TestEmpty_RoundTrip tests that Empty maintains consistency
|
||||
func TestEmpty_RoundTrip(t *testing.T) {
|
||||
t.Run("decode then encode returns default output", func(t *testing.T) {
|
||||
defaultCodec := Empty[int, string, any](lazy.Of(pair.MakePair("output", 42)))
|
||||
defaultCodec := Empty[any, int, string](lazy.Of(pair.MakePair("output", 42)))
|
||||
|
||||
// Decode
|
||||
result := defaultCodec.Decode("input")
|
||||
@@ -2124,7 +2124,7 @@ func TestEmpty_RoundTrip(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("multiple round trips are consistent", func(t *testing.T) {
|
||||
defaultCodec := Empty[int, int, any](lazy.Of(pair.MakePair(100, 50)))
|
||||
defaultCodec := Empty[any, int, int](lazy.Of(pair.MakePair(100, 50)))
|
||||
|
||||
// First round trip
|
||||
result1 := defaultCodec.Decode("input1")
|
||||
|
||||
@@ -22,15 +22,19 @@
|
||||
package codec
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/optics/codec/validate"
|
||||
"github.com/IBM/fp-go/v2/optics/codec/validation"
|
||||
"github.com/IBM/fp-go/v2/optics/prism"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
)
|
||||
|
||||
// validateFromParser creates a validation function from a parser that may fail.
|
||||
@@ -338,3 +342,183 @@ func Int64FromString() Type[int64, string, string] {
|
||||
prism.ParseInt64().ReverseGet,
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
err := dec.UnmarshalJSON(b)
|
||||
return result.TryCatchError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeText[T any](dec encoding.TextUnmarshaler) ReaderResult[[]byte, T] {
|
||||
return func(b []byte) Result[T] {
|
||||
var t T
|
||||
err := dec.UnmarshalText(b)
|
||||
return result.TryCatchError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalText creates a bidirectional codec for types that implement encoding.TextMarshaler
|
||||
// and encoding.TextUnmarshaler. This codec handles binary text serialization formats.
|
||||
//
|
||||
// The codec:
|
||||
// - Decodes: Calls dec.UnmarshalText(b) to deserialize []byte into the target type T
|
||||
// - Encodes: Calls enc.MarshalText() to serialize the value to []byte
|
||||
// - Validates: Returns a failure if UnmarshalText returns an error
|
||||
//
|
||||
// Note: The enc and dec parameters are external marshaler/unmarshaler instances. The
|
||||
// decoded value is the zero value of T after UnmarshalText has been called on dec
|
||||
// (the caller is responsible for ensuring dec holds the decoded state).
|
||||
//
|
||||
// Type Parameters:
|
||||
// - T: The Go type to encode/decode
|
||||
//
|
||||
// Parameters:
|
||||
// - enc: An encoding.TextMarshaler used for encoding values to []byte
|
||||
// - dec: An encoding.TextUnmarshaler used for decoding []byte to the target type
|
||||
//
|
||||
// Returns:
|
||||
// - A Type[T, []byte, []byte] codec that handles text marshaling/unmarshaling
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type MyType struct{ Value string }
|
||||
//
|
||||
// var instance MyType
|
||||
// codec := MarshalText[MyType](instance, &instance)
|
||||
//
|
||||
// // Decode bytes to MyType
|
||||
// result := codec.Decode([]byte(`some text`))
|
||||
//
|
||||
// // Encode MyType to bytes
|
||||
// encoded := codec.Encode(instance)
|
||||
func MarshalText[T any](
|
||||
enc encoding.TextMarshaler,
|
||||
dec encoding.TextUnmarshaler,
|
||||
) Type[T, []byte, []byte] {
|
||||
return MakeType(
|
||||
"UnmarshalText",
|
||||
Is[T](),
|
||||
F.Pipe2(
|
||||
dec,
|
||||
decodeText[T],
|
||||
validate.FromReaderResult,
|
||||
),
|
||||
func(t T) []byte {
|
||||
b, _ := enc.MarshalText()
|
||||
return b
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// MarshalJSON creates a bidirectional codec for types that implement encoding/json's
|
||||
// json.Marshaler and json.Unmarshaler interfaces. This codec handles JSON serialization.
|
||||
//
|
||||
// The codec:
|
||||
// - Decodes: Calls dec.UnmarshalJSON(b) to deserialize []byte JSON into the target type T
|
||||
// - Encodes: Calls enc.MarshalJSON() to serialize the value to []byte JSON
|
||||
// - Validates: Returns a failure if UnmarshalJSON returns an error
|
||||
//
|
||||
// Note: The enc and dec parameters are external marshaler/unmarshaler instances. The
|
||||
// decoded value is the zero value of T after UnmarshalJSON has been called on dec
|
||||
// (the caller is responsible for ensuring dec holds the decoded state).
|
||||
//
|
||||
// Type Parameters:
|
||||
// - T: The Go type to encode/decode
|
||||
//
|
||||
// Parameters:
|
||||
// - enc: A json.Marshaler used for encoding values to JSON []byte
|
||||
// - dec: A json.Unmarshaler used for decoding JSON []byte to the target type
|
||||
//
|
||||
// Returns:
|
||||
// - A Type[T, []byte, []byte] codec that handles JSON marshaling/unmarshaling
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type MyData struct {
|
||||
// Name string `json:"name"`
|
||||
// Value int `json:"value"`
|
||||
// }
|
||||
//
|
||||
// var instance MyData
|
||||
// codec := MarshalJSON[MyData](&instance, &instance)
|
||||
//
|
||||
// // Decode JSON bytes to MyData
|
||||
// result := codec.Decode([]byte(`{"name":"test","value":42}`))
|
||||
//
|
||||
// // Encode MyData to JSON bytes
|
||||
// encoded := codec.Encode(instance)
|
||||
func MarshalJSON[T any](
|
||||
enc json.Marshaler,
|
||||
dec json.Unmarshaler,
|
||||
) Type[T, []byte, []byte] {
|
||||
return MakeType(
|
||||
"UnmarshalJSON",
|
||||
Is[T](),
|
||||
F.Pipe2(
|
||||
dec,
|
||||
decodeJSON[T],
|
||||
validate.FromReaderResult,
|
||||
),
|
||||
func(t T) []byte {
|
||||
b, _ := enc.MarshalJSON()
|
||||
return b
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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