1
0
mirror of https://github.com/IBM/fp-go.git synced 2026-01-21 01:07:29 +02:00

Compare commits

...

40 Commits

Author SHA1 Message Date
Dr. Carsten Leue
cfa48985ec fix: WithLocal
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-19 18:36:02 +01:00
Dr. Carsten Leue
677523b70f fix: add nested reader transformer
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-19 16:34:39 +01:00
Dr. Carsten Leue
8243242cf1 doc: explain use of ReaderIOResult
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-19 10:10:43 +01:00
Dr. Carsten Leue
9021a8e274 fix: simplify tests
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-18 18:33:15 +01:00
Dr. Carsten Leue
f3128e887b fix: more doc and tests
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-18 17:43:57 +01:00
Dr. Carsten Leue
4583694211 fix: more type magic
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-18 00:21:48 +01:00
Dr. Carsten Leue
b87c20d139 fix: better reader abstraction
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-17 22:36:26 +01:00
Dr. Carsten Leue
9fd5b90138 fix: add codec and implement some helpers along the way
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-16 23:21:10 +01:00
Dr. Carsten Leue
cdc2041d8e fix: implement ReadIO consistently
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-15 13:07:19 +01:00
Dr. Carsten Leue
777fff9a5a fix: implement ReadIO
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-15 12:24:46 +01:00
Carsten Leue
8acea9043f fix: refactor circuitbreaker (#152)
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-15 11:36:32 +01:00
Dr. Carsten Leue
c6445ac021 fix: better tests and docs
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-14 12:09:01 +01:00
Dr. Carsten Leue
840ffbb51d fix: documentation and missing tests
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-13 20:27:46 +01:00
Dr. Carsten Leue
380ba2853c fix: update profunctor docs
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-13 16:15:37 +01:00
Dr. Carsten Leue
c18e5e2107 fix: add monoid to state
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-12 23:03:57 +01:00
Dr. Carsten Leue
89766bdb26 fix: add monoid to state
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-12 23:03:37 +01:00
Dr. Carsten Leue
21d116d325 fix: implement monoid for Pair
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-12 22:53:19 +01:00
Dr. Carsten Leue
7f2e76dd94 fix: implement monoid for Pair
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-12 22:32:33 +01:00
Dr. Carsten Leue
77965a12ff fix: implement monoid for Pair
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-12 22:32:04 +01:00
Dr. Carsten Leue
ed77bd7971 fix: implement get last and get first monoids
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-12 20:44:04 +01:00
Dr. Carsten Leue
f154790d88 fix: optimize iter a bit
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-12 18:54:33 +01:00
Carsten Leue
e010f13dce fix: initial version of circuit breaker (#151)
* fix: add circuitbreaker and doc

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: refactor and more low level tests

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: document thread safety

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: add stateio

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: documentation of StateIO monad

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: initial version of circuitbreaker

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

---------

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-12 18:19:39 +01:00
Carsten Leue
86a260a204 Introduce IORef (#150)
* fix: add ioref and tests

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: better tests

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

---------

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-04 16:45:40 +01:00
lif
6a6b982779 feat: Add OrElse to ioeither for error recovery (#148)
* feat: add OrElse to ioeither for error recovery

Add OrElse function to both v1 and v2 ioeither packages for error recovery.
This allows recovering from a Left value by applying a function to the error
and returning a new IOEither, consistent with the Either package's API.

- Add OrElse to ioeither/generic/ioeither.go
- Add OrElse wrapper to ioeither/ioeither.go
- Add OrElse to v2/ioeither/generic/ioeither.go
- Add OrElse to v2/ioeither/ioeither.go
- Add comprehensive tests for both v1 and v2

Closes #146

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: majiayu000 <1835304752@qq.com>

* chore(v2): drop ioeither OrElse addition

---------

Signed-off-by: majiayu000 <1835304752@qq.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:41:13 +01:00
Carsten Leue
9d31752887 Rewrite the Retry logic based on Trampoline (#149)
* fix: implement retry via tail rec

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: base retry on Trampoline

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: refactor retry

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

---------

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-01-02 15:43:51 +01:00
Carsten Leue
14b52568b5 Add OrElse consistently and improve docs (#147)
* fix: OrElse

Signed-off-by: Carsten Leue <carsten.leue@de.ibm.com>

* fix: improve tests

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: FilterOrElse

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: tests and doc

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: add sample

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: add tests for CopyFile

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

* fix: signature of Close

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>

---------

Signed-off-by: Carsten Leue <carsten.leue@de.ibm.com>
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-31 15:59:10 +01:00
Dr. Carsten Leue
49227551b6 fix: more iter methods
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-22 15:03:47 +01:00
Dr. Carsten Leue
69691e9e70 fix: iterators
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-20 16:38:36 +01:00
Dr. Carsten Leue
d3c466bfb7 fix: some cleanup
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-19 13:18:49 +01:00
Dr. Carsten Leue
a6c6ea804f fix: overhaul record
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-18 18:32:45 +01:00
Dr. Carsten Leue
31ff98901e fix: latest doc fixes
BREAKING CHANGE: new v2

Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-18 16:59:23 +01:00
Dr. Carsten Leue
255cf4353c fix: better formatting
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-18 16:07:26 +01:00
Dr. Carsten Leue
4dfc1b5a44 fix: better doc and implementation of retry
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-17 16:28:28 +01:00
Dr. Carsten Leue
20398e67a9 fix: better doc and implementation of retry
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-17 15:58:11 +01:00
Dr. Carsten Leue
fceda15701 doc: improve docs
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-17 10:11:58 +01:00
Dr. Carsten Leue
4ebfcadabe fix: add better tests
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-16 14:03:01 +01:00
Dr. Carsten Leue
acb601fc01 fix: reuse some more code
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-15 16:30:40 +01:00
Dr. Carsten Leue
d17663f016 fix: better doc
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-15 11:16:09 +01:00
Dr. Carsten Leue
829365fc24 doc: improve docs
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-12 13:30:10 +01:00
Dr. Carsten Leue
64b5660b4e doc: remove some comments
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-12-12 12:35:53 +01:00
611 changed files with 88755 additions and 3883 deletions

View File

@@ -369,6 +369,11 @@ func ToIOOption[GA ~func() O.Option[A], GEA ~func() ET.Either[E, A], E, A any](i
)
}
// OrElse returns the original IOEither if it is a Right, otherwise it applies the given function to the error and returns the result.
func OrElse[GA ~func() ET.Either[E, A], E, A any](onLeft func(E) GA) func(GA) GA {
return eithert.OrElse(IO.MonadChain[GA, GA, ET.Either[E, A], ET.Either[E, A]], IO.Of[GA, ET.Either[E, A]], onLeft)
}
func FromIOOption[GEA ~func() ET.Either[E, A], GA ~func() O.Option[A], E, A any](onNone func() E) func(ioo GA) GEA {
return IO.Map[GA, GEA](ET.FromOption[A](onNone))
}

View File

@@ -266,6 +266,11 @@ func Alt[E, A any](second L.Lazy[IOEither[E, A]]) func(IOEither[E, A]) IOEither[
return G.Alt(second)
}
// OrElse returns the original IOEither if it is a Right, otherwise it applies the given function to the error and returns the result.
func OrElse[E, A any](onLeft func(E) IOEither[E, A]) func(IOEither[E, A]) IOEither[E, A] {
return G.OrElse[IOEither[E, A]](onLeft)
}
func MonadFlap[E, B, A any](fab IOEither[E, func(A) B], a A) IOEither[E, B] {
return G.MonadFlap[IOEither[E, func(A) B], IOEither[E, B]](fab, a)
}

View File

@@ -134,3 +134,44 @@ func TestApSecond(t *testing.T) {
assert.Equal(t, E.Of[error]("b"), x())
}
func TestOrElse(t *testing.T) {
// Test that OrElse recovers from a Left
recover := OrElse(func(err string) IOEither[string, int] {
return Right[string](42)
})
// When input is Left, should recover
leftResult := F.Pipe1(
Left[int]("error"),
recover,
)
assert.Equal(t, E.Right[string](42), leftResult())
// When input is Right, should pass through unchanged
rightResult := F.Pipe1(
Right[string](100),
recover,
)
assert.Equal(t, E.Right[string](100), rightResult())
// Test that OrElse can also return a Left (propagate different error)
recoverOrFail := OrElse(func(err string) IOEither[string, int] {
if err == "recoverable" {
return Right[string](0)
}
return Left[int]("unrecoverable: " + err)
})
recoverable := F.Pipe1(
Left[int]("recoverable"),
recoverOrFail,
)
assert.Equal(t, E.Right[string](0), recoverable())
unrecoverable := F.Pipe1(
Left[int]("fatal"),
recoverOrFail,
)
assert.Equal(t, E.Left[int]("unrecoverable: fatal"), unrecoverable())
}

1
v2/.bobignore Normal file
View File

@@ -0,0 +1 @@
reflect\reflect.go

View File

@@ -14,6 +14,8 @@ This document explains the key design decisions and principles behind fp-go's AP
fp-go follows the **"data last"** principle, where the data being operated on is always the last parameter in a function. This design choice enables powerful function composition and partial application patterns.
This principle is deeply rooted in functional programming tradition, particularly in **Haskell's design philosophy**. Haskell functions are automatically curried and follow the data-last convention, making function composition natural and elegant. For example, Haskell's `map` function has the signature `(a -> b) -> [a] -> [b]`, where the transformation function comes before the list.
### What is "Data Last"?
In the "data last" style, functions are structured so that:
@@ -31,6 +33,8 @@ The "data last" principle enables:
3. **Point-Free Style**: Write transformations without explicitly mentioning the data
4. **Reusability**: Create reusable transformation pipelines
This design aligns with Haskell's approach where all functions are curried by default, enabling elegant composition patterns that have proven effective over decades of functional programming practice.
### Examples
#### Basic Transformation
@@ -55,7 +59,7 @@ import (
// Create a pipeline of transformations
pipeline := F.Flow3(
A.Filter(func(x int) bool { return x > 0 }), // Keep positive numbers
A.Filter(N.MoreThan(0)), // Keep positive numbers
A.Map(N.Mul(2)), // Double each number
A.Reduce(func(acc, x int) int { return acc + x }, 0), // Sum them up
)
@@ -181,8 +185,18 @@ result := O.MonadMap(O.Some("hello"), strings.ToUpper)
The data-last currying pattern is well-documented in the functional programming community:
#### Haskell Design Philosophy
- [Haskell Wiki - Currying](https://wiki.haskell.org/Currying) - Comprehensive explanation of currying in Haskell
- [Learn You a Haskell - Higher Order Functions](http://learnyouahaskell.com/higher-order-functions) - Introduction to currying and partial application
- [Haskell's Prelude](https://hackage.haskell.org/package/base/docs/Prelude.html) - Standard library showing data-last convention throughout
#### General Functional Programming
- [Mostly Adequate Guide - Ch. 4: Currying](https://mostly-adequate.gitbook.io/mostly-adequate-guide/ch04) - Excellent introduction with clear examples
- [Curry and Function Composition](https://medium.com/javascript-scene/curry-and-function-composition-2c208d774983) by Eric Elliott
- [Why Curry Helps](https://hughfdjackson.com/javascript/why-curry-helps/) - Practical benefits of currying
#### Related Libraries
- [fp-ts Documentation](https://gcanti.github.io/fp-ts/) - TypeScript library that inspired fp-go's design
- [fp-ts Issue #1238](https://github.com/gcanti/fp-ts/issues/1238) - Real-world examples of data-last refactoring
## Kleisli and Operator Types
@@ -570,5 +584,7 @@ func process(input string) types.Result[types.Option[int]] {
For more information, see:
- [README.md](./README.md) - Overview and quick start
- [FUNCTIONAL_IO.md](./FUNCTIONAL_IO.md) - Functional I/O patterns with Context and Reader
- [IDIOMATIC_COMPARISON.md](./IDIOMATIC_COMPARISON.md) - Performance comparison between standard and idiomatic packages
- [API Documentation](https://pkg.go.dev/github.com/IBM/fp-go/v2) - Complete API reference
- [Samples](./samples/) - Practical examples

View File

@@ -0,0 +1,212 @@
# Example Tests Progress
This document tracks the progress of converting documentation examples into executable example test files.
## Overview
The codebase has 300+ documentation examples across many packages. This document tracks which packages have been completed and which still need work.
## Completed Packages
### Core Packages
- [x] **result** - Created `examples_bind_test.go`, `examples_curry_test.go`, `examples_apply_test.go`
- Files: `bind.go` (10 examples), `curry.go` (5 examples), `apply.go` (2 examples)
- Status: ✅ 17 tests passing
### Utility Packages
- [x] **pair** - Created `examples_test.go`
- Files: `pair.go` (14 examples)
- Status: ✅ 14 tests passing
- [x] **tuple** - Created `examples_test.go`
- Files: `tuple.go` (6 examples)
- Status: ✅ 6 tests passing
### Type Class Packages
- [x] **semigroup** - Created `examples_test.go`
- Files: `semigroup.go` (7 examples)
- Status: ✅ 7 tests passing
### Utility Packages (continued)
- [x] **predicate** - Created `examples_test.go`
- Files: `bool.go` (3 examples), `contramap.go` (1 example)
- Status: ✅ 4 tests passing
### Context Reader Packages
- [x] **idiomatic/context/readerresult** - Created `examples_reader_test.go`, `examples_bind_test.go`
- Files: `reader.go` (8 examples), `bind.go` (14 examples)
- Status: ✅ 22 tests passing
## Summary Statistics
- **Total Example Tests Created**: 74
- **Total Packages Completed**: 7 (result, pair, tuple, semigroup, predicate, idiomatic/context/readerresult)
- **All Tests Status**: ✅ PASSING
### Breakdown by Package
- **result**: 21 tests (bind: 10, curry: 5, apply: 2, array: 4)
- **pair**: 14 tests
- **tuple**: 6 tests
- **semigroup**: 7 tests
- **predicate**: 4 tests
- **idiomatic/context/readerresult**: 22 tests (reader: 8, bind: 14)
## Packages with Existing Examples
These packages already have some example test files:
- result (has `examples_create_test.go`, `examples_extract_test.go`)
- option (has `examples_create_test.go`, `examples_extract_test.go`)
- either (has `examples_create_test.go`, `examples_extract_test.go`)
- ioeither (has `examples_create_test.go`, `examples_do_test.go`, `examples_extract_test.go`)
- ioresult (has `examples_create_test.go`, `examples_do_test.go`, `examples_extract_test.go`)
- lazy (has `example_lazy_test.go`)
- array (has `examples_basic_test.go`, `examples_sort_test.go`, `example_any_test.go`, `example_find_test.go`)
- readerioeither (has `traverse_example_test.go`)
- context/readerioresult (has `flip_example_test.go`)
## Packages Needing Example Tests
### Core Packages (High Priority)
- [ ] **result** - Additional files need examples:
- `apply.go` (2 examples)
- `array.go` (7 examples)
- `core.go` (6 examples)
- `either.go` (26 examples)
- `eq.go` (2 examples)
- `functor.go` (1 example)
- [ ] **option** - Additional files need examples
- [ ] **either** - Additional files need examples
### Reader Packages (High Priority)
- [ ] **reader** - Many examples in:
- `array.go` (12 examples)
- `bind.go` (10 examples)
- `curry.go` (8 examples)
- `flip.go` (2 examples)
- `reader.go` (21 examples)
- [ ] **readeroption** - Examples in:
- `array.go` (3 examples)
- `bind.go` (7 examples)
- `curry.go` (5 examples)
- `flip.go` (2 examples)
- `from.go` (4 examples)
- `reader.go` (18 examples)
- `sequence.go` (4 examples)
- [ ] **readerresult** - Examples in:
- `array.go` (3 examples)
- `bind.go` (24 examples)
- `curry.go` (7 examples)
- `flip.go` (2 examples)
- `from.go` (4 examples)
- `monoid.go` (3 examples)
- [ ] **readereither** - Examples in:
- `array.go` (3 examples)
- `bind.go` (7 examples)
- `flip.go` (3 examples)
- [ ] **readerio** - Examples in:
- `array.go` (3 examples)
- `bind.go` (7 examples)
- `flip.go` (2 examples)
- `logging.go` (4 examples)
- `reader.go` (30 examples)
- [ ] **readerioeither** - Examples in:
- `bind.go` (7 examples)
- `flip.go` (1 example)
- [ ] **readerioresult** - Examples in:
- `array.go` (8 examples)
- `bind.go` (24 examples)
### State Packages
- [ ] **statereaderioeither** - Examples in:
- `bind.go` (5 examples)
- `resource.go` (1 example)
- `state.go` (13 examples)
### Utility Packages
- [ ] **lazy** - Additional examples in:
- `apply.go` (2 examples)
- `bind.go` (7 examples)
- `lazy.go` (10 examples)
- `sequence.go` (4 examples)
- `traverse.go` (2 examples)
- [ ] **pair** - Additional examples in:
- `monad.go` (12 examples)
- `pair.go` (remaining ~20 examples)
- [ ] **tuple** - Examples in:
- `tuple.go` (6 examples)
- [ ] **predicate** - Examples in:
- `bool.go` (3 examples)
- `contramap.go` (1 example)
- `monoid.go` (4 examples)
- [ ] **retry** - Examples in:
- `retry.go` (7 examples)
- [ ] **logging** - Examples in:
- `logger.go` (5 examples)
### Collection Packages
- [ ] **record** - Examples in:
- `bind.go` (3 examples)
### Type Class Packages
- [ ] **semigroup** - Examples in:
- `alt.go` (1 example)
- `apply.go` (1 example)
- `array.go` (4 examples)
- `semigroup.go` (7 examples)
- [ ] **ord** - Examples in:
- `ord.go` (1 example)
## Strategy for Completion
1. **Prioritize by usage**: Focus on core packages (result, option, either) first
2. **Group by package**: Complete all examples for one package before moving to next
3. **Test incrementally**: Run tests after each file to catch errors early
4. **Follow patterns**: Use existing example test files as templates
5. **Document as you go**: Update this file with progress
## Example Test File Template
```go
// Copyright header...
package packagename_test
import (
"fmt"
PKG "github.com/IBM/fp-go/v2/packagename"
)
func ExampleFunctionName() {
// Copy example from doc comment
// Ensure it compiles and produces correct output
fmt.Println(result)
// Output:
// expected output
}
```
## Notes
- Use `F.Constant1[error](defaultValue)` for GetOrElse in result package
- Use `F.Pipe1` instead of `F.Pipe2` when only one transformation
- Check function signatures carefully for type parameters
- Some functions like `BiMap` are capitalized differently than in docs
- **Prefer `R.Eitherize1(func)` over manual error handling** - converts `func(T) (R, error)` to `func(T) Result[R]`
- Example: Use `R.Eitherize1(strconv.Atoi)` instead of manual if/else error checking
- **Add Go documentation comments to all example functions** - Each example should have a comment explaining what it demonstrates
- **Idiomatic vs Non-Idiomatic packages**:
- Non-idiomatic (e.g., `result`): Uses `Result[A]` type (Either monad)
- Idiomatic (e.g., `idiomatic/result`): Uses `(A, error)` tuples (Go-style)
- Context readers use non-idiomatic `Result[A]` internally

829
v2/FUNCTIONAL_IO.md Normal file
View File

@@ -0,0 +1,829 @@
# 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.
## Table of Contents
- [Why Context in I/O Operations](#why-context-in-io-operations)
- [The Error-Value Tuple Pattern](#the-error-value-tuple-pattern)
- [Functional Approach: Reader Pattern](#functional-approach-reader-pattern)
- [Benefits of the Functional Approach](#benefits-of-the-functional-approach)
- [Side-by-Side Comparison](#side-by-side-comparison)
- [Advanced Patterns](#advanced-patterns)
- [When to Use Each Approach](#when-to-use-each-approach)
## Why Context in I/O Operations
In idiomatic Go, I/O operations conventionally take a `context.Context` as their first parameter:
```go
func QueryDatabase(ctx context.Context, query string) (Result, error)
func MakeHTTPRequest(ctx context.Context, url string) (*http.Response, error)
func ReadFile(ctx context.Context, path string) ([]byte, error)
```
### The Purpose of Context
The `context.Context` parameter serves several critical purposes:
1. **Cancellation Propagation**: Operations can be cancelled when the context is cancelled
2. **Deadline Management**: Operations respect timeouts and deadlines
3. **Request-Scoped Values**: Carry request metadata (trace IDs, user info, etc.)
4. **Resource Cleanup**: Signal to release resources when work is no longer needed
### Why Context Matters for I/O
I/O operations are inherently **effectful** - they interact with the outside world:
- Reading from disk, network, or database
- Writing to external systems
- Generating random numbers
- Reading the current time
These operations can:
- **Take time**: Network calls may be slow
- **Fail**: Connections drop, files don't exist
- **Block**: Waiting for external resources
- **Need cancellation**: User navigates away, request times out
Context provides a standard mechanism to control these operations across your entire application.
## The Error-Value Tuple Pattern
### Why Operations Must Return Errors
In Go, I/O operations return `(value, error)` tuples because:
1. **Context can be cancelled**: Even if the operation would succeed, cancellation must be represented
2. **External systems fail**: Networks fail, files are missing, permissions are denied
3. **Resources are exhausted**: Out of memory, disk full, connection pool exhausted
4. **Timeouts occur**: Operations exceed their deadline
**There cannot be I/O operations without error handling** because the context itself introduces a failure mode (cancellation) that must be represented in the return type.
### Traditional Go Pattern
```go
func ProcessUser(ctx context.Context, userID int) (User, error) {
// Check context before starting
if err := ctx.Err(); err != nil {
return User{}, err
}
// Fetch user from database
user, err := db.QueryUser(ctx, userID)
if err != nil {
return User{}, fmt.Errorf("query user: %w", err)
}
// Validate user
if user.Age < 18 {
return User{}, errors.New("user too young")
}
// Fetch user's posts
posts, err := db.QueryPosts(ctx, user.ID)
if err != nil {
return User{}, fmt.Errorf("query posts: %w", err)
}
user.Posts = posts
return user, nil
}
```
**Characteristics:**
- Explicit error checking at each step
- Manual error wrapping and propagation
- Context checked manually
- Imperative control flow
- Error handling mixed with business logic
## Functional Approach: Reader Pattern
### The Core Insight
In functional programming, we separate **what to compute** from **how to execute it**. Instead of functions that perform I/O directly, we create functions that **return descriptions of I/O operations**.
### Key Type: ReaderIOResult
```go
// A function that takes a context and returns a value or error
type ReaderIOResult[A any] = func(context.Context) (A, error)
```
This type represents:
- **Reader**: Depends on an environment (context.Context)
- **IO**: Performs side effects (I/O operations)
- **Result**: Can fail with an error
### Why This Is Better
The functional approach **carries the I/O aspect as the return value, not on the input**:
```go
// Traditional: I/O is implicit in the function execution
func fetchUser(ctx context.Context, id int) (User, error) {
// Performs I/O immediately
}
// Functional: I/O is explicit in the return type
func fetchUser(id int) ReaderIOResult[User] {
// Returns a description of I/O, doesn't execute yet
return func(ctx context.Context) (User, error) {
// I/O happens here when the function is called
}
}
```
**Key difference**: The functional version is a **curried function** where:
1. Business parameters come first: `fetchUser(id)`
2. Context comes last: `fetchUser(id)(ctx)`
3. The intermediate result is composable: `ReaderIOResult[User]`
## Benefits of the Functional Approach
### 1. Separation of Pure and Impure Code
```go
// Pure computation - no I/O, no context needed
func validateAge(user User) (User, error) {
if user.Age < 18 {
return User{}, errors.New("user too young")
}
return user, nil
}
// Impure I/O operation - needs context
func fetchUser(id int) ReaderIOResult[User] {
return func(ctx context.Context) (User, error) {
return db.QueryUser(ctx, id)
}
}
// Compose them - pure logic lifted into ReaderIOResult
pipeline := F.Pipe2(
fetchUser(42), // ReaderIOResult[User]
readerioresult.ChainEitherK(validateAge), // Lift pure function
)
// Execute when ready
user, err := pipeline(ctx)
```
**Benefits:**
- Pure functions are easier to test (no mocking needed)
- Pure functions are easier to reason about (no side effects)
- Clear boundary between logic and I/O
- Can test business logic independently
### 2. Composability
Functions compose naturally without manual error checking:
```go
// Traditional approach - manual error handling
func ProcessUserTraditional(ctx context.Context, userID int) (UserWithPosts, error) {
user, err := fetchUser(ctx, userID)
if err != nil {
return UserWithPosts{}, err
}
validated, err := validateUser(user)
if err != nil {
return UserWithPosts{}, err
}
posts, err := fetchPosts(ctx, validated.ID)
if err != nil {
return UserWithPosts{}, err
}
return enrichUser(validated, posts), nil
}
// Functional approach - automatic error propagation
func ProcessUserFunctional(userID int) ReaderIOResult[UserWithPosts] {
return F.Pipe3(
fetchUser(userID),
readerioresult.ChainEitherK(validateUser),
readerioresult.Chain(func(user User) ReaderIOResult[UserWithPosts] {
return F.Pipe2(
fetchPosts(user.ID),
readerioresult.Map(func(posts []Post) UserWithPosts {
return enrichUser(user, posts)
}),
)
}),
)
}
```
**Benefits:**
- No manual error checking
- Automatic short-circuiting on first error
- Clear data flow
- Easier to refactor and extend
### 3. Testability
```go
// Mock I/O operations by providing test implementations
func TestProcessUser(t *testing.T) {
// Create a mock that returns test data
mockFetchUser := func(id int) ReaderIOResult[User] {
return func(ctx context.Context) (User, error) {
return User{ID: id, Age: 25}, nil
}
}
// Test with mock - no database needed
result, err := mockFetchUser(42)(context.Background())
assert.NoError(t, err)
assert.Equal(t, 25, result.Age)
}
```
### 4. Lazy Evaluation
Operations are not executed until you provide the context:
```go
// Build the pipeline - no I/O happens yet
pipeline := F.Pipe3(
fetchUser(42),
readerioresult.Map(enrichUser),
readerioresult.Chain(saveUser),
)
// I/O only happens when we call it with a context
user, err := pipeline(ctx)
```
**Benefits:**
- Build complex operations as pure data structures
- Defer execution until needed
- Reuse pipelines with different contexts
- Test pipelines without executing I/O
### 5. Context Propagation
Context is automatically threaded through all operations:
```go
// Traditional - must pass context explicitly everywhere
func Process(ctx context.Context) error {
user, err := fetchUser(ctx, 42)
if err != nil {
return err
}
posts, err := fetchPosts(ctx, user.ID)
if err != nil {
return err
}
return savePosts(ctx, posts)
}
// Functional - context provided once at execution
func Process() ReaderIOResult[any] {
return F.Pipe2(
fetchUser(42),
readerioresult.Chain(func(user User) ReaderIOResult[any] {
return F.Pipe2(
fetchPosts(user.ID),
readerioresult.Chain(savePosts),
)
}),
)
}
// Context provided once
err := readerioresult.Fold(
func(err error) error { return err },
func(any) error { return nil },
)(Process())(ctx)
```
## Side-by-Side Comparison
### Example: User Service with Database Operations
#### Traditional Go Style
```go
package traditional
import (
"context"
"database/sql"
"fmt"
)
type User struct {
ID int
Name string
Email string
Age int
}
type UserService struct {
db *sql.DB
}
// Fetch user from database
func (s *UserService) GetUser(ctx context.Context, id int) (User, error) {
var user User
// Check context
if err := ctx.Err(); err != nil {
return User{}, err
}
// Query database
row := s.db.QueryRowContext(ctx,
"SELECT id, name, email, age FROM users WHERE id = ?", id)
err := row.Scan(&user.ID, &user.Name, &user.Email, &user.Age)
if err != nil {
return User{}, fmt.Errorf("scan user: %w", err)
}
return user, nil
}
// Validate user
func (s *UserService) ValidateUser(ctx context.Context, user User) (User, error) {
if user.Age < 18 {
return User{}, fmt.Errorf("user %d is too young", user.ID)
}
if user.Email == "" {
return User{}, fmt.Errorf("user %d has no email", user.ID)
}
return user, nil
}
// Update user email
func (s *UserService) UpdateEmail(ctx context.Context, id int, email string) (User, error) {
// Check context
if err := ctx.Err(); err != nil {
return User{}, err
}
// Update database
_, err := s.db.ExecContext(ctx,
"UPDATE users SET email = ? WHERE id = ?", email, id)
if err != nil {
return User{}, fmt.Errorf("update email: %w", err)
}
// Fetch updated user
return s.GetUser(ctx, id)
}
// Process user: fetch, validate, update email
func (s *UserService) ProcessUser(ctx context.Context, id int, newEmail string) (User, error) {
// Fetch user
user, err := s.GetUser(ctx, id)
if err != nil {
return User{}, fmt.Errorf("get user: %w", err)
}
// Validate user
validated, err := s.ValidateUser(ctx, user)
if err != nil {
return User{}, fmt.Errorf("validate user: %w", err)
}
// Update email
updated, err := s.UpdateEmail(ctx, validated.ID, newEmail)
if err != nil {
return User{}, fmt.Errorf("update email: %w", err)
}
return updated, nil
}
```
**Characteristics:**
- ✗ Manual error checking at every step
- ✗ Context passed explicitly to every function
- ✗ Error wrapping is manual and verbose
- ✗ Business logic mixed with error handling
- ✗ Hard to test without database
- ✗ Difficult to compose operations
- ✓ Familiar to Go developers
- ✓ Explicit control flow
#### Functional Go Style (context/readerioresult)
```go
package functional
import (
"context"
"database/sql"
"fmt"
F "github.com/IBM/fp-go/v2/function"
RIO "github.com/IBM/fp-go/v2/context/readerioresult"
)
type User struct {
ID int
Name string
Email string
Age int
}
type UserService struct {
db *sql.DB
}
// Fetch user from database - returns a ReaderIOResult
func (s *UserService) GetUser(id int) RIO.ReaderIOResult[User] {
return func(ctx context.Context) (User, error) {
var user User
row := s.db.QueryRowContext(ctx,
"SELECT id, name, email, age FROM users WHERE id = ?", id)
err := row.Scan(&user.ID, &user.Name, &user.Email, &user.Age)
if err != nil {
return User{}, fmt.Errorf("scan user: %w", err)
}
return user, nil
}
}
// Validate user - pure function (no I/O, no context)
func ValidateUser(user User) (User, error) {
if user.Age < 18 {
return User{}, fmt.Errorf("user %d is too young", user.ID)
}
if user.Email == "" {
return User{}, fmt.Errorf("user %d has no email", user.ID)
}
return user, nil
}
// Update user email - returns a ReaderIOResult
func (s *UserService) UpdateEmail(id int, email string) RIO.ReaderIOResult[User] {
return func(ctx context.Context) (User, error) {
_, err := s.db.ExecContext(ctx,
"UPDATE users SET email = ? WHERE id = ?", email, id)
if err != nil {
return User{}, fmt.Errorf("update email: %w", err)
}
// Chain to GetUser
return s.GetUser(id)(ctx)
}
}
// Process user: fetch, validate, update email - composable pipeline
func (s *UserService) ProcessUser(id int, newEmail string) RIO.ReaderIOResult[User] {
return F.Pipe3(
s.GetUser(id), // Fetch user
RIO.ChainEitherK(ValidateUser), // Validate (pure function)
RIO.Chain(func(user User) RIO.ReaderIOResult[User] {
return s.UpdateEmail(user.ID, newEmail) // Update email
}),
)
}
// Alternative: Using Do-notation for more complex flows
func (s *UserService) ProcessUserDo(id int, newEmail string) RIO.ReaderIOResult[User] {
return RIO.Chain(func(user User) RIO.ReaderIOResult[User] {
// Validate is pure, lift it into ReaderIOResult
validated, err := ValidateUser(user)
if err != nil {
return RIO.Left[User](err)
}
// Update with validated user
return s.UpdateEmail(validated.ID, newEmail)
})(s.GetUser(id))
}
```
**Characteristics:**
- ✓ Automatic error propagation (no manual checking)
- ✓ Context threaded automatically
- ✓ Pure functions separated from I/O
- ✓ Business logic clear and composable
- ✓ Easy to test (mock ReaderIOResult)
- ✓ Operations compose naturally
- ✓ Lazy evaluation (build pipeline, execute later)
- ✗ Requires understanding of functional patterns
- ✗ Less familiar to traditional Go developers
#### Idiomatic Functional Style (idiomatic/context/readerresult)
For even better performance with the same functional benefits:
```go
package idiomatic
import (
"context"
"database/sql"
"fmt"
F "github.com/IBM/fp-go/v2/function"
RR "github.com/IBM/fp-go/v2/idiomatic/context/readerresult"
)
type User struct {
ID int
Name string
Email string
Age int
}
type UserService struct {
db *sql.DB
}
// ReaderResult is just: func(context.Context) (A, error)
// Same as ReaderIOResult but using native Go tuples
func (s *UserService) GetUser(id int) RR.ReaderResult[User] {
return func(ctx context.Context) (User, error) {
var user User
row := s.db.QueryRowContext(ctx,
"SELECT id, name, email, age FROM users WHERE id = ?", id)
err := row.Scan(&user.ID, &user.Name, &user.Email, &user.Age)
return user, err // Native tuple return
}
}
// Pure validation - returns native (User, error) tuple
func ValidateUser(user User) (User, error) {
if user.Age < 18 {
return User{}, fmt.Errorf("user %d is too young", user.ID)
}
if user.Email == "" {
return User{}, fmt.Errorf("user %d has no email", user.ID)
}
return user, nil
}
func (s *UserService) UpdateEmail(id int, email string) RR.ReaderResult[User] {
return func(ctx context.Context) (User, error) {
_, err := s.db.ExecContext(ctx,
"UPDATE users SET email = ? WHERE id = ?", email, id)
if err != nil {
return User{}, err
}
return s.GetUser(id)(ctx)
}
}
// Composable pipeline with native tuples
func (s *UserService) ProcessUser(id int, newEmail string) RR.ReaderResult[User] {
return F.Pipe3(
s.GetUser(id),
RR.ChainEitherK(ValidateUser), // Lift pure function
RR.Chain(func(user User) RR.ReaderResult[User] {
return s.UpdateEmail(user.ID, newEmail)
}),
)
}
```
**Characteristics:**
- ✓ All benefits of functional approach
-**2-10x better performance** (native tuples)
-**Zero allocations** for many operations
- ✓ More familiar to Go developers (uses (value, error))
- ✓ Seamless integration with existing Go code
- ✓ Same composability as ReaderIOResult
### Usage Comparison
```go
// Traditional
func HandleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
service := &UserService{db: db}
user, err := service.ProcessUser(ctx, 42, "new@email.com")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(user)
}
// Functional (both styles)
func HandleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
service := &UserService{db: db}
// Build the pipeline (no execution yet)
pipeline := service.ProcessUser(42, "new@email.com")
// Execute with context
user, err := pipeline(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(user)
}
// Or using Fold for cleaner error handling
func HandleRequestFold(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
service := &UserService{db: db}
RR.Fold(
func(err error) {
http.Error(w, err.Error(), http.StatusInternalServerError)
},
func(user User) {
json.NewEncoder(w).Encode(user)
},
)(service.ProcessUser(42, "new@email.com"))(ctx)
}
```
## Advanced Patterns
### Resource Management with Bracket
```go
// Traditional
func ProcessFile(ctx context.Context, path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return "", err
}
return string(data), nil
}
// Functional - guaranteed cleanup even on panic
func ProcessFile(path string) RIO.ReaderIOResult[string] {
return RIO.Bracket(
// Acquire resource
func(ctx context.Context) (*os.File, error) {
return os.Open(path)
},
// Release resource (always called)
func(file *os.File, err error) RIO.ReaderIOResult[any] {
return func(ctx context.Context) (any, error) {
return nil, file.Close()
}
},
// Use resource
func(file *os.File) RIO.ReaderIOResult[string] {
return func(ctx context.Context) (string, error) {
data, err := io.ReadAll(file)
return string(data), err
}
},
)
}
```
### Parallel Execution
```go
// Traditional - manual goroutines and sync
func FetchMultipleUsers(ctx context.Context, ids []int) ([]User, error) {
var wg sync.WaitGroup
users := make([]User, len(ids))
errs := make([]error, len(ids))
for i, id := range ids {
wg.Add(1)
go func(i, id int) {
defer wg.Done()
users[i], errs[i] = fetchUser(ctx, id)
}(i, id)
}
wg.Wait()
for _, err := range errs {
if err != nil {
return nil, err
}
}
return users, nil
}
// Functional - automatic parallelization
func FetchMultipleUsers(ids []int) RIO.ReaderIOResult[[]User] {
operations := A.Map(func(id int) RIO.ReaderIOResult[User] {
return fetchUser(id)
})(ids)
return RIO.TraverseArrayPar(F.Identity[RIO.ReaderIOResult[User]])(operations)
}
```
### Retry Logic
```go
// Traditional
func FetchWithRetry(ctx context.Context, url string, maxRetries int) ([]byte, error) {
var lastErr error
for i := 0; i < maxRetries; i++ {
if ctx.Err() != nil {
return nil, ctx.Err()
}
resp, err := http.Get(url)
if err == nil {
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
lastErr = err
time.Sleep(time.Second * time.Duration(i+1))
}
return nil, lastErr
}
// Functional
func FetchWithRetry(url string, maxRetries int) RIO.ReaderIOResult[[]byte] {
operation := func(ctx context.Context) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
return RIO.Retry(
maxRetries,
func(attempt int) time.Duration {
return time.Second * time.Duration(attempt)
},
)(operation)
}
```
## When to Use Each Approach
### Use Traditional Go Style When:
1. **Team familiarity**: Team is not familiar with functional programming
2. **Simple operations**: Single I/O operation with straightforward error handling
3. **Existing codebase**: Large codebase already using traditional patterns
4. **Learning curve**: Want to minimize onboarding time
5. **Explicit control**: Need very explicit control flow
### Use Functional Style (ReaderIOResult) When:
1. **Complex pipelines**: Multiple I/O operations that need composition
2. **Testability**: Need to test business logic separately from I/O
3. **Reusability**: Want to build reusable operation pipelines
4. **Error handling**: Want automatic error propagation
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
### Use Idiomatic Functional Style (idiomatic/context/readerresult) When:
1. **All functional benefits**: Want functional patterns with Go idioms
2. **Performance critical**: Need 2-10x better performance
3. **Zero allocations**: Memory efficiency is important
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
## Summary
The functional approach to I/O in Go offers significant advantages:
1. **Separation of Concerns**: Pure logic separated from I/O effects
2. **Composability**: Operations compose naturally without manual error checking
3. **Testability**: Easy to test without mocking I/O
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
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.
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.
## Further Reading
- [DESIGN.md](./DESIGN.md) - Design principles and patterns
- [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

View File

@@ -61,6 +61,7 @@ package main
import (
"fmt"
"github.com/IBM/fp-go/v2/option"
N "github.com/IBM/fp-go/v2/number"
)
func main() {
@@ -145,6 +146,8 @@ func main() {
}
```
## ⚠️ Breaking Changes
### From V1 to V2
#### 1. Generic Type Aliases
@@ -443,23 +446,36 @@ func process() IOResult[string] {
## 📚 Documentation
- **[Design Decisions](./DESIGN.md)** - Key design principles and patterns explained
- **[Functional I/O in Go](./FUNCTIONAL_IO.md)** - Understanding Context, errors, and the Reader pattern for I/O operations
- **[Idiomatic vs Standard Packages](./IDIOMATIC_COMPARISON.md)** - Performance comparison and when to use each approach
- **[API Documentation](https://pkg.go.dev/github.com/IBM/fp-go/v2)** - Complete API reference
- **[Code Samples](./samples/)** - Practical examples and use cases
- **[Go 1.24 Release Notes](https://tip.golang.org/doc/go1.24)** - Information about generic type aliases
### Core Modules
#### Standard Packages (Struct-based)
- **Option** - Represent optional values without nil
- **Either** - Type-safe error handling with left/right values
- **Result** - Simplified Either with error as left type
- **Result** - Simplified Either with error as left type (recommended for error handling)
- **IO** - Lazy evaluation and side effect management
- **IOEither** - Combine IO with error handling
- **IOResult** - Combine IO with Result for error handling (recommended over IOEither)
- **Reader** - Dependency injection pattern
- **ReaderIOEither** - Combine Reader, IO, and Either for complex workflows
- **ReaderIOResult** - Combine Reader, IO, and Result for complex workflows
- **Array** - Functional array operations
- **Record** - Functional record/map operations
- **Optics** - Lens, Prism, Optional, and Traversal for immutable updates
#### Idiomatic Packages (Tuple-based, High Performance)
- **idiomatic/option** - Option monad using native Go `(value, bool)` tuples
- **idiomatic/result** - Result monad using native Go `(value, error)` tuples
- **idiomatic/ioresult** - IOResult monad using `func() (value, error)` for IO operations
- **idiomatic/readerresult** - Reader monad combined with Result pattern
- **idiomatic/readerioresult** - Reader monad combined with IOResult pattern
The idiomatic packages offer 2-10x performance improvements and zero allocations by using Go's native tuple patterns instead of struct wrappers. Use them for performance-critical code or when you prefer Go's native error handling style.
## 🤔 Should I Migrate?
**Migrate to V2 if:**

View File

@@ -514,6 +514,83 @@ func Push[A any](a A) Operator[A, A] {
return G.Push[Operator[A, A]](a)
}
// Concat concatenates two arrays, appending the provided array to the end of the input array.
// This is a curried function that takes an array to append and returns a function that
// takes the base array and returns the concatenated result.
//
// The function creates a new array containing all elements from the base array followed
// by all elements from the appended array. Neither input array is modified.
//
// Type Parameters:
// - A: The type of elements in the arrays
//
// Parameters:
// - as: The array to append to the end of the base array
//
// Returns:
// - A function that takes a base array and returns a new array with `as` appended to its end
//
// Behavior:
// - Creates a new array with length equal to the sum of both input arrays
// - Copies all elements from the base array first
// - Appends all elements from the `as` array at the end
// - Returns the base array unchanged if `as` is empty
// - Returns `as` unchanged if the base array is empty
// - Does not modify either input array
//
// Example:
//
// base := []int{1, 2, 3}
// toAppend := []int{4, 5, 6}
// result := array.Concat(toAppend)(base)
// // result: []int{1, 2, 3, 4, 5, 6}
// // base: []int{1, 2, 3} (unchanged)
// // toAppend: []int{4, 5, 6} (unchanged)
//
// Example with empty arrays:
//
// base := []int{1, 2, 3}
// empty := []int{}
// result := array.Concat(empty)(base)
// // result: []int{1, 2, 3}
//
// Example with strings:
//
// words1 := []string{"hello", "world"}
// words2 := []string{"foo", "bar"}
// result := array.Concat(words2)(words1)
// // result: []string{"hello", "world", "foo", "bar"}
//
// Example with functional composition:
//
// numbers := []int{1, 2, 3}
// result := F.Pipe2(
// numbers,
// array.Map(N.Mul(2)),
// array.Concat([]int{10, 20}),
// )
// // result: []int{2, 4, 6, 10, 20}
//
// Use cases:
// - Combining multiple arrays into one
// - Building arrays incrementally
// - Implementing array-based data structures (queues, buffers)
// - Merging results from multiple operations
// - Creating array pipelines with functional composition
//
// Performance:
// - Time complexity: O(n + m) where n and m are the lengths of the arrays
// - Space complexity: O(n + m) for the new array
// - Optimized to avoid allocation when one array is empty
//
// Note: This function is immutable - it creates a new array rather than modifying
// the input arrays. For appending a single element, consider using Append or Push.
//
//go:inline
func Concat[A any](as []A) Operator[A, A] {
return F.Bind2nd(array.Concat[[]A, A], as)
}
// MonadFlap applies a value to an array of functions, producing an array of results.
// This is the monadic version that takes both parameters.
//
@@ -536,3 +613,214 @@ func Flap[B, A any](a A) Operator[func(A) B, B] {
func Prepend[A any](head A) Operator[A, A] {
return G.Prepend[Operator[A, A]](head)
}
// Reverse returns a new slice with elements in reverse order.
// This function creates a new slice containing all elements from the input slice
// in reverse order, without modifying the original slice.
//
// Type Parameters:
// - A: The type of elements in the slice
//
// Parameters:
// - as: The input slice to reverse
//
// Returns:
// - A new slice with elements in reverse order
//
// Behavior:
// - Creates a new slice with the same length as the input
// - Copies elements from the input slice in reverse order
// - Does not modify the original slice
// - Returns an empty slice if the input is empty
// - Returns a single-element slice unchanged if input has one element
//
// Example:
//
// numbers := []int{1, 2, 3, 4, 5}
// reversed := array.Reverse(numbers)
// // reversed: []int{5, 4, 3, 2, 1}
// // numbers: []int{1, 2, 3, 4, 5} (unchanged)
//
// Example with strings:
//
// words := []string{"hello", "world", "foo", "bar"}
// reversed := array.Reverse(words)
// // reversed: []string{"bar", "foo", "world", "hello"}
//
// Example with empty slice:
//
// empty := []int{}
// reversed := array.Reverse(empty)
// // reversed: []int{} (empty slice)
//
// Example with single element:
//
// single := []string{"only"}
// reversed := array.Reverse(single)
// // reversed: []string{"only"}
//
// Use cases:
// - Reversing the order of elements for display or processing
// - Implementing stack-like behavior (LIFO)
// - Processing data in reverse chronological order
// - Reversing transformation pipelines
// - Creating palindrome checks
// - Implementing undo/redo functionality
//
// Example with processing in reverse:
//
// events := []string{"start", "middle", "end"}
// reversed := array.Reverse(events)
// // Process events in reverse order
// for _, event := range reversed {
// fmt.Println(event) // Prints: "end", "middle", "start"
// }
//
// Example with functional composition:
//
// numbers := []int{1, 2, 3, 4, 5}
// result := F.Pipe2(
// numbers,
// array.Map(N.Mul(2)),
// array.Reverse,
// )
// // result: []int{10, 8, 6, 4, 2}
//
// Performance:
// - Time complexity: O(n) where n is the length of the slice
// - Space complexity: O(n) for the new slice
// - Does not allocate if the input slice is empty
//
// Note: This function is immutable - it does not modify the original slice.
// If you need to reverse a slice in-place, consider using a different approach
// or modifying the slice directly.
//
//go:inline
func Reverse[A any](as []A) []A {
return G.Reverse(as)
}
// Extend applies a function to every suffix of an array, creating a new array of results.
// This is the comonad extend operation for arrays.
//
// The function f is applied to progressively smaller suffixes of the input array:
// - f(as[0:]) for the first element
// - f(as[1:]) for the second element
// - f(as[2:]) for the third element
// - and so on...
//
// Type Parameters:
// - A: The type of elements in the input array
// - B: The type of elements in the output array
//
// Parameters:
// - f: A function that takes an array suffix and returns a value
//
// Returns:
// - A function that transforms an array of A into an array of B
//
// Behavior:
// - Creates a new array with the same length as the input
// - For each position i, applies f to the suffix starting at i
// - Returns an empty array if the input is empty
//
// Example:
//
// // Sum all elements from current position to end
// sumSuffix := array.Extend(func(as []int) int {
// return array.Reduce(func(acc, x int) int { return acc + x }, 0)(as)
// })
// result := sumSuffix([]int{1, 2, 3, 4})
// // result: []int{10, 9, 7, 4}
// // Explanation: [1+2+3+4, 2+3+4, 3+4, 4]
//
// Example with length:
//
// // Get remaining length at each position
// lengths := array.Extend(array.Size[int])
// result := lengths([]int{10, 20, 30})
// // result: []int{3, 2, 1}
//
// Example with head:
//
// // Duplicate each element (extract head of each suffix)
// duplicate := array.Extend(func(as []int) int {
// return F.Pipe1(as, array.Head[int], O.GetOrElse(F.Constant(0)))
// })
// result := duplicate([]int{1, 2, 3})
// // result: []int{1, 2, 3}
//
// Use cases:
// - Computing cumulative or rolling operations
// - Implementing sliding window algorithms
// - Creating context-aware transformations
// - Building comonadic computations
//
// Comonad laws:
// - Left identity: Extend(Extract) == Identity
// - Right identity: Extract ∘ Extend(f) == f
// - Associativity: Extend(f) ∘ Extend(g) == Extend(f ∘ Extend(g))
//
//go:inline
func Extend[A, B any](f func([]A) B) Operator[A, B] {
return func(as []A) []B {
return G.MakeBy[[]B](len(as), func(i int) B { return f(as[i:]) })
}
}
// Extract returns the first element of an array, or a zero value if empty.
// This is the comonad extract operation for arrays.
//
// Extract is the dual of the monadic return/of operation. While Of wraps a value
// in a context, Extract unwraps a value from its context.
//
// Type Parameters:
// - A: The type of elements in the array
//
// Parameters:
// - as: The input array
//
// Returns:
// - The first element if the array is non-empty, otherwise the zero value of type A
//
// Behavior:
// - Returns as[0] if the array has at least one element
// - Returns the zero value of A if the array is empty
// - Does not modify the input array
//
// Example:
//
// result := array.Extract([]int{1, 2, 3})
// // result: 1
//
// Example with empty array:
//
// result := array.Extract([]int{})
// // result: 0 (zero value for int)
//
// Example with strings:
//
// result := array.Extract([]string{"hello", "world"})
// // result: "hello"
//
// Example with empty string array:
//
// result := array.Extract([]string{})
// // result: "" (zero value for string)
//
// Use cases:
// - Extracting the current focus from a comonadic context
// - Getting the head element with a default zero value
// - Implementing comonad-based computations
//
// Comonad laws:
// - Extract ∘ Of == Identity (extracting from a singleton returns the value)
// - Extract ∘ Extend(f) == f (extract after extend equals applying f)
//
// Note: For a safer alternative that handles empty arrays explicitly,
// consider using Head which returns an Option[A].
//
//go:inline
func Extract[A any](as []A) A {
return G.Extract(as)
}

View File

@@ -22,6 +22,7 @@ import (
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/internal/utils"
N "github.com/IBM/fp-go/v2/number"
O "github.com/IBM/fp-go/v2/option"
S "github.com/IBM/fp-go/v2/string"
T "github.com/IBM/fp-go/v2/tuple"
@@ -214,3 +215,890 @@ func ExampleFoldMap() {
// Output: ABC
}
// TestReverse tests the Reverse function
func TestReverse(t *testing.T) {
t.Run("Reverse integers", func(t *testing.T) {
input := []int{1, 2, 3, 4, 5}
result := Reverse(input)
expected := []int{5, 4, 3, 2, 1}
assert.Equal(t, expected, result)
})
t.Run("Reverse strings", func(t *testing.T) {
input := []string{"hello", "world", "foo", "bar"}
result := Reverse(input)
expected := []string{"bar", "foo", "world", "hello"}
assert.Equal(t, expected, result)
})
t.Run("Reverse empty slice", func(t *testing.T) {
input := []int{}
result := Reverse(input)
assert.Equal(t, []int{}, result)
})
t.Run("Reverse single element", func(t *testing.T) {
input := []string{"only"}
result := Reverse(input)
assert.Equal(t, []string{"only"}, result)
})
t.Run("Reverse two elements", func(t *testing.T) {
input := []int{1, 2}
result := Reverse(input)
assert.Equal(t, []int{2, 1}, result)
})
t.Run("Does not modify original slice", func(t *testing.T) {
original := []int{1, 2, 3, 4, 5}
originalCopy := []int{1, 2, 3, 4, 5}
_ = Reverse(original)
assert.Equal(t, originalCopy, original)
})
t.Run("Reverse with floats", func(t *testing.T) {
input := []float64{1.1, 2.2, 3.3}
result := Reverse(input)
expected := []float64{3.3, 2.2, 1.1}
assert.Equal(t, expected, result)
})
t.Run("Reverse with structs", func(t *testing.T) {
type Person struct {
Name string
Age int
}
input := []Person{
{"Alice", 30},
{"Bob", 25},
{"Charlie", 35},
}
result := Reverse(input)
expected := []Person{
{"Charlie", 35},
{"Bob", 25},
{"Alice", 30},
}
assert.Equal(t, expected, result)
})
t.Run("Reverse with pointers", func(t *testing.T) {
a, b, c := 1, 2, 3
input := []*int{&a, &b, &c}
result := Reverse(input)
assert.Equal(t, []*int{&c, &b, &a}, result)
})
t.Run("Double reverse returns original order", func(t *testing.T) {
original := []int{1, 2, 3, 4, 5}
reversed := Reverse(original)
doubleReversed := Reverse(reversed)
assert.Equal(t, original, doubleReversed)
})
t.Run("Reverse with large slice", func(t *testing.T) {
input := MakeBy(1000, F.Identity[int])
result := Reverse(input)
// Check first and last elements
assert.Equal(t, 999, result[0])
assert.Equal(t, 0, result[999])
// Check length
assert.Equal(t, 1000, len(result))
})
t.Run("Reverse palindrome", func(t *testing.T) {
input := []int{1, 2, 3, 2, 1}
result := Reverse(input)
assert.Equal(t, input, result)
})
}
// TestReverseComposition tests Reverse with other array operations
func TestReverseComposition(t *testing.T) {
t.Run("Reverse after Map", func(t *testing.T) {
input := []int{1, 2, 3, 4, 5}
result := F.Pipe2(
input,
Map(N.Mul(2)),
Reverse[int],
)
expected := []int{10, 8, 6, 4, 2}
assert.Equal(t, expected, result)
})
t.Run("Map after Reverse", func(t *testing.T) {
input := []int{1, 2, 3, 4, 5}
result := F.Pipe2(
input,
Reverse[int],
Map(N.Mul(2)),
)
expected := []int{10, 8, 6, 4, 2}
assert.Equal(t, expected, result)
})
t.Run("Reverse with Filter", func(t *testing.T) {
input := []int{1, 2, 3, 4, 5, 6}
result := F.Pipe2(
input,
Filter(func(n int) bool { return n%2 == 0 }),
Reverse[int],
)
expected := []int{6, 4, 2}
assert.Equal(t, expected, result)
})
t.Run("Reverse with Reduce", func(t *testing.T) {
input := []string{"a", "b", "c"}
reversed := Reverse(input)
result := Reduce(func(acc, val string) string {
return acc + val
}, "")(reversed)
assert.Equal(t, "cba", result)
})
t.Run("Reverse with Flatten", func(t *testing.T) {
input := [][]int{{1, 2}, {3, 4}, {5, 6}}
result := F.Pipe2(
input,
Reverse[[]int],
Flatten[int],
)
expected := []int{5, 6, 3, 4, 1, 2}
assert.Equal(t, expected, result)
})
}
// TestReverseUseCases demonstrates practical use cases for Reverse
func TestReverseUseCases(t *testing.T) {
t.Run("Process events in reverse chronological order", func(t *testing.T) {
events := []string{"2024-01-01", "2024-01-02", "2024-01-03"}
reversed := Reverse(events)
// Most recent first
assert.Equal(t, "2024-01-03", reversed[0])
assert.Equal(t, "2024-01-01", reversed[2])
})
t.Run("Implement stack behavior (LIFO)", func(t *testing.T) {
stack := []int{1, 2, 3, 4, 5}
reversed := Reverse(stack)
// Pop from reversed (LIFO)
assert.Equal(t, 5, reversed[0])
assert.Equal(t, 4, reversed[1])
})
t.Run("Reverse string characters", func(t *testing.T) {
chars := []rune("hello")
reversed := Reverse(chars)
result := string(reversed)
assert.Equal(t, "olleh", result)
})
t.Run("Check palindrome", func(t *testing.T) {
word := []rune("racecar")
reversed := Reverse(word)
assert.Equal(t, word, reversed)
notPalindrome := []rune("hello")
reversedNot := Reverse(notPalindrome)
assert.NotEqual(t, notPalindrome, reversedNot)
})
t.Run("Reverse transformation pipeline", func(t *testing.T) {
// Apply transformations in reverse order
numbers := []int{1, 2, 3}
// Normal: add 10, then multiply by 2
normal := F.Pipe2(
numbers,
Map(N.Add(10)),
Map(N.Mul(2)),
)
// Reversed order of operations
reversed := F.Pipe2(
numbers,
Map(N.Mul(2)),
Map(N.Add(10)),
)
assert.NotEqual(t, normal, reversed)
assert.Equal(t, []int{22, 24, 26}, normal)
assert.Equal(t, []int{12, 14, 16}, reversed)
})
}
// TestReverseProperties tests mathematical properties of Reverse
func TestReverseProperties(t *testing.T) {
t.Run("Involution property: Reverse(Reverse(x)) == x", func(t *testing.T) {
testCases := [][]int{
{1, 2, 3, 4, 5},
{1},
{},
{1, 2},
{5, 4, 3, 2, 1},
}
for _, original := range testCases {
result := Reverse(Reverse(original))
assert.Equal(t, original, result)
}
})
t.Run("Length preservation: len(Reverse(x)) == len(x)", func(t *testing.T) {
testCases := [][]int{
{1, 2, 3, 4, 5},
{1},
{},
MakeBy(100, F.Identity[int]),
}
for _, input := range testCases {
result := Reverse(input)
assert.Equal(t, len(input), len(result))
}
})
t.Run("First element becomes last", func(t *testing.T) {
input := []int{1, 2, 3, 4, 5}
result := Reverse(input)
if len(input) > 0 {
assert.Equal(t, input[0], result[len(result)-1])
assert.Equal(t, input[len(input)-1], result[0])
}
})
}
// TestExtract tests the Extract function
func TestExtract(t *testing.T) {
t.Run("Extract from non-empty array", func(t *testing.T) {
input := []int{1, 2, 3, 4, 5}
result := Extract(input)
assert.Equal(t, 1, result)
})
t.Run("Extract from single element array", func(t *testing.T) {
input := []string{"hello"}
result := Extract(input)
assert.Equal(t, "hello", result)
})
t.Run("Extract from empty array returns zero value", func(t *testing.T) {
input := []int{}
result := Extract(input)
assert.Equal(t, 0, result)
})
t.Run("Extract from empty string array returns empty string", func(t *testing.T) {
input := []string{}
result := Extract(input)
assert.Equal(t, "", result)
})
t.Run("Extract does not modify original array", func(t *testing.T) {
original := []int{1, 2, 3}
originalCopy := []int{1, 2, 3}
_ = Extract(original)
assert.Equal(t, originalCopy, original)
})
t.Run("Extract with floats", func(t *testing.T) {
input := []float64{3.14, 2.71, 1.41}
result := Extract(input)
assert.Equal(t, 3.14, result)
})
t.Run("Extract with structs", func(t *testing.T) {
type Person struct {
Name string
Age int
}
input := []Person{
{"Alice", 30},
{"Bob", 25},
}
result := Extract(input)
assert.Equal(t, Person{"Alice", 30}, result)
})
}
// TestExtractComonadLaws tests comonad laws for Extract
func TestExtractComonadLaws(t *testing.T) {
t.Run("Extract ∘ Of == Identity", func(t *testing.T) {
value := 42
result := Extract(Of(value))
assert.Equal(t, value, result)
})
t.Run("Extract ∘ Extend(f) == f", func(t *testing.T) {
input := []int{1, 2, 3, 4}
f := func(as []int) int {
return MonadReduce(as, func(acc, x int) int { return acc + x }, 0)
}
// Extract(Extend(f)(input)) should equal f(input)
extended := Extend(f)(input)
result := Extract(extended)
expected := f(input)
assert.Equal(t, expected, result)
})
}
// TestExtend tests the Extend function
func TestExtend(t *testing.T) {
t.Run("Extend with sum of suffixes", func(t *testing.T) {
input := []int{1, 2, 3, 4}
sumSuffix := Extend(func(as []int) int {
return MonadReduce(as, func(acc, x int) int { return acc + x }, 0)
})
result := sumSuffix(input)
expected := []int{10, 9, 7, 4} // [1+2+3+4, 2+3+4, 3+4, 4]
assert.Equal(t, expected, result)
})
t.Run("Extend with length of suffixes", func(t *testing.T) {
input := []int{10, 20, 30}
lengths := Extend(Size[int])
result := lengths(input)
expected := []int{3, 2, 1}
assert.Equal(t, expected, result)
})
t.Run("Extend with head extraction", func(t *testing.T) {
input := []int{1, 2, 3}
duplicate := Extend(func(as []int) int {
return F.Pipe2(as, Head[int], O.GetOrElse(F.Constant(0)))
})
result := duplicate(input)
expected := []int{1, 2, 3}
assert.Equal(t, expected, result)
})
t.Run("Extend with empty array", func(t *testing.T) {
input := []int{}
result := Extend(Size[int])(input)
assert.Equal(t, []int{}, result)
})
t.Run("Extend with single element", func(t *testing.T) {
input := []string{"hello"}
result := Extend(func(as []string) int { return len(as) })(input)
expected := []int{1}
assert.Equal(t, expected, result)
})
t.Run("Extend does not modify original array", func(t *testing.T) {
original := []int{1, 2, 3}
originalCopy := []int{1, 2, 3}
_ = Extend(Size[int])(original)
assert.Equal(t, originalCopy, original)
})
t.Run("Extend with string concatenation", func(t *testing.T) {
input := []string{"a", "b", "c"}
concat := Extend(func(as []string) string {
return MonadReduce(as, func(acc, s string) string { return acc + s }, "")
})
result := concat(input)
expected := []string{"abc", "bc", "c"}
assert.Equal(t, expected, result)
})
t.Run("Extend with max of suffixes", func(t *testing.T) {
input := []int{3, 1, 4, 1, 5}
maxSuffix := Extend(func(as []int) int {
if len(as) == 0 {
return 0
}
max := as[0]
for _, v := range as[1:] {
if v > max {
max = v
}
}
return max
})
result := maxSuffix(input)
expected := []int{5, 5, 5, 5, 5}
assert.Equal(t, expected, result)
})
}
// TestExtendComonadLaws tests comonad laws for Extend
func TestExtendComonadLaws(t *testing.T) {
t.Run("Left identity: Extend(Extract) == Identity", func(t *testing.T) {
input := []int{1, 2, 3, 4, 5}
result := Extend(Extract[int])(input)
assert.Equal(t, input, result)
})
t.Run("Right identity: Extract ∘ Extend(f) == f", func(t *testing.T) {
input := []int{1, 2, 3, 4}
f := func(as []int) int {
return MonadReduce(as, func(acc, x int) int { return acc + x }, 0)
}
// Extract(Extend(f)(input)) should equal f(input)
result := F.Pipe2(input, Extend(f), Extract[int])
expected := f(input)
assert.Equal(t, expected, result)
})
t.Run("Associativity: Extend(f) ∘ Extend(g) == Extend(f ∘ Extend(g))", func(t *testing.T) {
input := []int{1, 2, 3}
// f: sum of array
f := func(as []int) int {
return MonadReduce(as, func(acc, x int) int { return acc + x }, 0)
}
// g: length of array
g := func(as []int) int {
return len(as)
}
// Left side: Extend(f) ∘ Extend(g)
left := F.Pipe2(input, Extend(g), Extend(f))
// Right side: Extend(f ∘ Extend(g))
right := Extend(func(as []int) int {
return f(Extend(g)(as))
})(input)
assert.Equal(t, left, right)
})
}
// TestExtendComposition tests Extend with other array operations
func TestExtendComposition(t *testing.T) {
t.Run("Extend after Map", func(t *testing.T) {
input := []int{1, 2, 3}
result := F.Pipe2(
input,
Map(N.Mul(2)),
Extend(func(as []int) int {
return MonadReduce(as, func(acc, x int) int { return acc + x }, 0)
}),
)
expected := []int{12, 10, 6} // [2+4+6, 4+6, 6]
assert.Equal(t, expected, result)
})
t.Run("Map after Extend", func(t *testing.T) {
input := []int{1, 2, 3}
result := F.Pipe2(
input,
Extend(Size[int]),
Map(N.Mul(10)),
)
expected := []int{30, 20, 10}
assert.Equal(t, expected, result)
})
t.Run("Extend with Filter", func(t *testing.T) {
input := []int{1, 2, 3, 4, 5, 6}
result := F.Pipe2(
input,
Filter(func(n int) bool { return n%2 == 0 }),
Extend(Size[int]),
)
expected := []int{3, 2, 1} // lengths of [2,4,6], [4,6], [6]
assert.Equal(t, expected, result)
})
}
// TestExtendUseCases demonstrates practical use cases for Extend
func TestExtendUseCases(t *testing.T) {
t.Run("Running sum (cumulative sum from each position)", func(t *testing.T) {
input := []int{1, 2, 3, 4, 5}
runningSum := Extend(func(as []int) int {
return MonadReduce(as, func(acc, x int) int { return acc + x }, 0)
})
result := runningSum(input)
expected := []int{15, 14, 12, 9, 5}
assert.Equal(t, expected, result)
})
t.Run("Sliding window average", func(t *testing.T) {
input := []float64{1.0, 2.0, 3.0, 4.0, 5.0}
windowAvg := Extend(func(as []float64) float64 {
if len(as) == 0 {
return 0
}
sum := MonadReduce(as, func(acc, x float64) float64 { return acc + x }, 0.0)
return sum / float64(len(as))
})
result := windowAvg(input)
expected := []float64{3.0, 3.5, 4.0, 4.5, 5.0}
assert.Equal(t, expected, result)
})
t.Run("Check if suffix is sorted", func(t *testing.T) {
input := []int{1, 2, 3, 2, 1}
isSorted := Extend(func(as []int) bool {
for i := 1; i < len(as); i++ {
if as[i] < as[i-1] {
return false
}
}
return true
})
result := isSorted(input)
expected := []bool{false, false, false, false, true}
assert.Equal(t, expected, result)
})
t.Run("Count remaining elements", func(t *testing.T) {
events := []string{"start", "middle", "end"}
remaining := Extend(Size[string])
result := remaining(events)
expected := []int{3, 2, 1}
assert.Equal(t, expected, result)
})
}
// TestConcat tests the Concat function
func TestConcat(t *testing.T) {
t.Run("Concat two non-empty arrays", func(t *testing.T) {
base := []int{1, 2, 3}
toAppend := []int{4, 5, 6}
result := Concat(toAppend)(base)
expected := []int{1, 2, 3, 4, 5, 6}
assert.Equal(t, expected, result)
})
t.Run("Concat with empty array to append", func(t *testing.T) {
base := []int{1, 2, 3}
empty := []int{}
result := Concat(empty)(base)
assert.Equal(t, base, result)
})
t.Run("Concat to empty base array", func(t *testing.T) {
empty := []int{}
toAppend := []int{1, 2, 3}
result := Concat(toAppend)(empty)
assert.Equal(t, toAppend, result)
})
t.Run("Concat two empty arrays", func(t *testing.T) {
empty1 := []int{}
empty2 := []int{}
result := Concat(empty2)(empty1)
assert.Equal(t, []int{}, result)
})
t.Run("Concat strings", func(t *testing.T) {
words1 := []string{"hello", "world"}
words2 := []string{"foo", "bar"}
result := Concat(words2)(words1)
expected := []string{"hello", "world", "foo", "bar"}
assert.Equal(t, expected, result)
})
t.Run("Concat single element arrays", func(t *testing.T) {
arr1 := []int{1}
arr2 := []int{2}
result := Concat(arr2)(arr1)
expected := []int{1, 2}
assert.Equal(t, expected, result)
})
t.Run("Does not modify original arrays", func(t *testing.T) {
base := []int{1, 2, 3}
toAppend := []int{4, 5, 6}
baseCopy := []int{1, 2, 3}
toAppendCopy := []int{4, 5, 6}
_ = Concat(toAppend)(base)
assert.Equal(t, baseCopy, base)
assert.Equal(t, toAppendCopy, toAppend)
})
t.Run("Concat with floats", func(t *testing.T) {
arr1 := []float64{1.1, 2.2}
arr2 := []float64{3.3, 4.4}
result := Concat(arr2)(arr1)
expected := []float64{1.1, 2.2, 3.3, 4.4}
assert.Equal(t, expected, result)
})
t.Run("Concat with structs", func(t *testing.T) {
type Person struct {
Name string
Age int
}
arr1 := []Person{{"Alice", 30}, {"Bob", 25}}
arr2 := []Person{{"Charlie", 35}}
result := Concat(arr2)(arr1)
expected := []Person{{"Alice", 30}, {"Bob", 25}, {"Charlie", 35}}
assert.Equal(t, expected, result)
})
t.Run("Concat large arrays", func(t *testing.T) {
arr1 := MakeBy(500, F.Identity[int])
arr2 := MakeBy(500, func(i int) int { return i + 500 })
result := Concat(arr2)(arr1)
assert.Equal(t, 1000, len(result))
assert.Equal(t, 0, result[0])
assert.Equal(t, 499, result[499])
assert.Equal(t, 500, result[500])
assert.Equal(t, 999, result[999])
})
t.Run("Concat multiple times", func(t *testing.T) {
arr1 := []int{1}
arr2 := []int{2}
arr3 := []int{3}
result := F.Pipe2(
arr1,
Concat(arr2),
Concat(arr3),
)
expected := []int{1, 2, 3}
assert.Equal(t, expected, result)
})
}
// TestConcatComposition tests Concat with other array operations
func TestConcatComposition(t *testing.T) {
t.Run("Concat after Map", func(t *testing.T) {
numbers := []int{1, 2, 3}
result := F.Pipe2(
numbers,
Map(N.Mul(2)),
Concat([]int{10, 20}),
)
expected := []int{2, 4, 6, 10, 20}
assert.Equal(t, expected, result)
})
t.Run("Map after Concat", func(t *testing.T) {
arr1 := []int{1, 2}
arr2 := []int{3, 4}
result := F.Pipe2(
arr1,
Concat(arr2),
Map(N.Mul(2)),
)
expected := []int{2, 4, 6, 8}
assert.Equal(t, expected, result)
})
t.Run("Concat with Filter", func(t *testing.T) {
arr1 := []int{1, 2, 3, 4}
arr2 := []int{5, 6, 7, 8}
result := F.Pipe2(
arr1,
Concat(arr2),
Filter(func(n int) bool { return n%2 == 0 }),
)
expected := []int{2, 4, 6, 8}
assert.Equal(t, expected, result)
})
t.Run("Concat with Reduce", func(t *testing.T) {
arr1 := []int{1, 2, 3}
arr2 := []int{4, 5, 6}
result := F.Pipe2(
arr1,
Concat(arr2),
Reduce(func(acc, x int) int { return acc + x }, 0),
)
expected := 21 // 1+2+3+4+5+6
assert.Equal(t, expected, result)
})
t.Run("Concat with Reverse", func(t *testing.T) {
arr1 := []int{1, 2, 3}
arr2 := []int{4, 5, 6}
result := F.Pipe2(
arr1,
Concat(arr2),
Reverse[int],
)
expected := []int{6, 5, 4, 3, 2, 1}
assert.Equal(t, expected, result)
})
t.Run("Concat with Flatten", func(t *testing.T) {
arr1 := [][]int{{1, 2}, {3, 4}}
arr2 := [][]int{{5, 6}}
result := F.Pipe2(
arr1,
Concat(arr2),
Flatten[int],
)
expected := []int{1, 2, 3, 4, 5, 6}
assert.Equal(t, expected, result)
})
t.Run("Multiple Concat operations", func(t *testing.T) {
arr1 := []int{1}
arr2 := []int{2}
arr3 := []int{3}
arr4 := []int{4}
result := Concat(arr4)(Concat(arr3)(Concat(arr2)(arr1)))
expected := []int{1, 2, 3, 4}
assert.Equal(t, expected, result)
})
}
// TestConcatUseCases demonstrates practical use cases for Concat
func TestConcatUseCases(t *testing.T) {
t.Run("Building array incrementally", func(t *testing.T) {
header := []string{"Name", "Age"}
data := []string{"Alice", "30"}
footer := []string{"Total: 1"}
result := F.Pipe2(
header,
Concat(data),
Concat(footer),
)
expected := []string{"Name", "Age", "Alice", "30", "Total: 1"}
assert.Equal(t, expected, result)
})
t.Run("Merging results from multiple operations", func(t *testing.T) {
evens := Filter(func(n int) bool { return n%2 == 0 })([]int{1, 2, 3, 4, 5, 6})
odds := Filter(func(n int) bool { return n%2 != 0 })([]int{1, 2, 3, 4, 5, 6})
result := Concat(odds)(evens)
expected := []int{2, 4, 6, 1, 3, 5}
assert.Equal(t, expected, result)
})
t.Run("Combining prefix and suffix", func(t *testing.T) {
prefix := []string{"Mr.", "Dr."}
names := []string{"Smith", "Jones"}
addPrefix := func(name string) []string {
return Map(func(p string) string { return p + " " + name })(prefix)
}
result := F.Pipe2(
names,
Chain(addPrefix),
F.Identity[[]string],
)
expected := []string{"Mr. Smith", "Dr. Smith", "Mr. Jones", "Dr. Jones"}
assert.Equal(t, expected, result)
})
t.Run("Queue-like behavior", func(t *testing.T) {
queue := []int{1, 2, 3}
newItems := []int{4, 5}
// Add items to end of queue
updatedQueue := Concat(newItems)(queue)
assert.Equal(t, []int{1, 2, 3, 4, 5}, updatedQueue)
assert.Equal(t, 1, updatedQueue[0]) // Front of queue
assert.Equal(t, 5, updatedQueue[len(updatedQueue)-1]) // Back of queue
})
t.Run("Combining configuration arrays", func(t *testing.T) {
defaultConfig := []string{"--verbose", "--color"}
userConfig := []string{"--output=file.txt", "--format=json"}
finalConfig := Concat(userConfig)(defaultConfig)
expected := []string{"--verbose", "--color", "--output=file.txt", "--format=json"}
assert.Equal(t, expected, finalConfig)
})
}
// TestConcatProperties tests mathematical properties of Concat
func TestConcatProperties(t *testing.T) {
t.Run("Associativity: (a + b) + c == a + (b + c)", func(t *testing.T) {
a := []int{1, 2}
b := []int{3, 4}
c := []int{5, 6}
// (a + b) + c
left := Concat(c)(Concat(b)(a))
// a + (b + c)
right := Concat(Concat(c)(b))(a)
assert.Equal(t, left, right)
assert.Equal(t, []int{1, 2, 3, 4, 5, 6}, left)
})
t.Run("Identity: a + [] == a and [] + a == a", func(t *testing.T) {
arr := []int{1, 2, 3}
empty := []int{}
// Right identity
rightResult := Concat(empty)(arr)
assert.Equal(t, arr, rightResult)
// Left identity
leftResult := Concat(arr)(empty)
assert.Equal(t, arr, leftResult)
})
t.Run("Length property: len(a + b) == len(a) + len(b)", func(t *testing.T) {
testCases := []struct {
arr1 []int
arr2 []int
}{
{[]int{1, 2, 3}, []int{4, 5}},
{[]int{1}, []int{2, 3, 4, 5}},
{[]int{}, []int{1, 2, 3}},
{[]int{1, 2, 3}, []int{}},
{MakeBy(100, F.Identity[int]), MakeBy(50, F.Identity[int])},
}
for _, tc := range testCases {
result := Concat(tc.arr2)(tc.arr1)
expectedLen := len(tc.arr1) + len(tc.arr2)
assert.Equal(t, expectedLen, len(result))
}
})
t.Run("Order preservation: elements maintain their relative order", func(t *testing.T) {
arr1 := []int{1, 2, 3}
arr2 := []int{4, 5, 6}
result := Concat(arr2)(arr1)
// Check arr1 elements are in order
assert.Equal(t, 1, result[0])
assert.Equal(t, 2, result[1])
assert.Equal(t, 3, result[2])
// Check arr2 elements are in order after arr1
assert.Equal(t, 4, result[3])
assert.Equal(t, 5, result[4])
assert.Equal(t, 6, result[5])
})
t.Run("Immutability: original arrays are not modified", func(t *testing.T) {
original1 := []int{1, 2, 3}
original2 := []int{4, 5, 6}
copy1 := []int{1, 2, 3}
copy2 := []int{4, 5, 6}
_ = Concat(original2)(original1)
assert.Equal(t, copy1, original1)
assert.Equal(t, copy2, original2)
})
}

View File

@@ -16,22 +16,11 @@
package array
import (
"slices"
E "github.com/IBM/fp-go/v2/eq"
)
func equals[T any](left []T, right []T, eq func(T, T) bool) bool {
if len(left) != len(right) {
return false
}
for i, v1 := range left {
v2 := right[i]
if !eq(v1, v2) {
return false
}
}
return true
}
// Eq creates an equality checker for arrays given an equality checker for elements.
// Two arrays are considered equal if they have the same length and all corresponding
// elements are equal according to the provided Eq instance.
@@ -46,6 +35,11 @@ func equals[T any](left []T, right []T, eq func(T, T) bool) bool {
func Eq[T any](e E.Eq[T]) E.Eq[[]T] {
eq := e.Equals
return E.FromEquals(func(left, right []T) bool {
return equals(left, right, eq)
return slices.EqualFunc(left, right, eq)
})
}
//go:inline
func StrictEquals[T comparable]() E.Eq[[]T] {
return E.FromEquals(slices.Equal[[]T])
}

View File

@@ -140,22 +140,27 @@ func Empty[GA ~[]A, A any]() GA {
return array.Empty[GA]()
}
//go:inline
func UpsertAt[GA ~[]A, A any](a A) func(GA) GA {
return array.UpsertAt[GA](a)
}
//go:inline
func MonadMap[GA ~[]A, GB ~[]B, A, B any](as GA, f func(a A) B) GB {
return array.MonadMap[GA, GB](as, f)
}
//go:inline
func Map[GA ~[]A, GB ~[]B, A, B any](f func(a A) B) func(GA) GB {
return array.Map[GA, GB](f)
}
//go:inline
func MonadMapWithIndex[GA ~[]A, GB ~[]B, A, B any](as GA, f func(int, A) B) GB {
return array.MonadMapWithIndex[GA, GB](as, f)
}
//go:inline
func MapWithIndex[GA ~[]A, GB ~[]B, A, B any](f func(int, A) B) func(GA) GB {
return F.Bind2nd(MonadMapWithIndex[GA, GB, A, B], f)
}
@@ -297,7 +302,7 @@ func MatchLeft[AS ~[]A, A, B any](onEmpty func() B, onNonEmpty func(A, AS) B) fu
}
//go:inline
func Slice[AS ~[]A, A any](start int, end int) func(AS) AS {
func Slice[AS ~[]A, A any](start, end int) func(AS) AS {
return array.Slice[AS](start, end)
}
@@ -361,6 +366,111 @@ func Flap[FAB ~func(A) B, GFAB ~[]FAB, GB ~[]B, A, B any](a A) func(GFAB) GB {
return FC.Flap(Map[GFAB, GB], a)
}
//go:inline
func Prepend[ENDO ~func(AS) AS, AS []A, A any](head A) ENDO {
return array.Prepend[ENDO](head)
}
//go:inline
func Reverse[GT ~[]T, T any](as GT) GT {
return array.Reverse(as)
}
// Extract returns the first element of an array, or a zero value if empty.
// This is the comonad extract operation for arrays.
//
// Extract is the dual of the monadic return/of operation. While Of wraps a value
// in a context, Extract unwraps a value from its context.
//
// Type Parameters:
// - GA: The array type constraint
// - A: The type of elements in the array
//
// Parameters:
// - as: The input array
//
// Returns:
// - The first element if the array is non-empty, otherwise the zero value of type A
//
// Behavior:
// - Returns as[0] if the array has at least one element
// - Returns the zero value of A if the array is empty
// - Does not modify the input array
//
// Example:
//
// result := Extract([]int{1, 2, 3})
// // result: 1
//
// Example with empty array:
//
// result := Extract([]int{})
// // result: 0 (zero value for int)
//
// Comonad laws:
// - Extract ∘ Of == Identity (extracting from a singleton returns the value)
// - Extract ∘ Extend(f) == f (extract after extend equals applying f)
//
//go:inline
func Extract[GA ~[]A, A any](as GA) A {
if len(as) > 0 {
return as[0]
}
var zero A
return zero
}
// Extend applies a function to every suffix of an array, creating a new array of results.
// This is the comonad extend operation for arrays.
//
// The function f is applied to progressively smaller suffixes of the input array:
// - f(as[0:]) for the first element
// - f(as[1:]) for the second element
// - f(as[2:]) for the third element
// - and so on...
//
// Type Parameters:
// - GA: The input array type constraint
// - GB: The output array type constraint
// - A: The type of elements in the input array
// - B: The type of elements in the output array
//
// Parameters:
// - f: A function that takes an array suffix and returns a value
//
// Returns:
// - A function that transforms an array of A into an array of B
//
// Behavior:
// - Creates a new array with the same length as the input
// - For each position i, applies f to the suffix starting at i
// - Returns an empty array if the input is empty
//
// Example:
//
// // Sum all elements from current position to end
// sumSuffix := Extend[[]int, []int](func(as []int) int {
// return MonadReduce(as, func(acc, x int) int { return acc + x }, 0)
// })
// result := sumSuffix([]int{1, 2, 3, 4})
// // result: []int{10, 9, 7, 4}
// // Explanation: [1+2+3+4, 2+3+4, 3+4, 4]
//
// Example with length:
//
// // Get remaining length at each position
// lengths := Extend[[]int, []int](Size[[]int, int])
// result := lengths([]int{10, 20, 30})
// // result: []int{3, 2, 1}
//
// Comonad laws:
// - Left identity: Extend(Extract) == Identity
// - Right identity: Extract ∘ Extend(f) == f
// - Associativity: Extend(f) ∘ Extend(g) == Extend(f ∘ Extend(g))
//
//go:inline
func Extend[GA ~[]A, GB ~[]B, A, B any](f func(GA) B) func(GA) GB {
return func(as GA) GB {
return MakeBy[GB](len(as), func(i int) B { return f(as[i:]) })
}
}

View File

@@ -0,0 +1,298 @@
// 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 generic
import (
"testing"
F "github.com/IBM/fp-go/v2/function"
"github.com/stretchr/testify/assert"
)
// TestExtract tests the Extract function
func TestExtract(t *testing.T) {
t.Run("Extract from non-empty array", func(t *testing.T) {
input := []int{1, 2, 3, 4, 5}
result := Extract(input)
assert.Equal(t, 1, result)
})
t.Run("Extract from single element array", func(t *testing.T) {
input := []string{"hello"}
result := Extract(input)
assert.Equal(t, "hello", result)
})
t.Run("Extract from empty array returns zero value", func(t *testing.T) {
input := []int{}
result := Extract(input)
assert.Equal(t, 0, result)
})
t.Run("Extract from empty string array returns empty string", func(t *testing.T) {
input := []string{}
result := Extract(input)
assert.Equal(t, "", result)
})
t.Run("Extract does not modify original array", func(t *testing.T) {
original := []int{1, 2, 3}
originalCopy := []int{1, 2, 3}
_ = Extract(original)
assert.Equal(t, originalCopy, original)
})
t.Run("Extract with floats", func(t *testing.T) {
input := []float64{3.14, 2.71, 1.41}
result := Extract(input)
assert.Equal(t, 3.14, result)
})
t.Run("Extract with custom slice type", func(t *testing.T) {
type IntSlice []int
input := IntSlice{10, 20, 30}
result := Extract(input)
assert.Equal(t, 10, result)
})
}
// TestExtractComonadLaws tests comonad laws for Extract
func TestExtractComonadLaws(t *testing.T) {
t.Run("Extract ∘ Of == Identity", func(t *testing.T) {
value := 42
result := Extract(Of[[]int](value))
assert.Equal(t, value, result)
})
t.Run("Extract ∘ Extend(f) == f", func(t *testing.T) {
input := []int{1, 2, 3, 4}
f := func(as []int) int {
return MonadReduce(as, func(acc, x int) int { return acc + x }, 0)
}
// Extract(Extend(f)(input)) should equal f(input)
extended := Extend[[]int, []int](f)(input)
result := Extract(extended)
expected := f(input)
assert.Equal(t, expected, result)
})
}
// TestExtend tests the Extend function
func TestExtend(t *testing.T) {
t.Run("Extend with sum of suffixes", func(t *testing.T) {
input := []int{1, 2, 3, 4}
sumSuffix := Extend[[]int, []int](func(as []int) int {
return MonadReduce(as, func(acc, x int) int { return acc + x }, 0)
})
result := sumSuffix(input)
expected := []int{10, 9, 7, 4} // [1+2+3+4, 2+3+4, 3+4, 4]
assert.Equal(t, expected, result)
})
t.Run("Extend with length of suffixes", func(t *testing.T) {
input := []int{10, 20, 30}
lengths := Extend[[]int, []int](Size[[]int, int])
result := lengths(input)
expected := []int{3, 2, 1}
assert.Equal(t, expected, result)
})
t.Run("Extend with head extraction", func(t *testing.T) {
input := []int{1, 2, 3}
duplicate := Extend[[]int, []int](Extract[[]int, int])
result := duplicate(input)
expected := []int{1, 2, 3}
assert.Equal(t, expected, result)
})
t.Run("Extend with empty array", func(t *testing.T) {
input := []int{}
result := Extend[[]int, []int](Size[[]int, int])(input)
assert.Equal(t, []int{}, result)
})
t.Run("Extend with single element", func(t *testing.T) {
input := []string{"hello"}
result := Extend[[]string, []int](func(as []string) int { return len(as) })(input)
expected := []int{1}
assert.Equal(t, expected, result)
})
t.Run("Extend does not modify original array", func(t *testing.T) {
original := []int{1, 2, 3}
originalCopy := []int{1, 2, 3}
_ = Extend[[]int, []int](Size[[]int, int])(original)
assert.Equal(t, originalCopy, original)
})
t.Run("Extend with string concatenation", func(t *testing.T) {
input := []string{"a", "b", "c"}
concat := Extend[[]string, []string](func(as []string) string {
return MonadReduce(as, func(acc, s string) string { return acc + s }, "")
})
result := concat(input)
expected := []string{"abc", "bc", "c"}
assert.Equal(t, expected, result)
})
t.Run("Extend with custom slice types", func(t *testing.T) {
type IntSlice []int
type ResultSlice []int
input := IntSlice{1, 2, 3}
sumSuffix := Extend[IntSlice, ResultSlice](func(as IntSlice) int {
return MonadReduce(as, func(acc, x int) int { return acc + x }, 0)
})
result := sumSuffix(input)
expected := ResultSlice{6, 5, 3}
assert.Equal(t, expected, result)
})
}
// TestExtendComonadLaws tests comonad laws for Extend
func TestExtendComonadLaws(t *testing.T) {
t.Run("Left identity: Extend(Extract) == Identity", func(t *testing.T) {
input := []int{1, 2, 3, 4, 5}
result := Extend[[]int, []int](Extract[[]int, int])(input)
assert.Equal(t, input, result)
})
t.Run("Right identity: Extract ∘ Extend(f) == f", func(t *testing.T) {
input := []int{1, 2, 3, 4}
f := func(as []int) int {
return MonadReduce(as, func(acc, x int) int { return acc + x }, 0)
}
// Extract(Extend(f)(input)) should equal f(input)
result := F.Pipe2(input, Extend[[]int, []int](f), Extract[[]int, int])
expected := f(input)
assert.Equal(t, expected, result)
})
t.Run("Associativity: Extend(f) ∘ Extend(g) == Extend(f ∘ Extend(g))", func(t *testing.T) {
input := []int{1, 2, 3}
// f: sum of array
f := func(as []int) int {
return MonadReduce(as, func(acc, x int) int { return acc + x }, 0)
}
// g: length of array
g := func(as []int) int {
return len(as)
}
// Left side: Extend(f) ∘ Extend(g)
left := F.Pipe2(input, Extend[[]int, []int](g), Extend[[]int, []int](f))
// Right side: Extend(f ∘ Extend(g))
right := Extend[[]int, []int](func(as []int) int {
return f(Extend[[]int, []int](g)(as))
})(input)
assert.Equal(t, left, right)
})
}
// TestExtendComposition tests Extend with other array operations
func TestExtendComposition(t *testing.T) {
t.Run("Extend after Map", func(t *testing.T) {
input := []int{1, 2, 3}
result := F.Pipe2(
input,
Map[[]int, []int](func(x int) int { return x * 2 }),
Extend[[]int, []int](func(as []int) int {
return MonadReduce(as, func(acc, x int) int { return acc + x }, 0)
}),
)
expected := []int{12, 10, 6} // [2+4+6, 4+6, 6]
assert.Equal(t, expected, result)
})
t.Run("Map after Extend", func(t *testing.T) {
input := []int{1, 2, 3}
result := F.Pipe2(
input,
Extend[[]int, []int](Size[[]int, int]),
Map[[]int, []int](func(x int) int { return x * 10 }),
)
expected := []int{30, 20, 10}
assert.Equal(t, expected, result)
})
t.Run("Extend with Filter", func(t *testing.T) {
input := []int{1, 2, 3, 4, 5, 6}
result := F.Pipe2(
input,
Filter[[]int](func(n int) bool { return n%2 == 0 }),
Extend[[]int, []int](Size[[]int, int]),
)
expected := []int{3, 2, 1} // lengths of [2,4,6], [4,6], [6]
assert.Equal(t, expected, result)
})
}
// TestExtendUseCases demonstrates practical use cases for Extend
func TestExtendUseCases(t *testing.T) {
t.Run("Running sum (cumulative sum from each position)", func(t *testing.T) {
input := []int{1, 2, 3, 4, 5}
runningSum := Extend[[]int, []int](func(as []int) int {
return MonadReduce(as, func(acc, x int) int { return acc + x }, 0)
})
result := runningSum(input)
expected := []int{15, 14, 12, 9, 5}
assert.Equal(t, expected, result)
})
t.Run("Sliding window average", func(t *testing.T) {
input := []float64{1.0, 2.0, 3.0, 4.0, 5.0}
windowAvg := Extend[[]float64, []float64](func(as []float64) float64 {
if len(as) == 0 {
return 0
}
sum := MonadReduce(as, func(acc, x float64) float64 { return acc + x }, 0.0)
return sum / float64(len(as))
})
result := windowAvg(input)
expected := []float64{3.0, 3.5, 4.0, 4.5, 5.0}
assert.Equal(t, expected, result)
})
t.Run("Check if suffix is sorted", func(t *testing.T) {
input := []int{1, 2, 3, 2, 1}
isSorted := Extend[[]int, []bool](func(as []int) bool {
for i := 1; i < len(as); i++ {
if as[i] < as[i-1] {
return false
}
}
return true
})
result := isSorted(input)
expected := []bool{false, false, false, false, true}
assert.Equal(t, expected, result)
})
t.Run("Count remaining elements", func(t *testing.T) {
events := []string{"start", "middle", "end"}
remaining := Extend[[]string, []int](Size[[]string, string])
result := remaining(events)
expected := []int{3, 2, 1}
assert.Equal(t, expected, result)
})
}

View File

@@ -18,20 +18,50 @@ package nonempty
import (
G "github.com/IBM/fp-go/v2/array/generic"
EM "github.com/IBM/fp-go/v2/endomorphism"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/internal/array"
"github.com/IBM/fp-go/v2/option"
S "github.com/IBM/fp-go/v2/semigroup"
)
// NonEmptyArray represents an array with at least one element
type NonEmptyArray[A any] []A
// Of constructs a single element array
// Of constructs a single element NonEmptyArray.
// This is the simplest way to create a NonEmptyArray with exactly one element.
//
// Type Parameters:
// - A: The element type
//
// Parameters:
// - first: The single element to include in the array
//
// Returns:
// - NonEmptyArray[A]: A NonEmptyArray containing only the provided element
//
// Example:
//
// arr := Of(42) // NonEmptyArray[int]{42}
// str := Of("hello") // NonEmptyArray[string]{"hello"}
func Of[A any](first A) NonEmptyArray[A] {
return G.Of[NonEmptyArray[A]](first)
}
// From constructs a [NonEmptyArray] from a set of variadic arguments
// From constructs a NonEmptyArray from a set of variadic arguments.
// The first argument is required to ensure the array is non-empty, and additional
// elements can be provided as variadic arguments.
//
// Type Parameters:
// - A: The element type
//
// Parameters:
// - first: The first element (required to ensure non-emptiness)
// - data: Additional elements (optional)
//
// Returns:
// - NonEmptyArray[A]: A NonEmptyArray containing all provided elements
//
// Example:
//
// arr1 := From(1) // NonEmptyArray[int]{1}
// arr2 := From(1, 2, 3) // NonEmptyArray[int]{1, 2, 3}
// arr3 := From("a", "b", "c") // NonEmptyArray[string]{"a", "b", "c"}
func From[A any](first A, data ...A) NonEmptyArray[A] {
count := len(data)
if count == 0 {
@@ -44,70 +74,358 @@ func From[A any](first A, data ...A) NonEmptyArray[A] {
return buffer
}
// IsEmpty always returns false for NonEmptyArray since it's guaranteed to have at least one element.
// This function exists for API consistency with regular arrays.
//
// Type Parameters:
// - A: The element type
//
// Parameters:
// - _: The NonEmptyArray (unused, as the result is always false)
//
// Returns:
// - bool: Always false
//
//go:inline
func IsEmpty[A any](_ NonEmptyArray[A]) bool {
return false
}
// IsNonEmpty always returns true for NonEmptyArray since it's guaranteed to have at least one element.
// This function exists for API consistency with regular arrays.
//
// Type Parameters:
// - A: The element type
//
// Parameters:
// - _: The NonEmptyArray (unused, as the result is always true)
//
// Returns:
// - bool: Always true
//
//go:inline
func IsNonEmpty[A any](_ NonEmptyArray[A]) bool {
return true
}
// MonadMap applies a function to each element of a NonEmptyArray, returning a new NonEmptyArray with the results.
// This is the monadic version of Map that takes the array as the first parameter.
//
// Type Parameters:
// - A: The input element type
// - B: The output element type
//
// Parameters:
// - as: The input NonEmptyArray
// - f: The function to apply to each element
//
// Returns:
// - NonEmptyArray[B]: A new NonEmptyArray with the transformed elements
//
// Example:
//
// arr := From(1, 2, 3)
// doubled := MonadMap(arr, func(x int) int { return x * 2 }) // NonEmptyArray[int]{2, 4, 6}
//
//go:inline
func MonadMap[A, B any](as NonEmptyArray[A], f func(a A) B) NonEmptyArray[B] {
return G.MonadMap[NonEmptyArray[A], NonEmptyArray[B]](as, f)
}
func Map[A, B any](f func(a A) B) func(NonEmptyArray[A]) NonEmptyArray[B] {
return F.Bind2nd(MonadMap[A, B], f)
// Map applies a function to each element of a NonEmptyArray, returning a new NonEmptyArray with the results.
// This is the curried version that returns a function.
//
// Type Parameters:
// - A: The input element type
// - B: The output element type
//
// Parameters:
// - f: The function to apply to each element
//
// Returns:
// - Operator[A, B]: A function that transforms NonEmptyArray[A] to NonEmptyArray[B]
//
// Example:
//
// double := Map(func(x int) int { return x * 2 })
// result := double(From(1, 2, 3)) // NonEmptyArray[int]{2, 4, 6}
//
//go:inline
func Map[A, B any](f func(a A) B) Operator[A, B] {
return G.Map[NonEmptyArray[A], NonEmptyArray[B]](f)
}
// Reduce applies a function to each element of a NonEmptyArray from left to right,
// accumulating a result starting from an initial value.
//
// Type Parameters:
// - A: The element type of the array
// - B: The accumulator type
//
// Parameters:
// - f: The reducer function that takes (accumulator, element) and returns a new accumulator
// - initial: The initial value for the accumulator
//
// Returns:
// - func(NonEmptyArray[A]) B: A function that reduces the array to a single value
//
// Example:
//
// sum := Reduce(func(acc int, x int) int { return acc + x }, 0)
// result := sum(From(1, 2, 3, 4)) // 10
//
// concat := Reduce(func(acc string, x string) string { return acc + x }, "")
// result := concat(From("a", "b", "c")) // "abc"
func Reduce[A, B any](f func(B, A) B, initial B) func(NonEmptyArray[A]) B {
return func(as NonEmptyArray[A]) B {
return array.Reduce(as, f, initial)
}
}
// ReduceRight applies a function to each element of a NonEmptyArray from right to left,
// accumulating a result starting from an initial value.
//
// Type Parameters:
// - A: The element type of the array
// - B: The accumulator type
//
// Parameters:
// - f: The reducer function that takes (element, accumulator) and returns a new accumulator
// - initial: The initial value for the accumulator
//
// Returns:
// - func(NonEmptyArray[A]) B: A function that reduces the array to a single value
//
// Example:
//
// concat := ReduceRight(func(x string, acc string) string { return acc + x }, "")
// result := concat(From("a", "b", "c")) // "cba"
func ReduceRight[A, B any](f func(A, B) B, initial B) func(NonEmptyArray[A]) B {
return func(as NonEmptyArray[A]) B {
return array.ReduceRight(as, f, initial)
}
}
// Tail returns all elements of a NonEmptyArray except the first one.
// Returns an empty slice if the array has only one element.
//
// Type Parameters:
// - A: The element type
//
// Parameters:
// - as: The input NonEmptyArray
//
// Returns:
// - []A: A slice containing all elements except the first (may be empty)
//
// Example:
//
// arr := From(1, 2, 3, 4)
// tail := Tail(arr) // []int{2, 3, 4}
//
// single := From(1)
// tail := Tail(single) // []int{}
//
//go:inline
func Tail[A any](as NonEmptyArray[A]) []A {
return as[1:]
}
// Head returns the first element of a NonEmptyArray.
// This operation is always safe since NonEmptyArray is guaranteed to have at least one element.
//
// Type Parameters:
// - A: The element type
//
// Parameters:
// - as: The input NonEmptyArray
//
// Returns:
// - A: The first element
//
// Example:
//
// arr := From(1, 2, 3)
// first := Head(arr) // 1
//
//go:inline
func Head[A any](as NonEmptyArray[A]) A {
return as[0]
}
// First returns the first element of a NonEmptyArray.
// This is an alias for Head.
//
// Type Parameters:
// - A: The element type
//
// Parameters:
// - as: The input NonEmptyArray
//
// Returns:
// - A: The first element
//
// Example:
//
// arr := From(1, 2, 3)
// first := First(arr) // 1
//
//go:inline
func First[A any](as NonEmptyArray[A]) A {
return as[0]
}
// Last returns the last element of a NonEmptyArray.
// This operation is always safe since NonEmptyArray is guaranteed to have at least one element.
//
// Type Parameters:
// - A: The element type
//
// Parameters:
// - as: The input NonEmptyArray
//
// Returns:
// - A: The last element
//
// Example:
//
// arr := From(1, 2, 3)
// last := Last(arr) // 3
//
//go:inline
func Last[A any](as NonEmptyArray[A]) A {
return as[len(as)-1]
}
// Size returns the number of elements in a NonEmptyArray.
// The result is always at least 1.
//
// Type Parameters:
// - A: The element type
//
// Parameters:
// - as: The input NonEmptyArray
//
// Returns:
// - int: The number of elements (always >= 1)
//
// Example:
//
// arr := From(1, 2, 3)
// size := Size(arr) // 3
//
//go:inline
func Size[A any](as NonEmptyArray[A]) int {
return G.Size(as)
}
// Flatten flattens a NonEmptyArray of NonEmptyArrays into a single NonEmptyArray.
// This operation concatenates all inner arrays into one.
//
// Type Parameters:
// - A: The element type
//
// Parameters:
// - mma: A NonEmptyArray of NonEmptyArrays
//
// Returns:
// - NonEmptyArray[A]: A flattened NonEmptyArray containing all elements
//
// Example:
//
// nested := From(From(1, 2), From(3, 4), From(5))
// flat := Flatten(nested) // NonEmptyArray[int]{1, 2, 3, 4, 5}
func Flatten[A any](mma NonEmptyArray[NonEmptyArray[A]]) NonEmptyArray[A] {
return G.Flatten(mma)
}
func MonadChain[A, B any](fa NonEmptyArray[A], f func(a A) NonEmptyArray[B]) NonEmptyArray[B] {
// MonadChain applies a function that returns a NonEmptyArray to each element and flattens the results.
// This is the monadic bind operation (flatMap) that takes the array as the first parameter.
//
// Type Parameters:
// - A: The input element type
// - B: The output element type
//
// Parameters:
// - fa: The input NonEmptyArray
// - f: A function that takes an element and returns a NonEmptyArray
//
// Returns:
// - NonEmptyArray[B]: The flattened result
//
// Example:
//
// arr := From(1, 2, 3)
// result := MonadChain(arr, func(x int) NonEmptyArray[int] {
// return From(x, x*10)
// }) // NonEmptyArray[int]{1, 10, 2, 20, 3, 30}
func MonadChain[A, B any](fa NonEmptyArray[A], f Kleisli[A, B]) NonEmptyArray[B] {
return G.MonadChain(fa, f)
}
func Chain[A, B any](f func(A) NonEmptyArray[B]) func(NonEmptyArray[A]) NonEmptyArray[B] {
// Chain applies a function that returns a NonEmptyArray to each element and flattens the results.
// This is the curried version of MonadChain.
//
// Type Parameters:
// - A: The input element type
// - B: The output element type
//
// Parameters:
// - f: A function that takes an element and returns a NonEmptyArray
//
// Returns:
// - Operator[A, B]: A function that transforms NonEmptyArray[A] to NonEmptyArray[B]
//
// Example:
//
// duplicate := Chain(func(x int) NonEmptyArray[int] { return From(x, x) })
// result := duplicate(From(1, 2, 3)) // NonEmptyArray[int]{1, 1, 2, 2, 3, 3}
func Chain[A, B any](f func(A) NonEmptyArray[B]) Operator[A, B] {
return G.Chain[NonEmptyArray[A]](f)
}
// MonadAp applies a NonEmptyArray of functions to a NonEmptyArray of values.
// Each function is applied to each value, producing a cartesian product of results.
//
// Type Parameters:
// - B: The output element type
// - A: The input element type
//
// Parameters:
// - fab: A NonEmptyArray of functions
// - fa: A NonEmptyArray of values
//
// Returns:
// - NonEmptyArray[B]: The result of applying all functions to all values
//
// Example:
//
// fns := From(func(x int) int { return x * 2 }, func(x int) int { return x + 10 })
// vals := From(1, 2)
// result := MonadAp(fns, vals) // NonEmptyArray[int]{2, 4, 11, 12}
func MonadAp[B, A any](fab NonEmptyArray[func(A) B], fa NonEmptyArray[A]) NonEmptyArray[B] {
return G.MonadAp[NonEmptyArray[B]](fab, fa)
}
// Ap applies a NonEmptyArray of functions to a NonEmptyArray of values.
// This is the curried version of MonadAp.
//
// Type Parameters:
// - B: The output element type
// - A: The input element type
//
// Parameters:
// - fa: A NonEmptyArray of values
//
// Returns:
// - func(NonEmptyArray[func(A) B]) NonEmptyArray[B]: A function that applies functions to the values
//
// Example:
//
// vals := From(1, 2)
// applyTo := Ap[int](vals)
// fns := From(func(x int) int { return x * 2 }, func(x int) int { return x + 10 })
// result := applyTo(fns) // NonEmptyArray[int]{2, 4, 11, 12}
func Ap[B, A any](fa NonEmptyArray[A]) func(NonEmptyArray[func(A) B]) NonEmptyArray[B] {
return G.Ap[NonEmptyArray[B], NonEmptyArray[func(A) B]](fa)
}
@@ -130,7 +448,165 @@ func Fold[A any](s S.Semigroup[A]) func(NonEmptyArray[A]) A {
}
}
// Prepend prepends a single value to an array
// Prepend prepends a single value to the beginning of a NonEmptyArray.
// Returns a new NonEmptyArray with the value at the front.
//
// Type Parameters:
// - A: The element type
//
// Parameters:
// - head: The value to prepend
//
// Returns:
// - EM.Endomorphism[NonEmptyArray[A]]: A function that prepends the value to a NonEmptyArray
//
// Example:
//
// arr := From(2, 3, 4)
// prepend1 := Prepend(1)
// result := prepend1(arr) // NonEmptyArray[int]{1, 2, 3, 4}
func Prepend[A any](head A) EM.Endomorphism[NonEmptyArray[A]] {
return array.Prepend[EM.Endomorphism[NonEmptyArray[A]]](head)
}
// ToNonEmptyArray attempts to convert a regular slice into a NonEmptyArray.
// This function provides a safe way to create a NonEmptyArray from a slice that might be empty,
// returning an Option type to handle the case where the input slice is empty.
//
// Type Parameters:
// - A: The element type of the array
//
// Parameters:
// - as: A regular slice that may or may not be empty
//
// Returns:
// - Option[NonEmptyArray[A]]: Some(NonEmptyArray) if the input slice is non-empty, None if empty
//
// Behavior:
// - If the input slice is empty, returns None
// - If the input slice has at least one element, wraps it in Some and returns it as a NonEmptyArray
// - The conversion is a type cast, so no data is copied
//
// Example:
//
// // Convert non-empty slice
// numbers := []int{1, 2, 3}
// result := ToNonEmptyArray(numbers) // Some(NonEmptyArray[1, 2, 3])
//
// // Convert empty slice
// empty := []int{}
// result := ToNonEmptyArray(empty) // None
//
// // Use with Option methods
// numbers := []int{1, 2, 3}
// result := ToNonEmptyArray(numbers)
// if O.IsSome(result) {
// nea := O.GetOrElse(F.Constant(From(0)))(result)
// head := Head(nea) // 1
// }
//
// Use cases:
// - Safely converting user input or external data to NonEmptyArray
// - Validating that a collection has at least one element before processing
// - Converting results from functions that return regular slices
// - Ensuring type safety when working with collections that must not be empty
//
// Example with validation:
//
// func processItems(items []string) Option[string] {
// return F.Pipe2(
// items,
// ToNonEmptyArray[string],
// O.Map(func(nea NonEmptyArray[string]) string {
// return Head(nea) // Safe to get head since we know it's non-empty
// }),
// )
// }
//
// Example with error handling:
//
// items := []int{1, 2, 3}
// result := ToNonEmptyArray(items)
// switch {
// case O.IsSome(result):
// nea := O.GetOrElse(F.Constant(From(0)))(result)
// fmt.Println("First item:", Head(nea))
// case O.IsNone(result):
// fmt.Println("Array is empty")
// }
//
// Example with chaining:
//
// // Process only if non-empty
// result := F.Pipe3(
// []int{1, 2, 3},
// ToNonEmptyArray[int],
// O.Map(Map(func(x int) int { return x * 2 })),
// O.Map(Head[int]),
// ) // Some(2)
//
// Note: This function is particularly useful when working with APIs or functions
// that return regular slices but you need the type-level guarantee that the
// collection is non-empty for subsequent operations.
func ToNonEmptyArray[A any](as []A) Option[NonEmptyArray[A]] {
if G.IsEmpty(as) {
return option.None[NonEmptyArray[A]]()
}
return option.Some(NonEmptyArray[A](as))
}
// Extract returns the first element of a NonEmptyArray.
// This is an alias for Head and is part of the Comonad interface.
//
// Type Parameters:
// - A: The element type
//
// Parameters:
// - as: The input NonEmptyArray
//
// Returns:
// - A: The first element
//
// Example:
//
// arr := From(1, 2, 3)
// first := Extract(arr) // 1
//
//go:inline
func Extract[A any](as NonEmptyArray[A]) A {
return Head(as)
}
// Extend applies a function to all suffixes of a NonEmptyArray.
// For each position i, it applies the function to the subarray starting at position i.
// This is part of the Comonad interface.
//
// Type Parameters:
// - A: The input element type
// - B: The output element type
//
// Parameters:
// - f: A function that takes a NonEmptyArray and returns a value
//
// Returns:
// - Operator[A, B]: A function that transforms NonEmptyArray[A] to NonEmptyArray[B]
//
// Example:
//
// arr := From(1, 2, 3, 4)
// sumSuffix := Extend(func(xs NonEmptyArray[int]) int {
// sum := 0
// for _, x := range xs {
// sum += x
// }
// return sum
// })
// result := sumSuffix(arr) // NonEmptyArray[int]{10, 9, 7, 4}
// // [1,2,3,4] -> 10, [2,3,4] -> 9, [3,4] -> 7, [4] -> 4
//
//go:inline
func Extend[A, B any](f func(NonEmptyArray[A]) B) Operator[A, B] {
return func(as NonEmptyArray[A]) NonEmptyArray[B] {
return G.MakeBy[NonEmptyArray[B]](len(as), func(i int) B { return f(as[i:]) })
}
}

View File

@@ -0,0 +1,892 @@
// 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 nonempty
import (
"fmt"
"testing"
F "github.com/IBM/fp-go/v2/function"
N "github.com/IBM/fp-go/v2/number"
O "github.com/IBM/fp-go/v2/option"
STR "github.com/IBM/fp-go/v2/string"
"github.com/stretchr/testify/assert"
)
// TestToNonEmptyArray tests the ToNonEmptyArray function
func TestToNonEmptyArray(t *testing.T) {
t.Run("Convert non-empty slice of integers", func(t *testing.T) {
input := []int{1, 2, 3}
result := ToNonEmptyArray(input)
assert.True(t, O.IsSome(result))
nea := O.GetOrElse(F.Constant(From(0)))(result)
assert.Equal(t, 3, Size(nea))
assert.Equal(t, 1, Head(nea))
assert.Equal(t, 3, Last(nea))
})
t.Run("Convert empty slice returns None", func(t *testing.T) {
input := []int{}
result := ToNonEmptyArray(input)
assert.True(t, O.IsNone(result))
})
t.Run("Convert single element slice", func(t *testing.T) {
input := []string{"hello"}
result := ToNonEmptyArray(input)
assert.True(t, O.IsSome(result))
nea := O.GetOrElse(F.Constant(From("")))(result)
assert.Equal(t, 1, Size(nea))
assert.Equal(t, "hello", Head(nea))
})
t.Run("Convert non-empty slice of strings", func(t *testing.T) {
input := []string{"a", "b", "c", "d"}
result := ToNonEmptyArray(input)
assert.True(t, O.IsSome(result))
nea := O.GetOrElse(F.Constant(From("")))(result)
assert.Equal(t, 4, Size(nea))
assert.Equal(t, "a", Head(nea))
assert.Equal(t, "d", Last(nea))
})
t.Run("Convert nil slice returns None", func(t *testing.T) {
var input []int
result := ToNonEmptyArray(input)
assert.True(t, O.IsNone(result))
})
t.Run("Convert slice with struct elements", func(t *testing.T) {
type Person struct {
Name string
Age int
}
input := []Person{
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 25},
}
result := ToNonEmptyArray(input)
assert.True(t, O.IsSome(result))
nea := O.GetOrElse(F.Constant(From(Person{})))(result)
assert.Equal(t, 2, Size(nea))
assert.Equal(t, "Alice", Head(nea).Name)
})
t.Run("Convert slice with pointer elements", func(t *testing.T) {
val1, val2 := 10, 20
input := []*int{&val1, &val2}
result := ToNonEmptyArray(input)
assert.True(t, O.IsSome(result))
nea := O.GetOrElse(F.Constant(From[*int](nil)))(result)
assert.Equal(t, 2, Size(nea))
assert.Equal(t, 10, *Head(nea))
})
t.Run("Convert large slice", func(t *testing.T) {
input := make([]int, 1000)
for i := range input {
input[i] = i
}
result := ToNonEmptyArray(input)
assert.True(t, O.IsSome(result))
nea := O.GetOrElse(F.Constant(From(0)))(result)
assert.Equal(t, 1000, Size(nea))
assert.Equal(t, 0, Head(nea))
assert.Equal(t, 999, Last(nea))
})
t.Run("Convert slice with float64 elements", func(t *testing.T) {
input := []float64{1.5, 2.5, 3.5}
result := ToNonEmptyArray(input)
assert.True(t, O.IsSome(result))
nea := O.GetOrElse(F.Constant(From(0.0)))(result)
assert.Equal(t, 3, Size(nea))
assert.Equal(t, 1.5, Head(nea))
})
t.Run("Convert slice with boolean elements", func(t *testing.T) {
input := []bool{true, false, true}
result := ToNonEmptyArray(input)
assert.True(t, O.IsSome(result))
nea := O.GetOrElse(F.Constant(From(false)))(result)
assert.Equal(t, 3, Size(nea))
assert.True(t, Head(nea))
})
}
// TestToNonEmptyArrayWithOption tests ToNonEmptyArray with Option operations
func TestToNonEmptyArrayWithOption(t *testing.T) {
t.Run("Chain with Map to process elements", func(t *testing.T) {
input := []int{1, 2, 3}
result := F.Pipe2(
input,
ToNonEmptyArray[int],
O.Map(Map(func(x int) int { return x * 2 })),
)
assert.True(t, O.IsSome(result))
nea := O.GetOrElse(F.Constant(From(0)))(result)
assert.Equal(t, 2, Head(nea))
assert.Equal(t, 6, Last(nea))
})
t.Run("Chain with Map to get head", func(t *testing.T) {
input := []string{"first", "second", "third"}
result := F.Pipe2(
input,
ToNonEmptyArray[string],
O.Map(Head[string]),
)
assert.True(t, O.IsSome(result))
value := O.GetOrElse(F.Constant(""))(result)
assert.Equal(t, "first", value)
})
t.Run("GetOrElse with default value for empty slice", func(t *testing.T) {
input := []int{}
defaultValue := From(42)
result := F.Pipe2(
input,
ToNonEmptyArray[int],
O.GetOrElse(F.Constant(defaultValue)),
)
assert.Equal(t, 1, Size(result))
assert.Equal(t, 42, Head(result))
})
t.Run("GetOrElse with default value for non-empty slice", func(t *testing.T) {
input := []int{1, 2, 3}
defaultValue := From(42)
result := F.Pipe2(
input,
ToNonEmptyArray[int],
O.GetOrElse(F.Constant(defaultValue)),
)
assert.Equal(t, 3, Size(result))
assert.Equal(t, 1, Head(result))
})
t.Run("Fold with Some case", func(t *testing.T) {
input := []int{1, 2, 3}
result := F.Pipe2(
input,
ToNonEmptyArray[int],
O.Fold(
F.Constant(0),
func(nea NonEmptyArray[int]) int { return Head(nea) },
),
)
assert.Equal(t, 1, result)
})
t.Run("Fold with None case", func(t *testing.T) {
input := []int{}
result := F.Pipe2(
input,
ToNonEmptyArray[int],
O.Fold(
F.Constant(-1),
func(nea NonEmptyArray[int]) int { return Head(nea) },
),
)
assert.Equal(t, -1, result)
})
}
// TestToNonEmptyArrayComposition tests composing ToNonEmptyArray with other operations
func TestToNonEmptyArrayComposition(t *testing.T) {
t.Run("Compose with filter-like operation", func(t *testing.T) {
input := []int{1, 2, 3, 4, 5}
// Filter even numbers then convert
filtered := []int{}
for _, x := range input {
if x%2 == 0 {
filtered = append(filtered, x)
}
}
result := ToNonEmptyArray(filtered)
assert.True(t, O.IsSome(result))
nea := O.GetOrElse(F.Constant(From(0)))(result)
assert.Equal(t, 2, Size(nea))
assert.Equal(t, 2, Head(nea))
})
t.Run("Compose with map operation before conversion", func(t *testing.T) {
input := []int{1, 2, 3}
// Map then convert
mapped := make([]int, len(input))
for i, x := range input {
mapped[i] = x * 10
}
result := ToNonEmptyArray(mapped)
assert.True(t, O.IsSome(result))
nea := O.GetOrElse(F.Constant(From(0)))(result)
assert.Equal(t, 10, Head(nea))
assert.Equal(t, 30, Last(nea))
})
t.Run("Chain multiple Option operations", func(t *testing.T) {
input := []int{5, 10, 15}
result := F.Pipe3(
input,
ToNonEmptyArray[int],
O.Map(Map(func(x int) int { return x / 5 })),
O.Map(func(nea NonEmptyArray[int]) int {
return Head(nea) + Last(nea)
}),
)
assert.True(t, O.IsSome(result))
value := O.GetOrElse(F.Constant(0))(result)
assert.Equal(t, 4, value) // 1 + 3
})
}
// TestToNonEmptyArrayUseCases demonstrates practical use cases
func TestToNonEmptyArrayUseCases(t *testing.T) {
t.Run("Validate user input has at least one item", func(t *testing.T) {
// Simulate user input
userInput := []string{"item1", "item2"}
result := ToNonEmptyArray(userInput)
if O.IsSome(result) {
nea := O.GetOrElse(F.Constant(From("")))(result)
firstItem := Head(nea)
assert.Equal(t, "item1", firstItem)
} else {
t.Fatal("Expected Some but got None")
}
})
t.Run("Process only non-empty collections", func(t *testing.T) {
processItems := func(items []int) Option[int] {
return F.Pipe2(
items,
ToNonEmptyArray[int],
O.Map(func(nea NonEmptyArray[int]) int {
// Safe to use Head since we know it's non-empty
return Head(nea) * 2
}),
)
}
result1 := processItems([]int{5, 10, 15})
assert.True(t, O.IsSome(result1))
assert.Equal(t, 10, O.GetOrElse(F.Constant(0))(result1))
result2 := processItems([]int{})
assert.True(t, O.IsNone(result2))
})
t.Run("Convert API response to NonEmptyArray", func(t *testing.T) {
// Simulate API response
type APIResponse struct {
Items []string
}
response := APIResponse{Items: []string{"data1", "data2", "data3"}}
result := F.Pipe2(
response.Items,
ToNonEmptyArray[string],
O.Map(func(nea NonEmptyArray[string]) string {
return "First item: " + Head(nea)
}),
)
assert.True(t, O.IsSome(result))
message := O.GetOrElse(F.Constant("No items"))(result)
assert.Equal(t, "First item: data1", message)
})
t.Run("Ensure collection is non-empty before processing", func(t *testing.T) {
calculateAverage := func(numbers []float64) Option[float64] {
return F.Pipe2(
numbers,
ToNonEmptyArray[float64],
O.Map(func(nea NonEmptyArray[float64]) float64 {
sum := 0.0
for _, n := range nea {
sum += n
}
return sum / float64(Size(nea))
}),
)
}
result1 := calculateAverage([]float64{10.0, 20.0, 30.0})
assert.True(t, O.IsSome(result1))
assert.Equal(t, 20.0, O.GetOrElse(F.Constant(0.0))(result1))
result2 := calculateAverage([]float64{})
assert.True(t, O.IsNone(result2))
})
t.Run("Safe head extraction with type guarantee", func(t *testing.T) {
getFirstOrDefault := func(items []string, defaultValue string) string {
return F.Pipe2(
items,
ToNonEmptyArray[string],
O.Fold(
F.Constant(defaultValue),
Head[string],
),
)
}
result1 := getFirstOrDefault([]string{"a", "b", "c"}, "default")
assert.Equal(t, "a", result1)
result2 := getFirstOrDefault([]string{}, "default")
assert.Equal(t, "default", result2)
})
}
// TestOf tests the Of function
func TestOf(t *testing.T) {
t.Run("Create single element array with int", func(t *testing.T) {
arr := Of(42)
assert.Equal(t, 1, Size(arr))
assert.Equal(t, 42, Head(arr))
})
t.Run("Create single element array with string", func(t *testing.T) {
arr := Of("hello")
assert.Equal(t, 1, Size(arr))
assert.Equal(t, "hello", Head(arr))
})
t.Run("Create single element array with struct", func(t *testing.T) {
type Person struct {
Name string
Age int
}
person := Person{Name: "Alice", Age: 30}
arr := Of(person)
assert.Equal(t, 1, Size(arr))
assert.Equal(t, "Alice", Head(arr).Name)
})
}
// TestFrom tests the From function
func TestFrom(t *testing.T) {
t.Run("Create array with single element", func(t *testing.T) {
arr := From(1)
assert.Equal(t, 1, Size(arr))
assert.Equal(t, 1, Head(arr))
})
t.Run("Create array with multiple elements", func(t *testing.T) {
arr := From(1, 2, 3, 4, 5)
assert.Equal(t, 5, Size(arr))
assert.Equal(t, 1, Head(arr))
assert.Equal(t, 5, Last(arr))
})
t.Run("Create array with strings", func(t *testing.T) {
arr := From("a", "b", "c")
assert.Equal(t, 3, Size(arr))
assert.Equal(t, "a", Head(arr))
assert.Equal(t, "c", Last(arr))
})
}
// TestIsEmpty tests the IsEmpty function
func TestIsEmpty(t *testing.T) {
t.Run("IsEmpty always returns false", func(t *testing.T) {
arr := From(1, 2, 3)
assert.False(t, IsEmpty(arr))
})
t.Run("IsEmpty returns false for single element", func(t *testing.T) {
arr := Of(1)
assert.False(t, IsEmpty(arr))
})
}
// TestIsNonEmpty tests the IsNonEmpty function
func TestIsNonEmpty(t *testing.T) {
t.Run("IsNonEmpty always returns true", func(t *testing.T) {
arr := From(1, 2, 3)
assert.True(t, IsNonEmpty(arr))
})
t.Run("IsNonEmpty returns true for single element", func(t *testing.T) {
arr := Of(1)
assert.True(t, IsNonEmpty(arr))
})
}
// TestMonadMap tests the MonadMap function
func TestMonadMap(t *testing.T) {
t.Run("Map integers to doubles", func(t *testing.T) {
arr := From(1, 2, 3, 4)
result := MonadMap(arr, func(x int) int { return x * 2 })
assert.Equal(t, 4, Size(result))
assert.Equal(t, 2, Head(result))
assert.Equal(t, 8, Last(result))
})
t.Run("Map strings to lengths", func(t *testing.T) {
arr := From("a", "bb", "ccc")
result := MonadMap(arr, func(s string) int { return len(s) })
assert.Equal(t, 3, Size(result))
assert.Equal(t, 1, Head(result))
assert.Equal(t, 3, Last(result))
})
t.Run("Map single element", func(t *testing.T) {
arr := Of(5)
result := MonadMap(arr, func(x int) int { return x * 10 })
assert.Equal(t, 1, Size(result))
assert.Equal(t, 50, Head(result))
})
}
// TestMap tests the Map function
func TestMap(t *testing.T) {
t.Run("Curried map with integers", func(t *testing.T) {
double := Map(func(x int) int { return x * 2 })
arr := From(1, 2, 3)
result := double(arr)
assert.Equal(t, 3, Size(result))
assert.Equal(t, 2, Head(result))
assert.Equal(t, 6, Last(result))
})
t.Run("Curried map with strings", func(t *testing.T) {
toUpper := Map(func(s string) string { return s + "!" })
arr := From("hello", "world")
result := toUpper(arr)
assert.Equal(t, 2, Size(result))
assert.Equal(t, "hello!", Head(result))
assert.Equal(t, "world!", Last(result))
})
}
// TestReduce tests the Reduce function
func TestReduce(t *testing.T) {
t.Run("Sum integers", func(t *testing.T) {
sum := Reduce(func(acc int, x int) int { return acc + x }, 0)
arr := From(1, 2, 3, 4, 5)
result := sum(arr)
assert.Equal(t, 15, result)
})
t.Run("Concatenate strings", func(t *testing.T) {
concat := Reduce(func(acc string, x string) string { return acc + x }, "")
arr := From("a", "b", "c")
result := concat(arr)
assert.Equal(t, "abc", result)
})
t.Run("Product of numbers", func(t *testing.T) {
product := Reduce(func(acc int, x int) int { return acc * x }, 1)
arr := From(2, 3, 4)
result := product(arr)
assert.Equal(t, 24, result)
})
t.Run("Reduce single element", func(t *testing.T) {
sum := Reduce(func(acc int, x int) int { return acc + x }, 10)
arr := Of(5)
result := sum(arr)
assert.Equal(t, 15, result)
})
}
// TestReduceRight tests the ReduceRight function
func TestReduceRight(t *testing.T) {
t.Run("Concatenate strings right to left", func(t *testing.T) {
concat := ReduceRight(func(x string, acc string) string { return acc + x }, "")
arr := From("a", "b", "c")
result := concat(arr)
assert.Equal(t, "cba", result)
})
t.Run("Build list right to left", func(t *testing.T) {
buildList := ReduceRight(func(x int, acc []int) []int { return append(acc, x) }, []int{})
arr := From(1, 2, 3)
result := buildList(arr)
assert.Equal(t, []int{3, 2, 1}, result)
})
}
// TestTail tests the Tail function
func TestTail(t *testing.T) {
t.Run("Get tail of multi-element array", func(t *testing.T) {
arr := From(1, 2, 3, 4)
tail := Tail(arr)
assert.Equal(t, 3, len(tail))
assert.Equal(t, []int{2, 3, 4}, tail)
})
t.Run("Get tail of single element array", func(t *testing.T) {
arr := Of(1)
tail := Tail(arr)
assert.Equal(t, 0, len(tail))
assert.Equal(t, []int{}, tail)
})
t.Run("Get tail of two element array", func(t *testing.T) {
arr := From(1, 2)
tail := Tail(arr)
assert.Equal(t, 1, len(tail))
assert.Equal(t, []int{2}, tail)
})
}
// TestHead tests the Head function
func TestHead(t *testing.T) {
t.Run("Get head of multi-element array", func(t *testing.T) {
arr := From(1, 2, 3)
head := Head(arr)
assert.Equal(t, 1, head)
})
t.Run("Get head of single element array", func(t *testing.T) {
arr := Of(42)
head := Head(arr)
assert.Equal(t, 42, head)
})
t.Run("Get head of string array", func(t *testing.T) {
arr := From("first", "second", "third")
head := Head(arr)
assert.Equal(t, "first", head)
})
}
// TestFirst tests the First function
func TestFirst(t *testing.T) {
t.Run("First is alias for Head", func(t *testing.T) {
arr := From(1, 2, 3)
assert.Equal(t, Head(arr), First(arr))
})
t.Run("Get first element", func(t *testing.T) {
arr := From("a", "b", "c")
first := First(arr)
assert.Equal(t, "a", first)
})
}
// TestLast tests the Last function
func TestLast(t *testing.T) {
t.Run("Get last of multi-element array", func(t *testing.T) {
arr := From(1, 2, 3, 4, 5)
last := Last(arr)
assert.Equal(t, 5, last)
})
t.Run("Get last of single element array", func(t *testing.T) {
arr := Of(42)
last := Last(arr)
assert.Equal(t, 42, last)
})
t.Run("Get last of string array", func(t *testing.T) {
arr := From("first", "second", "third")
last := Last(arr)
assert.Equal(t, "third", last)
})
}
// TestSize tests the Size function
func TestSize(t *testing.T) {
t.Run("Size of multi-element array", func(t *testing.T) {
arr := From(1, 2, 3, 4, 5)
size := Size(arr)
assert.Equal(t, 5, size)
})
t.Run("Size of single element array", func(t *testing.T) {
arr := Of(1)
size := Size(arr)
assert.Equal(t, 1, size)
})
t.Run("Size of large array", func(t *testing.T) {
elements := make([]int, 1000)
arr := From(1, elements...)
size := Size(arr)
assert.Equal(t, 1001, size)
})
}
// TestFlatten tests the Flatten function
func TestFlatten(t *testing.T) {
t.Run("Flatten nested arrays", func(t *testing.T) {
nested := From(From(1, 2), From(3, 4), From(5))
flat := Flatten(nested)
assert.Equal(t, 5, Size(flat))
assert.Equal(t, 1, Head(flat))
assert.Equal(t, 5, Last(flat))
})
t.Run("Flatten single nested array", func(t *testing.T) {
nested := Of(From(1, 2, 3))
flat := Flatten(nested)
assert.Equal(t, 3, Size(flat))
assert.Equal(t, []int{1, 2, 3}, []int(flat))
})
t.Run("Flatten arrays of different sizes", func(t *testing.T) {
nested := From(Of(1), From(2, 3, 4), From(5, 6))
flat := Flatten(nested)
assert.Equal(t, 6, Size(flat))
assert.Equal(t, []int{1, 2, 3, 4, 5, 6}, []int(flat))
})
}
// TestMonadChain tests the MonadChain function
func TestMonadChain(t *testing.T) {
t.Run("Chain with duplication", func(t *testing.T) {
arr := From(1, 2, 3)
result := MonadChain(arr, func(x int) NonEmptyArray[int] {
return From(x, x*10)
})
assert.Equal(t, 6, Size(result))
assert.Equal(t, []int{1, 10, 2, 20, 3, 30}, []int(result))
})
t.Run("Chain with expansion", func(t *testing.T) {
arr := From(1, 2)
result := MonadChain(arr, func(x int) NonEmptyArray[int] {
return From(x, x+1, x+2)
})
assert.Equal(t, 6, Size(result))
assert.Equal(t, []int{1, 2, 3, 2, 3, 4}, []int(result))
})
t.Run("Chain single element", func(t *testing.T) {
arr := Of(5)
result := MonadChain(arr, func(x int) NonEmptyArray[int] {
return From(x, x*2)
})
assert.Equal(t, 2, Size(result))
assert.Equal(t, []int{5, 10}, []int(result))
})
}
// TestChain tests the Chain function
func TestChain(t *testing.T) {
t.Run("Curried chain with duplication", func(t *testing.T) {
duplicate := Chain(func(x int) NonEmptyArray[int] {
return From(x, x)
})
arr := From(1, 2, 3)
result := duplicate(arr)
assert.Equal(t, 6, Size(result))
assert.Equal(t, []int{1, 1, 2, 2, 3, 3}, []int(result))
})
t.Run("Curried chain with transformation", func(t *testing.T) {
expand := Chain(func(x int) NonEmptyArray[string] {
return Of(fmt.Sprintf("%d", x))
})
arr := From(1, 2, 3)
result := expand(arr)
assert.Equal(t, 3, Size(result))
assert.Equal(t, "1", Head(result))
})
}
// TestMonadAp tests the MonadAp function
func TestMonadAp(t *testing.T) {
t.Run("Apply functions to values", func(t *testing.T) {
fns := From(
func(x int) int { return x * 2 },
func(x int) int { return x + 10 },
)
vals := From(1, 2)
result := MonadAp(fns, vals)
assert.Equal(t, 4, Size(result))
assert.Equal(t, []int{2, 4, 11, 12}, []int(result))
})
t.Run("Apply single function to multiple values", func(t *testing.T) {
fns := Of(func(x int) int { return x * 3 })
vals := From(1, 2, 3)
result := MonadAp(fns, vals)
assert.Equal(t, 3, Size(result))
assert.Equal(t, []int{3, 6, 9}, []int(result))
})
}
// TestAp tests the Ap function
func TestAp(t *testing.T) {
t.Run("Curried apply", func(t *testing.T) {
vals := From(1, 2)
applyTo := Ap[int](vals)
fns := From(
func(x int) int { return x * 2 },
func(x int) int { return x + 10 },
)
result := applyTo(fns)
assert.Equal(t, 4, Size(result))
assert.Equal(t, []int{2, 4, 11, 12}, []int(result))
})
}
// TestFoldMap tests the FoldMap function
func TestFoldMap(t *testing.T) {
t.Run("FoldMap with sum semigroup", func(t *testing.T) {
sumSemigroup := N.SemigroupSum[int]()
arr := From(1, 2, 3, 4)
result := FoldMap[int, int](sumSemigroup)(func(x int) int { return x * 2 })(arr)
assert.Equal(t, 20, result) // (1*2) + (2*2) + (3*2) + (4*2) = 20
})
t.Run("FoldMap with string concatenation", func(t *testing.T) {
concatSemigroup := STR.Semigroup
arr := From(1, 2, 3)
result := FoldMap[int, string](concatSemigroup)(func(x int) string { return fmt.Sprintf("%d", x) })(arr)
assert.Equal(t, "123", result)
})
}
// TestFold tests the Fold function
func TestFold(t *testing.T) {
t.Run("Fold with sum semigroup", func(t *testing.T) {
sumSemigroup := N.SemigroupSum[int]()
arr := From(1, 2, 3, 4, 5)
result := Fold(sumSemigroup)(arr)
assert.Equal(t, 15, result)
})
t.Run("Fold with string concatenation", func(t *testing.T) {
concatSemigroup := STR.Semigroup
arr := From("a", "b", "c")
result := Fold(concatSemigroup)(arr)
assert.Equal(t, "abc", result)
})
t.Run("Fold single element", func(t *testing.T) {
sumSemigroup := N.SemigroupSum[int]()
arr := Of(42)
result := Fold(sumSemigroup)(arr)
assert.Equal(t, 42, result)
})
}
// TestPrepend tests the Prepend function
func TestPrepend(t *testing.T) {
t.Run("Prepend to multi-element array", func(t *testing.T) {
arr := From(2, 3, 4)
prepend1 := Prepend(1)
result := prepend1(arr)
assert.Equal(t, 4, Size(result))
assert.Equal(t, 1, Head(result))
assert.Equal(t, 4, Last(result))
})
t.Run("Prepend to single element array", func(t *testing.T) {
arr := Of(2)
prepend1 := Prepend(1)
result := prepend1(arr)
assert.Equal(t, 2, Size(result))
assert.Equal(t, []int{1, 2}, []int(result))
})
t.Run("Prepend string", func(t *testing.T) {
arr := From("world")
prependHello := Prepend("hello")
result := prependHello(arr)
assert.Equal(t, 2, Size(result))
assert.Equal(t, "hello", Head(result))
})
}
// TestExtract tests the Extract function
func TestExtract(t *testing.T) {
t.Run("Extract from multi-element array", func(t *testing.T) {
arr := From(1, 2, 3)
result := Extract(arr)
assert.Equal(t, 1, result)
})
t.Run("Extract from single element array", func(t *testing.T) {
arr := Of(42)
result := Extract(arr)
assert.Equal(t, 42, result)
})
t.Run("Extract is same as Head", func(t *testing.T) {
arr := From("a", "b", "c")
assert.Equal(t, Head(arr), Extract(arr))
})
}
// TestExtend tests the Extend function
func TestExtend(t *testing.T) {
t.Run("Extend with sum of suffixes", func(t *testing.T) {
arr := From(1, 2, 3, 4)
sumSuffix := Extend(func(xs NonEmptyArray[int]) int {
sum := 0
for _, x := range xs {
sum += x
}
return sum
})
result := sumSuffix(arr)
assert.Equal(t, 4, Size(result))
assert.Equal(t, []int{10, 9, 7, 4}, []int(result))
})
t.Run("Extend with head of suffixes", func(t *testing.T) {
arr := From(1, 2, 3)
getHeads := Extend(Head[int])
result := getHeads(arr)
assert.Equal(t, 3, Size(result))
assert.Equal(t, []int{1, 2, 3}, []int(result))
})
t.Run("Extend with size of suffixes", func(t *testing.T) {
arr := From("a", "b", "c", "d")
getSizes := Extend(Size[string])
result := getSizes(arr)
assert.Equal(t, 4, Size(result))
assert.Equal(t, []int{4, 3, 2, 1}, []int(result))
})
t.Run("Extend single element", func(t *testing.T) {
arr := Of(5)
double := Extend(func(xs NonEmptyArray[int]) int {
return Head(xs) * 2
})
result := double(arr)
assert.Equal(t, 1, Size(result))
assert.Equal(t, 10, Head(result))
})
}

View File

@@ -0,0 +1,20 @@
package nonempty
import "github.com/IBM/fp-go/v2/option"
type (
// NonEmptyArray represents an array that is guaranteed to have at least one element.
// This provides compile-time safety for operations that require non-empty collections.
NonEmptyArray[A any] []A
// Kleisli represents a Kleisli arrow for the NonEmptyArray monad.
// It's a function from A to NonEmptyArray[B], used for composing operations that produce non-empty arrays.
Kleisli[A, B any] = func(A) NonEmptyArray[B]
// Operator represents a function that transforms one NonEmptyArray into another.
// It takes a NonEmptyArray[A] and produces a NonEmptyArray[B].
Operator[A, B any] = Kleisli[NonEmptyArray[A], B]
// Option represents an optional value that may or may not be present.
Option[A any] = option.Option[A]
)

View File

@@ -16,7 +16,11 @@
package array
import (
"github.com/IBM/fp-go/v2/internal/apply"
"github.com/IBM/fp-go/v2/internal/array"
"github.com/IBM/fp-go/v2/internal/functor"
"github.com/IBM/fp-go/v2/internal/pointed"
"github.com/IBM/fp-go/v2/internal/traversable"
)
// Traverse maps each element of an array to an effect (HKT), then collects the results
@@ -55,9 +59,9 @@ import (
//
//go:inline
func Traverse[A, B, HKTB, HKTAB, HKTRB any](
fof func([]B) HKTRB,
fmap func(func([]B) func(B) []B) func(HKTRB) HKTAB,
fap func(HKTB) func(HKTAB) HKTRB,
fof pointed.OfType[[]B, HKTRB],
fmap functor.MapType[[]B, func(B) []B, HKTRB, HKTAB],
fap apply.ApType[HKTB, HKTRB, HKTAB],
f func(A) HKTB) func([]A) HKTRB {
return array.Traverse[[]A](fof, fmap, fap, f)
@@ -71,7 +75,7 @@ func Traverse[A, B, HKTB, HKTAB, HKTRB any](
//
//go:inline
func MonadTraverse[A, B, HKTB, HKTAB, HKTRB any](
fof func([]B) HKTRB,
fof pointed.OfType[[]B, HKTRB],
fmap func(func([]B) func(B) []B) func(HKTRB) HKTAB,
fap func(HKTB) func(HKTAB) HKTRB,
@@ -83,7 +87,7 @@ func MonadTraverse[A, B, HKTB, HKTAB, HKTRB any](
//go:inline
func TraverseWithIndex[A, B, HKTB, HKTAB, HKTRB any](
fof func([]B) HKTRB,
fof pointed.OfType[[]B, HKTRB],
fmap func(func([]B) func(B) []B) func(HKTRB) HKTAB,
fap func(HKTB) func(HKTAB) HKTRB,
@@ -93,7 +97,7 @@ func TraverseWithIndex[A, B, HKTB, HKTAB, HKTRB any](
//go:inline
func MonadTraverseWithIndex[A, B, HKTB, HKTAB, HKTRB any](
fof func([]B) HKTRB,
fof pointed.OfType[[]B, HKTRB],
fmap func(func([]B) func(B) []B) func(HKTRB) HKTAB,
fap func(HKTB) func(HKTAB) HKTRB,
@@ -102,3 +106,22 @@ func MonadTraverseWithIndex[A, B, HKTB, HKTAB, HKTRB any](
return array.MonadTraverseWithIndex(fof, fmap, fap, ta, f)
}
func MakeTraverseType[A, B, HKT_F_B, HKT_F_T_B, HKT_F_B_T_B any]() traversable.TraverseType[A, B, []A, []B, HKT_F_B, HKT_F_T_B, HKT_F_B_T_B] {
return func(
// ap
fof_b pointed.OfType[[]B, HKT_F_T_B],
fmap_b functor.MapType[[]B, func(B) []B, HKT_F_T_B, HKT_F_B_T_B],
fap_b apply.ApType[HKT_F_B, HKT_F_T_B, HKT_F_B_T_B],
) func(func(A) HKT_F_B) func([]A) HKT_F_T_B {
return func(f func(A) HKT_F_B) func([]A) HKT_F_T_B {
return Traverse(
fof_b,
fmap_b,
fap_b,
f,
)
}
}
}

View File

@@ -3,7 +3,14 @@ package array
import "github.com/IBM/fp-go/v2/option"
type (
Kleisli[A, B any] = func(A) []B
// Kleisli represents a Kleisli arrow for arrays.
// It's a function from A to []B, used for composing operations that produce arrays.
Kleisli[A, B any] = func(A) []B
// Operator represents a function that transforms one array into another.
// It takes a []A and produces a []B.
Operator[A, B any] = Kleisli[[]A, B]
Option[A any] = option.Option[A]
// Option represents an optional value that may or may not be present.
Option[A any] = option.Option[A]
)

View File

@@ -57,7 +57,7 @@
// assert.ArrayNotEmpty(arr)(t)
//
// // Partial application - create reusable assertions
// isPositive := assert.That(func(n int) bool { return n > 0 })
// isPositive := assert.That(N.MoreThan(0))
// // Later, apply to different values:
// isPositive(42)(t) // Passes
// isPositive(-5)(t) // Fails
@@ -416,7 +416,7 @@ func NotContainsKey[T any, K comparable](expected K) Kleisli[map[K]T] {
//
// func TestThat(t *testing.T) {
// // Test if a number is positive
// isPositive := func(n int) bool { return n > 0 }
// isPositive := N.MoreThan(0)
// assert.That(isPositive)(42)(t) // Passes
// assert.That(isPositive)(-5)(t) // Fails
//
@@ -519,6 +519,8 @@ func RunAll(testcases map[string]Reader) Reader {
// by providing a function that converts R2 to R1. This allows you to focus a test on a
// specific property or subset of a larger data structure.
//
// See: https://github.com/fantasyland/fantasy-land?tab=readme-ov-file#profunctor
//
// This is particularly useful when you have an assertion that operates on a specific field
// or property, and you want to apply it to a complete object. Instead of extracting the
// property and then asserting on it, you can transform the assertion to work directly

View File

@@ -22,6 +22,7 @@ import (
"github.com/IBM/fp-go/v2/optics/prism"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/result"
S "github.com/IBM/fp-go/v2/string"
)
func TestEqual(t *testing.T) {
@@ -334,7 +335,7 @@ func TestThat(t *testing.T) {
})
t.Run("should work with string predicates", func(t *testing.T) {
startsWithH := func(s string) bool { return len(s) > 0 && s[0] == 'h' }
startsWithH := func(s string) bool { return S.IsNonEmpty(s) && s[0] == 'h' }
result := That(startsWithH)("hello")(t)
if !result {
t.Error("Expected That to pass for string predicate")
@@ -484,7 +485,7 @@ func TestLocal(t *testing.T) {
t.Run("should compose with other assertions", func(t *testing.T) {
// Create multiple focused assertions
nameNotEmpty := Local(func(u User) string { return u.Name })(
That(func(name string) bool { return len(name) > 0 }),
That(S.IsNonEmpty),
)
ageInRange := Local(func(u User) int { return u.Age })(
That(func(age int) bool { return age >= 18 && age <= 100 }),

View File

@@ -21,6 +21,7 @@ import (
"testing"
"github.com/IBM/fp-go/v2/assert"
N "github.com/IBM/fp-go/v2/number"
"github.com/IBM/fp-go/v2/result"
)
@@ -98,7 +99,7 @@ func Example_resultAssertions() {
var t *testing.T // placeholder for example
// Assert success
successResult := result.Of[int](42)
successResult := result.Of(42)
assert.Success(successResult)(t)
// Assert failure
@@ -111,7 +112,7 @@ func Example_predicateAssertions() {
var t *testing.T // placeholder for example
// Test if a number is positive
isPositive := func(n int) bool { return n > 0 }
isPositive := N.MoreThan(0)
assert.That(isPositive)(42)(t)
// Test if a string is uppercase

View File

@@ -12,11 +12,24 @@ import (
)
type (
Result[T any] = result.Result[T]
Reader = reader.Reader[*testing.T, bool]
Kleisli[T any] = reader.Reader[T, Reader]
Predicate[T any] = predicate.Predicate[T]
Lens[S, T any] = lens.Lens[S, T]
// Result represents a computation that may fail with an error.
Result[T any] = result.Result[T]
// Reader represents a test assertion that depends on a testing.T context and returns a boolean.
Reader = reader.Reader[*testing.T, bool]
// Kleisli represents a function that produces a test assertion Reader from a value of type T.
Kleisli[T any] = reader.Reader[T, Reader]
// Predicate represents a function that tests a value of type T and returns a boolean.
Predicate[T any] = predicate.Predicate[T]
// Lens is a functional reference to a subpart of a data structure.
Lens[S, T any] = lens.Lens[S, T]
// Optional is an optic that focuses on a value that may or may not be present.
Optional[S, T any] = optional.Optional[S, T]
Prism[S, T any] = prism.Prism[S, T]
// Prism is an optic that focuses on a case of a sum type.
Prism[S, T any] = prism.Prism[S, T]
)

View File

@@ -18,5 +18,7 @@ package boolean
import "github.com/IBM/fp-go/v2/monoid"
type (
// Monoid represents a monoid structure for boolean values.
// A monoid provides an associative binary operation and an identity element.
Monoid = monoid.Monoid[bool]
)

View File

@@ -1,7 +1,81 @@
// Copyright (c) 2024 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 builder provides a generic Builder pattern interface for constructing
// complex objects with validation.
//
// The Builder pattern is useful when:
// - Object construction requires multiple steps
// - Construction may fail with validation errors
// - You want to separate construction logic from the object itself
//
// Example usage:
//
// type PersonBuilder struct {
// name string
// age int
// }
//
// func (b PersonBuilder) Build() result.Result[Person] {
// if b.name == "" {
// return result.Error[Person](errors.New("name is required"))
// }
// if b.age < 0 {
// return result.Error[Person](errors.New("age must be non-negative"))
// }
// return result.Of(Person{Name: b.name, Age: b.age})
// }
package builder
type (
// Builder is a generic interface for the Builder pattern that constructs
// objects of type T with validation.
//
// The Build method returns a Result[T] which can be either:
// - Success: containing the constructed object of type T
// - Error: containing an error if validation or construction fails
//
// This allows builders to perform validation and return meaningful errors
// during the construction process, making it explicit that object creation
// may fail.
//
// Type Parameters:
// - T: The type of object being built
//
// Example:
//
// type ConfigBuilder struct {
// host string
// port int
// }
//
// func (b ConfigBuilder) Build() result.Result[Config] {
// if b.host == "" {
// return result.Error[Config](errors.New("host is required"))
// }
// if b.port <= 0 || b.port > 65535 {
// return result.Error[Config](errors.New("invalid port"))
// }
// return result.Of(Config{Host: b.host, Port: b.port})
// }
Builder[T any] interface {
// Build constructs and validates an object of type T.
//
// Returns:
// - Result[T]: A Result containing either the successfully built object
// or an error if validation or construction fails.
Build() Result[T]
}
)

374
v2/builder/builder_test.go Normal file
View File

@@ -0,0 +1,374 @@
// Copyright (c) 2024 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 builder
import (
"errors"
"testing"
O "github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
// Test types for demonstration
type Person struct {
Name string
Age int
}
type PersonBuilder struct {
name string
age int
}
func (b PersonBuilder) WithName(name string) PersonBuilder {
b.name = name
return b
}
func (b PersonBuilder) WithAge(age int) PersonBuilder {
b.age = age
return b
}
func (b PersonBuilder) Build() Result[Person] {
if b.name == "" {
return result.Left[Person](errors.New("name is required"))
}
if b.age < 0 {
return result.Left[Person](errors.New("age must be non-negative"))
}
if b.age > 150 {
return result.Left[Person](errors.New("age must be realistic"))
}
return result.Of(Person{Name: b.name, Age: b.age})
}
func NewPersonBuilder(p Person) PersonBuilder {
return PersonBuilder{name: p.Name, age: p.Age}
}
// Config example for additional test coverage
type Config struct {
Host string
Port int
}
type ConfigBuilder struct {
host string
port int
}
func (b ConfigBuilder) WithHost(host string) ConfigBuilder {
b.host = host
return b
}
func (b ConfigBuilder) WithPort(port int) ConfigBuilder {
b.port = port
return b
}
func (b ConfigBuilder) Build() Result[Config] {
if b.host == "" {
return result.Left[Config](errors.New("host is required"))
}
if b.port <= 0 || b.port > 65535 {
return result.Left[Config](errors.New("port must be between 1 and 65535"))
}
return result.Of(Config{Host: b.host, Port: b.port})
}
func NewConfigBuilder(c Config) ConfigBuilder {
return ConfigBuilder{host: c.Host, port: c.Port}
}
// Tests for Builder interface
func TestBuilder_SuccessfulBuild(t *testing.T) {
builder := PersonBuilder{}.
WithName("Alice").
WithAge(30)
res := builder.Build()
assert.True(t, result.IsRight(res), "Build should succeed")
person := result.ToOption(res)
assert.True(t, O.IsSome(person), "Result should contain a person")
p := O.GetOrElse(func() Person { return Person{} })(person)
assert.Equal(t, "Alice", p.Name)
assert.Equal(t, 30, p.Age)
}
func TestBuilder_ValidationFailure_MissingName(t *testing.T) {
builder := PersonBuilder{}.WithAge(30)
res := builder.Build()
assert.True(t, result.IsLeft(res), "Build should fail when name is missing")
err := result.Fold(
func(e error) error { return e },
func(Person) error { return errors.New("unexpected success") },
)(res)
assert.Equal(t, "name is required", err.Error())
}
func TestBuilder_ValidationFailure_NegativeAge(t *testing.T) {
builder := PersonBuilder{}.
WithName("Bob").
WithAge(-5)
res := builder.Build()
assert.True(t, result.IsLeft(res), "Build should fail when age is negative")
err := result.Fold(
func(e error) error { return e },
func(Person) error { return errors.New("unexpected success") },
)(res)
assert.Equal(t, "age must be non-negative", err.Error())
}
func TestBuilder_ValidationFailure_UnrealisticAge(t *testing.T) {
builder := PersonBuilder{}.
WithName("Charlie").
WithAge(200)
res := builder.Build()
assert.True(t, result.IsLeft(res), "Build should fail when age is unrealistic")
err := result.Fold(
func(e error) error { return e },
func(Person) error { return errors.New("unexpected success") },
)(res)
assert.Equal(t, "age must be realistic", err.Error())
}
func TestBuilder_ConfigSuccessfulBuild(t *testing.T) {
builder := ConfigBuilder{}.
WithHost("localhost").
WithPort(8080)
res := builder.Build()
assert.True(t, result.IsRight(res), "Build should succeed")
config := result.ToOption(res)
assert.True(t, O.IsSome(config), "Result should contain a config")
c := O.GetOrElse(func() Config { return Config{} })(config)
assert.Equal(t, "localhost", c.Host)
assert.Equal(t, 8080, c.Port)
}
func TestBuilder_ConfigValidationFailure_MissingHost(t *testing.T) {
builder := ConfigBuilder{}.WithPort(8080)
res := builder.Build()
assert.True(t, result.IsLeft(res), "Build should fail when host is missing")
err := result.Fold(
func(e error) error { return e },
func(Config) error { return errors.New("unexpected success") },
)(res)
assert.Equal(t, "host is required", err.Error())
}
func TestBuilder_ConfigValidationFailure_InvalidPort(t *testing.T) {
tests := []struct {
name string
port int
}{
{"zero port", 0},
{"negative port", -1},
{"port too large", 70000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
builder := ConfigBuilder{}.
WithHost("localhost").
WithPort(tt.port)
res := builder.Build()
assert.True(t, result.IsLeft(res), "Build should fail for invalid port")
err := result.Fold(
func(e error) error { return e },
func(Config) error { return errors.New("unexpected success") },
)(res)
assert.Equal(t, "port must be between 1 and 65535", err.Error())
})
}
}
// Tests for BuilderPrism
func TestBuilderPrism_GetOption_ValidBuilder(t *testing.T) {
prism := BuilderPrism(NewPersonBuilder)
builder := PersonBuilder{}.
WithName("Alice").
WithAge(30)
personOpt := prism.GetOption(builder)
assert.True(t, O.IsSome(personOpt), "GetOption should return Some for valid builder")
person := O.GetOrElse(func() Person { return Person{} })(personOpt)
assert.Equal(t, "Alice", person.Name)
assert.Equal(t, 30, person.Age)
}
func TestBuilderPrism_GetOption_InvalidBuilder(t *testing.T) {
prism := BuilderPrism(NewPersonBuilder)
builder := PersonBuilder{}.WithAge(30) // Missing name
personOpt := prism.GetOption(builder)
assert.True(t, O.IsNone(personOpt), "GetOption should return None for invalid builder")
}
func TestBuilderPrism_ReverseGet(t *testing.T) {
prism := BuilderPrism(NewPersonBuilder)
person := Person{Name: "Bob", Age: 25}
builder := prism.ReverseGet(person)
assert.Equal(t, "Bob", builder.name)
assert.Equal(t, 25, builder.age)
// Verify the builder can build the same person
res := builder.Build()
assert.True(t, result.IsRight(res), "Builder from ReverseGet should be valid")
rebuilt := O.GetOrElse(func() Person { return Person{} })(result.ToOption(res))
assert.Equal(t, person, rebuilt)
}
func TestBuilderPrism_RoundTrip_ValidBuilder(t *testing.T) {
prism := BuilderPrism(NewPersonBuilder)
originalBuilder := PersonBuilder{}.
WithName("Charlie").
WithAge(35)
// Extract person from builder
personOpt := prism.GetOption(originalBuilder)
assert.True(t, O.IsSome(personOpt), "Should extract person from valid builder")
person := O.GetOrElse(func() Person { return Person{} })(personOpt)
// Reconstruct builder from person
rebuiltBuilder := prism.ReverseGet(person)
// Verify the rebuilt builder produces the same person
rebuiltRes := rebuiltBuilder.Build()
assert.True(t, result.IsRight(rebuiltRes), "Rebuilt builder should be valid")
rebuiltPerson := O.GetOrElse(func() Person { return Person{} })(result.ToOption(rebuiltRes))
assert.Equal(t, person, rebuiltPerson)
}
func TestBuilderPrism_ConfigPrism(t *testing.T) {
prism := BuilderPrism(NewConfigBuilder)
builder := ConfigBuilder{}.
WithHost("example.com").
WithPort(443)
configOpt := prism.GetOption(builder)
assert.True(t, O.IsSome(configOpt), "GetOption should return Some for valid config builder")
config := O.GetOrElse(func() Config { return Config{} })(configOpt)
assert.Equal(t, "example.com", config.Host)
assert.Equal(t, 443, config.Port)
}
func TestBuilderPrism_ConfigPrism_InvalidBuilder(t *testing.T) {
prism := BuilderPrism(NewConfigBuilder)
builder := ConfigBuilder{}.WithPort(8080) // Missing host
configOpt := prism.GetOption(builder)
assert.True(t, O.IsNone(configOpt), "GetOption should return None for invalid config builder")
}
func TestBuilderPrism_ConfigPrism_ReverseGet(t *testing.T) {
prism := BuilderPrism(NewConfigBuilder)
config := Config{Host: "api.example.com", Port: 9000}
builder := prism.ReverseGet(config)
assert.Equal(t, "api.example.com", builder.host)
assert.Equal(t, 9000, builder.port)
// Verify the builder can build the same config
res := builder.Build()
assert.True(t, result.IsRight(res), "Builder from ReverseGet should be valid")
rebuilt := O.GetOrElse(func() Config { return Config{} })(result.ToOption(res))
assert.Equal(t, config, rebuilt)
}
// Benchmark tests
func BenchmarkBuilder_SuccessfulBuild(b *testing.B) {
builder := PersonBuilder{}.
WithName("Alice").
WithAge(30)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = builder.Build()
}
}
func BenchmarkBuilder_FailedBuild(b *testing.B) {
builder := PersonBuilder{}.WithAge(30) // Missing name
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = builder.Build()
}
}
func BenchmarkBuilderPrism_GetOption(b *testing.B) {
prism := BuilderPrism(NewPersonBuilder)
builder := PersonBuilder{}.
WithName("Alice").
WithAge(30)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = prism.GetOption(builder)
}
}
func BenchmarkBuilderPrism_ReverseGet(b *testing.B) {
prism := BuilderPrism(NewPersonBuilder)
person := Person{Name: "Bob", Age: 25}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = prism.ReverseGet(person)
}
}

View File

@@ -1,3 +1,18 @@
// Copyright (c) 2024 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 builder
import (
@@ -6,7 +21,61 @@ import (
"github.com/IBM/fp-go/v2/result"
)
// BuilderPrism createa a [Prism] that converts between a builder and its type
// BuilderPrism creates a [Prism] that converts between a builder and its built type.
//
// A Prism is an optic that focuses on a case of a sum type, providing bidirectional
// conversion with the possibility of failure. This function creates a prism that:
// - Extracts: Attempts to build the object from the builder (may fail)
// - Constructs: Creates a builder from a valid object (always succeeds)
//
// The extraction direction (builder -> object) uses the Build method and converts
// the Result to an Option, where errors become None. The construction direction
// (object -> builder) uses the provided creator function.
//
// Type Parameters:
// - T: The type of the object being built
// - B: The builder type that implements Builder[T]
//
// Parameters:
// - creator: A function that creates a builder from a valid object of type T.
// This function should initialize the builder with all fields from the object.
//
// Returns:
// - Prism[B, T]: A prism that can convert between the builder and the built type.
//
// Example:
//
// type Person struct {
// Name string
// Age int
// }
//
// type PersonBuilder struct {
// name string
// age int
// }
//
// func (b PersonBuilder) Build() result.Result[Person] {
// if b.name == "" {
// return result.Error[Person](errors.New("name required"))
// }
// return result.Of(Person{Name: b.name, Age: b.age})
// }
//
// func NewPersonBuilder(p Person) PersonBuilder {
// return PersonBuilder{name: p.Name, age: p.Age}
// }
//
// // Create a prism for PersonBuilder
// prism := BuilderPrism(NewPersonBuilder)
//
// // Use the prism to extract a Person from a valid builder
// builder := PersonBuilder{name: "Alice", age: 30}
// person := prism.GetOption(builder) // Some(Person{Name: "Alice", Age: 30})
//
// // Use the prism to create a builder from a Person
// p := Person{Name: "Bob", Age: 25}
// b := prism.ReverseGet(p) // PersonBuilder{name: "Bob", age: 25}
func BuilderPrism[T any, B Builder[T]](creator func(T) B) Prism[B, T] {
return prism.MakePrismWithName(F.Flow2(B.Build, result.ToOption[T]), creator, "BuilderPrism")
}

View File

@@ -7,9 +7,14 @@ import (
)
type (
// Result represents a computation that may fail with an error.
// It's an alias for Either[error, T].
Result[T any] = result.Result[T]
// Prism is an optic that focuses on a case of a sum type.
// It provides a way to extract and construct values of a specific variant.
Prism[S, A any] = prism.Prism[S, A]
// Option represents an optional value that may or may not be present.
Option[T any] = option.Option[T]
)

View File

@@ -0,0 +1,630 @@
package circuitbreaker
import (
"time"
"github.com/IBM/fp-go/v2/either"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/identity"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioref"
"github.com/IBM/fp-go/v2/lazy"
"github.com/IBM/fp-go/v2/optics/lens"
"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/readerio"
"github.com/IBM/fp-go/v2/retry"
)
var (
canaryRequestLens = lens.MakeLensWithName(
func(os openState) bool { return os.canaryRequest },
func(os openState, flag bool) openState {
os.canaryRequest = flag
return os
},
"openState.CanaryRequest",
)
retryStatusLens = lens.MakeLensWithName(
func(os openState) retry.RetryStatus { return os.retryStatus },
func(os openState, status retry.RetryStatus) openState {
os.retryStatus = status
return os
},
"openState.RetryStatus",
)
resetAtLens = lens.MakeLensWithName(
func(os openState) time.Time { return os.resetAt },
func(os openState, tm time.Time) openState {
os.resetAt = tm
return os
},
"openState.ResetAt",
)
openedAtLens = lens.MakeLensWithName(
func(os openState) time.Time { return os.openedAt },
func(os openState, tm time.Time) openState {
os.openedAt = tm
return os
},
"openState.OpenedAt",
)
createClosedCircuit = either.Right[openState, ClosedState]
createOpenCircuit = either.Left[ClosedState, openState]
// MakeClosedIORef creates an IORef containing a closed circuit breaker state.
// It wraps the provided ClosedState in a Right (closed) BreakerState and creates
// a mutable reference to it.
//
// Parameters:
// - closedState: The initial closed state configuration
//
// Returns:
// - An IO operation that creates an IORef[BreakerState] initialized to closed state
//
// Thread Safety: The returned IORef[BreakerState] is thread-safe. It uses atomic
// operations for all read/write/modify operations. The BreakerState itself is immutable.
MakeClosedIORef = F.Flow2(
createClosedCircuit,
ioref.MakeIORef,
)
// IsOpen checks if a BreakerState is in the open state.
// Returns true if the circuit breaker is open (blocking requests), false otherwise.
IsOpen = either.IsLeft[openState, ClosedState]
// IsClosed checks if a BreakerState is in the closed state.
// Returns true if the circuit breaker is closed (allowing requests), false otherwise.
IsClosed = either.IsRight[openState, ClosedState]
// modifyV creates a Reader that sequences an IORef modification operation.
// It takes an IORef[BreakerState] and returns a Reader that, when given an endomorphism
// (a function from BreakerState to BreakerState), produces an IO operation that modifies
// the IORef and returns the new state.
//
// This is used internally to create state modification operations that can be composed
// with other Reader-based operations in the circuit breaker logic.
//
// Thread Safety: The IORef modification is atomic. Multiple concurrent calls will be
// serialized by the IORef's atomic operations.
//
// Type signature: Reader[IORef[BreakerState], IO[Endomorphism[BreakerState]]]
modifyV = reader.Sequence(ioref.Modify[BreakerState])
initialRetry = retry.DefaultRetryStatus
// testCircuit sets the canaryRequest flag to true in an openState.
// This is used to mark that the circuit breaker is in half-open state,
// allowing a single test request (canary) to check if the service has recovered.
//
// When canaryRequest is true:
// - One request is allowed through to test the service
// - If the canary succeeds, the circuit closes
// - If the canary fails, the circuit remains open with an extended reset time
//
// Thread Safety: This is a pure function that returns a new openState; it does not
// modify its input. Safe for concurrent use.
//
// Type signature: Endomorphism[openState]
testCircuit = canaryRequestLens.Set(true)
)
// makeOpenCircuitFromPolicy creates a function that constructs an openState from a retry policy.
// This is a curried function that takes a retry policy and returns a function that takes a retry status
// and current time to produce an openState with calculated reset time.
//
// The function applies the retry policy to determine the next retry delay and calculates
// the resetAt time by adding the delay to the current time. If no previous delay exists
// (first failure), the resetAt is set to the current time.
//
// Parameters:
// - policy: The retry policy that determines backoff strategy (e.g., exponential backoff)
//
// Returns:
// - A curried function that takes:
// 1. rs (retry.RetryStatus): The current retry status containing retry count and previous delay
// 2. ct (time.Time): The current time when the circuit is opening
// And returns an openState with:
// - openedAt: Set to the current time (ct)
// - resetAt: Current time plus the delay from the retry policy
// - retryStatus: The updated retry status from applying the policy
// - canaryRequest: false (will be set to true when reset time is reached)
//
// Thread Safety: This is a pure function that creates new openState instances.
// Safe for concurrent use.
//
// Example:
//
// policy := retry.ExponentialBackoff(1*time.Second, 2.0, 10)
// makeOpen := makeOpenCircuitFromPolicy(policy)
// openState := makeOpen(retry.DefaultRetryStatus)(time.Now())
// // openState.resetAt will be approximately 1 second from now
func makeOpenCircuitFromPolicy(policy retry.RetryPolicy) func(rs retry.RetryStatus) func(ct time.Time) openState {
return func(rs retry.RetryStatus) func(ct time.Time) openState {
retryStatus := retry.ApplyPolicy(policy, rs)
return func(ct time.Time) openState {
resetTime := F.Pipe2(
retryStatus,
retry.PreviousDelayLens.Get,
option.Fold(
F.Pipe1(
ct,
lazy.Of,
),
ct.Add,
),
)
return openState{openedAt: ct, resetAt: resetTime, retryStatus: retryStatus}
}
}
}
// extendOpenCircuitFromMakeCircuit creates a function that extends the open state of a circuit breaker
// when a canary request fails. It takes a circuit maker function and returns a function that,
// given the current time, produces an endomorphism that updates an openState.
//
// This function is used when a canary request (test request in half-open state) fails.
// It extends the circuit breaker's open period by:
// 1. Extracting the current retry status from the open state
// 2. Using the makeCircuit function to calculate a new open state with updated retry status
// 3. Applying the current time to get the new state
// 4. Setting the canaryRequest flag to true to allow another test request later
//
// Parameters:
// - makeCircuit: A function that creates an openState from a retry status and current time.
// This is typically created by makeOpenCircuitFromPolicy.
//
// Returns:
// - A curried function that takes:
// 1. ct (time.Time): The current time when extending the circuit
// And returns an Endomorphism[openState] that:
// - Increments the retry count
// - Calculates a new resetAt time based on the retry policy (typically with exponential backoff)
// - Sets canaryRequest to true for the next test attempt
//
// Thread Safety: This is a pure function that returns new openState instances.
// Safe for concurrent use.
//
// Usage Context:
// - Called when a canary request fails in the half-open state
// - Extends the open period with increased backoff delay
// - Prepares the circuit for another canary attempt at the new resetAt time
func extendOpenCircuitFromMakeCircuit(
makeCircuit func(rs retry.RetryStatus) func(ct time.Time) openState,
) func(time.Time) Endomorphism[openState] {
return func(ct time.Time) Endomorphism[openState] {
return F.Flow4(
retryStatusLens.Get,
makeCircuit,
identity.Flap[openState](ct),
testCircuit,
)
}
}
// isResetTimeExceeded checks if the reset time for an open circuit has been exceeded.
// This is used to determine if the circuit breaker should transition from open to half-open state
// by allowing a canary request.
//
// The function returns an option.Kleisli that succeeds (returns Some) only when:
// 1. The circuit is not already in canary mode (canaryRequest is false)
// 2. The current time is after the resetAt time
//
// Parameters:
// - ct: The current time to compare against the reset time
//
// Returns:
// - An option.Kleisli[openState, openState] that:
// - Returns Some(openState) if the reset time has been exceeded and no canary is active
// - Returns None if the reset time has not been exceeded or a canary request is already active
//
// Thread Safety: This is a pure function that does not modify its input.
// Safe for concurrent use.
//
// Usage Context:
// - Called when the circuit is open to check if it's time to attempt a canary request
// - If this returns Some, the circuit transitions to half-open state (canary mode)
// - If this returns None, the circuit remains fully open and requests are blocked
func isResetTimeExceeded(ct time.Time) option.Kleisli[openState, openState] {
return option.FromPredicate(func(open openState) bool {
return !open.canaryRequest && ct.After(resetAtLens.Get(open))
})
}
// handleSuccessOnClosed creates a Reader that handles successful requests when the circuit is closed.
// This function is used to update the circuit breaker state after a successful operation completes
// while the circuit is in the closed state.
//
// The function takes a Reader that adds a success record to the ClosedState and lifts it to work
// with BreakerState by mapping over the Right (closed) side of the Either type. This ensures that
// success tracking only affects the closed state and leaves any open state unchanged.
//
// Parameters:
// - addSuccess: A Reader that takes the current time and returns an Endomorphism that updates
// the ClosedState by recording a successful operation. This typically increments a success
// counter or updates a success history.
//
// Returns:
// - A Reader[time.Time, Endomorphism[BreakerState]] that, when given the current time, produces
// an endomorphism that updates the BreakerState by applying the success update to the closed
// state (if closed) or leaving the state unchanged (if open).
//
// Thread Safety: This is a pure function that creates new state instances. The returned
// endomorphism is safe for concurrent use as it does not mutate its input.
//
// Usage Context:
// - Called after a successful request completes while the circuit is closed
// - Updates success metrics/counters in the ClosedState
// - Does not affect the circuit state if it's already open
// - Part of the normal operation flow when the circuit breaker is functioning properly
func handleSuccessOnClosed(
addSuccess Reader[time.Time, Endomorphism[ClosedState]],
) Reader[time.Time, Endomorphism[BreakerState]] {
return F.Flow2(
addSuccess,
either.Map[openState],
)
}
// handleFailureOnClosed creates a Reader that handles failed requests when the circuit is closed.
// This function manages the critical logic for determining whether a failure should cause the
// circuit breaker to open (transition from closed to open state).
//
// The function orchestrates three key operations:
// 1. Records the failure in the ClosedState using addError
// 2. Checks if the failure threshold has been exceeded using checkClosedState
// 3. If threshold exceeded, opens the circuit; otherwise, keeps it closed with updated error count
//
// The decision flow is:
// - Add the error to the closed state's error tracking
// - Check if the updated closed state exceeds the failure threshold
// - If threshold exceeded (checkClosedState returns None):
// - Create a new openState with calculated reset time based on retry policy
// - Transition the circuit to open state (Left side of Either)
// - If threshold not exceeded (checkClosedState returns Some):
// - Keep the circuit closed with the updated error count
// - Continue allowing requests through
//
// Parameters:
// - addError: A Reader that takes the current time and returns an Endomorphism that updates
// the ClosedState by recording a failed operation. This typically increments an error
// counter or adds to an error history.
// - checkClosedState: A Reader that takes the current time and returns an option.Kleisli that
// validates whether the ClosedState is still within acceptable failure thresholds.
// Returns Some(ClosedState) if threshold not exceeded, None if threshold exceeded.
// - openCircuit: A Reader that takes the current time and creates a new openState with
// appropriate reset time calculated from the retry policy. Used when transitioning to open.
//
// Returns:
// - A Reader[time.Time, Endomorphism[BreakerState]] that, when given the current time, produces
// an endomorphism that either:
// - Keeps the circuit closed with updated error tracking (if threshold not exceeded)
// - Opens the circuit with calculated reset time (if threshold exceeded)
//
// Thread Safety: This is a pure function that creates new state instances. The returned
// endomorphism is safe for concurrent use as it does not mutate its input.
//
// Usage Context:
// - Called after a failed request completes while the circuit is closed
// - Implements the core circuit breaker logic for opening the circuit
// - Determines when to stop allowing requests through to protect the failing service
// - Critical for preventing cascading failures in distributed systems
//
// State Transition:
// - Closed (under threshold) -> Closed (with incremented error count)
// - Closed (at/over threshold) -> Open (with reset time for recovery attempt)
func handleFailureOnClosed(
addError Reader[time.Time, Endomorphism[ClosedState]],
checkClosedState Reader[time.Time, option.Kleisli[ClosedState, ClosedState]],
openCircuit Reader[time.Time, openState],
) Reader[time.Time, Endomorphism[BreakerState]] {
return F.Pipe2(
F.Pipe1(
addError,
reader.ApS(reader.Map[ClosedState], checkClosedState),
),
reader.Chain(F.Flow2(
reader.Map[ClosedState](option.Fold(
F.Pipe2(
openCircuit,
reader.Map[time.Time](createOpenCircuit),
lazy.Of,
),
F.Flow2(
createClosedCircuit,
reader.Of[time.Time],
),
)),
reader.Sequence,
)),
reader.Map[time.Time](either.Chain[openState, ClosedState, ClosedState]),
)
}
func handleErrorOnClosed2[E any](
checkError option.Kleisli[E, E],
onSuccess Reader[time.Time, Endomorphism[BreakerState]],
onFailure Reader[time.Time, Endomorphism[BreakerState]],
) reader.Kleisli[time.Time, E, Endomorphism[BreakerState]] {
return F.Flow3(
checkError,
option.MapTo[E](onFailure),
option.GetOrElse(lazy.Of(onSuccess)),
)
}
func stateModifier(
modify io.Kleisli[Endomorphism[BreakerState], BreakerState],
) reader.Operator[time.Time, Endomorphism[BreakerState], IO[BreakerState]] {
return reader.Map[time.Time](modify)
}
func reportOnClose2(
onClosed ReaderIO[time.Time, Void],
onOpened ReaderIO[time.Time, Void],
) readerio.Operator[time.Time, BreakerState, Void] {
return readerio.Chain(either.Fold(
reader.Of[openState](onOpened),
reader.Of[ClosedState](onClosed),
))
}
func applyAndReportClose2(
currentTime IO[time.Time],
metrics readerio.Operator[time.Time, BreakerState, Void],
) func(io.Kleisli[Endomorphism[BreakerState], BreakerState]) func(Reader[time.Time, Endomorphism[BreakerState]]) IO[Void] {
return func(modify io.Kleisli[Endomorphism[BreakerState], BreakerState]) func(Reader[time.Time, Endomorphism[BreakerState]]) IO[Void] {
return F.Flow3(
reader.Map[time.Time](modify),
metrics,
readerio.ReadIO[Void](currentTime),
)
}
}
// MakeCircuitBreaker creates a circuit breaker implementation for a higher-kinded type.
//
// This is a generic circuit breaker factory that works with any monad-like type (HKTT).
// It implements the circuit breaker pattern by wrapping operations and managing state transitions
// between closed, open, and half-open states based on failure rates and retry policies.
//
// Type Parameters:
// - E: The error type
// - T: The success value type
// - HKTT: The higher-kinded type representing the computation (e.g., IO[T], ReaderIO[R, T])
// - HKTOP: The higher-kinded type for operators (e.g., IO[func(HKTT) HKTT])
// - HKTHKTT: The nested higher-kinded type (e.g., IO[IO[T]])
//
// Parameters:
// - left: Constructs an error result in HKTT from an error value
// - chainFirstIOK: Chains an IO operation that runs after success, preserving the original value
// - chainFirstLeftIOK: Chains an IO operation that runs after error, preserving the original error
// - fromIO: Lifts an IO operation into HKTOP
// - flap: Applies a value to a function wrapped in a higher-kinded type
// - flatten: Flattens nested higher-kinded types (join operation)
// - currentTime: IO operation that provides the current time
// - closedState: The initial closed state configuration
// - makeError: Creates an error from a reset time when the circuit is open
// - checkError: Predicate to determine if an error should trigger circuit breaker logic
// - policy: Retry policy for determining reset times when circuit opens
// - logger: Logging function for circuit breaker events
//
// Thread Safety: The returned State monad creates operations that are thread-safe when
// executed. The IORef[BreakerState] uses atomic operations for all state modifications.
// Multiple concurrent requests will be properly serialized at the IORef level.
//
// Returns:
// - A State monad that transforms a pair of (IORef[BreakerState], HKTT) into HKTT,
// applying circuit breaker logic to the computation
func MakeCircuitBreaker[E, T, HKTT, HKTOP, HKTHKTT any](
left func(E) HKTT,
chainFirstIOK func(io.Kleisli[T, BreakerState]) func(HKTT) HKTT,
chainFirstLeftIOK func(io.Kleisli[E, BreakerState]) func(HKTT) HKTT,
chainFirstIOK2 func(io.Kleisli[Either[E, T], Void]) func(HKTT) HKTT,
fromIO func(IO[func(HKTT) HKTT]) HKTOP,
flap func(HKTT) func(HKTOP) HKTHKTT,
flatten func(HKTHKTT) HKTT,
currentTime IO[time.Time],
closedState ClosedState,
makeError Reader[time.Time, E],
checkError option.Kleisli[E, E],
policy retry.RetryPolicy,
metrics Metrics,
) State[Pair[IORef[BreakerState], HKTT], HKTT] {
type Operator = func(HKTT) HKTT
addSuccess := reader.From1(ClosedState.AddSuccess)
addError := reader.From1(ClosedState.AddError)
checkClosedState := reader.From1(ClosedState.Check)
closedCircuit := createClosedCircuit(closedState.Empty())
makeOpenCircuit := makeOpenCircuitFromPolicy(policy)
openCircuit := F.Pipe1(
initialRetry,
makeOpenCircuit,
)
extendOpenCircuit := extendOpenCircuitFromMakeCircuit(makeOpenCircuit)
failWithError := F.Flow4(
resetAtLens.Get,
makeError,
left,
reader.Of[HKTT],
)
handleSuccess2 := handleSuccessOnClosed(addSuccess)
handleFailure2 := handleFailureOnClosed(addError, checkClosedState, openCircuit)
handleError2 := handleErrorOnClosed2(checkError, handleSuccess2, handleFailure2)
metricsClose2 := reportOnClose2(metrics.Accept, metrics.Open)
apply2 := applyAndReportClose2(currentTime, metricsClose2)
onClosed := func(modify io.Kleisli[Endomorphism[BreakerState], BreakerState]) Operator {
return chainFirstIOK2(F.Flow2(
either.Fold(
handleError2,
reader.Of[T](handleSuccess2),
),
apply2(modify),
))
}
onCanary := func(modify io.Kleisli[Endomorphism[BreakerState], BreakerState]) Operator {
handleSuccess := F.Pipe2(
closedCircuit,
reader.Of[BreakerState],
modify,
)
return F.Flow2(
// the canary request fails
chainFirstLeftIOK(F.Flow2(
checkError,
option.Fold(
// the canary request succeeds, we close the circuit
F.Pipe1(
handleSuccess,
lazy.Of,
),
// the canary request fails, we extend the circuit
F.Pipe1(
F.Pipe1(
currentTime,
io.Chain(func(ct time.Time) IO[BreakerState] {
return F.Pipe1(
F.Flow2(
either.Fold(
extendOpenCircuit(ct),
F.Pipe1(
openCircuit(ct),
reader.Of[ClosedState],
),
),
createOpenCircuit,
),
modify,
)
}),
),
reader.Of[E],
),
),
)),
// the canary request succeeds, we'll close the circuit
chainFirstIOK(F.Pipe1(
handleSuccess,
reader.Of[T],
)),
)
}
onOpen := func(ref IORef[BreakerState]) Operator {
modify := modifyV(ref)
return F.Pipe3(
currentTime,
io.Chain(func(ct time.Time) IO[Operator] {
return F.Pipe1(
ref,
ioref.ModifyWithResult(either.Fold(
func(open openState) Pair[BreakerState, Operator] {
return option.Fold(
func() Pair[BreakerState, Operator] {
return pair.MakePair(createOpenCircuit(open), failWithError(open))
},
func(open openState) Pair[BreakerState, Operator] {
return pair.MakePair(createOpenCircuit(testCircuit(open)), onCanary(modify))
},
)(isResetTimeExceeded(ct)(open))
},
func(closed ClosedState) Pair[BreakerState, Operator] {
return pair.MakePair(createClosedCircuit(closed), onClosed(modify))
},
)),
)
}),
fromIO,
func(src HKTOP) Operator {
return func(rdr HKTT) HKTT {
return F.Pipe2(
src,
flap(rdr),
flatten,
)
}
},
)
}
return func(e Pair[IORef[BreakerState], HKTT]) Pair[Pair[IORef[BreakerState], HKTT], HKTT] {
return pair.MakePair(e, onOpen(pair.Head(e))(pair.Tail(e)))
}
}
// MakeSingletonBreaker creates a singleton circuit breaker operator for a higher-kinded type.
//
// This function creates a circuit breaker that maintains its own internal state reference.
// It's called "singleton" because it creates a single, self-contained circuit breaker instance
// with its own IORef for state management. The returned function can be used to wrap
// computations with circuit breaker protection.
//
// Type Parameters:
// - HKTT: The higher-kinded type representing the computation (e.g., IO[T], ReaderIO[R, T])
//
// Parameters:
// - cb: The circuit breaker State monad created by MakeCircuitBreaker
// - closedState: The initial closed state configuration for the circuit breaker
//
// Returns:
// - A function that wraps a computation (HKTT) with circuit breaker logic.
// The circuit breaker state is managed internally and persists across invocations.
//
// Thread Safety: The returned function is thread-safe. The internal IORef[BreakerState]
// uses atomic operations to manage state. Multiple concurrent calls to the returned function
// will be properly serialized at the state modification level.
//
// Example Usage:
//
// // Create a circuit breaker for IO operations
// breaker := MakeSingletonBreaker(
// MakeCircuitBreaker(...),
// MakeClosedStateCounter(3),
// )
//
// // Use it to wrap operations
// protectedOp := breaker(myIOOperation)
func MakeSingletonBreaker[HKTT any](
cb State[Pair[IORef[BreakerState], HKTT], HKTT],
closedState ClosedState,
) func(HKTT) HKTT {
return F.Flow3(
F.Pipe3(
closedState,
MakeClosedIORef,
io.Run,
pair.FromHead[HKTT],
),
cb,
pair.Tail,
)
}

View File

@@ -0,0 +1,951 @@
package circuitbreaker
import (
"sync"
"testing"
"time"
"github.com/IBM/fp-go/v2/either"
"github.com/IBM/fp-go/v2/function"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioref"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/retry"
"github.com/stretchr/testify/assert"
)
type testMetrics struct {
accepts int
rejects int
opens int
closes int
canary int
mu sync.Mutex
}
func (m *testMetrics) Accept(_ time.Time) IO[Void] {
return func() Void {
m.mu.Lock()
defer m.mu.Unlock()
m.accepts++
return function.VOID
}
}
func (m *testMetrics) Open(_ time.Time) IO[Void] {
return func() Void {
m.mu.Lock()
defer m.mu.Unlock()
m.opens++
return function.VOID
}
}
func (m *testMetrics) Close(_ time.Time) IO[Void] {
return func() Void {
m.mu.Lock()
defer m.mu.Unlock()
m.closes++
return function.VOID
}
}
func (m *testMetrics) Reject(_ time.Time) IO[Void] {
return func() Void {
m.mu.Lock()
defer m.mu.Unlock()
m.rejects++
return function.VOID
}
}
func (m *testMetrics) Canary(_ time.Time) IO[Void] {
return func() Void {
m.mu.Lock()
defer m.mu.Unlock()
m.canary++
return function.VOID
}
}
// VirtualTimer provides a controllable time source for testing
type VirtualTimer struct {
mu sync.Mutex
current time.Time
}
func NewMockMetrics() Metrics {
return &testMetrics{}
}
// NewVirtualTimer creates a new virtual timer starting at the given time
func NewVirtualTimer(start time.Time) *VirtualTimer {
return &VirtualTimer{current: start}
}
// Now returns the current virtual time
func (vt *VirtualTimer) Now() time.Time {
vt.mu.Lock()
defer vt.mu.Unlock()
return vt.current
}
// Advance moves the virtual time forward by the given duration
func (vt *VirtualTimer) Advance(d time.Duration) {
vt.mu.Lock()
defer vt.mu.Unlock()
vt.current = vt.current.Add(d)
}
// Set sets the virtual time to a specific value
func (vt *VirtualTimer) Set(t time.Time) {
vt.mu.Lock()
defer vt.mu.Unlock()
vt.current = t
}
// TestModifyV tests the modifyV variable
func TestModifyV(t *testing.T) {
t.Run("modifyV creates a Reader that modifies IORef", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
// Create initial state
initialState := createClosedCircuit(MakeClosedStateCounter(3))
ref := io.Run(ioref.MakeIORef(initialState))
// Create an endomorphism that opens the circuit
now := vt.Now()
openState := openState{
openedAt: now,
resetAt: now.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
endomorphism := func(bs BreakerState) BreakerState {
return createOpenCircuit(openState)
}
// Apply modifyV
modifyOp := modifyV(ref)
result := io.Run(modifyOp(endomorphism))
// Verify the state was modified
assert.True(t, IsOpen(result), "state should be open after modification")
})
t.Run("modifyV returns the new state", func(t *testing.T) {
initialState := createClosedCircuit(MakeClosedStateCounter(3))
ref := io.Run(ioref.MakeIORef(initialState))
// Create a simple endomorphism
endomorphism := F.Identity[BreakerState]
modifyOp := modifyV(ref)
result := io.Run(modifyOp(endomorphism))
assert.True(t, IsClosed(result), "state should remain closed")
})
}
// TestTestCircuit tests the testCircuit variable
func TestTestCircuit(t *testing.T) {
t.Run("testCircuit sets canaryRequest to true", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
now := vt.Now()
openState := openState{
openedAt: now,
resetAt: now.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
result := testCircuit(openState)
assert.True(t, result.canaryRequest, "canaryRequest should be set to true")
assert.Equal(t, openState.openedAt, result.openedAt, "openedAt should be unchanged")
assert.Equal(t, openState.resetAt, result.resetAt, "resetAt should be unchanged")
})
t.Run("testCircuit is idempotent", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
now := vt.Now()
openState := openState{
openedAt: now,
resetAt: now.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: true, // already true
}
result := testCircuit(openState)
assert.True(t, result.canaryRequest, "canaryRequest should remain true")
})
t.Run("testCircuit preserves other fields", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
now := vt.Now()
resetTime := now.Add(2 * time.Minute)
retryStatus := retry.RetryStatus{
IterNumber: 5,
PreviousDelay: option.Some(30 * time.Second),
}
openState := openState{
openedAt: now,
resetAt: resetTime,
retryStatus: retryStatus,
canaryRequest: false,
}
result := testCircuit(openState)
assert.Equal(t, now, result.openedAt, "openedAt should be preserved")
assert.Equal(t, resetTime, result.resetAt, "resetAt should be preserved")
assert.Equal(t, retryStatus.IterNumber, result.retryStatus.IterNumber, "retryStatus should be preserved")
assert.True(t, result.canaryRequest, "canaryRequest should be set to true")
})
}
// TestMakeOpenCircuitFromPolicy tests the makeOpenCircuitFromPolicy function
func TestMakeOpenCircuitFromPolicy(t *testing.T) {
t.Run("creates openState with calculated reset time", func(t *testing.T) {
policy := retry.LimitRetries(5)
makeOpen := makeOpenCircuitFromPolicy(policy)
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
result := makeOpen(retry.DefaultRetryStatus)(currentTime)
assert.Equal(t, currentTime, result.openedAt, "openedAt should be current time")
assert.False(t, result.canaryRequest, "canaryRequest should be false initially")
assert.NotNil(t, result.retryStatus, "retryStatus should be set")
})
t.Run("applies retry policy to calculate delay", func(t *testing.T) {
// Use exponential backoff policy with limit and cap
policy := retry.Monoid.Concat(
retry.LimitRetries(10),
retry.CapDelay(10*time.Second, retry.ExponentialBackoff(1*time.Second)),
)
makeOpen := makeOpenCircuitFromPolicy(policy)
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
// First retry (iter 0)
result1 := makeOpen(retry.DefaultRetryStatus)(currentTime)
// The first delay should be approximately 1 second
expectedResetTime1 := currentTime.Add(1 * time.Second)
assert.WithinDuration(t, expectedResetTime1, result1.resetAt, 100*time.Millisecond,
"first reset time should be ~1 second from now")
// Second retry (iter 1) - should double
result2 := makeOpen(result1.retryStatus)(currentTime)
expectedResetTime2 := currentTime.Add(2 * time.Second)
assert.WithinDuration(t, expectedResetTime2, result2.resetAt, 100*time.Millisecond,
"second reset time should be ~2 seconds from now")
})
t.Run("handles first failure with no previous delay", func(t *testing.T) {
policy := retry.LimitRetries(3)
makeOpen := makeOpenCircuitFromPolicy(policy)
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
result := makeOpen(retry.DefaultRetryStatus)(currentTime)
// With no previous delay, resetAt should be current time
assert.Equal(t, currentTime, result.resetAt, "resetAt should be current time when no previous delay")
})
t.Run("increments retry iteration number", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
policy := retry.LimitRetries(10)
makeOpen := makeOpenCircuitFromPolicy(policy)
currentTime := vt.Now()
initialStatus := retry.DefaultRetryStatus
result := makeOpen(initialStatus)(currentTime)
assert.Greater(t, result.retryStatus.IterNumber, initialStatus.IterNumber,
"retry iteration should be incremented")
})
t.Run("curried function can be partially applied", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
policy := retry.LimitRetries(5)
makeOpen := makeOpenCircuitFromPolicy(policy)
// Partially apply with retry status
makeOpenWithStatus := makeOpen(retry.DefaultRetryStatus)
currentTime := vt.Now()
result := makeOpenWithStatus(currentTime)
assert.NotNil(t, result, "partially applied function should work")
assert.Equal(t, currentTime, result.openedAt)
})
}
// TestExtendOpenCircuitFromMakeCircuit tests the extendOpenCircuitFromMakeCircuit function
func TestExtendOpenCircuitFromMakeCircuit(t *testing.T) {
t.Run("extends open circuit with new retry status", func(t *testing.T) {
policy := retry.Monoid.Concat(
retry.LimitRetries(10),
retry.ExponentialBackoff(1*time.Second),
)
makeCircuit := makeOpenCircuitFromPolicy(policy)
extendCircuit := extendOpenCircuitFromMakeCircuit(makeCircuit)
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
// Create initial open state
initialOpen := openState{
openedAt: currentTime.Add(-1 * time.Minute),
resetAt: currentTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
// Extend the circuit
extendOp := extendCircuit(currentTime)
result := extendOp(initialOpen)
assert.True(t, result.canaryRequest, "canaryRequest should be set to true")
assert.Greater(t, result.retryStatus.IterNumber, initialOpen.retryStatus.IterNumber,
"retry iteration should be incremented")
assert.True(t, result.resetAt.After(currentTime), "resetAt should be in the future")
})
t.Run("sets canaryRequest to true for next test", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
policy := retry.LimitRetries(5)
makeCircuit := makeOpenCircuitFromPolicy(policy)
extendCircuit := extendOpenCircuitFromMakeCircuit(makeCircuit)
currentTime := vt.Now()
initialOpen := openState{
openedAt: currentTime.Add(-30 * time.Second),
resetAt: currentTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
result := extendCircuit(currentTime)(initialOpen)
assert.True(t, result.canaryRequest, "canaryRequest must be true after extension")
})
t.Run("applies exponential backoff on successive extensions", func(t *testing.T) {
policy := retry.Monoid.Concat(
retry.LimitRetries(10),
retry.ExponentialBackoff(1*time.Second),
)
makeCircuit := makeOpenCircuitFromPolicy(policy)
extendCircuit := extendOpenCircuitFromMakeCircuit(makeCircuit)
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
// First extension
state1 := openState{
openedAt: currentTime,
resetAt: currentTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
result1 := extendCircuit(currentTime)(state1)
delay1 := result1.resetAt.Sub(currentTime)
// Second extension (should have longer delay)
result2 := extendCircuit(currentTime)(result1)
delay2 := result2.resetAt.Sub(currentTime)
assert.Greater(t, delay2, delay1, "second extension should have longer delay due to exponential backoff")
})
}
// TestIsResetTimeExceeded tests the isResetTimeExceeded function
func TestIsResetTimeExceeded(t *testing.T) {
t.Run("returns Some when reset time is exceeded and no canary active", func(t *testing.T) {
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
resetTime := currentTime.Add(-1 * time.Second) // in the past
openState := openState{
openedAt: currentTime.Add(-1 * time.Minute),
resetAt: resetTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
result := isResetTimeExceeded(currentTime)(openState)
assert.True(t, option.IsSome(result), "should return Some when reset time exceeded")
})
t.Run("returns None when reset time not yet exceeded", func(t *testing.T) {
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
resetTime := currentTime.Add(1 * time.Minute) // in the future
openState := openState{
openedAt: currentTime.Add(-30 * time.Second),
resetAt: resetTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
result := isResetTimeExceeded(currentTime)(openState)
assert.True(t, option.IsNone(result), "should return None when reset time not exceeded")
})
t.Run("returns None when canary request is already active", func(t *testing.T) {
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
resetTime := currentTime.Add(-1 * time.Second) // in the past
openState := openState{
openedAt: currentTime.Add(-1 * time.Minute),
resetAt: resetTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: true, // canary already active
}
result := isResetTimeExceeded(currentTime)(openState)
assert.True(t, option.IsNone(result), "should return None when canary is already active")
})
t.Run("returns Some at exact reset time boundary", func(t *testing.T) {
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
resetTime := currentTime.Add(-1 * time.Nanosecond) // just passed
openState := openState{
openedAt: currentTime.Add(-1 * time.Minute),
resetAt: resetTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
result := isResetTimeExceeded(currentTime)(openState)
assert.True(t, option.IsSome(result), "should return Some when current time is after reset time")
})
t.Run("returns None when current time equals reset time", func(t *testing.T) {
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
resetTime := currentTime // exactly equal
openState := openState{
openedAt: currentTime.Add(-1 * time.Minute),
resetAt: resetTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
result := isResetTimeExceeded(currentTime)(openState)
assert.True(t, option.IsNone(result), "should return None when times are equal (not After)")
})
}
// TestHandleSuccessOnClosed tests the handleSuccessOnClosed function
func TestHandleSuccessOnClosed(t *testing.T) {
t.Run("updates closed state with success when circuit is closed", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now()
// Create a simple addSuccess reader that increments a counter
addSuccess := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddSuccess(ct)
}
}
// Create initial closed state
initialClosed := MakeClosedStateCounter(3)
initialState := createClosedCircuit(initialClosed)
// Apply handleSuccessOnClosed
handler := handleSuccessOnClosed(addSuccess)
endomorphism := handler(currentTime)
result := endomorphism(initialState)
// Verify the state is still closed
assert.True(t, IsClosed(result), "state should remain closed after success")
// Verify the closed state was updated
closedState := either.Fold(
func(openState) ClosedState { return initialClosed },
F.Identity[ClosedState],
)(result)
// The success should have been recorded (implementation-specific verification)
assert.NotNil(t, closedState, "closed state should be present")
})
t.Run("does not affect open state", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now()
addSuccess := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddSuccess(ct)
}
}
// Create initial open state
initialOpen := openState{
openedAt: currentTime.Add(-1 * time.Minute),
resetAt: currentTime.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
initialState := createOpenCircuit(initialOpen)
// Apply handleSuccessOnClosed
handler := handleSuccessOnClosed(addSuccess)
endomorphism := handler(currentTime)
result := endomorphism(initialState)
// Verify the state remains open and unchanged
assert.True(t, IsOpen(result), "state should remain open")
// Extract and verify the open state is unchanged
openResult := either.Fold(
func(os openState) openState { return os },
func(ClosedState) openState { return initialOpen },
)(result)
assert.Equal(t, initialOpen.openedAt, openResult.openedAt, "openedAt should be unchanged")
assert.Equal(t, initialOpen.resetAt, openResult.resetAt, "resetAt should be unchanged")
assert.Equal(t, initialOpen.canaryRequest, openResult.canaryRequest, "canaryRequest should be unchanged")
})
t.Run("preserves time parameter through reader", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
time1 := vt.Now()
vt.Advance(1 * time.Hour)
time2 := vt.Now()
var capturedTime time.Time
addSuccess := func(ct time.Time) Endomorphism[ClosedState] {
capturedTime = ct
return F.Identity[ClosedState]
}
initialClosed := MakeClosedStateCounter(3)
initialState := createClosedCircuit(initialClosed)
handler := handleSuccessOnClosed(addSuccess)
// Apply with time1
endomorphism1 := handler(time1)
endomorphism1(initialState)
assert.Equal(t, time1, capturedTime, "should pass time1 to addSuccess")
// Apply with time2
endomorphism2 := handler(time2)
endomorphism2(initialState)
assert.Equal(t, time2, capturedTime, "should pass time2 to addSuccess")
})
t.Run("composes correctly with multiple successes", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now()
addSuccess := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddSuccess(ct)
}
}
initialClosed := MakeClosedStateCounter(3)
initialState := createClosedCircuit(initialClosed)
handler := handleSuccessOnClosed(addSuccess)
endomorphism := handler(currentTime)
// Apply multiple times
result1 := endomorphism(initialState)
result2 := endomorphism(result1)
result3 := endomorphism(result2)
// All should remain closed
assert.True(t, IsClosed(result1), "state should remain closed after first success")
assert.True(t, IsClosed(result2), "state should remain closed after second success")
assert.True(t, IsClosed(result3), "state should remain closed after third success")
})
}
// TestHandleFailureOnClosed tests the handleFailureOnClosed function
func TestHandleFailureOnClosed(t *testing.T) {
t.Run("keeps circuit closed when threshold not exceeded", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now()
// Create a closed state that allows 3 errors
initialClosed := MakeClosedStateCounter(3)
// addError increments error count
addError := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddError(ct)
}
}
// checkClosedState returns Some if under threshold
checkClosedState := func(ct time.Time) option.Kleisli[ClosedState, ClosedState] {
return func(cs ClosedState) Option[ClosedState] {
return cs.Check(ct)
}
}
// openCircuit creates an open state (shouldn't be called in this test)
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
resetAt: ct.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
}
initialState := createClosedCircuit(initialClosed)
handler := handleFailureOnClosed(addError, checkClosedState, openCircuit)
endomorphism := handler(currentTime)
// First error - should stay closed
result1 := endomorphism(initialState)
assert.True(t, IsClosed(result1), "circuit should remain closed after first error")
// Second error - should stay closed
result2 := endomorphism(result1)
assert.True(t, IsClosed(result2), "circuit should remain closed after second error")
})
t.Run("opens circuit when threshold exceeded", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now()
// Create a closed state that allows only 2 errors (opens at 2nd error)
initialClosed := MakeClosedStateCounter(2)
addError := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddError(ct)
}
}
checkClosedState := func(ct time.Time) option.Kleisli[ClosedState, ClosedState] {
return func(cs ClosedState) Option[ClosedState] {
return cs.Check(ct)
}
}
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
resetAt: ct.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
}
initialState := createClosedCircuit(initialClosed)
handler := handleFailureOnClosed(addError, checkClosedState, openCircuit)
endomorphism := handler(currentTime)
// First error - should stay closed (count=1, threshold=2)
result1 := endomorphism(initialState)
assert.True(t, IsClosed(result1), "circuit should remain closed after first error")
// Second error - should open (count=2, threshold=2)
result2 := endomorphism(result1)
assert.True(t, IsOpen(result2), "circuit should open when threshold reached")
})
t.Run("creates open state with correct reset time", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now()
expectedResetTime := currentTime.Add(5 * time.Minute)
initialClosed := MakeClosedStateCounter(1) // Opens at 1st error
addError := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddError(ct)
}
}
checkClosedState := func(ct time.Time) option.Kleisli[ClosedState, ClosedState] {
return func(cs ClosedState) Option[ClosedState] {
return cs.Check(ct)
}
}
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
resetAt: expectedResetTime,
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
}
initialState := createClosedCircuit(initialClosed)
handler := handleFailureOnClosed(addError, checkClosedState, openCircuit)
endomorphism := handler(currentTime)
// First error - should open immediately (threshold=1)
result1 := endomorphism(initialState)
assert.True(t, IsOpen(result1), "circuit should open after first error")
// Verify the open state has correct reset time
resultOpen := either.Fold(
func(os openState) openState { return os },
func(ClosedState) openState { return openState{} },
)(result1)
assert.Equal(t, expectedResetTime, resultOpen.resetAt, "reset time should match expected")
assert.Equal(t, currentTime, resultOpen.openedAt, "opened time should be current time")
})
t.Run("edge case: zero error threshold", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now()
// Create a closed state that allows 0 errors (opens immediately)
initialClosed := MakeClosedStateCounter(0)
addError := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddError(ct)
}
}
checkClosedState := func(ct time.Time) option.Kleisli[ClosedState, ClosedState] {
return func(cs ClosedState) Option[ClosedState] {
return cs.Check(ct)
}
}
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
resetAt: ct.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
}
initialState := createClosedCircuit(initialClosed)
handler := handleFailureOnClosed(addError, checkClosedState, openCircuit)
endomorphism := handler(currentTime)
// First error should immediately open the circuit
result := endomorphism(initialState)
assert.True(t, IsOpen(result), "circuit should open immediately with zero threshold")
})
t.Run("edge case: very high error threshold", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now()
// Create a closed state that allows 1000 errors
initialClosed := MakeClosedStateCounter(1000)
addError := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddError(ct)
}
}
checkClosedState := func(ct time.Time) option.Kleisli[ClosedState, ClosedState] {
return func(cs ClosedState) Option[ClosedState] {
return cs.Check(ct)
}
}
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
resetAt: ct.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
}
initialState := createClosedCircuit(initialClosed)
handler := handleFailureOnClosed(addError, checkClosedState, openCircuit)
endomorphism := handler(currentTime)
// Apply many errors
result := initialState
for i := 0; i < 100; i++ {
result = endomorphism(result)
}
// Should still be closed after 100 errors
assert.True(t, IsClosed(result), "circuit should remain closed with high threshold")
})
t.Run("preserves time parameter through reader chain", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
time1 := vt.Now()
vt.Advance(2 * time.Hour)
time2 := vt.Now()
var capturedAddErrorTime, capturedCheckTime, capturedOpenTime time.Time
initialClosed := MakeClosedStateCounter(2) // Need 2 errors to open
addError := func(ct time.Time) Endomorphism[ClosedState] {
capturedAddErrorTime = ct
return func(cs ClosedState) ClosedState {
return cs.AddError(ct)
}
}
checkClosedState := func(ct time.Time) option.Kleisli[ClosedState, ClosedState] {
capturedCheckTime = ct
return func(cs ClosedState) Option[ClosedState] {
return cs.Check(ct)
}
}
openCircuit := func(ct time.Time) openState {
capturedOpenTime = ct
return openState{
openedAt: ct,
resetAt: ct.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
}
initialState := createClosedCircuit(initialClosed)
handler := handleFailureOnClosed(addError, checkClosedState, openCircuit)
// Apply with time1 - first error, stays closed
endomorphism1 := handler(time1)
result1 := endomorphism1(initialState)
assert.Equal(t, time1, capturedAddErrorTime, "addError should receive time1")
assert.Equal(t, time1, capturedCheckTime, "checkClosedState should receive time1")
// Apply with time2 - second error, should trigger open
endomorphism2 := handler(time2)
endomorphism2(result1)
assert.Equal(t, time2, capturedAddErrorTime, "addError should receive time2")
assert.Equal(t, time2, capturedCheckTime, "checkClosedState should receive time2")
assert.Equal(t, time2, capturedOpenTime, "openCircuit should receive time2")
})
t.Run("handles transition from closed to open correctly", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now()
initialClosed := MakeClosedStateCounter(2) // Opens at 2nd error
addError := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddError(ct)
}
}
checkClosedState := func(ct time.Time) option.Kleisli[ClosedState, ClosedState] {
return func(cs ClosedState) Option[ClosedState] {
return cs.Check(ct)
}
}
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
resetAt: ct.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
}
handler := handleFailureOnClosed(addError, checkClosedState, openCircuit)
endomorphism := handler(currentTime)
// Start with closed state
state := createClosedCircuit(initialClosed)
assert.True(t, IsClosed(state), "initial state should be closed")
// First error - should stay closed (count=1, threshold=2)
state = endomorphism(state)
assert.True(t, IsClosed(state), "should remain closed after first error")
// Second error - should open (count=2, threshold=2)
state = endomorphism(state)
assert.True(t, IsOpen(state), "should open after second error")
// Verify it's truly open with correct properties
resultOpen := either.Fold(
func(os openState) openState { return os },
func(ClosedState) openState { return openState{} },
)(state)
assert.False(t, resultOpen.canaryRequest, "canaryRequest should be false initially")
assert.Equal(t, currentTime, resultOpen.openedAt, "openedAt should be current time")
})
t.Run("does not affect already open state", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
currentTime := vt.Now()
addError := func(ct time.Time) Endomorphism[ClosedState] {
return func(cs ClosedState) ClosedState {
return cs.AddError(ct)
}
}
checkClosedState := func(ct time.Time) option.Kleisli[ClosedState, ClosedState] {
return func(cs ClosedState) Option[ClosedState] {
return cs.Check(ct)
}
}
openCircuit := func(ct time.Time) openState {
return openState{
openedAt: ct,
resetAt: ct.Add(1 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: false,
}
}
// Start with an already open state
existingOpen := openState{
openedAt: currentTime.Add(-5 * time.Minute),
resetAt: currentTime.Add(5 * time.Minute),
retryStatus: retry.DefaultRetryStatus,
canaryRequest: true,
}
initialState := createOpenCircuit(existingOpen)
handler := handleFailureOnClosed(addError, checkClosedState, openCircuit)
endomorphism := handler(currentTime)
// Apply to open state - should not change it
result := endomorphism(initialState)
assert.True(t, IsOpen(result), "state should remain open")
// The open state should be unchanged since handleFailureOnClosed
// only operates on the Right (closed) side of the Either
openResult := either.Fold(
func(os openState) openState { return os },
func(ClosedState) openState { return openState{} },
)(result)
assert.Equal(t, existingOpen.openedAt, openResult.openedAt, "openedAt should be unchanged")
assert.Equal(t, existingOpen.resetAt, openResult.resetAt, "resetAt should be unchanged")
assert.Equal(t, existingOpen.canaryRequest, openResult.canaryRequest, "canaryRequest should be unchanged")
})
}

329
v2/circuitbreaker/closed.go Normal file
View File

@@ -0,0 +1,329 @@
package circuitbreaker
import (
"slices"
"time"
A "github.com/IBM/fp-go/v2/array"
F "github.com/IBM/fp-go/v2/function"
N "github.com/IBM/fp-go/v2/number"
"github.com/IBM/fp-go/v2/optics/lens"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/ord"
)
type (
// ClosedState represents the closed state of a circuit breaker.
// In the closed state, requests are allowed to pass through, but failures are tracked.
// If a failure condition is met, the circuit breaker transitions to an open state.
//
// # Thread Safety
//
// All ClosedState implementations MUST be thread-safe. The recommended approach is to
// make all methods return new copies rather than modifying the receiver, which provides
// automatic thread safety through immutability.
//
// Implementations should ensure that:
// - Empty() returns a new instance with cleared state
// - AddError() returns a new instance with the error recorded
// - AddSuccess() returns a new instance with success recorded
// - Check() does not modify the receiver
//
// Both provided implementations (closedStateWithErrorCount and closedStateWithHistory)
// follow this pattern and are safe for concurrent use.
ClosedState interface {
// Empty returns a new ClosedState with all tracked failures cleared.
// This is used when transitioning back to a closed state from an open state.
//
// Thread Safety: Returns a new instance; safe for concurrent use.
Empty() ClosedState
// AddError records a failure at the given time.
// Returns an updated ClosedState reflecting the recorded failure.
//
// Thread Safety: Returns a new instance; safe for concurrent use.
// The original ClosedState is not modified.
AddError(time.Time) ClosedState
// AddSuccess records a successful request at the given time.
// Returns an updated ClosedState reflecting the successful request.
//
// Thread Safety: Returns a new instance; safe for concurrent use.
// The original ClosedState is not modified.
AddSuccess(time.Time) ClosedState
// Check verifies if the circuit breaker should remain closed at the given time.
// Returns Some(ClosedState) if the circuit should stay closed,
// or None if the circuit should open due to exceeding the failure threshold.
//
// Thread Safety: Does not modify the receiver; safe for concurrent use.
Check(time.Time) Option[ClosedState]
}
// closedStateWithErrorCount is a counter-based implementation of ClosedState.
// It tracks the number of consecutive failures and opens the circuit when
// the failure count exceeds a configured threshold.
//
// Thread Safety: This implementation is immutable. All methods return new instances
// rather than modifying the receiver, making it safe for concurrent use without locks.
closedStateWithErrorCount struct {
// checkFailures is a Kleisli arrow that checks if the failure count exceeds the threshold.
// Returns Some(count) if threshold is exceeded, None otherwise.
checkFailures option.Kleisli[uint, uint]
// failureCount tracks the current number of consecutive failures.
failureCount uint
}
// closedStateWithHistory is a time-window-based implementation of ClosedState.
// It tracks failures within a sliding time window and opens the circuit when
// the failure count within the window exceeds a configured threshold.
//
// Thread Safety: This implementation is immutable. All methods return new instances
// with new slices rather than modifying the receiver, making it safe for concurrent
// use without locks. The history slice is never modified in place; addToSlice always
// creates a new slice.
closedStateWithHistory struct {
ordTime Ord[time.Time]
// maxFailures is the maximum number of failures allowed within the time window.
checkFailures option.Kleisli[int, int]
timeWindow time.Duration
history []time.Time
}
)
var (
failureCountLens = lens.MakeLensStrictWithName(
func(s *closedStateWithErrorCount) uint { return s.failureCount },
func(s *closedStateWithErrorCount, c uint) *closedStateWithErrorCount {
s.failureCount = c
return s
},
"closeStateWithErrorCount.failureCount",
)
historyLens = lens.MakeLensRefWithName(
func(s *closedStateWithHistory) []time.Time { return s.history },
func(s *closedStateWithHistory, c []time.Time) *closedStateWithHistory {
s.history = c
return s
},
"closedStateWithHistory.history",
)
resetHistory = historyLens.Set(A.Empty[time.Time]())
resetFailureCount = failureCountLens.Set(0)
incFailureCount = lens.Modify[*closedStateWithErrorCount](N.Add(uint(1)))(failureCountLens)
)
// Empty returns a new closedStateWithErrorCount with the failure count reset to zero.
//
// Thread Safety: Returns a new instance; the original is not modified.
// Safe for concurrent use.
func (s *closedStateWithErrorCount) Empty() ClosedState {
return resetFailureCount(s)
}
// AddError increments the failure count and returns a new closedStateWithErrorCount.
// The time parameter is ignored in this counter-based implementation.
//
// Thread Safety: Returns a new instance; the original is not modified.
// Safe for concurrent use.
func (s *closedStateWithErrorCount) AddError(_ time.Time) ClosedState {
return incFailureCount(s)
}
// AddSuccess resets the failure count to zero and returns a new closedStateWithErrorCount.
// The time parameter is ignored in this counter-based implementation.
//
// Thread Safety: Returns a new instance; the original is not modified.
// Safe for concurrent use.
func (s *closedStateWithErrorCount) AddSuccess(_ time.Time) ClosedState {
return resetFailureCount(s)
}
// Check verifies if the failure count is below the threshold.
// Returns Some(ClosedState) if below threshold, None if at or above threshold.
// The time parameter is ignored in this counter-based implementation.
//
// Thread Safety: Does not modify the receiver; safe for concurrent use.
func (s *closedStateWithErrorCount) Check(_ time.Time) Option[ClosedState] {
return F.Pipe3(
s,
failureCountLens.Get,
s.checkFailures,
option.MapTo[uint](ClosedState(s)),
)
}
// MakeClosedStateCounter creates a counter-based ClosedState implementation.
// The circuit breaker will open when the number of consecutive failures reaches maxFailures.
//
// Parameters:
// - maxFailures: The threshold for consecutive failures. The circuit opens when
// failureCount >= maxFailures (greater than or equal to).
//
// Returns:
// - A ClosedState that tracks failures using a simple counter.
//
// Example:
// - If maxFailures is 3, the circuit will open on the 3rd consecutive failure.
// - Each AddError call increments the counter.
// - Each AddSuccess call resets the counter to 0 (only consecutive failures count).
// - Empty resets the counter to 0.
//
// Behavior:
// - Check returns Some(ClosedState) when failureCount < maxFailures (circuit stays closed)
// - Check returns None when failureCount >= maxFailures (circuit should open)
// - AddSuccess resets the failure count, so only consecutive failures trigger circuit opening
//
// Thread Safety: The returned ClosedState is safe for concurrent use. All methods
// return new instances rather than modifying the receiver.
func MakeClosedStateCounter(maxFailures uint) ClosedState {
return &closedStateWithErrorCount{
checkFailures: option.FromPredicate(N.LessThan(maxFailures)),
}
}
// Empty returns a new closedStateWithHistory with an empty failure history.
//
// Thread Safety: Returns a new instance with a new empty slice; the original is not modified.
// Safe for concurrent use.
func (s *closedStateWithHistory) Empty() ClosedState {
return resetHistory(s)
}
// addToSlice creates a new sorted slice by adding an item to an existing slice.
// This function does not modify the input slice; it creates a new slice with the item added
// and returns it in sorted order.
//
// Parameters:
// - o: An Ord instance for comparing time.Time values to determine sort order
// - ar: The existing slice of time.Time values (assumed to be sorted)
// - item: The new time.Time value to add to the slice
//
// Returns:
// - A new slice containing all elements from ar plus the new item, sorted in ascending order
//
// Implementation Details:
// - Creates a new slice with capacity len(ar)+1
// - Copies all elements from ar to the new slice
// - Appends the new item
// - Sorts the entire slice using the provided Ord comparator
//
// Thread Safety: This function is pure and does not modify its inputs. It always returns
// a new slice, making it safe for concurrent use. This is a key component of the immutable
// design of closedStateWithHistory.
//
// Note: This function is used internally by closedStateWithHistory.AddError to maintain
// a sorted history of failure timestamps for efficient binary search operations.
func addToSlice(o ord.Ord[time.Time], ar []time.Time, item time.Time) []time.Time {
cpy := make([]time.Time, len(ar)+1)
cpy[copy(cpy, ar)] = item
slices.SortFunc(cpy, o.Compare)
return cpy
}
// AddError records a failure at the given time and returns a new closedStateWithHistory.
// The new instance contains the failure in its history, with old failures outside the
// time window automatically pruned.
//
// Thread Safety: Returns a new instance with a new history slice; the original is not modified.
// Safe for concurrent use. The addToSlice function creates a new slice, ensuring immutability.
func (s *closedStateWithHistory) AddError(currentTime time.Time) ClosedState {
addFailureToHistory := F.Pipe1(
historyLens,
lens.Modify[*closedStateWithHistory](func(old []time.Time) []time.Time {
// oldest valid entry
idx, _ := slices.BinarySearchFunc(old, currentTime.Add(-s.timeWindow), s.ordTime.Compare)
return addToSlice(s.ordTime, old[idx:], currentTime)
}),
)
return addFailureToHistory(s)
}
// AddSuccess purges the entire failure history and returns a new closedStateWithHistory.
// The time parameter is ignored; any success clears all tracked failures.
//
// Thread Safety: Returns a new instance with a new empty slice; the original is not modified.
// Safe for concurrent use.
func (s *closedStateWithHistory) AddSuccess(_ time.Time) ClosedState {
return resetHistory(s)
}
// Check verifies if the number of failures in the history is below the threshold.
// Returns Some(ClosedState) if below threshold, None if at or above threshold.
// The time parameter is ignored; the check is based on the current history size.
//
// Thread Safety: Does not modify the receiver; safe for concurrent use.
func (s *closedStateWithHistory) Check(_ time.Time) Option[ClosedState] {
return F.Pipe4(
s,
historyLens.Get,
A.Size,
s.checkFailures,
option.MapTo[int](ClosedState(s)),
)
}
// MakeClosedStateHistory creates a time-window-based ClosedState implementation.
// The circuit breaker will open when the number of failures within a sliding time window reaches maxFailures.
//
// Unlike MakeClosedStateCounter which tracks consecutive failures, this implementation tracks
// all failures within a time window. However, any successful request will purge the entire history,
// effectively resetting the failure tracking.
//
// Parameters:
// - timeWindow: The duration of the sliding time window. Failures older than this are automatically
// discarded from the history when new failures are added.
// - maxFailures: The threshold for failures within the time window. The circuit opens when
// the number of failures in the window reaches this value (failureCount >= maxFailures).
//
// Returns:
// - A ClosedState that tracks failures using a time-based sliding window.
//
// Example:
// - If timeWindow is 1 minute and maxFailures is 5, the circuit will open when 5 failures
// occur within any 1-minute period.
// - Failures older than 1 minute are automatically removed from the history when AddError is called.
// - Any successful request immediately purges all tracked failures from the history.
//
// Behavior:
// - AddError records the failure timestamp and removes failures outside the time window
// (older than currentTime - timeWindow).
// - AddSuccess purges the entire failure history (all tracked failures are removed).
// - Check returns Some(ClosedState) when failureCount < maxFailures (circuit stays closed).
// - Check returns None when failureCount >= maxFailures (circuit should open).
// - Empty purges the entire failure history.
//
// Time Window Management:
// - The history is automatically pruned on each AddError call to remove failures older than
// currentTime - timeWindow.
// - The history is kept sorted by time for efficient binary search and pruning.
//
// Important Note:
// - A successful request resets everything by purging the entire history. This means that
// unlike a pure sliding window, a single success will clear all tracked failures, even
// those within the time window. This behavior is similar to MakeClosedStateCounter but
// with time-based tracking for failures.
//
// Thread Safety: The returned ClosedState is safe for concurrent use. All methods return
// new instances with new slices rather than modifying the receiver. The history slice is
// never modified in place.
//
// Use Cases:
// - Systems where a successful request indicates recovery and past failures should be forgotten.
// - Rate limiting with success-based reset: Allow bursts of failures but reset on success.
// - Hybrid approach: Time-based failure tracking with success-based recovery.
func MakeClosedStateHistory(
timeWindow time.Duration,
maxFailures uint) ClosedState {
return &closedStateWithHistory{
checkFailures: option.FromPredicate(N.LessThan(int(maxFailures))),
ordTime: ord.OrdTime(),
history: A.Empty[time.Time](),
timeWindow: timeWindow,
}
}

View File

@@ -0,0 +1,934 @@
package circuitbreaker
import (
"testing"
"time"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/ord"
"github.com/stretchr/testify/assert"
)
func TestMakeClosedStateCounter(t *testing.T) {
t.Run("creates a valid ClosedState", func(t *testing.T) {
maxFailures := uint(3)
state := MakeClosedStateCounter(maxFailures)
assert.NotNil(t, state, "MakeClosedStateCounter should return a non-nil ClosedState")
})
t.Run("initial state passes Check", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(3)
state := MakeClosedStateCounter(maxFailures)
now := vt.Now()
result := state.Check(now)
assert.True(t, option.IsSome(result), "initial state should pass Check (return Some, circuit stays closed)")
})
t.Run("Empty resets failure count", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(2)
state := MakeClosedStateCounter(maxFailures)
now := vt.Now()
// Add some errors
state = state.AddError(now)
state = state.AddError(now)
// Reset the state
state = state.Empty()
// Should pass check after reset
result := state.Check(now)
assert.True(t, option.IsSome(result), "state should pass Check after Empty")
})
t.Run("AddSuccess resets failure count", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(3)
state := MakeClosedStateCounter(maxFailures)
// Add errors
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
// Add success (should reset counter)
state = state.AddSuccess(vt.Now())
vt.Advance(1 * time.Second)
// Add another error (this is now the first consecutive error)
state = state.AddError(vt.Now())
// Should still pass check (only 1 consecutive error, threshold is 3)
result := state.Check(vt.Now())
assert.True(t, option.IsSome(result), "AddSuccess should reset failure count")
})
t.Run("circuit opens when failures reach threshold", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(3)
state := MakeClosedStateCounter(maxFailures)
now := vt.Now()
// Add errors up to but not including threshold
state = state.AddError(now)
state = state.AddError(now)
// Should still pass before threshold
result := state.Check(now)
assert.True(t, option.IsSome(result), "should pass Check before threshold")
// Add one more error to reach threshold
state = state.AddError(now)
// Should fail check at threshold
result = state.Check(now)
assert.True(t, option.IsNone(result), "should fail Check when reaching threshold")
})
t.Run("circuit opens exactly at maxFailures", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(5)
state := MakeClosedStateCounter(maxFailures)
now := vt.Now()
// Add exactly maxFailures - 1 errors
for i := uint(0); i < maxFailures-1; i++ {
state = state.AddError(now)
}
// Should still pass
result := state.Check(now)
assert.True(t, option.IsSome(result), "should pass Check before maxFailures")
// Add one more to reach maxFailures
state = state.AddError(now)
// Should fail now
result = state.Check(now)
assert.True(t, option.IsNone(result), "should fail Check at maxFailures")
})
t.Run("zero maxFailures means circuit is always open", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(0)
state := MakeClosedStateCounter(maxFailures)
now := vt.Now()
// Initial state should already fail (0 >= 0)
result := state.Check(now)
assert.True(t, option.IsNone(result), "initial state should fail Check with maxFailures=0")
// Add one error
state = state.AddError(now)
// Should still fail
result = state.Check(now)
assert.True(t, option.IsNone(result), "should fail Check after error with maxFailures=0")
})
t.Run("AddSuccess resets counter between errors", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(3)
state := MakeClosedStateCounter(maxFailures)
// Add errors
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
// Add success (resets counter)
state = state.AddSuccess(vt.Now())
vt.Advance(1 * time.Second)
// Add more errors
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
state = state.AddError(vt.Now())
// Should still pass (only 2 consecutive errors after reset)
result := state.Check(vt.Now())
assert.True(t, option.IsSome(result), "should pass with 2 consecutive errors after reset")
// Add one more to reach threshold
vt.Advance(1 * time.Second)
state = state.AddError(vt.Now())
// Should fail at threshold
result = state.Check(vt.Now())
assert.True(t, option.IsNone(result), "should fail after reaching threshold")
})
t.Run("Empty can be called multiple times", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(2)
state := MakeClosedStateCounter(maxFailures)
now := vt.Now()
// Add errors
state = state.AddError(now)
state = state.AddError(now)
state = state.AddError(now)
// Reset multiple times
state = state.Empty()
state = state.Empty()
state = state.Empty()
// Should still pass
result := state.Check(now)
assert.True(t, option.IsSome(result), "state should pass Check after multiple Empty calls")
})
t.Run("time parameter is ignored in counter implementation", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(3)
state := MakeClosedStateCounter(maxFailures)
// Use different times for each operation
time1 := vt.Now()
time2 := time1.Add(1 * time.Hour)
state = state.AddError(time1)
state = state.AddError(time2)
// Check with yet another time
time3 := time1.Add(2 * time.Hour)
result := state.Check(time3)
// Should still pass (2 errors, threshold is 3, not reached yet)
assert.True(t, option.IsSome(result), "time parameter should not affect counter behavior")
// Add one more to reach threshold
state = state.AddError(time1)
result = state.Check(time1)
assert.True(t, option.IsNone(result), "should fail after reaching threshold regardless of time")
})
t.Run("large maxFailures value", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(1000)
state := MakeClosedStateCounter(maxFailures)
now := vt.Now()
// Add many errors but not reaching threshold
for i := uint(0); i < maxFailures-1; i++ {
state = state.AddError(now)
}
// Should still pass
result := state.Check(now)
assert.True(t, option.IsSome(result), "should pass Check with large maxFailures before threshold")
// Add one more to reach threshold
state = state.AddError(now)
// Should fail
result = state.Check(now)
assert.True(t, option.IsNone(result), "should fail Check with large maxFailures at threshold")
})
t.Run("state is immutable - original unchanged after AddError", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(2)
originalState := MakeClosedStateCounter(maxFailures)
now := vt.Now()
// Create new state by adding error
newState := originalState.AddError(now)
// Original should still pass check
result := originalState.Check(now)
assert.True(t, option.IsSome(result), "original state should be unchanged")
// New state should reach threshold (2 errors total, threshold is 2)
newState = newState.AddError(now)
result = newState.Check(now)
assert.True(t, option.IsNone(result), "new state should fail after reaching threshold")
// Original should still pass
result = originalState.Check(now)
assert.True(t, option.IsSome(result), "original state should still be unchanged")
})
t.Run("state is immutable - original unchanged after Empty", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(2)
state := MakeClosedStateCounter(maxFailures)
now := vt.Now()
// Add errors to original
state = state.AddError(now)
state = state.AddError(now)
stateWithErrors := state
// Create new state by calling Empty
emptyState := stateWithErrors.Empty()
// Original with errors should reach threshold (2 errors total, threshold is 2)
result := stateWithErrors.Check(now)
assert.True(t, option.IsNone(result), "state with errors should fail after reaching threshold")
// Empty state should pass
result = emptyState.Check(now)
assert.True(t, option.IsSome(result), "empty state should pass Check")
})
t.Run("AddSuccess prevents circuit from opening", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(3)
state := MakeClosedStateCounter(maxFailures)
// Add errors close to threshold
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
// Add success before reaching threshold
state = state.AddSuccess(vt.Now())
vt.Advance(1 * time.Second)
// Add more errors
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
state = state.AddError(vt.Now())
// Should still pass (only 2 consecutive errors)
result := state.Check(vt.Now())
assert.True(t, option.IsSome(result), "circuit should stay closed after success reset")
})
t.Run("multiple AddSuccess calls keep counter at zero", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(2)
state := MakeClosedStateCounter(maxFailures)
// Add error
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
// Multiple successes
state = state.AddSuccess(vt.Now())
vt.Advance(1 * time.Second)
state = state.AddSuccess(vt.Now())
vt.Advance(1 * time.Second)
state = state.AddSuccess(vt.Now())
vt.Advance(1 * time.Second)
// Should still pass
result := state.Check(vt.Now())
assert.True(t, option.IsSome(result), "multiple AddSuccess should keep counter at zero")
// Add errors to reach threshold
state = state.AddError(vt.Now())
vt.Advance(1 * time.Second)
state = state.AddError(vt.Now())
// Should fail
result = state.Check(vt.Now())
assert.True(t, option.IsNone(result), "should fail after reaching threshold")
})
t.Run("alternating errors and successes never opens circuit", func(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
maxFailures := uint(3)
state := MakeClosedStateCounter(maxFailures)
// Alternate errors and successes
for i := 0; i < 10; i++ {
state = state.AddError(vt.Now())
vt.Advance(500 * time.Millisecond)
state = state.AddSuccess(vt.Now())
vt.Advance(500 * time.Millisecond)
}
// Should still pass (never had consecutive failures)
result := state.Check(vt.Now())
assert.True(t, option.IsSome(result), "alternating errors and successes should never open circuit")
})
}
func TestAddToSlice(t *testing.T) {
ordTime := ord.OrdTime()
t.Run("adds item to empty slice and returns sorted result", func(t *testing.T) {
input := []time.Time{}
item := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
result := addToSlice(ordTime, input, item)
assert.Len(t, result, 1, "result should have 1 element")
assert.Equal(t, item, result[0], "result should contain the added item")
})
t.Run("adds item and maintains sorted order", func(t *testing.T) {
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
input := []time.Time{
baseTime,
baseTime.Add(20 * time.Second),
baseTime.Add(40 * time.Second),
}
item := baseTime.Add(30 * time.Second)
result := addToSlice(ordTime, input, item)
assert.Len(t, result, 4, "result should have 4 elements")
// Verify sorted order
assert.Equal(t, baseTime, result[0])
assert.Equal(t, baseTime.Add(20*time.Second), result[1])
assert.Equal(t, baseTime.Add(30*time.Second), result[2])
assert.Equal(t, baseTime.Add(40*time.Second), result[3])
})
t.Run("adds item at beginning when it's earliest", func(t *testing.T) {
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
input := []time.Time{
baseTime.Add(20 * time.Second),
baseTime.Add(40 * time.Second),
}
item := baseTime
result := addToSlice(ordTime, input, item)
assert.Len(t, result, 3, "result should have 3 elements")
assert.Equal(t, baseTime, result[0], "earliest item should be first")
assert.Equal(t, baseTime.Add(20*time.Second), result[1])
assert.Equal(t, baseTime.Add(40*time.Second), result[2])
})
t.Run("adds item at end when it's latest", func(t *testing.T) {
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
input := []time.Time{
baseTime,
baseTime.Add(20 * time.Second),
}
item := baseTime.Add(40 * time.Second)
result := addToSlice(ordTime, input, item)
assert.Len(t, result, 3, "result should have 3 elements")
assert.Equal(t, baseTime, result[0])
assert.Equal(t, baseTime.Add(20*time.Second), result[1])
assert.Equal(t, baseTime.Add(40*time.Second), result[2], "latest item should be last")
})
t.Run("does not modify original slice", func(t *testing.T) {
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
input := []time.Time{
baseTime,
baseTime.Add(20 * time.Second),
}
originalLen := len(input)
originalFirst := input[0]
originalLast := input[1]
item := baseTime.Add(10 * time.Second)
result := addToSlice(ordTime, input, item)
// Verify original slice is unchanged
assert.Len(t, input, originalLen, "original slice length should be unchanged")
assert.Equal(t, originalFirst, input[0], "original slice first element should be unchanged")
assert.Equal(t, originalLast, input[1], "original slice last element should be unchanged")
// Verify result is different and has correct length
assert.Len(t, result, 3, "result should have new length")
// Verify the result contains the new item in sorted order
assert.Equal(t, baseTime, result[0])
assert.Equal(t, baseTime.Add(10*time.Second), result[1])
assert.Equal(t, baseTime.Add(20*time.Second), result[2])
})
t.Run("handles duplicate timestamps", func(t *testing.T) {
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
input := []time.Time{
baseTime,
baseTime.Add(20 * time.Second),
}
item := baseTime // duplicate of first element
result := addToSlice(ordTime, input, item)
assert.Len(t, result, 3, "result should have 3 elements including duplicate")
// Both instances of baseTime should be present
count := 0
for _, t := range result {
if t.Equal(baseTime) {
count++
}
}
assert.Equal(t, 2, count, "should have 2 instances of the duplicate timestamp")
})
t.Run("maintains sort order with unsorted input", func(t *testing.T) {
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Input is intentionally unsorted
input := []time.Time{
baseTime.Add(40 * time.Second),
baseTime,
baseTime.Add(20 * time.Second),
}
item := baseTime.Add(30 * time.Second)
result := addToSlice(ordTime, input, item)
assert.Len(t, result, 4, "result should have 4 elements")
// Verify result is sorted regardless of input order
for i := 0; i < len(result)-1; i++ {
assert.True(t, result[i].Before(result[i+1]) || result[i].Equal(result[i+1]),
"result should be sorted: element %d (%v) should be <= element %d (%v)",
i, result[i], i+1, result[i+1])
}
})
t.Run("works with nanosecond precision", func(t *testing.T) {
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
input := []time.Time{
baseTime,
baseTime.Add(2 * time.Nanosecond),
}
item := baseTime.Add(1 * time.Nanosecond)
result := addToSlice(ordTime, input, item)
assert.Len(t, result, 3, "result should have 3 elements")
assert.Equal(t, baseTime, result[0])
assert.Equal(t, baseTime.Add(1*time.Nanosecond), result[1])
assert.Equal(t, baseTime.Add(2*time.Nanosecond), result[2])
})
}
func TestMakeClosedStateHistory(t *testing.T) {
t.Run("creates a valid ClosedState", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
assert.NotNil(t, state, "MakeClosedStateHistory should return a non-nil ClosedState")
})
t.Run("initial state passes Check", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
result := state.Check(now)
assert.True(t, option.IsSome(result), "initial state should pass Check (return Some, circuit stays closed)")
})
t.Run("Empty purges failure history", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(2)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add some errors
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
// Reset the state
state = state.Empty()
// Should pass check after reset
result := state.Check(baseTime.Add(20 * time.Second))
assert.True(t, option.IsSome(result), "state should pass Check after Empty")
})
t.Run("AddSuccess purges entire failure history", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
// Add success (should purge all history)
state = state.AddSuccess(baseTime.Add(20 * time.Second))
// Add another error (this is now the first error in history)
state = state.AddError(baseTime.Add(30 * time.Second))
// Should still pass check (only 1 error in history, threshold is 3)
result := state.Check(baseTime.Add(30 * time.Second))
assert.True(t, option.IsSome(result), "AddSuccess should purge entire failure history")
})
t.Run("circuit opens when failures reach threshold within time window", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors within time window but not reaching threshold
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
// Should still pass before threshold
result := state.Check(baseTime.Add(20 * time.Second))
assert.True(t, option.IsSome(result), "should pass Check before threshold")
// Add one more error to reach threshold
state = state.AddError(baseTime.Add(30 * time.Second))
// Should fail check at threshold
result = state.Check(baseTime.Add(30 * time.Second))
assert.True(t, option.IsNone(result), "should fail Check when reaching threshold")
})
t.Run("old failures outside time window are automatically removed", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors that will be outside the time window
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
// Add error after time window has passed (this should remove old errors)
state = state.AddError(baseTime.Add(2 * time.Minute))
// Should pass check (only 1 error in window, old ones removed)
result := state.Check(baseTime.Add(2 * time.Minute))
assert.True(t, option.IsSome(result), "old failures should be removed from history")
})
t.Run("failures within time window are retained", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors within time window
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(30 * time.Second))
state = state.AddError(baseTime.Add(50 * time.Second))
// All errors are within 1 minute window, should fail check
result := state.Check(baseTime.Add(50 * time.Second))
assert.True(t, option.IsNone(result), "failures within time window should be retained")
})
t.Run("sliding window behavior - errors slide out over time", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add 3 errors to reach threshold
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
state = state.AddError(baseTime.Add(20 * time.Second))
// Circuit should be open
result := state.Check(baseTime.Add(20 * time.Second))
assert.True(t, option.IsNone(result), "circuit should be open with 3 failures")
// Add error after first failure has expired (> 1 minute from first error)
// This should remove the first error, leaving only 3 in window
state = state.AddError(baseTime.Add(70 * time.Second))
// Should still fail check (3 errors in window after pruning)
result = state.Check(baseTime.Add(70 * time.Second))
assert.True(t, option.IsNone(result), "circuit should remain open with 3 failures in window")
})
t.Run("zero maxFailures means circuit is always open", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(0)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Initial state should already fail (0 >= 0)
result := state.Check(baseTime)
assert.True(t, option.IsNone(result), "initial state should fail Check with maxFailures=0")
// Add one error
state = state.AddError(baseTime)
// Should still fail
result = state.Check(baseTime)
assert.True(t, option.IsNone(result), "should fail Check after error with maxFailures=0")
})
t.Run("success purges history even with failures in time window", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors within time window
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
// Add success (purges all history)
state = state.AddSuccess(baseTime.Add(20 * time.Second))
// Add more errors
state = state.AddError(baseTime.Add(30 * time.Second))
state = state.AddError(baseTime.Add(40 * time.Second))
// Should still pass (only 2 errors after purge)
result := state.Check(baseTime.Add(40 * time.Second))
assert.True(t, option.IsSome(result), "success should purge all history")
// Add one more to reach threshold
state = state.AddError(baseTime.Add(50 * time.Second))
// Should fail at threshold
result = state.Check(baseTime.Add(50 * time.Second))
assert.True(t, option.IsNone(result), "should fail after reaching threshold")
})
t.Run("multiple successes keep history empty", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(2)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add error
state = state.AddError(baseTime)
// Multiple successes
state = state.AddSuccess(baseTime.Add(10 * time.Second))
state = state.AddSuccess(baseTime.Add(20 * time.Second))
state = state.AddSuccess(baseTime.Add(30 * time.Second))
// Should still pass
result := state.Check(baseTime.Add(30 * time.Second))
assert.True(t, option.IsSome(result), "multiple AddSuccess should keep history empty")
// Add errors to reach threshold
state = state.AddError(baseTime.Add(40 * time.Second))
state = state.AddError(baseTime.Add(50 * time.Second))
// Should fail
result = state.Check(baseTime.Add(50 * time.Second))
assert.True(t, option.IsNone(result), "should fail after reaching threshold")
})
t.Run("state is immutable - original unchanged after AddError", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(2)
originalState := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Create new state by adding error
newState := originalState.AddError(baseTime)
// Original should still pass check
result := originalState.Check(baseTime)
assert.True(t, option.IsSome(result), "original state should be unchanged")
// New state should reach threshold after another error
newState = newState.AddError(baseTime.Add(10 * time.Second))
result = newState.Check(baseTime.Add(10 * time.Second))
assert.True(t, option.IsNone(result), "new state should fail after reaching threshold")
// Original should still pass
result = originalState.Check(baseTime)
assert.True(t, option.IsSome(result), "original state should still be unchanged")
})
t.Run("state is immutable - original unchanged after Empty", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(2)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors to original
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
stateWithErrors := state
// Create new state by calling Empty
emptyState := stateWithErrors.Empty()
// Original with errors should fail check
result := stateWithErrors.Check(baseTime.Add(10 * time.Second))
assert.True(t, option.IsNone(result), "state with errors should fail after reaching threshold")
// Empty state should pass
result = emptyState.Check(baseTime.Add(10 * time.Second))
assert.True(t, option.IsSome(result), "empty state should pass Check")
})
t.Run("exact time window boundary behavior", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add error at baseTime
state = state.AddError(baseTime)
// Add error exactly at time window boundary
state = state.AddError(baseTime.Add(1 * time.Minute))
// The first error should be removed (it's now outside the window)
// Only 1 error should remain
result := state.Check(baseTime.Add(1 * time.Minute))
assert.True(t, option.IsSome(result), "error at exact window boundary should remove older errors")
})
t.Run("multiple errors at same timestamp", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add multiple errors at same time
state = state.AddError(baseTime)
state = state.AddError(baseTime)
state = state.AddError(baseTime)
// Should fail check (3 errors at same time)
result := state.Check(baseTime)
assert.True(t, option.IsNone(result), "multiple errors at same timestamp should count separately")
})
t.Run("errors added out of chronological order are sorted", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(4)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors out of order
state = state.AddError(baseTime.Add(30 * time.Second))
state = state.AddError(baseTime.Add(5 * time.Second))
state = state.AddError(baseTime.Add(50 * time.Second))
// Add error that should trigger pruning
state = state.AddError(baseTime.Add(70 * time.Second))
// The error at 5s should be removed (> 1 minute from 70s: 70-5=65 > 60)
// Should have 3 errors remaining (30s, 50s, 70s)
result := state.Check(baseTime.Add(70 * time.Second))
assert.True(t, option.IsSome(result), "errors should be sorted and pruned correctly")
})
t.Run("large time window with many failures", func(t *testing.T) {
timeWindow := 24 * time.Hour
maxFailures := uint(100)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add many failures within the window
for i := 0; i < 99; i++ {
state = state.AddError(baseTime.Add(time.Duration(i) * time.Minute))
}
// Should still pass (99 < 100)
result := state.Check(baseTime.Add(99 * time.Minute))
assert.True(t, option.IsSome(result), "should pass with 99 failures when threshold is 100")
// Add one more to reach threshold
state = state.AddError(baseTime.Add(100 * time.Minute))
// Should fail
result = state.Check(baseTime.Add(100 * time.Minute))
assert.True(t, option.IsNone(result), "should fail at threshold with large window")
})
t.Run("very short time window", func(t *testing.T) {
timeWindow := 100 * time.Millisecond
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors within short window
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(50 * time.Millisecond))
state = state.AddError(baseTime.Add(90 * time.Millisecond))
// Should fail (3 errors within 100ms)
result := state.Check(baseTime.Add(90 * time.Millisecond))
assert.True(t, option.IsNone(result), "should fail with errors in short time window")
// Add error after window expires
state = state.AddError(baseTime.Add(200 * time.Millisecond))
// Should pass (old errors removed, only 1 in window)
result = state.Check(baseTime.Add(200 * time.Millisecond))
assert.True(t, option.IsSome(result), "should pass after short window expires")
})
t.Run("success prevents circuit from opening", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(3)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors close to threshold
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
// Add success before reaching threshold
state = state.AddSuccess(baseTime.Add(20 * time.Second))
// Add more errors
state = state.AddError(baseTime.Add(30 * time.Second))
state = state.AddError(baseTime.Add(40 * time.Second))
// Should still pass (only 2 errors after success purge)
result := state.Check(baseTime.Add(40 * time.Second))
assert.True(t, option.IsSome(result), "circuit should stay closed after success purge")
})
t.Run("Empty can be called multiple times", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(2)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add errors
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(10 * time.Second))
state = state.AddError(baseTime.Add(20 * time.Second))
// Reset multiple times
state = state.Empty()
state = state.Empty()
state = state.Empty()
// Should still pass
result := state.Check(baseTime.Add(30 * time.Second))
assert.True(t, option.IsSome(result), "state should pass Check after multiple Empty calls")
})
t.Run("gradual failure accumulation within window", func(t *testing.T) {
timeWindow := 1 * time.Minute
maxFailures := uint(5)
state := MakeClosedStateHistory(timeWindow, maxFailures)
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Add failures gradually
state = state.AddError(baseTime)
state = state.AddError(baseTime.Add(15 * time.Second))
state = state.AddError(baseTime.Add(30 * time.Second))
state = state.AddError(baseTime.Add(45 * time.Second))
// Should still pass (4 < 5)
result := state.Check(baseTime.Add(45 * time.Second))
assert.True(t, option.IsSome(result), "should pass before threshold")
// Add one more within window
state = state.AddError(baseTime.Add(55 * time.Second))
// Should fail (5 >= 5)
result = state.Check(baseTime.Add(55 * time.Second))
assert.True(t, option.IsNone(result), "should fail at threshold")
})
}

338
v2/circuitbreaker/error.go Normal file
View File

@@ -0,0 +1,338 @@
// Package circuitbreaker provides error types and utilities for circuit breaker implementations.
package circuitbreaker
import (
"crypto/x509"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"syscall"
"time"
E "github.com/IBM/fp-go/v2/errors"
FH "github.com/IBM/fp-go/v2/http"
"github.com/IBM/fp-go/v2/option"
)
// CircuitBreakerError represents an error that occurs when a circuit breaker is in the open state.
//
// When a circuit breaker opens due to too many failures, it prevents further operations
// from executing until a reset time is reached. This error type communicates that state
// and provides information about when the circuit breaker will attempt to close again.
//
// Fields:
// - Name: The name identifying this circuit breaker instance
// - ResetAt: The time at which the circuit breaker will transition from open to half-open state
//
// Thread Safety: This type is immutable and safe for concurrent use.
type CircuitBreakerError struct {
// Name: The name identifying this circuit breaker instance
Name string
// ResetAt: The time at which the circuit breaker will transition from open to half-open state
ResetAt time.Time
}
// Error implements the error interface for CircuitBreakerError.
//
// Returns a formatted error message indicating that the circuit breaker is open
// and when it will attempt to close.
//
// Returns:
// - A string describing the circuit breaker state and reset time
//
// Thread Safety: This method is safe for concurrent use as it only reads immutable fields.
//
// Example:
//
// err := &CircuitBreakerError{Name: "API", ResetAt: time.Now().Add(30 * time.Second)}
// fmt.Println(err.Error())
// // Output: circuit breaker is open [API], will close at 2026-01-09 12:20:47.123 +0100 CET
func (e *CircuitBreakerError) Error() string {
return fmt.Sprintf("circuit breaker is open [%s], will close at %s", e.Name, e.ResetAt)
}
// MakeCircuitBreakerErrorWithName creates a circuit breaker error constructor with a custom name.
//
// This function returns a constructor that creates CircuitBreakerError instances with a specific
// circuit breaker name. This is useful when you have multiple circuit breakers in your system
// and want to identify which one is open in error messages.
//
// Parameters:
// - name: The name to identify this circuit breaker in error messages
//
// Returns:
// - A function that takes a reset time and returns a CircuitBreakerError with the specified name
//
// Thread Safety: The returned function is safe for concurrent use as it creates new error
// instances on each call.
//
// Example:
//
// makeDBError := MakeCircuitBreakerErrorWithName("Database Circuit Breaker")
// err := makeDBError(time.Now().Add(30 * time.Second))
// fmt.Println(err.Error())
// // Output: circuit breaker is open [Database Circuit Breaker], will close at 2026-01-09 12:20:47.123 +0100 CET
func MakeCircuitBreakerErrorWithName(name string) func(time.Time) error {
return func(resetTime time.Time) error {
return &CircuitBreakerError{Name: name, ResetAt: resetTime}
}
}
// MakeCircuitBreakerError creates a new CircuitBreakerError with the specified reset time.
//
// This constructor function creates a circuit breaker error that indicates when the
// circuit breaker will transition from the open state to the half-open state, allowing
// test requests to determine if the underlying service has recovered.
//
// Parameters:
// - resetTime: The time at which the circuit breaker will attempt to close
//
// Returns:
// - An error representing the circuit breaker open state
//
// Thread Safety: This function is safe for concurrent use as it creates new error
// instances on each call.
//
// Example:
//
// resetTime := time.Now().Add(30 * time.Second)
// err := MakeCircuitBreakerError(resetTime)
// if cbErr, ok := err.(*CircuitBreakerError); ok {
// fmt.Printf("Circuit breaker will reset at: %s\n", cbErr.ResetAt)
// }
var MakeCircuitBreakerError = MakeCircuitBreakerErrorWithName("Generic Circuit Breaker")
// AnyError converts an error to an Option, wrapping non-nil errors in Some and nil errors in None.
//
// This variable provides a functional way to handle errors by converting them to Option types.
// It's particularly useful in functional programming contexts where you want to treat errors
// as optional values rather than using traditional error handling patterns.
//
// Behavior:
// - If the error is non-nil, returns Some(error)
// - If the error is nil, returns None
//
// Thread Safety: This function is pure and safe for concurrent use.
//
// Example:
//
// err := errors.New("something went wrong")
// optErr := AnyError(err) // Some(error)
//
// var noErr error = nil
// optNoErr := AnyError(noErr) // None
//
// // Using in functional pipelines
// result := F.Pipe2(
// someOperation(),
// AnyError,
// O.Map(func(e error) string { return e.Error() }),
// )
var AnyError = option.FromPredicate(E.IsNonNil)
// shouldOpenCircuit determines if an error should cause a circuit breaker to open.
//
// This function checks if an error represents an infrastructure or server problem
// that indicates the service is unhealthy and should trigger circuit breaker protection.
// It examines both the error type and, for HTTP errors, the status code.
//
// Errors that should open the circuit include:
// - HTTP 5xx server errors (500-599) indicating server-side problems
// - Network errors (connection refused, connection reset, timeouts)
// - DNS resolution errors
// - TLS/certificate errors
// - Other infrastructure-related errors
//
// Errors that should NOT open the circuit include:
// - HTTP 4xx client errors (bad request, unauthorized, not found, etc.)
// - Application-level validation errors
// - Business logic errors
//
// The function unwraps error chains to find the root cause, making it compatible
// with wrapped errors created by fmt.Errorf with %w or errors.Join.
//
// Parameters:
// - err: The error to evaluate (may be nil)
//
// Returns:
// - true if the error should cause the circuit to open, false otherwise
//
// Thread Safety: This function is pure and safe for concurrent use. It does not
// modify any state.
//
// Example:
//
// // HTTP 500 error - should open circuit
// httpErr := &FH.HttpError{...} // status 500
// if shouldOpenCircuit(httpErr) {
// // Open circuit breaker
// }
//
// // HTTP 404 error - should NOT open circuit (client error)
// notFoundErr := &FH.HttpError{...} // status 404
// if !shouldOpenCircuit(notFoundErr) {
// // Don't open circuit, this is a client error
// }
//
// // Network timeout - should open circuit
// timeoutErr := &net.OpError{Op: "dial", Err: syscall.ETIMEDOUT}
// if shouldOpenCircuit(timeoutErr) {
// // Open circuit breaker
// }
func shouldOpenCircuit(err error) bool {
if err == nil {
return false
}
// Check for HTTP errors with server status codes (5xx)
var httpErr *FH.HttpError
if errors.As(err, &httpErr) {
statusCode := httpErr.StatusCode()
// Only 5xx errors should open the circuit
// 4xx errors are client errors and shouldn't affect circuit state
return statusCode >= http.StatusInternalServerError && statusCode < 600
}
// Check for network operation errors
var opErr *net.OpError
if errors.As(err, &opErr) {
// Network timeouts should open the circuit
if opErr.Timeout() {
return true
}
// Check the underlying error
if opErr.Err != nil {
return isInfrastructureError(opErr.Err)
}
return true
}
// Check for DNS errors
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return true
}
// Check for URL errors (often wrap network errors)
var urlErr *url.Error
if errors.As(err, &urlErr) {
if urlErr.Timeout() {
return true
}
// Recursively check the wrapped error
return shouldOpenCircuit(urlErr.Err)
}
// Check for specific syscall errors that indicate infrastructure problems
return isInfrastructureError(err) || isTLSError(err)
}
// isInfrastructureError checks if an error is a low-level infrastructure error
// that should cause the circuit to open.
//
// This function examines syscall errors to identify network and system-level failures
// that indicate the service is unavailable or unreachable.
//
// Infrastructure errors include:
// - ECONNREFUSED: Connection refused (service not listening)
// - ECONNRESET: Connection reset by peer (service crashed or network issue)
// - ECONNABORTED: Connection aborted (network issue)
// - ENETUNREACH: Network unreachable (routing problem)
// - EHOSTUNREACH: Host unreachable (host down or network issue)
// - EPIPE: Broken pipe (connection closed unexpectedly)
// - ETIMEDOUT: Operation timed out (service not responding)
//
// Parameters:
// - err: The error to check
//
// Returns:
// - true if the error is an infrastructure error, false otherwise
//
// Thread Safety: This function is pure and safe for concurrent use.
func isInfrastructureError(err error) bool {
var syscallErr *syscall.Errno
if errors.As(err, &syscallErr) {
switch *syscallErr {
case syscall.ECONNREFUSED,
syscall.ECONNRESET,
syscall.ECONNABORTED,
syscall.ENETUNREACH,
syscall.EHOSTUNREACH,
syscall.EPIPE,
syscall.ETIMEDOUT:
return true
}
}
return false
}
// isTLSError checks if an error is a TLS/certificate error that should cause the circuit to open.
//
// TLS errors typically indicate infrastructure or configuration problems that prevent
// secure communication with the service. These errors suggest the service is not properly
// configured or accessible.
//
// TLS errors include:
// - Certificate verification failures (invalid, expired, or malformed certificates)
// - Unknown certificate authority errors (untrusted CA)
//
// Parameters:
// - err: The error to check
//
// Returns:
// - true if the error is a TLS/certificate error, false otherwise
//
// Thread Safety: This function is pure and safe for concurrent use.
func isTLSError(err error) bool {
// Certificate verification failed
var certErr *x509.CertificateInvalidError
if errors.As(err, &certErr) {
return true
}
// Unknown authority
var unknownAuthErr *x509.UnknownAuthorityError
if errors.As(err, &unknownAuthErr) {
return true
}
return false
}
// InfrastructureError is a predicate that converts errors to Options based on whether
// they should trigger circuit breaker opening.
//
// This variable provides a functional way to filter errors that represent infrastructure
// failures (network issues, server errors, timeouts, etc.) from application-level errors
// (validation errors, business logic errors, client errors).
//
// Behavior:
// - Returns Some(error) if the error should open the circuit (infrastructure failure)
// - Returns None if the error should not open the circuit (application error)
//
// Thread Safety: This function is pure and safe for concurrent use.
//
// Use this in circuit breaker configurations to determine which errors should count
// toward the failure threshold.
//
// Example:
//
// // In a circuit breaker configuration
// breaker := MakeCircuitBreaker(
// ...,
// checkError: InfrastructureError, // Only infrastructure errors open the circuit
// ...,
// )
//
// // HTTP 500 error - returns Some(error)
// result := InfrastructureError(&FH.HttpError{...}) // Some(error)
//
// // HTTP 404 error - returns None
// result := InfrastructureError(&FH.HttpError{...}) // None
var InfrastructureError = option.FromPredicate(shouldOpenCircuit)

View File

@@ -0,0 +1,503 @@
package circuitbreaker
import (
"crypto/x509"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"testing"
"time"
FH "github.com/IBM/fp-go/v2/http"
"github.com/IBM/fp-go/v2/option"
"github.com/stretchr/testify/assert"
)
// TestCircuitBreakerError tests the CircuitBreakerError type
func TestCircuitBreakerError(t *testing.T) {
t.Run("Error returns formatted message with reset time", func(t *testing.T) {
resetTime := time.Date(2026, 1, 9, 12, 30, 0, 0, time.UTC)
err := &CircuitBreakerError{ResetAt: resetTime}
result := err.Error()
assert.Contains(t, result, "circuit breaker is open")
assert.Contains(t, result, "will close at")
assert.Contains(t, result, resetTime.String())
})
t.Run("Error message includes full timestamp", func(t *testing.T) {
resetTime := time.Now().Add(30 * time.Second)
err := &CircuitBreakerError{ResetAt: resetTime}
result := err.Error()
assert.NotEmpty(t, result)
assert.Contains(t, result, "circuit breaker is open")
})
}
// TestMakeCircuitBreakerError tests the constructor function
func TestMakeCircuitBreakerError(t *testing.T) {
t.Run("creates CircuitBreakerError with correct reset time", func(t *testing.T) {
resetTime := time.Date(2026, 1, 9, 13, 0, 0, 0, time.UTC)
err := MakeCircuitBreakerError(resetTime)
assert.NotNil(t, err)
cbErr, ok := err.(*CircuitBreakerError)
assert.True(t, ok, "should return *CircuitBreakerError type")
assert.Equal(t, resetTime, cbErr.ResetAt)
})
t.Run("returns error interface", func(t *testing.T) {
resetTime := time.Now().Add(1 * time.Minute)
err := MakeCircuitBreakerError(resetTime)
// Should be assignable to error interface
var _ error = err
assert.NotNil(t, err)
})
t.Run("created error can be type asserted", func(t *testing.T) {
resetTime := time.Now().Add(45 * time.Second)
err := MakeCircuitBreakerError(resetTime)
cbErr, ok := err.(*CircuitBreakerError)
assert.True(t, ok)
assert.Equal(t, resetTime, cbErr.ResetAt)
})
}
// TestAnyError tests the AnyError function
func TestAnyError(t *testing.T) {
t.Run("returns Some for non-nil error", func(t *testing.T) {
err := errors.New("test error")
result := AnyError(err)
assert.True(t, option.IsSome(result), "should return Some for non-nil error")
value := option.GetOrElse(func() error { return nil })(result)
assert.Equal(t, err, value)
})
t.Run("returns None for nil error", func(t *testing.T) {
var err error = nil
result := AnyError(err)
assert.True(t, option.IsNone(result), "should return None for nil error")
})
t.Run("works with different error types", func(t *testing.T) {
err1 := fmt.Errorf("wrapped: %w", errors.New("inner"))
err2 := &CircuitBreakerError{ResetAt: time.Now()}
result1 := AnyError(err1)
result2 := AnyError(err2)
assert.True(t, option.IsSome(result1))
assert.True(t, option.IsSome(result2))
})
}
// TestShouldOpenCircuit tests the shouldOpenCircuit function
func TestShouldOpenCircuit(t *testing.T) {
t.Run("returns false for nil error", func(t *testing.T) {
result := shouldOpenCircuit(nil)
assert.False(t, result)
})
t.Run("HTTP 5xx errors should open circuit", func(t *testing.T) {
testCases := []struct {
name string
statusCode int
expected bool
}{
{"500 Internal Server Error", 500, true},
{"501 Not Implemented", 501, true},
{"502 Bad Gateway", 502, true},
{"503 Service Unavailable", 503, true},
{"504 Gateway Timeout", 504, true},
{"599 Custom Server Error", 599, true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
testURL, _ := url.Parse("http://example.com")
resp := &http.Response{
StatusCode: tc.statusCode,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
result := shouldOpenCircuit(httpErr)
assert.Equal(t, tc.expected, result)
})
}
})
t.Run("HTTP 4xx errors should NOT open circuit", func(t *testing.T) {
testCases := []struct {
name string
statusCode int
expected bool
}{
{"400 Bad Request", 400, false},
{"401 Unauthorized", 401, false},
{"403 Forbidden", 403, false},
{"404 Not Found", 404, false},
{"422 Unprocessable Entity", 422, false},
{"429 Too Many Requests", 429, false},
{"499 Custom Client Error", 499, false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
testURL, _ := url.Parse("http://example.com")
resp := &http.Response{
StatusCode: tc.statusCode,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
result := shouldOpenCircuit(httpErr)
assert.Equal(t, tc.expected, result)
})
}
})
t.Run("HTTP 2xx and 3xx should NOT open circuit", func(t *testing.T) {
testCases := []int{200, 201, 204, 301, 302, 304}
for _, statusCode := range testCases {
t.Run(fmt.Sprintf("Status %d", statusCode), func(t *testing.T) {
testURL, _ := url.Parse("http://example.com")
resp := &http.Response{
StatusCode: statusCode,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
result := shouldOpenCircuit(httpErr)
assert.False(t, result)
})
}
})
t.Run("network timeout errors should open circuit", func(t *testing.T) {
opErr := &net.OpError{
Op: "dial",
Err: &timeoutError{},
}
result := shouldOpenCircuit(opErr)
assert.True(t, result)
})
t.Run("DNS errors should open circuit", func(t *testing.T) {
dnsErr := &net.DNSError{
Err: "no such host",
Name: "example.com",
}
result := shouldOpenCircuit(dnsErr)
assert.True(t, result)
})
t.Run("URL timeout errors should open circuit", func(t *testing.T) {
urlErr := &url.Error{
Op: "Get",
URL: "http://example.com",
Err: &timeoutError{},
}
result := shouldOpenCircuit(urlErr)
assert.True(t, result)
})
t.Run("URL errors with nested network timeout should open circuit", func(t *testing.T) {
urlErr := &url.Error{
Op: "Get",
URL: "http://example.com",
Err: &net.OpError{
Op: "dial",
Err: &timeoutError{},
},
}
result := shouldOpenCircuit(urlErr)
assert.True(t, result)
})
t.Run("OpError with nil Err should open circuit", func(t *testing.T) {
opErr := &net.OpError{
Op: "dial",
Err: nil,
}
result := shouldOpenCircuit(opErr)
assert.True(t, result)
})
t.Run("wrapped HTTP 5xx error should open circuit", func(t *testing.T) {
testURL, _ := url.Parse("http://example.com")
resp := &http.Response{
StatusCode: 503,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
wrappedErr := fmt.Errorf("service error: %w", httpErr)
result := shouldOpenCircuit(wrappedErr)
assert.True(t, result)
})
t.Run("wrapped HTTP 4xx error should NOT open circuit", func(t *testing.T) {
testURL, _ := url.Parse("http://example.com")
resp := &http.Response{
StatusCode: 404,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
wrappedErr := fmt.Errorf("not found: %w", httpErr)
result := shouldOpenCircuit(wrappedErr)
assert.False(t, result)
})
t.Run("generic application error should NOT open circuit", func(t *testing.T) {
err := errors.New("validation failed")
result := shouldOpenCircuit(err)
assert.False(t, result)
})
}
// TestIsInfrastructureError tests infrastructure error detection through shouldOpenCircuit
func TestIsInfrastructureError(t *testing.T) {
t.Run("network timeout is infrastructure error", func(t *testing.T) {
opErr := &net.OpError{Op: "dial", Err: &timeoutError{}}
result := shouldOpenCircuit(opErr)
assert.True(t, result)
})
t.Run("OpError with nil Err is infrastructure error", func(t *testing.T) {
opErr := &net.OpError{Op: "dial", Err: nil}
result := shouldOpenCircuit(opErr)
assert.True(t, result)
})
t.Run("generic error returns false", func(t *testing.T) {
err := errors.New("generic error")
result := shouldOpenCircuit(err)
assert.False(t, result)
})
t.Run("wrapped network timeout is detected", func(t *testing.T) {
opErr := &net.OpError{Op: "dial", Err: &timeoutError{}}
wrappedErr := fmt.Errorf("connection failed: %w", opErr)
result := shouldOpenCircuit(wrappedErr)
assert.True(t, result)
})
}
// TestIsTLSError tests the isTLSError function
func TestIsTLSError(t *testing.T) {
t.Run("certificate invalid error is TLS error", func(t *testing.T) {
certErr := &x509.CertificateInvalidError{
Reason: x509.Expired,
}
result := isTLSError(certErr)
assert.True(t, result)
})
t.Run("unknown authority error is TLS error", func(t *testing.T) {
authErr := &x509.UnknownAuthorityError{}
result := isTLSError(authErr)
assert.True(t, result)
})
t.Run("generic error is not TLS error", func(t *testing.T) {
err := errors.New("generic error")
result := isTLSError(err)
assert.False(t, result)
})
t.Run("wrapped certificate error is detected", func(t *testing.T) {
certErr := &x509.CertificateInvalidError{
Reason: x509.Expired,
}
wrappedErr := fmt.Errorf("TLS handshake failed: %w", certErr)
result := isTLSError(wrappedErr)
assert.True(t, result)
})
t.Run("wrapped unknown authority error is detected", func(t *testing.T) {
authErr := &x509.UnknownAuthorityError{}
wrappedErr := fmt.Errorf("certificate verification failed: %w", authErr)
result := isTLSError(wrappedErr)
assert.True(t, result)
})
}
// TestInfrastructureError tests the InfrastructureError variable
func TestInfrastructureError(t *testing.T) {
t.Run("returns Some for infrastructure errors", func(t *testing.T) {
testURL, _ := url.Parse("http://example.com")
resp := &http.Response{
StatusCode: 503,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
result := InfrastructureError(httpErr)
assert.True(t, option.IsSome(result))
})
t.Run("returns None for non-infrastructure errors", func(t *testing.T) {
testURL, _ := url.Parse("http://example.com")
resp := &http.Response{
StatusCode: 404,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
result := InfrastructureError(httpErr)
assert.True(t, option.IsNone(result))
})
t.Run("returns None for nil error", func(t *testing.T) {
result := InfrastructureError(nil)
assert.True(t, option.IsNone(result))
})
t.Run("returns Some for network timeout", func(t *testing.T) {
opErr := &net.OpError{
Op: "dial",
Err: &timeoutError{},
}
result := InfrastructureError(opErr)
assert.True(t, option.IsSome(result))
})
}
// TestComplexErrorScenarios tests complex real-world error scenarios
func TestComplexErrorScenarios(t *testing.T) {
t.Run("deeply nested URL error with HTTP 5xx", func(t *testing.T) {
testURL, _ := url.Parse("http://api.example.com")
resp := &http.Response{
StatusCode: 502,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
urlErr := &url.Error{
Op: "Get",
URL: "http://api.example.com",
Err: httpErr,
}
wrappedErr := fmt.Errorf("API call failed: %w", urlErr)
result := shouldOpenCircuit(wrappedErr)
assert.True(t, result, "should detect HTTP 5xx through multiple layers")
})
t.Run("URL error with timeout nested in OpError", func(t *testing.T) {
opErr := &net.OpError{
Op: "dial",
Err: &timeoutError{},
}
urlErr := &url.Error{
Op: "Post",
URL: "http://api.example.com",
Err: opErr,
}
result := shouldOpenCircuit(urlErr)
assert.True(t, result, "should detect timeout through URL error")
})
t.Run("multiple wrapped errors with infrastructure error at core", func(t *testing.T) {
coreErr := &net.OpError{Op: "dial", Err: &timeoutError{}}
layer1 := fmt.Errorf("connection attempt failed: %w", coreErr)
layer2 := fmt.Errorf("retry exhausted: %w", layer1)
layer3 := fmt.Errorf("service unavailable: %w", layer2)
result := shouldOpenCircuit(layer3)
assert.True(t, result, "should unwrap to find infrastructure error")
})
t.Run("OpError with nil Err should open circuit", func(t *testing.T) {
opErr := &net.OpError{
Op: "dial",
Err: nil,
}
result := shouldOpenCircuit(opErr)
assert.True(t, result, "OpError with nil Err should be treated as infrastructure error")
})
t.Run("mixed error types - HTTP 4xx with network error", func(t *testing.T) {
// This tests that we correctly identify the error type
testURL, _ := url.Parse("http://example.com")
resp := &http.Response{
StatusCode: 400,
Request: &http.Request{URL: testURL},
Body: http.NoBody,
}
httpErr := FH.StatusCodeError(resp)
result := shouldOpenCircuit(httpErr)
assert.False(t, result, "HTTP 4xx should not open circuit even if wrapped")
})
}
// Helper type for testing timeout errors
type timeoutError struct{}
func (e *timeoutError) Error() string { return "timeout" }
func (e *timeoutError) Timeout() bool { return true }
func (e *timeoutError) Temporary() bool { return true }

View File

@@ -0,0 +1,304 @@
// Package circuitbreaker provides metrics collection for circuit breaker state transitions and events.
package circuitbreaker
import (
"log"
"time"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
)
type (
// Metrics defines the interface for collecting circuit breaker metrics and events.
// Implementations can use this interface to track circuit breaker behavior for
// monitoring, alerting, and debugging purposes.
//
// All methods accept a time.Time parameter representing when the event occurred,
// and return an IO[Void] operation that performs the metric recording when executed.
//
// Thread Safety: Implementations must be thread-safe as circuit breakers may be
// accessed concurrently from multiple goroutines.
//
// Example Usage:
//
// logger := log.New(os.Stdout, "[CircuitBreaker] ", log.LstdFlags)
// metrics := MakeMetricsFromLogger("API-Service", logger)
//
// // In circuit breaker implementation
// io.Run(metrics.Accept(time.Now())) // Record accepted request
// io.Run(metrics.Reject(time.Now())) // Record rejected request
// io.Run(metrics.Open(time.Now())) // Record circuit opening
// io.Run(metrics.Close(time.Now())) // Record circuit closing
// io.Run(metrics.Canary(time.Now())) // Record canary request
Metrics interface {
// Accept records that a request was accepted and allowed through the circuit breaker.
// This is called when the circuit is closed or in half-open state (canary request).
//
// Parameters:
// - time.Time: The timestamp when the request was accepted
//
// Returns:
// - IO[Void]: An IO operation that records the acceptance when executed
//
// Thread Safety: Must be safe to call concurrently.
Accept(time.Time) IO[Void]
// Reject records that a request was rejected because the circuit breaker is open.
// This is called when a request is blocked due to the circuit being in open state
// and the reset time has not been reached.
//
// Parameters:
// - time.Time: The timestamp when the request was rejected
//
// Returns:
// - IO[Void]: An IO operation that records the rejection when executed
//
// Thread Safety: Must be safe to call concurrently.
Reject(time.Time) IO[Void]
// Open records that the circuit breaker transitioned to the open state.
// This is called when the failure threshold is exceeded and the circuit opens
// to prevent further requests from reaching the failing service.
//
// Parameters:
// - time.Time: The timestamp when the circuit opened
//
// Returns:
// - IO[Void]: An IO operation that records the state transition when executed
//
// Thread Safety: Must be safe to call concurrently.
Open(time.Time) IO[Void]
// Close records that the circuit breaker transitioned to the closed state.
// This is called when:
// - A canary request succeeds in half-open state
// - The circuit is manually reset
// - The circuit breaker is initialized
//
// Parameters:
// - time.Time: The timestamp when the circuit closed
//
// Returns:
// - IO[Void]: An IO operation that records the state transition when executed
//
// Thread Safety: Must be safe to call concurrently.
Close(time.Time) IO[Void]
// Canary records that a canary (test) request is being attempted.
// This is called when the circuit is in half-open state and a single test request
// is allowed through to check if the service has recovered.
//
// Parameters:
// - time.Time: The timestamp when the canary request was initiated
//
// Returns:
// - IO[Void]: An IO operation that records the canary attempt when executed
//
// Thread Safety: Must be safe to call concurrently.
Canary(time.Time) IO[Void]
}
// loggingMetrics is a simple implementation of the Metrics interface that logs
// circuit breaker events using Go's standard log.Logger.
//
// This implementation is thread-safe as log.Logger is safe for concurrent use.
//
// Fields:
// - name: A human-readable name identifying the circuit breaker instance
// - logger: The log.Logger instance used for writing log messages
loggingMetrics struct {
name string
logger *log.Logger
}
// voidMetrics is a no-op implementation of the Metrics interface that does nothing.
// All methods return the same pre-allocated IO[Void] operation that immediately returns
// without performing any action.
//
// This implementation is useful for:
// - Testing scenarios where metrics collection is not needed
// - Production environments where metrics overhead should be eliminated
// - Benchmarking circuit breaker logic without metrics interference
// - Default initialization when no metrics implementation is provided
//
// Thread Safety: This implementation is safe for concurrent use. The noop IO operation
// is immutable and can be safely shared across goroutines.
//
// Performance: This is the most efficient Metrics implementation as it performs no
// operations and has minimal memory overhead (single shared IO[Void] instance).
voidMetrics struct {
noop IO[Void]
}
)
// doLog is a helper method that creates an IO operation for logging a circuit breaker event.
// It formats the log message with the event prefix, circuit breaker name, and timestamp.
//
// Parameters:
// - prefix: The event type (e.g., "Accept", "Reject", "Open", "Close", "Canary")
// - ct: The timestamp when the event occurred
//
// Returns:
// - IO[Void]: An IO operation that logs the event when executed
//
// Thread Safety: Safe for concurrent use as log.Logger is thread-safe.
//
// Log Format: "<prefix>: <name>, <timestamp>"
// Example: "Open: API-Service, 2026-01-09 15:30:45.123 +0100 CET"
func (m *loggingMetrics) doLog(prefix string, ct time.Time) IO[Void] {
return func() Void {
m.logger.Printf("%s: %s, %s\n", prefix, m.name, ct)
return function.VOID
}
}
// Accept implements the Metrics interface for loggingMetrics.
// Logs when a request is accepted through the circuit breaker.
//
// Thread Safety: Safe for concurrent use.
func (m *loggingMetrics) Accept(ct time.Time) IO[Void] {
return m.doLog("Accept", ct)
}
// Open implements the Metrics interface for loggingMetrics.
// Logs when the circuit breaker transitions to open state.
//
// Thread Safety: Safe for concurrent use.
func (m *loggingMetrics) Open(ct time.Time) IO[Void] {
return m.doLog("Open", ct)
}
// Close implements the Metrics interface for loggingMetrics.
// Logs when the circuit breaker transitions to closed state.
//
// Thread Safety: Safe for concurrent use.
func (m *loggingMetrics) Close(ct time.Time) IO[Void] {
return m.doLog("Close", ct)
}
// Reject implements the Metrics interface for loggingMetrics.
// Logs when a request is rejected because the circuit breaker is open.
//
// Thread Safety: Safe for concurrent use.
func (m *loggingMetrics) Reject(ct time.Time) IO[Void] {
return m.doLog("Reject", ct)
}
// Canary implements the Metrics interface for loggingMetrics.
// Logs when a canary (test) request is attempted in half-open state.
//
// Thread Safety: Safe for concurrent use.
func (m *loggingMetrics) Canary(ct time.Time) IO[Void] {
return m.doLog("Canary", ct)
}
// MakeMetricsFromLogger creates a Metrics implementation that logs circuit breaker events
// using the provided log.Logger.
//
// This is a simple metrics implementation suitable for development, debugging, and
// basic production monitoring. For more sophisticated metrics collection (e.g., Prometheus,
// StatsD), implement the Metrics interface with a custom type.
//
// Parameters:
// - name: A human-readable name identifying the circuit breaker instance.
// This name appears in all log messages to distinguish between multiple circuit breakers.
// - logger: The log.Logger instance to use for writing log messages.
// If nil, this will panic when metrics are recorded.
//
// Returns:
// - Metrics: A thread-safe Metrics implementation that logs events
//
// Thread Safety: The returned Metrics implementation is safe for concurrent use
// as log.Logger is thread-safe.
//
// Example:
//
// logger := log.New(os.Stdout, "[CB] ", log.LstdFlags)
// metrics := MakeMetricsFromLogger("UserService", logger)
//
// // Use with circuit breaker
// io.Run(metrics.Open(time.Now()))
// // Output: [CB] 2026/01/09 15:30:45 Open: UserService, 2026-01-09 15:30:45.123 +0100 CET
//
// io.Run(metrics.Reject(time.Now()))
// // Output: [CB] 2026/01/09 15:30:46 Reject: UserService, 2026-01-09 15:30:46.456 +0100 CET
func MakeMetricsFromLogger(name string, logger *log.Logger) Metrics {
return &loggingMetrics{name: name, logger: logger}
}
// Open implements the Metrics interface for voidMetrics.
// Returns a no-op IO operation that does nothing.
//
// Thread Safety: Safe for concurrent use.
func (m *voidMetrics) Open(_ time.Time) IO[Void] {
return m.noop
}
// Accept implements the Metrics interface for voidMetrics.
// Returns a no-op IO operation that does nothing.
//
// Thread Safety: Safe for concurrent use.
func (m *voidMetrics) Accept(_ time.Time) IO[Void] {
return m.noop
}
// Canary implements the Metrics interface for voidMetrics.
// Returns a no-op IO operation that does nothing.
//
// Thread Safety: Safe for concurrent use.
func (m *voidMetrics) Canary(_ time.Time) IO[Void] {
return m.noop
}
// Close implements the Metrics interface for voidMetrics.
// Returns a no-op IO operation that does nothing.
//
// Thread Safety: Safe for concurrent use.
func (m *voidMetrics) Close(_ time.Time) IO[Void] {
return m.noop
}
// Reject implements the Metrics interface for voidMetrics.
// Returns a no-op IO operation that does nothing.
//
// Thread Safety: Safe for concurrent use.
func (m *voidMetrics) Reject(_ time.Time) IO[Void] {
return m.noop
}
// MakeVoidMetrics creates a no-op Metrics implementation that performs no operations.
// All methods return the same pre-allocated IO[Void] operation that does nothing when executed.
//
// This is useful for:
// - Testing scenarios where metrics collection is not needed
// - Production environments where metrics overhead should be eliminated
// - Benchmarking circuit breaker logic without metrics interference
// - Default initialization when no metrics implementation is provided
//
// Returns:
// - Metrics: A thread-safe no-op Metrics implementation
//
// Thread Safety: The returned Metrics implementation is safe for concurrent use.
// All methods return the same immutable IO[Void] operation.
//
// Performance: This is the most efficient Metrics implementation with minimal overhead.
// The IO[Void] operation is pre-allocated once and reused for all method calls.
//
// Example:
//
// metrics := MakeVoidMetrics()
//
// // All operations do nothing
// io.Run(metrics.Open(time.Now())) // No-op
// io.Run(metrics.Accept(time.Now())) // No-op
// io.Run(metrics.Reject(time.Now())) // No-op
//
// // Useful for testing
// breaker := MakeCircuitBreaker(
// // ... other parameters ...
// MakeVoidMetrics(), // No metrics overhead
// )
func MakeVoidMetrics() Metrics {
return &voidMetrics{io.Of(function.VOID)}
}

View File

@@ -0,0 +1,946 @@
package circuitbreaker
import (
"bytes"
"log"
"strings"
"sync"
"testing"
"time"
"github.com/IBM/fp-go/v2/io"
"github.com/stretchr/testify/assert"
)
// TestMakeMetricsFromLogger tests the MakeMetricsFromLogger constructor
func TestMakeMetricsFromLogger(t *testing.T) {
t.Run("creates valid Metrics implementation", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
assert.NotNil(t, metrics, "MakeMetricsFromLogger should return non-nil Metrics")
})
t.Run("returns loggingMetrics type", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
_, ok := metrics.(*loggingMetrics)
assert.True(t, ok, "should return *loggingMetrics type")
})
t.Run("stores name correctly", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
name := "MyCircuitBreaker"
metrics := MakeMetricsFromLogger(name, logger).(*loggingMetrics)
assert.Equal(t, name, metrics.name, "name should be stored correctly")
})
t.Run("stores logger correctly", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger).(*loggingMetrics)
assert.Equal(t, logger, metrics.logger, "logger should be stored correctly")
})
}
// TestLoggingMetricsAccept tests the Accept method
func TestLoggingMetricsAccept(t *testing.T) {
t.Run("logs accept event with correct format", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
io.Run(metrics.Accept(timestamp))
output := buf.String()
assert.Contains(t, output, "Accept:", "should contain Accept prefix")
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
})
t.Run("returns IO[Void] that can be executed", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
ioOp := metrics.Accept(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
t.Run("logs multiple accept events", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
time1 := time.Date(2026, 1, 9, 15, 30, 0, 0, time.UTC)
time2 := time.Date(2026, 1, 9, 15, 31, 0, 0, time.UTC)
io.Run(metrics.Accept(time1))
io.Run(metrics.Accept(time2))
output := buf.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
assert.Len(t, lines, 2, "should have 2 log lines")
assert.Contains(t, lines[0], time1.String())
assert.Contains(t, lines[1], time2.String())
})
}
// TestLoggingMetricsReject tests the Reject method
func TestLoggingMetricsReject(t *testing.T) {
t.Run("logs reject event with correct format", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
io.Run(metrics.Reject(timestamp))
output := buf.String()
assert.Contains(t, output, "Reject:", "should contain Reject prefix")
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
})
t.Run("returns IO[Void] that can be executed", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
ioOp := metrics.Reject(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
}
// TestLoggingMetricsOpen tests the Open method
func TestLoggingMetricsOpen(t *testing.T) {
t.Run("logs open event with correct format", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
io.Run(metrics.Open(timestamp))
output := buf.String()
assert.Contains(t, output, "Open:", "should contain Open prefix")
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
})
t.Run("returns IO[Void] that can be executed", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
ioOp := metrics.Open(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
}
// TestLoggingMetricsClose tests the Close method
func TestLoggingMetricsClose(t *testing.T) {
t.Run("logs close event with correct format", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
io.Run(metrics.Close(timestamp))
output := buf.String()
assert.Contains(t, output, "Close:", "should contain Close prefix")
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
})
t.Run("returns IO[Void] that can be executed", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
ioOp := metrics.Close(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
}
// TestLoggingMetricsCanary tests the Canary method
func TestLoggingMetricsCanary(t *testing.T) {
t.Run("logs canary event with correct format", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
io.Run(metrics.Canary(timestamp))
output := buf.String()
assert.Contains(t, output, "Canary:", "should contain Canary prefix")
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
})
t.Run("returns IO[Void] that can be executed", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
ioOp := metrics.Canary(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
}
// TestLoggingMetricsDoLog tests the doLog helper method
func TestLoggingMetricsDoLog(t *testing.T) {
t.Run("formats log message correctly", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := &loggingMetrics{name: "TestCircuit", logger: logger}
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
io.Run(metrics.doLog("CustomEvent", timestamp))
output := buf.String()
assert.Contains(t, output, "CustomEvent:", "should contain custom prefix")
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
})
t.Run("handles different prefixes", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := &loggingMetrics{name: "TestCircuit", logger: logger}
timestamp := time.Now()
prefixes := []string{"Accept", "Reject", "Open", "Close", "Canary", "Custom"}
for _, prefix := range prefixes {
buf.Reset()
io.Run(metrics.doLog(prefix, timestamp))
output := buf.String()
assert.Contains(t, output, prefix+":", "should contain prefix: "+prefix)
}
})
}
// TestMetricsIntegration tests integration scenarios
func TestMetricsIntegration(t *testing.T) {
t.Run("logs complete circuit breaker lifecycle", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("APICircuit", logger)
baseTime := time.Date(2026, 1, 9, 15, 30, 0, 0, time.UTC)
// Simulate circuit breaker lifecycle
io.Run(metrics.Accept(baseTime)) // Request accepted
io.Run(metrics.Accept(baseTime.Add(1 * time.Second))) // Another request
io.Run(metrics.Open(baseTime.Add(2 * time.Second))) // Circuit opens
io.Run(metrics.Reject(baseTime.Add(3 * time.Second))) // Request rejected
io.Run(metrics.Canary(baseTime.Add(30 * time.Second))) // Canary attempt
io.Run(metrics.Close(baseTime.Add(31 * time.Second))) // Circuit closes
output := buf.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
assert.Len(t, lines, 6, "should have 6 log lines")
assert.Contains(t, lines[0], "Accept:")
assert.Contains(t, lines[1], "Accept:")
assert.Contains(t, lines[2], "Open:")
assert.Contains(t, lines[3], "Reject:")
assert.Contains(t, lines[4], "Canary:")
assert.Contains(t, lines[5], "Close:")
})
t.Run("distinguishes between multiple circuit breakers", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics1 := MakeMetricsFromLogger("Circuit1", logger)
metrics2 := MakeMetricsFromLogger("Circuit2", logger)
timestamp := time.Now()
io.Run(metrics1.Accept(timestamp))
io.Run(metrics2.Accept(timestamp))
output := buf.String()
assert.Contains(t, output, "Circuit1", "should contain first circuit name")
assert.Contains(t, output, "Circuit2", "should contain second circuit name")
})
}
// TestMetricsThreadSafety tests concurrent access to metrics
func TestMetricsThreadSafety(t *testing.T) {
t.Run("handles concurrent metric recording", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("ConcurrentCircuit", logger)
var wg sync.WaitGroup
numGoroutines := 100
wg.Add(numGoroutines)
// Launch multiple goroutines recording metrics concurrently
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
timestamp := time.Now()
io.Run(metrics.Accept(timestamp))
}(i)
}
wg.Wait()
output := buf.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
assert.Len(t, lines, numGoroutines, "should have logged all events")
})
t.Run("handles concurrent different event types", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("ConcurrentCircuit", logger)
var wg sync.WaitGroup
numIterations := 20
wg.Add(numIterations * 5) // 5 event types
timestamp := time.Now()
for i := 0; i < numIterations; i++ {
go func() {
defer wg.Done()
io.Run(metrics.Accept(timestamp))
}()
go func() {
defer wg.Done()
io.Run(metrics.Reject(timestamp))
}()
go func() {
defer wg.Done()
io.Run(metrics.Open(timestamp))
}()
go func() {
defer wg.Done()
io.Run(metrics.Close(timestamp))
}()
go func() {
defer wg.Done()
io.Run(metrics.Canary(timestamp))
}()
}
wg.Wait()
output := buf.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
assert.Len(t, lines, numIterations*5, "should have logged all events")
})
}
// TestMetricsEdgeCases tests edge cases and special scenarios
func TestMetricsEdgeCases(t *testing.T) {
t.Run("handles empty circuit breaker name", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("", logger)
timestamp := time.Now()
io.Run(metrics.Accept(timestamp))
output := buf.String()
assert.NotEmpty(t, output, "should still log even with empty name")
})
t.Run("handles very long circuit breaker name", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
longName := strings.Repeat("VeryLongCircuitBreakerName", 100)
metrics := MakeMetricsFromLogger(longName, logger)
timestamp := time.Now()
io.Run(metrics.Accept(timestamp))
output := buf.String()
assert.Contains(t, output, longName, "should handle long names")
})
t.Run("handles special characters in name", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
specialName := "Circuit-Breaker_123!@#$%^&*()"
metrics := MakeMetricsFromLogger(specialName, logger)
timestamp := time.Now()
io.Run(metrics.Accept(timestamp))
output := buf.String()
assert.Contains(t, output, specialName, "should handle special characters")
})
t.Run("handles zero time", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
zeroTime := time.Time{}
io.Run(metrics.Accept(zeroTime))
output := buf.String()
assert.NotEmpty(t, output, "should handle zero time")
assert.Contains(t, output, "Accept:")
})
t.Run("handles far future time", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
futureTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
io.Run(metrics.Accept(futureTime))
output := buf.String()
assert.NotEmpty(t, output, "should handle far future time")
assert.Contains(t, output, "9999")
})
}
// TestMetricsWithCustomLogger tests metrics with different logger configurations
func TestMetricsWithCustomLogger(t *testing.T) {
t.Run("works with logger with custom prefix", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "[CB] ", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
io.Run(metrics.Accept(timestamp))
output := buf.String()
assert.Contains(t, output, "[CB]", "should include custom prefix")
assert.Contains(t, output, "Accept:")
})
t.Run("works with logger with flags", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", log.Ldate|log.Ltime)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
io.Run(metrics.Accept(timestamp))
output := buf.String()
assert.NotEmpty(t, output, "should log with flags")
assert.Contains(t, output, "Accept:")
})
}
// TestMetricsIOOperations tests IO operation behavior
func TestMetricsIOOperations(t *testing.T) {
t.Run("IO operations are lazy", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
// Create IO operation but don't execute it
_ = metrics.Accept(timestamp)
// Buffer should be empty because IO wasn't executed
assert.Empty(t, buf.String(), "IO operation should be lazy")
})
t.Run("IO operations execute when run", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
ioOp := metrics.Accept(timestamp)
io.Run(ioOp)
assert.NotEmpty(t, buf.String(), "IO operation should execute when run")
})
t.Run("same IO operation can be executed multiple times", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
metrics := MakeMetricsFromLogger("TestCircuit", logger)
timestamp := time.Now()
ioOp := metrics.Accept(timestamp)
io.Run(ioOp)
io.Run(ioOp)
io.Run(ioOp)
output := buf.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
assert.Len(t, lines, 3, "should execute multiple times")
})
}
// TestMakeVoidMetrics tests the MakeVoidMetrics constructor
func TestMakeVoidMetrics(t *testing.T) {
t.Run("creates valid Metrics implementation", func(t *testing.T) {
metrics := MakeVoidMetrics()
assert.NotNil(t, metrics, "MakeVoidMetrics should return non-nil Metrics")
})
t.Run("returns voidMetrics type", func(t *testing.T) {
metrics := MakeVoidMetrics()
_, ok := metrics.(*voidMetrics)
assert.True(t, ok, "should return *voidMetrics type")
})
t.Run("initializes noop IO operation", func(t *testing.T) {
metrics := MakeVoidMetrics().(*voidMetrics)
assert.NotNil(t, metrics.noop, "noop IO operation should be initialized")
})
}
// TestVoidMetricsAccept tests the Accept method of voidMetrics
func TestVoidMetricsAccept(t *testing.T) {
t.Run("returns non-nil IO operation", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Accept(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
})
t.Run("IO operation executes without side effects", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Accept(timestamp)
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
t.Run("returns same IO operation instance", func(t *testing.T) {
metrics := MakeVoidMetrics().(*voidMetrics)
timestamp := time.Now()
ioOp1 := metrics.Accept(timestamp)
ioOp2 := metrics.Accept(timestamp)
// Both should be non-nil (we can't compare functions directly in Go)
assert.NotNil(t, ioOp1, "should return non-nil IO operation")
assert.NotNil(t, ioOp2, "should return non-nil IO operation")
// Verify they execute without error
io.Run(ioOp1)
io.Run(ioOp2)
})
t.Run("ignores timestamp parameter", func(t *testing.T) {
metrics := MakeVoidMetrics()
time1 := time.Date(2026, 1, 9, 15, 30, 0, 0, time.UTC)
time2 := time.Date(2026, 1, 9, 16, 30, 0, 0, time.UTC)
ioOp1 := metrics.Accept(time1)
ioOp2 := metrics.Accept(time2)
// Should return same operation regardless of timestamp
io.Run(ioOp1)
io.Run(ioOp2)
// No assertions needed - just verify it doesn't panic
})
}
// TestVoidMetricsReject tests the Reject method of voidMetrics
func TestVoidMetricsReject(t *testing.T) {
t.Run("returns non-nil IO operation", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Reject(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
})
t.Run("IO operation executes without side effects", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Reject(timestamp)
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
t.Run("returns same IO operation instance", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Reject(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
io.Run(ioOp) // Verify it executes without error
})
}
// TestVoidMetricsOpen tests the Open method of voidMetrics
func TestVoidMetricsOpen(t *testing.T) {
t.Run("returns non-nil IO operation", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Open(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
})
t.Run("IO operation executes without side effects", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Open(timestamp)
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
t.Run("returns same IO operation instance", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Open(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
io.Run(ioOp) // Verify it executes without error
})
}
// TestVoidMetricsClose tests the Close method of voidMetrics
func TestVoidMetricsClose(t *testing.T) {
t.Run("returns non-nil IO operation", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Close(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
})
t.Run("IO operation executes without side effects", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Close(timestamp)
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
t.Run("returns same IO operation instance", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Close(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
io.Run(ioOp) // Verify it executes without error
})
}
// TestVoidMetricsCanary tests the Canary method of voidMetrics
func TestVoidMetricsCanary(t *testing.T) {
t.Run("returns non-nil IO operation", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Canary(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
})
t.Run("IO operation executes without side effects", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Canary(timestamp)
result := io.Run(ioOp)
assert.NotNil(t, result, "IO operation should execute successfully")
})
t.Run("returns same IO operation instance", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Canary(timestamp)
assert.NotNil(t, ioOp, "should return non-nil IO operation")
io.Run(ioOp) // Verify it executes without error
})
}
// TestVoidMetricsThreadSafety tests concurrent access to voidMetrics
func TestVoidMetricsThreadSafety(t *testing.T) {
t.Run("handles concurrent metric calls", func(t *testing.T) {
metrics := MakeVoidMetrics()
var wg sync.WaitGroup
numGoroutines := 100
wg.Add(numGoroutines * 5) // 5 methods
timestamp := time.Now()
// Launch multiple goroutines calling all methods concurrently
for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
io.Run(metrics.Accept(timestamp))
}()
go func() {
defer wg.Done()
io.Run(metrics.Reject(timestamp))
}()
go func() {
defer wg.Done()
io.Run(metrics.Open(timestamp))
}()
go func() {
defer wg.Done()
io.Run(metrics.Close(timestamp))
}()
go func() {
defer wg.Done()
io.Run(metrics.Canary(timestamp))
}()
}
wg.Wait()
// Test passes if no panic occurs
})
t.Run("all methods return valid IO operations concurrently", func(t *testing.T) {
metrics := MakeVoidMetrics()
var wg sync.WaitGroup
numGoroutines := 50
wg.Add(numGoroutines)
timestamp := time.Now()
results := make([]IO[Void], numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(idx int) {
defer wg.Done()
// Each goroutine calls a different method
switch idx % 5 {
case 0:
results[idx] = metrics.Accept(timestamp)
case 1:
results[idx] = metrics.Reject(timestamp)
case 2:
results[idx] = metrics.Open(timestamp)
case 3:
results[idx] = metrics.Close(timestamp)
case 4:
results[idx] = metrics.Canary(timestamp)
}
}(i)
}
wg.Wait()
// All results should be non-nil and executable
for i, result := range results {
assert.NotNil(t, result, "result %d should be non-nil", i)
io.Run(result) // Verify it executes without error
}
})
}
// TestVoidMetricsPerformance tests performance characteristics
func TestVoidMetricsPerformance(t *testing.T) {
t.Run("has minimal overhead", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
// Execute many operations quickly
iterations := 10000
for i := 0; i < iterations; i++ {
io.Run(metrics.Accept(timestamp))
io.Run(metrics.Reject(timestamp))
io.Run(metrics.Open(timestamp))
io.Run(metrics.Close(timestamp))
io.Run(metrics.Canary(timestamp))
}
// Test passes if it completes quickly without issues
})
t.Run("all methods return valid IO operations", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
// All methods should return non-nil IO operations
accept := metrics.Accept(timestamp)
reject := metrics.Reject(timestamp)
open := metrics.Open(timestamp)
close := metrics.Close(timestamp)
canary := metrics.Canary(timestamp)
assert.NotNil(t, accept, "Accept should return non-nil")
assert.NotNil(t, reject, "Reject should return non-nil")
assert.NotNil(t, open, "Open should return non-nil")
assert.NotNil(t, close, "Close should return non-nil")
assert.NotNil(t, canary, "Canary should return non-nil")
// All should execute without error
io.Run(accept)
io.Run(reject)
io.Run(open)
io.Run(close)
io.Run(canary)
})
}
// TestVoidMetricsIntegration tests integration scenarios
func TestVoidMetricsIntegration(t *testing.T) {
t.Run("can be used as drop-in replacement for loggingMetrics", func(t *testing.T) {
// Create both types of metrics
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
loggingMetrics := MakeMetricsFromLogger("TestCircuit", logger)
voidMetrics := MakeVoidMetrics()
timestamp := time.Now()
// Both should implement the same interface
var m1 Metrics = loggingMetrics
var m2 Metrics = voidMetrics
// Both should be callable
io.Run(m1.Accept(timestamp))
io.Run(m2.Accept(timestamp))
// Logging metrics should have output
assert.NotEmpty(t, buf.String(), "logging metrics should produce output")
// Void metrics should have no observable side effects
// (we can't directly test this, but the test passes if no panic occurs)
})
t.Run("simulates complete circuit breaker lifecycle without side effects", func(t *testing.T) {
metrics := MakeVoidMetrics()
baseTime := time.Date(2026, 1, 9, 15, 30, 0, 0, time.UTC)
// Simulate circuit breaker lifecycle - all should be no-ops
io.Run(metrics.Accept(baseTime))
io.Run(metrics.Accept(baseTime.Add(1 * time.Second)))
io.Run(metrics.Open(baseTime.Add(2 * time.Second)))
io.Run(metrics.Reject(baseTime.Add(3 * time.Second)))
io.Run(metrics.Canary(baseTime.Add(30 * time.Second)))
io.Run(metrics.Close(baseTime.Add(31 * time.Second)))
// Test passes if no panic occurs and completes quickly
})
}
// TestVoidMetricsEdgeCases tests edge cases
func TestVoidMetricsEdgeCases(t *testing.T) {
t.Run("handles zero time", func(t *testing.T) {
metrics := MakeVoidMetrics()
zeroTime := time.Time{}
io.Run(metrics.Accept(zeroTime))
io.Run(metrics.Reject(zeroTime))
io.Run(metrics.Open(zeroTime))
io.Run(metrics.Close(zeroTime))
io.Run(metrics.Canary(zeroTime))
// Test passes if no panic occurs
})
t.Run("handles far future time", func(t *testing.T) {
metrics := MakeVoidMetrics()
futureTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
io.Run(metrics.Accept(futureTime))
io.Run(metrics.Reject(futureTime))
io.Run(metrics.Open(futureTime))
io.Run(metrics.Close(futureTime))
io.Run(metrics.Canary(futureTime))
// Test passes if no panic occurs
})
t.Run("IO operations are idempotent", func(t *testing.T) {
metrics := MakeVoidMetrics()
timestamp := time.Now()
ioOp := metrics.Accept(timestamp)
// Execute same operation multiple times
io.Run(ioOp)
io.Run(ioOp)
io.Run(ioOp)
// Test passes if no panic occurs
})
}
// TestMetricsComparison compares loggingMetrics and voidMetrics
func TestMetricsComparison(t *testing.T) {
t.Run("both implement Metrics interface", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
var m1 Metrics = MakeMetricsFromLogger("Test", logger)
var m2 Metrics = MakeVoidMetrics()
assert.NotNil(t, m1)
assert.NotNil(t, m2)
})
t.Run("voidMetrics has no observable side effects unlike loggingMetrics", func(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
loggingMetrics := MakeMetricsFromLogger("Test", logger)
voidMetrics := MakeVoidMetrics()
timestamp := time.Now()
// Logging metrics produces output
io.Run(loggingMetrics.Accept(timestamp))
assert.NotEmpty(t, buf.String(), "logging metrics should produce output")
// Void metrics has no observable output
// (we can only verify it doesn't panic)
io.Run(voidMetrics.Accept(timestamp))
})
}

122
v2/circuitbreaker/types.go Normal file
View File

@@ -0,0 +1,122 @@
// Package circuitbreaker provides a functional implementation of the circuit breaker pattern.
// A circuit breaker prevents cascading failures by temporarily blocking requests to a failing service,
// allowing it time to recover before retrying.
//
// # Thread Safety
//
// All data structures in this package are immutable except for IORef[BreakerState].
// The IORef provides thread-safe mutable state through atomic operations.
//
// Immutable types (safe for concurrent use):
// - BreakerState (Either[openState, ClosedState])
// - openState
// - ClosedState implementations (closedStateWithErrorCount, closedStateWithHistory)
// - All function types and readers
//
// Mutable types (thread-safe through atomic operations):
// - IORef[BreakerState] - provides atomic read/write/modify operations
//
// ClosedState implementations must be thread-safe. The recommended approach is to
// return new copies for all operations (Empty, AddError, AddSuccess, Check), which
// provides automatic thread safety through immutability.
package circuitbreaker
import (
"time"
"github.com/IBM/fp-go/v2/either"
"github.com/IBM/fp-go/v2/endomorphism"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioref"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/ord"
"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"
"github.com/IBM/fp-go/v2/retry"
"github.com/IBM/fp-go/v2/state"
)
type (
// Ord is a type alias for ord.Ord, representing a total ordering on type A.
// Used for comparing values in a consistent way.
Ord[A any] = ord.Ord[A]
// Option is a type alias for option.Option, representing an optional value.
// It can be either Some(value) or None, used for safe handling of nullable values.
Option[A any] = option.Option[A]
// Endomorphism is a type alias for endomorphism.Endomorphism, representing a function from A to A.
// Used for transformations that preserve the type.
Endomorphism[A any] = endomorphism.Endomorphism[A]
// IO is a type alias for io.IO, representing a lazy computation that produces a value of type T.
// Used for side-effectful operations that are deferred until execution.
IO[T any] = io.IO[T]
// Pair is a type alias for pair.Pair, representing a tuple of two values.
// Used for grouping related values together.
Pair[L, R any] = pair.Pair[L, R]
// IORef is a type alias for ioref.IORef, representing a mutable reference to a value of type T.
// Used for managing mutable state in a functional way with IO operations.
IORef[T any] = ioref.IORef[T]
// State is a type alias for state.State, representing a stateful computation.
// It transforms a state of type T and produces a result of type R.
State[T, R any] = state.State[T, R]
// Either is a type alias for either.Either, representing a value that can be one of two types.
// Left[E] represents an error or alternative path, Right[A] represents the success path.
Either[E, A any] = either.Either[E, A]
// Predicate is a type alias for predicate.Predicate, representing a function that tests a value.
// Returns true if the value satisfies the predicate condition, false otherwise.
Predicate[A any] = predicate.Predicate[A]
// Reader is a type alias for reader.Reader, representing a computation that depends on an environment R
// and produces a value of type A. Used for dependency injection and configuration.
Reader[R, A any] = reader.Reader[R, A]
ReaderIO[R, A any] = readerio.ReaderIO[R, A]
// openState represents the internal state when the circuit breaker is open.
// In the open state, requests are blocked to give the failing service time to recover.
// The circuit breaker will transition to a half-open state (canary request) after resetAt.
openState struct {
// openedAt is the time when the circuit breaker opened the circuit
openedAt time.Time
// resetAt is the time when the circuit breaker should attempt a canary request
// to test if the service has recovered. Calculated based on the retry policy.
resetAt time.Time
// retryStatus tracks the current retry attempt information, including the number
// of retries and the delay between attempts. Used by the retry policy to calculate
// exponential backoff or other retry strategies.
retryStatus retry.RetryStatus
// canaryRequest indicates whether the circuit is in half-open state, allowing
// a single test request (canary) to check if the service has recovered.
// If true, one request is allowed through to test the service.
// If the canary succeeds, the circuit closes; if it fails, the circuit remains open
// with an extended reset time.
canaryRequest bool
}
// BreakerState represents the current state of the circuit breaker.
// It is an Either type where:
// - Left[openState] represents an open circuit (requests are blocked)
// - Right[ClosedState] represents a closed circuit (requests are allowed through)
//
// State Transitions:
// - Closed -> Open: When failure threshold is exceeded in ClosedState
// - Open -> Half-Open: When resetAt is reached (canaryRequest = true)
// - Half-Open -> Closed: When canary request succeeds
// - Half-Open -> Open: When canary request fails (with extended resetAt)
BreakerState = Either[openState, ClosedState]
Void = function.Void
)

View File

@@ -27,13 +27,15 @@ import (
"strings"
"text/template"
S "github.com/IBM/fp-go/v2/string"
C "github.com/urfave/cli/v2"
)
const (
keyLensDir = "dir"
keyVerbose = "verbose"
lensAnnotation = "fp-go:Lens"
keyLensDir = "dir"
keyVerbose = "verbose"
keyIncludeTestFile = "include-test-files"
lensAnnotation = "fp-go:Lens"
)
var (
@@ -49,6 +51,13 @@ var (
Value: false,
Usage: "Enable verbose output",
}
flagIncludeTestFiles = &C.BoolFlag{
Name: keyIncludeTestFile,
Aliases: []string{"t"},
Value: false,
Usage: "Include test files (*_test.go) when scanning for annotated types",
}
)
// structInfo holds information about a struct that needs lens generation
@@ -67,6 +76,7 @@ type fieldInfo struct {
BaseType string // TypeName without leading * for pointer types
IsOptional bool // true if field is a pointer or has json omitempty tag
IsComparable bool // true if the type is comparable (can use ==)
IsEmbedded bool // true if this field comes from an embedded struct
}
// templateData holds data for template rendering
@@ -80,12 +90,12 @@ const lensStructTemplate = `
type {{.Name}}Lenses{{.TypeParams}} struct {
// mandatory fields
{{- range .Fields}}
{{.Name}} L.Lens[{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
{{.Name}} __lens.Lens[{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
{{- end}}
// optional fields
{{- range .Fields}}
{{- if .IsComparable}}
{{.Name}}O LO.LensO[{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
{{.Name}}O __lens_option.LensO[{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
{{- end}}
{{- end}}
}
@@ -94,13 +104,24 @@ type {{.Name}}Lenses{{.TypeParams}} struct {
type {{.Name}}RefLenses{{.TypeParams}} struct {
// mandatory fields
{{- range .Fields}}
{{.Name}} L.Lens[*{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
{{.Name}} __lens.Lens[*{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
{{- end}}
// optional fields
{{- range .Fields}}
{{- if .IsComparable}}
{{.Name}}O LO.LensO[*{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
{{.Name}}O __lens_option.LensO[*{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
{{- end}}
{{- end}}
// prisms
{{- range .Fields}}
{{.Name}}P __prism.Prism[*{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
{{- end}}
}
// {{.Name}}Prisms provides prisms for accessing fields of {{.Name}}
type {{.Name}}Prisms{{.TypeParams}} struct {
{{- range .Fields}}
{{.Name}} __prism.Prism[{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
{{- end}}
}
`
@@ -110,15 +131,16 @@ const lensConstructorTemplate = `
func Make{{.Name}}Lenses{{.TypeParams}}() {{.Name}}Lenses{{.TypeParamNames}} {
// mandatory lenses
{{- range .Fields}}
lens{{.Name}} := L.MakeLens(
lens{{.Name}} := __lens.MakeLensWithName(
func(s {{$.Name}}{{$.TypeParamNames}}) {{.TypeName}} { return s.{{.Name}} },
func(s {{$.Name}}{{$.TypeParamNames}}, v {{.TypeName}}) {{$.Name}}{{$.TypeParamNames}} { s.{{.Name}} = v; return s },
"{{$.Name}}{{$.TypeParamNames}}.{{.Name}}",
)
{{- end}}
// optional lenses
{{- range .Fields}}
{{- if .IsComparable}}
lens{{.Name}}O := LO.FromIso[{{$.Name}}{{$.TypeParamNames}}](IO.FromZero[{{.TypeName}}]())(lens{{.Name}})
lens{{.Name}}O := __lens_option.FromIso[{{$.Name}}{{$.TypeParamNames}}](__iso_option.FromZero[{{.TypeName}}]())(lens{{.Name}})
{{- end}}
{{- end}}
return {{.Name}}Lenses{{.TypeParamNames}}{
@@ -140,21 +162,23 @@ func Make{{.Name}}RefLenses{{.TypeParams}}() {{.Name}}RefLenses{{.TypeParamNames
// mandatory lenses
{{- range .Fields}}
{{- if .IsComparable}}
lens{{.Name}} := L.MakeLensStrict(
lens{{.Name}} := __lens.MakeLensStrictWithName(
func(s *{{$.Name}}{{$.TypeParamNames}}) {{.TypeName}} { return s.{{.Name}} },
func(s *{{$.Name}}{{$.TypeParamNames}}, v {{.TypeName}}) *{{$.Name}}{{$.TypeParamNames}} { s.{{.Name}} = v; return s },
"(*{{$.Name}}{{$.TypeParamNames}}).{{.Name}}",
)
{{- else}}
lens{{.Name}} := L.MakeLensRef(
lens{{.Name}} := __lens.MakeLensRefWithName(
func(s *{{$.Name}}{{$.TypeParamNames}}) {{.TypeName}} { return s.{{.Name}} },
func(s *{{$.Name}}{{$.TypeParamNames}}, v {{.TypeName}}) *{{$.Name}}{{$.TypeParamNames}} { s.{{.Name}} = v; return s },
"(*{{$.Name}}{{$.TypeParamNames}}).{{.Name}}",
)
{{- end}}
{{- end}}
// optional lenses
{{- range .Fields}}
{{- if .IsComparable}}
lens{{.Name}}O := LO.FromIso[*{{$.Name}}{{$.TypeParamNames}}](IO.FromZero[{{.TypeName}}]())(lens{{.Name}})
lens{{.Name}}O := __lens_option.FromIso[*{{$.Name}}{{$.TypeParamNames}}](__iso_option.FromZero[{{.TypeName}}]())(lens{{.Name}})
{{- end}}
{{- end}}
return {{.Name}}RefLenses{{.TypeParamNames}}{
@@ -170,6 +194,47 @@ func Make{{.Name}}RefLenses{{.TypeParams}}() {{.Name}}RefLenses{{.TypeParamNames
{{- end}}
}
}
// Make{{.Name}}Prisms creates a new {{.Name}}Prisms with prisms for all fields
func Make{{.Name}}Prisms{{.TypeParams}}() {{.Name}}Prisms{{.TypeParamNames}} {
{{- range .Fields}}
{{- if .IsComparable}}
_fromNonZero{{.Name}} := __option.FromNonZero[{{.TypeName}}]()
_prism{{.Name}} := __prism.MakePrismWithName(
func(s {{$.Name}}{{$.TypeParamNames}}) __option.Option[{{.TypeName}}] { return _fromNonZero{{.Name}}(s.{{.Name}}) },
func(v {{.TypeName}}) {{$.Name}}{{$.TypeParamNames}} {
{{- if .IsEmbedded}}
var result {{$.Name}}{{$.TypeParamNames}}
result.{{.Name}} = v
return result
{{- else}}
return {{$.Name}}{{$.TypeParamNames}}{ {{.Name}}: v }
{{- end}}
},
"{{$.Name}}{{$.TypeParamNames}}.{{.Name}}",
)
{{- else}}
_prism{{.Name}} := __prism.MakePrismWithName(
func(s {{$.Name}}{{$.TypeParamNames}}) __option.Option[{{.TypeName}}] { return __option.Some(s.{{.Name}}) },
func(v {{.TypeName}}) {{$.Name}}{{$.TypeParamNames}} {
{{- if .IsEmbedded}}
var result {{$.Name}}{{$.TypeParamNames}}
result.{{.Name}} = v
return result
{{- else}}
return {{$.Name}}{{$.TypeParamNames}}{ {{.Name}}: v }
{{- end}}
},
"{{$.Name}}{{$.TypeParamNames}}.{{.Name}}",
)
{{- end}}
{{- end}}
return {{.Name}}Prisms{{.TypeParamNames}} {
{{- range .Fields}}
{{.Name}}: _prism{{.Name}},
{{- end}}
}
}
`
var (
@@ -439,7 +504,7 @@ func extractEmbeddedFields(embedType ast.Expr, fileImports map[string]string, fi
return results
}
if typeName == "" || typeIdent == nil {
if S.IsEmpty(typeName) || typeIdent == nil {
return results
}
@@ -494,6 +559,7 @@ func extractEmbeddedFields(embedType ast.Expr, fileImports map[string]string, fi
BaseType: baseType,
IsOptional: isOptional,
IsComparable: isComparable,
IsEmbedded: true,
},
fieldType: field.Type,
})
@@ -695,7 +761,7 @@ func parseFile(filename string) ([]structInfo, string, error) {
}
// generateLensHelpers scans a directory for Go files and generates lens code
func generateLensHelpers(dir, filename string, verbose bool) error {
func generateLensHelpers(dir, filename string, verbose, includeTestFiles bool) error {
// Get absolute path
absDir, err := filepath.Abs(dir)
if err != nil {
@@ -716,21 +782,34 @@ func generateLensHelpers(dir, filename string, verbose bool) error {
log.Printf("Found %d Go files", len(files))
}
// Parse all files and collect structs
var allStructs []structInfo
// Parse all files and collect structs, separating test and non-test files
var regularStructs []structInfo
var testStructs []structInfo
var packageName string
for _, file := range files {
// Skip generated files and test files
if strings.HasSuffix(file, "_test.go") || strings.Contains(file, "gen.go") {
baseName := filepath.Base(file)
// Skip generated lens files (both regular and test)
if strings.HasPrefix(baseName, "gen_lens") && strings.HasSuffix(baseName, ".go") {
if verbose {
log.Printf("Skipping file: %s", filepath.Base(file))
log.Printf("Skipping generated lens file: %s", baseName)
}
continue
}
isTestFile := strings.HasSuffix(file, "_test.go")
// Skip test files unless includeTestFiles is true
if isTestFile && !includeTestFiles {
if verbose {
log.Printf("Skipping test file: %s", baseName)
}
continue
}
if verbose {
log.Printf("Parsing file: %s", filepath.Base(file))
log.Printf("Parsing file: %s", baseName)
}
structs, pkg, err := parseFile(file)
@@ -740,27 +819,52 @@ func generateLensHelpers(dir, filename string, verbose bool) error {
}
if verbose && len(structs) > 0 {
log.Printf("Found %d annotated struct(s) in %s", len(structs), filepath.Base(file))
log.Printf("Found %d annotated struct(s) in %s", len(structs), baseName)
for _, s := range structs {
log.Printf(" - %s (%d fields)", s.Name, len(s.Fields))
}
}
if packageName == "" {
if S.IsEmpty(packageName) {
packageName = pkg
}
allStructs = append(allStructs, structs...)
// Separate structs based on source file type
if isTestFile {
testStructs = append(testStructs, structs...)
} else {
regularStructs = append(regularStructs, structs...)
}
}
if len(allStructs) == 0 {
if len(regularStructs) == 0 && len(testStructs) == 0 {
log.Printf("No structs with %s annotation found in %s", lensAnnotation, absDir)
return nil
}
// Generate regular lens file if there are regular structs
if len(regularStructs) > 0 {
if err := generateLensFile(absDir, filename, packageName, regularStructs, verbose); err != nil {
return err
}
}
// Generate test lens file if there are test structs
if len(testStructs) > 0 {
testFilename := strings.TrimSuffix(filename, ".go") + "_test.go"
if err := generateLensFile(absDir, testFilename, packageName, testStructs, verbose); err != nil {
return err
}
}
return nil
}
// generateLensFile generates a lens file for the given structs
func generateLensFile(absDir, filename, packageName string, structs []structInfo, verbose bool) error {
// Collect all unique imports from all structs
allImports := make(map[string]string) // import path -> alias
for _, s := range allStructs {
for _, s := range structs {
for importPath, alias := range s.Imports {
allImports[importPath] = alias
}
@@ -774,7 +878,7 @@ func generateLensHelpers(dir, filename string, verbose bool) error {
}
defer f.Close()
log.Printf("Generating lens code in [%s] for package [%s] with [%d] structs ...", outPath, packageName, len(allStructs))
log.Printf("Generating lens code in [%s] for package [%s] with [%d] structs ...", outPath, packageName, len(structs))
// Write header
writePackage(f, packageName)
@@ -782,10 +886,11 @@ func generateLensHelpers(dir, filename string, verbose bool) error {
// Write imports
f.WriteString("import (\n")
// Standard fp-go imports always needed
f.WriteString("\tL \"github.com/IBM/fp-go/v2/optics/lens\"\n")
f.WriteString("\tLO \"github.com/IBM/fp-go/v2/optics/lens/option\"\n")
// f.WriteString("\tO \"github.com/IBM/fp-go/v2/option\"\n")
f.WriteString("\tIO \"github.com/IBM/fp-go/v2/optics/iso/option\"\n")
f.WriteString("\t__lens \"github.com/IBM/fp-go/v2/optics/lens\"\n")
f.WriteString("\t__option \"github.com/IBM/fp-go/v2/option\"\n")
f.WriteString("\t__prism \"github.com/IBM/fp-go/v2/optics/prism\"\n")
f.WriteString("\t__lens_option \"github.com/IBM/fp-go/v2/optics/lens/option\"\n")
f.WriteString("\t__iso_option \"github.com/IBM/fp-go/v2/optics/iso/option\"\n")
// Add additional imports collected from field types
for importPath, alias := range allImports {
@@ -795,7 +900,7 @@ func generateLensHelpers(dir, filename string, verbose bool) error {
f.WriteString(")\n")
// Generate lens code for each struct using templates
for _, s := range allStructs {
for _, s := range structs {
var buf bytes.Buffer
// Generate struct type
@@ -827,12 +932,14 @@ func LensCommand() *C.Command {
flagLensDir,
flagFilename,
flagVerbose,
flagIncludeTestFiles,
},
Action: func(ctx *C.Context) error {
return generateLensHelpers(
ctx.String(keyLensDir),
ctx.String(keyFilename),
ctx.Bool(keyVerbose),
ctx.Bool(keyIncludeTestFile),
)
},
}

View File

@@ -25,6 +25,7 @@ import (
"strings"
"testing"
S "github.com/IBM/fp-go/v2/string"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -60,7 +61,7 @@ func TestHasLensAnnotation(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var doc *ast.CommentGroup
if tt.comment != "" {
if S.IsNonEmpty(tt.comment) {
doc = &ast.CommentGroup{
List: []*ast.Comment{
{Text: tt.comment},
@@ -289,7 +290,7 @@ func TestHasOmitEmpty(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var tag *ast.BasicLit
if tt.tag != "" {
if S.IsNonEmpty(tt.tag) {
tag = &ast.BasicLit{
Value: tt.tag,
}
@@ -326,7 +327,7 @@ type Other struct {
}
`
err := os.WriteFile(testFile, []byte(testCode), 0644)
err := os.WriteFile(testFile, []byte(testCode), 0o644)
require.NoError(t, err)
// Parse the file
@@ -380,7 +381,7 @@ type Config struct {
}
`
err := os.WriteFile(testFile, []byte(testCode), 0644)
err := os.WriteFile(testFile, []byte(testCode), 0o644)
require.NoError(t, err)
// Parse the file
@@ -440,7 +441,7 @@ type TypeTest struct {
}
`
err := os.WriteFile(testFile, []byte(testCode), 0644)
err := os.WriteFile(testFile, []byte(testCode), 0o644)
require.NoError(t, err)
// Parse the file
@@ -514,16 +515,16 @@ func TestLensRefTemplatesWithComparable(t *testing.T) {
assert.Contains(t, constructorStr, "func MakeTestStructRefLenses() TestStructRefLenses")
// Name field - comparable, should use MakeLensStrict
assert.Contains(t, constructorStr, "lensName := L.MakeLensStrict(",
"comparable field Name should use MakeLensStrict in RefLenses")
assert.Contains(t, constructorStr, "lensName := __lens.MakeLensStrictWithName(",
"comparable field Name should use MakeLensStrictWithName in RefLenses")
// Age field - comparable, should use MakeLensStrict
assert.Contains(t, constructorStr, "lensAge := L.MakeLensStrict(",
"comparable field Age should use MakeLensStrict in RefLenses")
assert.Contains(t, constructorStr, "lensAge := __lens.MakeLensStrictWithName(",
"comparable field Age should use MakeLensStrictWithName in RefLenses")
// Data field - not comparable, should use MakeLensRef
assert.Contains(t, constructorStr, "lensData := L.MakeLensRef(",
"non-comparable field Data should use MakeLensRef in RefLenses")
assert.Contains(t, constructorStr, "lensData := __lens.MakeLensRefWithName(",
"non-comparable field Data should use MakeLensRefWithName in RefLenses")
}
@@ -542,12 +543,12 @@ type TestStruct struct {
`
testFile := filepath.Join(tmpDir, "test.go")
err := os.WriteFile(testFile, []byte(testCode), 0644)
err := os.WriteFile(testFile, []byte(testCode), 0o644)
require.NoError(t, err)
// Generate lens code
outputFile := "gen.go"
err = generateLensHelpers(tmpDir, outputFile, false)
err = generateLensHelpers(tmpDir, outputFile, false, false)
require.NoError(t, err)
// Verify the generated file exists
@@ -564,23 +565,23 @@ type TestStruct struct {
// Check for expected content in RefLenses
assert.Contains(t, contentStr, "MakeTestStructRefLenses")
// Name and Count are comparable, should use MakeLensStrict
assert.Contains(t, contentStr, "L.MakeLensStrict",
"comparable fields should use MakeLensStrict in RefLenses")
// Name and Count are comparable, should use MakeLensStrictWithName
assert.Contains(t, contentStr, "__lens.MakeLensStrictWithName",
"comparable fields should use MakeLensStrictWithName in RefLenses")
// Data is not comparable (slice), should use MakeLensRef
assert.Contains(t, contentStr, "L.MakeLensRef",
"non-comparable fields should use MakeLensRef in RefLenses")
// Data is not comparable (slice), should use MakeLensRefWithName
assert.Contains(t, contentStr, "__lens.MakeLensRefWithName",
"non-comparable fields should use MakeLensRefWithName in RefLenses")
// Verify the pattern appears for Name field (comparable)
namePattern := "lensName := L.MakeLensStrict("
namePattern := "lensName := __lens.MakeLensStrictWithName("
assert.Contains(t, contentStr, namePattern,
"Name field should use MakeLensStrict")
"Name field should use MakeLensStrictWithName")
// Verify the pattern appears for Data field (not comparable)
dataPattern := "lensData := L.MakeLensRef("
dataPattern := "lensData := __lens.MakeLensRefWithName("
assert.Contains(t, contentStr, dataPattern,
"Data field should use MakeLensRef")
"Data field should use MakeLensRefWithName")
}
func TestGenerateLensHelpers(t *testing.T) {
@@ -597,12 +598,12 @@ type TestStruct struct {
`
testFile := filepath.Join(tmpDir, "test.go")
err := os.WriteFile(testFile, []byte(testCode), 0644)
err := os.WriteFile(testFile, []byte(testCode), 0o644)
require.NoError(t, err)
// Generate lens code
outputFile := "gen.go"
err = generateLensHelpers(tmpDir, outputFile, false)
err = generateLensHelpers(tmpDir, outputFile, false, false)
require.NoError(t, err)
// Verify the generated file exists
@@ -621,9 +622,9 @@ type TestStruct struct {
assert.Contains(t, contentStr, "Code generated by go generate")
assert.Contains(t, contentStr, "TestStructLenses")
assert.Contains(t, contentStr, "MakeTestStructLenses")
assert.Contains(t, contentStr, "L.Lens[TestStruct, string]")
assert.Contains(t, contentStr, "LO.LensO[TestStruct, *int]")
assert.Contains(t, contentStr, "IO.FromZero")
assert.Contains(t, contentStr, "__lens.Lens[TestStruct, string]")
assert.Contains(t, contentStr, "__lens_option.LensO[TestStruct, *int]")
assert.Contains(t, contentStr, "__iso_option.FromZero")
}
func TestGenerateLensHelpersNoAnnotations(t *testing.T) {
@@ -639,12 +640,12 @@ type TestStruct struct {
`
testFile := filepath.Join(tmpDir, "test.go")
err := os.WriteFile(testFile, []byte(testCode), 0644)
err := os.WriteFile(testFile, []byte(testCode), 0o644)
require.NoError(t, err)
// Generate lens code (should not create file)
outputFile := "gen.go"
err = generateLensHelpers(tmpDir, outputFile, false)
err = generateLensHelpers(tmpDir, outputFile, false, false)
require.NoError(t, err)
// Verify the generated file does not exist
@@ -669,10 +670,10 @@ func TestLensTemplates(t *testing.T) {
structStr := structBuf.String()
assert.Contains(t, structStr, "type TestStructLenses struct")
assert.Contains(t, structStr, "Name L.Lens[TestStruct, string]")
assert.Contains(t, structStr, "NameO LO.LensO[TestStruct, string]")
assert.Contains(t, structStr, "Value L.Lens[TestStruct, *int]")
assert.Contains(t, structStr, "ValueO LO.LensO[TestStruct, *int]")
assert.Contains(t, structStr, "Name __lens.Lens[TestStruct, string]")
assert.Contains(t, structStr, "NameO __lens_option.LensO[TestStruct, string]")
assert.Contains(t, structStr, "Value __lens.Lens[TestStruct, *int]")
assert.Contains(t, structStr, "ValueO __lens_option.LensO[TestStruct, *int]")
// Test constructor template
var constructorBuf bytes.Buffer
@@ -686,7 +687,7 @@ func TestLensTemplates(t *testing.T) {
assert.Contains(t, constructorStr, "NameO: lensNameO,")
assert.Contains(t, constructorStr, "Value: lensValue,")
assert.Contains(t, constructorStr, "ValueO: lensValueO,")
assert.Contains(t, constructorStr, "IO.FromZero")
assert.Contains(t, constructorStr, "__iso_option.FromZero")
}
func TestLensTemplatesWithOmitEmpty(t *testing.T) {
@@ -707,14 +708,14 @@ func TestLensTemplatesWithOmitEmpty(t *testing.T) {
structStr := structBuf.String()
assert.Contains(t, structStr, "type ConfigStructLenses struct")
assert.Contains(t, structStr, "Name L.Lens[ConfigStruct, string]")
assert.Contains(t, structStr, "NameO LO.LensO[ConfigStruct, string]")
assert.Contains(t, structStr, "Value L.Lens[ConfigStruct, string]")
assert.Contains(t, structStr, "ValueO LO.LensO[ConfigStruct, string]", "comparable non-pointer with omitempty should have optional lens")
assert.Contains(t, structStr, "Count L.Lens[ConfigStruct, int]")
assert.Contains(t, structStr, "CountO LO.LensO[ConfigStruct, int]", "comparable non-pointer with omitempty should have optional lens")
assert.Contains(t, structStr, "Pointer L.Lens[ConfigStruct, *string]")
assert.Contains(t, structStr, "PointerO LO.LensO[ConfigStruct, *string]")
assert.Contains(t, structStr, "Name __lens.Lens[ConfigStruct, string]")
assert.Contains(t, structStr, "NameO __lens_option.LensO[ConfigStruct, string]")
assert.Contains(t, structStr, "Value __lens.Lens[ConfigStruct, string]")
assert.Contains(t, structStr, "ValueO __lens_option.LensO[ConfigStruct, string]", "comparable non-pointer with omitempty should have optional lens")
assert.Contains(t, structStr, "Count __lens.Lens[ConfigStruct, int]")
assert.Contains(t, structStr, "CountO __lens_option.LensO[ConfigStruct, int]", "comparable non-pointer with omitempty should have optional lens")
assert.Contains(t, structStr, "Pointer __lens.Lens[ConfigStruct, *string]")
assert.Contains(t, structStr, "PointerO __lens_option.LensO[ConfigStruct, *string]")
// Test constructor template
var constructorBuf bytes.Buffer
@@ -723,9 +724,9 @@ func TestLensTemplatesWithOmitEmpty(t *testing.T) {
constructorStr := constructorBuf.String()
assert.Contains(t, constructorStr, "func MakeConfigStructLenses() ConfigStructLenses")
assert.Contains(t, constructorStr, "IO.FromZero[string]()")
assert.Contains(t, constructorStr, "IO.FromZero[int]()")
assert.Contains(t, constructorStr, "IO.FromZero[*string]()")
assert.Contains(t, constructorStr, "__iso_option.FromZero[string]()")
assert.Contains(t, constructorStr, "__iso_option.FromZero[int]()")
assert.Contains(t, constructorStr, "__iso_option.FromZero[*string]()")
}
func TestLensCommandFlags(t *testing.T) {
@@ -737,9 +738,9 @@ func TestLensCommandFlags(t *testing.T) {
assert.Contains(t, strings.ToLower(cmd.Description), "lenso", "Description should mention LensO for optional lenses")
// Check flags
assert.Len(t, cmd.Flags, 3)
assert.Len(t, cmd.Flags, 4)
var hasDir, hasFilename, hasVerbose bool
var hasDir, hasFilename, hasVerbose, hasIncludeTestFiles bool
for _, flag := range cmd.Flags {
switch flag.Names()[0] {
case "dir":
@@ -748,12 +749,15 @@ func TestLensCommandFlags(t *testing.T) {
hasFilename = true
case "verbose":
hasVerbose = true
case "include-test-files":
hasIncludeTestFiles = true
}
}
assert.True(t, hasDir, "should have dir flag")
assert.True(t, hasFilename, "should have filename flag")
assert.True(t, hasVerbose, "should have verbose flag")
assert.True(t, hasIncludeTestFiles, "should have include-test-files flag")
}
func TestParseFileWithEmbeddedStruct(t *testing.T) {
@@ -776,7 +780,7 @@ type Extended struct {
}
`
err := os.WriteFile(testFile, []byte(testCode), 0644)
err := os.WriteFile(testFile, []byte(testCode), 0o644)
require.NoError(t, err)
// Parse the file
@@ -824,12 +828,12 @@ type Person struct {
`
testFile := filepath.Join(tmpDir, "test.go")
err := os.WriteFile(testFile, []byte(testCode), 0644)
err := os.WriteFile(testFile, []byte(testCode), 0o644)
require.NoError(t, err)
// Generate lens code
outputFile := "gen.go"
err = generateLensHelpers(tmpDir, outputFile, false)
err = generateLensHelpers(tmpDir, outputFile, false, false)
require.NoError(t, err)
// Verify the generated file exists
@@ -849,14 +853,14 @@ type Person struct {
assert.Contains(t, contentStr, "MakePersonLenses")
// Check that embedded fields are included
assert.Contains(t, contentStr, "Street L.Lens[Person, string]", "Should have lens for embedded Street field")
assert.Contains(t, contentStr, "City L.Lens[Person, string]", "Should have lens for embedded City field")
assert.Contains(t, contentStr, "Name L.Lens[Person, string]", "Should have lens for Name field")
assert.Contains(t, contentStr, "Age L.Lens[Person, int]", "Should have lens for Age field")
assert.Contains(t, contentStr, "Street __lens.Lens[Person, string]", "Should have lens for embedded Street field")
assert.Contains(t, contentStr, "City __lens.Lens[Person, string]", "Should have lens for embedded City field")
assert.Contains(t, contentStr, "Name __lens.Lens[Person, string]", "Should have lens for Name field")
assert.Contains(t, contentStr, "Age __lens.Lens[Person, int]", "Should have lens for Age field")
// Check that optional lenses are also generated for embedded fields
assert.Contains(t, contentStr, "StreetO LO.LensO[Person, string]")
assert.Contains(t, contentStr, "CityO LO.LensO[Person, string]")
assert.Contains(t, contentStr, "StreetO __lens_option.LensO[Person, string]")
assert.Contains(t, contentStr, "CityO __lens_option.LensO[Person, string]")
}
func TestParseFileWithPointerEmbeddedStruct(t *testing.T) {
@@ -880,7 +884,7 @@ type Document struct {
}
`
err := os.WriteFile(testFile, []byte(testCode), 0644)
err := os.WriteFile(testFile, []byte(testCode), 0o644)
require.NoError(t, err)
// Parse the file
@@ -922,7 +926,7 @@ type Container[T any] struct {
}
`
err := os.WriteFile(testFile, []byte(testCode), 0644)
err := os.WriteFile(testFile, []byte(testCode), 0o644)
require.NoError(t, err)
// Parse the file
@@ -960,7 +964,7 @@ type Pair[K comparable, V any] struct {
}
`
err := os.WriteFile(testFile, []byte(testCode), 0644)
err := os.WriteFile(testFile, []byte(testCode), 0o644)
require.NoError(t, err)
// Parse the file
@@ -998,12 +1002,12 @@ type Box[T any] struct {
`
testFile := filepath.Join(tmpDir, "test.go")
err := os.WriteFile(testFile, []byte(testCode), 0644)
err := os.WriteFile(testFile, []byte(testCode), 0o644)
require.NoError(t, err)
// Generate lens code
outputFile := "gen.go"
err = generateLensHelpers(tmpDir, outputFile, false)
err = generateLensHelpers(tmpDir, outputFile, false, false)
require.NoError(t, err)
// Verify the generated file exists
@@ -1025,14 +1029,14 @@ type Box[T any] struct {
assert.Contains(t, contentStr, "func MakeBoxRefLenses[T any]() BoxRefLenses[T]", "Should have generic ref constructor")
// Check that fields use the generic type parameter
assert.Contains(t, contentStr, "Content L.Lens[Box[T], T]", "Should have lens for generic Content field")
assert.Contains(t, contentStr, "Label L.Lens[Box[T], string]", "Should have lens for Label field")
assert.Contains(t, contentStr, "Content __lens.Lens[Box[T], T]", "Should have lens for generic Content field")
assert.Contains(t, contentStr, "Label __lens.Lens[Box[T], string]", "Should have lens for Label field")
// Check optional lenses - only for comparable types
// T any is not comparable, so ContentO should NOT be generated
assert.NotContains(t, contentStr, "ContentO LO.LensO[Box[T], T]", "T any is not comparable, should not have optional lens")
assert.NotContains(t, contentStr, "ContentO __lens_option.LensO[Box[T], T]", "T any is not comparable, should not have optional lens")
// string is comparable, so LabelO should be generated
assert.Contains(t, contentStr, "LabelO LO.LensO[Box[T], string]", "string is comparable, should have optional lens")
assert.Contains(t, contentStr, "LabelO __lens_option.LensO[Box[T], string]", "string is comparable, should have optional lens")
}
func TestGenerateLensHelpersWithComparableTypeParam(t *testing.T) {
@@ -1049,12 +1053,12 @@ type ComparableBox[T comparable] struct {
`
testFile := filepath.Join(tmpDir, "test.go")
err := os.WriteFile(testFile, []byte(testCode), 0644)
err := os.WriteFile(testFile, []byte(testCode), 0o644)
require.NoError(t, err)
// Generate lens code
outputFile := "gen.go"
err = generateLensHelpers(tmpDir, outputFile, false)
err = generateLensHelpers(tmpDir, outputFile, false, false)
require.NoError(t, err)
// Verify the generated file exists
@@ -1074,11 +1078,11 @@ type ComparableBox[T comparable] struct {
assert.Contains(t, contentStr, "type ComparableBoxRefLenses[T comparable] struct", "Should have generic ComparableBoxRefLenses type")
// Check that Key field (with comparable constraint) uses MakeLensStrict in RefLenses
assert.Contains(t, contentStr, "lensKey := L.MakeLensStrict(", "Key field with comparable constraint should use MakeLensStrict")
assert.Contains(t, contentStr, "lensKey := __lens.MakeLensStrictWithName(", "Key field with comparable constraint should use MakeLensStrictWithName")
// Check that Value field (string, always comparable) also uses MakeLensStrict
assert.Contains(t, contentStr, "lensValue := L.MakeLensStrict(", "Value field (string) should use MakeLensStrict")
assert.Contains(t, contentStr, "lensValue := __lens.MakeLensStrictWithName(", "Value field (string) should use MakeLensStrictWithName")
// Verify that MakeLensRef is NOT used (since both fields are comparable)
assert.NotContains(t, contentStr, "L.MakeLensRef(", "Should not use MakeLensRef when all fields are comparable")
assert.NotContains(t, contentStr, "__lens.MakeLensRefWithName(", "Should not use MakeLensRefWithName when all fields are comparable")
}

View File

@@ -19,6 +19,8 @@ import (
"fmt"
"os"
"strings"
S "github.com/IBM/fp-go/v2/string"
)
// Deprecated:
@@ -176,7 +178,7 @@ func generateTraverseTuple1(
}
fmt.Fprintf(f, "F%d ~func(A%d) %s", j+1, j+1, hkt(fmt.Sprintf("T%d", j+1)))
}
if infix != "" {
if S.IsNonEmpty(infix) {
fmt.Fprintf(f, ", %s", infix)
}
// types
@@ -209,7 +211,7 @@ func generateTraverseTuple1(
fmt.Fprintf(f, " return A.TraverseTuple%d(\n", i)
// map
fmt.Fprintf(f, " Map[")
if infix != "" {
if S.IsNonEmpty(infix) {
fmt.Fprintf(f, "%s, T1,", infix)
} else {
fmt.Fprintf(f, "T1,")
@@ -231,7 +233,7 @@ func generateTraverseTuple1(
fmt.Fprintf(f, " ")
}
fmt.Fprintf(f, "%s", tuple)
if infix != "" {
if S.IsNonEmpty(infix) {
fmt.Fprintf(f, ", %s", infix)
}
fmt.Fprintf(f, ", T%d],\n", j+1)
@@ -256,11 +258,11 @@ func generateSequenceTuple1(
fmt.Fprintf(f, "\n// SequenceTuple%d converts a [Tuple%d] of [%s] into an [%s].\n", i, i, hkt("T"), hkt(fmt.Sprintf("Tuple%d", i)))
fmt.Fprintf(f, "func SequenceTuple%d[", i)
if infix != "" {
if S.IsNonEmpty(infix) {
fmt.Fprintf(f, "%s", infix)
}
for j := 0; j < i; j++ {
if infix != "" || j > 0 {
if S.IsNonEmpty(infix) || j > 0 {
fmt.Fprintf(f, ", ")
}
fmt.Fprintf(f, "T%d", j+1)
@@ -276,7 +278,7 @@ func generateSequenceTuple1(
fmt.Fprintf(f, " return A.SequenceTuple%d(\n", i)
// map
fmt.Fprintf(f, " Map[")
if infix != "" {
if S.IsNonEmpty(infix) {
fmt.Fprintf(f, "%s, T1,", infix)
} else {
fmt.Fprintf(f, "T1,")
@@ -298,7 +300,7 @@ func generateSequenceTuple1(
fmt.Fprintf(f, " ")
}
fmt.Fprintf(f, "%s", tuple)
if infix != "" {
if S.IsNonEmpty(infix) {
fmt.Fprintf(f, ", %s", infix)
}
fmt.Fprintf(f, ", T%d],\n", j+1)
@@ -319,11 +321,11 @@ func generateSequenceT1(
fmt.Fprintf(f, "\n// SequenceT%d converts %d parameters of [%s] into a [%s].\n", i, i, hkt("T"), hkt(fmt.Sprintf("Tuple%d", i)))
fmt.Fprintf(f, "func SequenceT%d[", i)
if infix != "" {
if S.IsNonEmpty(infix) {
fmt.Fprintf(f, "%s", infix)
}
for j := 0; j < i; j++ {
if infix != "" || j > 0 {
if S.IsNonEmpty(infix) || j > 0 {
fmt.Fprintf(f, ", ")
}
fmt.Fprintf(f, "T%d", j+1)
@@ -339,7 +341,7 @@ func generateSequenceT1(
fmt.Fprintf(f, " return A.SequenceT%d(\n", i)
// map
fmt.Fprintf(f, " Map[")
if infix != "" {
if S.IsNonEmpty(infix) {
fmt.Fprintf(f, "%s, T1,", infix)
} else {
fmt.Fprintf(f, "T1,")
@@ -361,7 +363,7 @@ func generateSequenceT1(
fmt.Fprintf(f, " ")
}
fmt.Fprintf(f, "%s", tuple)
if infix != "" {
if S.IsNonEmpty(infix) {
fmt.Fprintf(f, ", %s", infix)
}
fmt.Fprintf(f, ", T%d],\n", j+1)

View File

@@ -19,11 +19,13 @@ package consumer
// This is the contravariant map operation for Consumers, analogous to reader.Local
// but operating on the input side rather than the output side.
//
// See: https://github.com/fantasyland/fantasy-land?tab=readme-ov-file#profunctor
//
// Given a Consumer[R1] that consumes values of type R1, and a function f that
// converts R2 to R1, Local creates a new Consumer[R2] that:
// 1. Takes a value of type R2
// 2. Applies f to convert it to R1
// 3. Passes the result to the original Consumer[R1]
// 1. Takes a value of type R2
// 2. Applies f to convert it to R1
// 3. Passes the result to the original Consumer[R1]
//
// This is particularly useful for adapting consumers to work with different input types,
// similar to how reader.Local adapts readers to work with different environment types.
@@ -168,7 +170,7 @@ package consumer
// - reader.Local transforms the environment before reading
// - consumer.Local transforms the input before consuming
// - Both are contravariant functors on their input type
func Local[R2, R1 any](f func(R2) R1) Operator[R1, R2] {
func Local[R1, R2 any](f func(R2) R1) Operator[R1, R2] {
return func(c Consumer[R1]) Consumer[R2] {
return func(r2 R2) {
c(f(r2))

View File

@@ -52,5 +52,7 @@ type (
// }
Consumer[A any] = func(A)
// Operator represents a function that transforms a Consumer[A] into a Consumer[B].
// This is useful for composing and adapting consumers to work with different types.
Operator[A, B any] = func(Consumer[A]) Consumer[B]
)

View File

@@ -21,7 +21,34 @@ import (
"github.com/IBM/fp-go/v2/result"
)
// withContext wraps an existing IOEither and performs a context check for cancellation before delegating
// WithContext wraps an IOResult and performs a context check for cancellation before executing.
// This ensures that if the context is already cancelled, the computation short-circuits immediately
// without executing the wrapped computation.
//
// This is useful for adding cancellation awareness to computations that might not check the context themselves.
//
// Type Parameters:
// - A: The type of the success value
//
// Parameters:
// - ctx: The context to check for cancellation
// - ma: The IOResult to wrap with context checking
//
// Returns:
// - An IOResult that checks for cancellation before executing
//
// Example:
//
// computation := func() Result[string] {
// // Long-running operation
// return result.Of("done")
// }
//
// ctx, cancel := context.WithCancel(context.Background())
// cancel() // Cancel immediately
//
// wrapped := WithContext(ctx, computation)
// result := wrapped() // Returns Left with context.Canceled error
func WithContext[A any](ctx context.Context, ma IOResult[A]) IOResult[A] {
return func() Result[A] {
if ctx.Err() != nil {

View File

@@ -6,6 +6,11 @@ import (
)
type (
// IOResult represents a synchronous computation that may fail with an error.
// It's an alias for ioresult.IOResult[T].
IOResult[T any] = ioresult.IOResult[T]
Result[T any] = result.Result[T]
// Result represents a computation that may fail with an error.
// It's an alias for result.Result[T].
Result[T any] = result.Result[T]
)

View File

@@ -4,6 +4,65 @@ import (
RIO "github.com/IBM/fp-go/v2/readerio"
)
// Bracket ensures that a resource is properly acquired, used, and released, even if an error occurs.
// This implements the bracket pattern for safe resource management with [ReaderIO].
//
// The bracket pattern guarantees that:
// - The acquire action is executed first to obtain the resource
// - The use function is called with the acquired resource
// - The release function is always called with the resource and result, regardless of success or failure
// - The final result from the use function is returned
//
// This is particularly useful for managing resources like file handles, database connections,
// or locks that must be cleaned up properly.
//
// Type Parameters:
// - A: The type of the acquired resource
// - B: The type of the result produced by the use function
// - ANY: The type returned by the release function (typically ignored)
//
// Parameters:
// - acquire: A ReaderIO that acquires the resource
// - use: A Kleisli arrow that uses the resource and produces a result
// - release: A function that releases the resource, receiving both the resource and the result
//
// Returns:
// - A ReaderIO[B] that safely manages the resource lifecycle
//
// Example:
//
// // Acquire a file handle
// acquireFile := func(ctx context.Context) IO[*os.File] {
// return func() *os.File {
// f, _ := os.Open("data.txt")
// return f
// }
// }
//
// // Use the file
// readFile := func(f *os.File) ReaderIO[string] {
// return func(ctx context.Context) IO[string] {
// return func() string {
// data, _ := io.ReadAll(f)
// return string(data)
// }
// }
// }
//
// // Release the file
// closeFile := func(f *os.File, result string) ReaderIO[any] {
// return func(ctx context.Context) IO[any] {
// return func() any {
// f.Close()
// return nil
// }
// }
// }
//
// // Safely read file with automatic cleanup
// safeRead := Bracket(acquireFile, readFile, closeFile)
// result := safeRead(context.Background())()
//
//go:inline
func Bracket[
A, B, ANY any](

View File

@@ -2,12 +2,64 @@ package readerio
import "github.com/IBM/fp-go/v2/io"
// ChainConsumer chains a consumer function into a ReaderIO computation, discarding the original value.
// This is useful for performing side effects (like logging or metrics) that consume a value
// but don't produce a meaningful result.
//
// The consumer is executed for its side effects, and the computation returns an empty struct.
//
// Type Parameters:
// - A: The type of value to consume
//
// Parameters:
// - c: A consumer function that performs side effects on the value
//
// Returns:
// - An Operator that chains the consumer and returns struct{}
//
// Example:
//
// logUser := func(u User) {
// log.Printf("Processing user: %s", u.Name)
// }
//
// pipeline := F.Pipe2(
// fetchUser(123),
// ChainConsumer(logUser),
// )
//
//go:inline
func ChainConsumer[A any](c Consumer[A]) Operator[A, struct{}] {
return ChainIOK(io.FromConsumerK(c))
func ChainConsumer[A any](c Consumer[A]) Operator[A, Void] {
return ChainIOK(io.FromConsumer(c))
}
// ChainFirstConsumer chains a consumer function into a ReaderIO computation, preserving the original value.
// This is useful for performing side effects (like logging or metrics) while passing the value through unchanged.
//
// The consumer is executed for its side effects, but the original value is returned.
//
// Type Parameters:
// - A: The type of value to consume and return
//
// Parameters:
// - c: A consumer function that performs side effects on the value
//
// Returns:
// - An Operator that chains the consumer and returns the original value
//
// Example:
//
// logUser := func(u User) {
// log.Printf("User: %s", u.Name)
// }
//
// pipeline := F.Pipe3(
// fetchUser(123),
// ChainFirstConsumer(logUser), // Logs but passes user through
// Map(func(u User) string { return u.Email }),
// )
//
//go:inline
func ChainFirstConsumer[A any](c Consumer[A]) Operator[A, A] {
return ChainFirstIOK(io.FromConsumerK(c))
return ChainFirstIOK(io.FromConsumer(c))
}

View File

@@ -7,11 +7,108 @@ import (
RIO "github.com/IBM/fp-go/v2/readerio"
)
// SequenceReader transforms a ReaderIO containing a Reader into a Reader containing a ReaderIO.
// This "flips" the nested structure, allowing you to provide the Reader's environment first,
// then get a ReaderIO that can be executed with a context.
//
// Type transformation:
//
// From: ReaderIO[Reader[R, A]]
// = func(context.Context) func() func(R) A
//
// To: Reader[R, ReaderIO[A]]
// = func(R) func(context.Context) func() A
//
// This is useful for point-free style programming where you want to partially apply
// the Reader's environment before dealing with the context.
//
// Type Parameters:
// - R: The environment type that the Reader depends on
// - A: The value type
//
// Parameters:
// - ma: A ReaderIO containing a Reader
//
// Returns:
// - A Reader that produces a ReaderIO when given an environment
//
// Example:
//
// type Config struct {
// Timeout int
// }
//
// // A computation that produces a Reader
// getMultiplier := func(ctx context.Context) IO[func(Config) int] {
// return func() func(Config) int {
// return func(cfg Config) int {
// return cfg.Timeout * 2
// }
// }
// }
//
// // Sequence it to apply Config first
// sequenced := SequenceReader[Config, int](getMultiplier)
// cfg := Config{Timeout: 30}
// result := sequenced(cfg)(context.Background())() // Returns 60
//
//go:inline
func SequenceReader[R, A any](ma ReaderIO[Reader[R, A]]) Reader[R, ReaderIO[A]] {
return RIO.SequenceReader(ma)
}
// TraverseReader applies a Reader-based transformation to a ReaderIO, introducing a new environment dependency.
//
// This function takes a Reader-based Kleisli arrow and returns a function that can transform
// a ReaderIO. The result allows you to provide the Reader's environment (R) first, which then
// produces a ReaderIO that depends on the context.
//
// Type transformation:
//
// From: ReaderIO[A]
// = func(context.Context) func() A
//
// With: reader.Kleisli[R, A, B]
// = func(A) func(R) B
//
// To: func(ReaderIO[A]) func(R) ReaderIO[B]
// = func(ReaderIO[A]) func(R) func(context.Context) func() B
//
// This enables transforming values within a ReaderIO using environment-dependent logic.
//
// Type Parameters:
// - R: The environment type that the Reader depends on
// - A: The input value type
// - B: The output value type
//
// Parameters:
// - f: A Reader-based Kleisli arrow that transforms A to B using environment R
//
// Returns:
// - A function that takes a ReaderIO[A] and returns a function from R to ReaderIO[B]
//
// Example:
//
// type Config struct {
// Multiplier int
// }
//
// // A Reader-based transformation
// multiply := func(x int) func(Config) int {
// return func(cfg Config) int {
// return x * cfg.Multiplier
// }
// }
//
// // Apply TraverseReader
// traversed := TraverseReader[Config, int, int](multiply)
// computation := Of(10)
// result := traversed(computation)
//
// // Provide Config to get final result
// cfg := Config{Multiplier: 5}
// finalResult := result(cfg)(context.Background())() // Returns 50
//
//go:inline
func TraverseReader[R, A, B any](
f reader.Kleisli[R, A, B],

View File

@@ -7,6 +7,40 @@ import (
"github.com/IBM/fp-go/v2/logging"
)
// SLogWithCallback creates a Kleisli arrow that logs a value with a custom logger and log level.
// The value is logged and then passed through unchanged, making this useful for debugging
// and monitoring values as they flow through a ReaderIO computation.
//
// Type Parameters:
// - A: The type of value to log and pass through
//
// Parameters:
// - logLevel: The slog.Level to use for logging (e.g., slog.LevelInfo, slog.LevelDebug)
// - cb: Callback function to retrieve the *slog.Logger from the context
// - message: A descriptive message to include in the log entry
//
// Returns:
// - A Kleisli arrow that logs the value and returns it unchanged
//
// Example:
//
// getMyLogger := func(ctx context.Context) *slog.Logger {
// if logger := ctx.Value("logger"); logger != nil {
// return logger.(*slog.Logger)
// }
// return slog.Default()
// }
//
// debugLog := SLogWithCallback[User](
// slog.LevelDebug,
// getMyLogger,
// "Processing user",
// )
//
// pipeline := F.Pipe2(
// fetchUser(123),
// Chain(debugLog),
// )
func SLogWithCallback[A any](
logLevel slog.Level,
cb func(context.Context) *slog.Logger,
@@ -23,6 +57,34 @@ func SLogWithCallback[A any](
}
}
// SLog creates a Kleisli arrow that logs a value at Info level and passes it through unchanged.
// This is a convenience wrapper around SLogWithCallback with standard settings.
//
// The value is logged with the provided message and then returned unchanged, making this
// useful for debugging and monitoring values in a ReaderIO computation pipeline.
//
// Type Parameters:
// - A: The type of value to log and pass through
//
// Parameters:
// - message: A descriptive message to include in the log entry
//
// Returns:
// - A Kleisli arrow that logs the value at Info level and returns it unchanged
//
// Example:
//
// pipeline := F.Pipe3(
// fetchUser(123),
// Chain(SLog[User]("Fetched user")),
// Map(func(u User) string { return u.Name }),
// Chain(SLog[string]("Extracted name")),
// )
//
// result := pipeline(context.Background())()
// // Logs: "Fetched user" value={ID:123 Name:"Alice"}
// // Logs: "Extracted name" value="Alice"
//
//go:inline
func SLog[A any](message string) Kleisli[A, A] {
return SLogWithCallback[A](slog.LevelInfo, logging.GetLoggerFromContext, message)

View File

@@ -0,0 +1,74 @@
// Copyright (c) 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"
"github.com/IBM/fp-go/v2/function"
)
// Promap is the profunctor map operation that transforms both the input and output of a context-based ReaderIO.
// It applies f to the input context (contravariantly) and g to the output value (covariantly).
//
// See: https://github.com/fantasyland/fantasy-land?tab=readme-ov-file#profunctor
//
// This operation allows you to:
// - Modify the context before passing it to the ReaderIO (via f)
// - Transform the result value after the IO effect completes (via g)
//
// The function f returns both a new context and a CancelFunc that should be called to release resources.
//
// Type Parameters:
// - A: The original result type produced by the ReaderIO
// - B: The new output result type
//
// Parameters:
// - f: Function to transform the input 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]
//
//go:inline
func Promap[A, B any](f func(context.Context) (context.Context, context.CancelFunc), g func(A) B) Operator[A, B] {
return function.Flow2(
Local[A](f),
Map(g),
)
}
// Contramap changes the context during the execution of a ReaderIO.
// This is the contravariant functor operation that transforms the input context.
//
// See: https://github.com/fantasyland/fantasy-land?tab=readme-ov-file#profunctor
//
// Contramap is an alias for Local and is useful for adapting a ReaderIO to work with
// a modified context by providing a function that transforms the context.
//
// Type Parameters:
// - A: The result type (unchanged)
//
// Parameters:
// - f: Function to transform the context, returning a new context and CancelFunc
//
// Returns:
// - An Operator that takes a ReaderIO[A] and returns a ReaderIO[A]
//
//go:inline
func Contramap[A any](f func(context.Context) (context.Context, context.CancelFunc)) Operator[A, A] {
return Local[A](f)
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) 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"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// TestPromapBasic tests basic Promap functionality
func TestPromapBasic(t *testing.T) {
t.Run("transform both context and output", func(t *testing.T) {
// ReaderIO that reads a value from context
getValue := func(ctx context.Context) IO[int] {
return func() int {
if v := ctx.Value("key"); v != nil {
return v.(int)
}
return 0
}
}
// Transform context and result
addKey := func(ctx context.Context) (context.Context, context.CancelFunc) {
newCtx := context.WithValue(ctx, "key", 42)
return newCtx, func() {}
}
toString := strconv.Itoa
adapted := Promap(addKey, toString)(getValue)
result := adapted(context.Background())()
assert.Equal(t, "42", result)
})
}
// TestContramapBasic tests basic Contramap functionality
func TestContramapBasic(t *testing.T) {
t.Run("context transformation", func(t *testing.T) {
getValue := func(ctx context.Context) IO[int] {
return func() int {
if v := ctx.Value("key"); v != nil {
return v.(int)
}
return 0
}
}
addKey := func(ctx context.Context) (context.Context, context.CancelFunc) {
newCtx := context.WithValue(ctx, "key", 100)
return newCtx, func() {}
}
adapted := Contramap[int](addKey)(getValue)
result := adapted(context.Background())()
assert.Equal(t, 100, result)
})
}
// TestLocalBasic tests basic Local functionality
func TestLocalBasic(t *testing.T) {
t.Run("adds timeout to context", func(t *testing.T) {
getValue := func(ctx context.Context) IO[bool] {
return func() bool {
_, hasDeadline := ctx.Deadline()
return hasDeadline
}
}
addTimeout := func(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, time.Second)
}
adapted := Local[bool](addTimeout)(getValue)
result := adapted(context.Background())()
assert.True(t, result)
})
}

View File

@@ -560,6 +560,63 @@ func Read[A any](r context.Context) func(ReaderIO[A]) IO[A] {
return RIO.Read[A](r)
}
// ReadIO executes a ReaderIO computation by providing a context wrapped in an IO effect.
// This is useful when the context itself needs to be computed or retrieved through side effects.
//
// The function takes an IO[context.Context] (an effectful computation that produces a context) and returns
// a function that can execute a ReaderIO[A] to produce an IO[A].
//
// This is particularly useful in scenarios where:
// - The context needs to be created with side effects (e.g., loading configuration)
// - The context requires initialization or setup
// - You want to compose context creation with the computation that uses it
//
// The execution flow is:
// 1. Execute the IO[context.Context] to get the context
// 2. Pass the context to the ReaderIO[A] to get an IO[A]
// 3. Execute the resulting IO[A] to get the final result A
//
// Type Parameters:
// - A: The result type of the ReaderIO computation
//
// Parameters:
// - r: An IO effect that produces a context.Context
//
// Returns:
// - A function that takes a ReaderIO[A] and returns an IO[A]
//
// Example:
//
// import (
// "context"
// G "github.com/IBM/fp-go/v2/io"
// F "github.com/IBM/fp-go/v2/function"
// )
//
// // Create context with side effects (e.g., loading config)
// createContext := G.Of(context.WithValue(context.Background(), "key", "value"))
//
// // A computation that uses the context
// getValue := readerio.FromReader(func(ctx context.Context) string {
// if val := ctx.Value("key"); val != nil {
// return val.(string)
// }
// return "default"
// })
//
// // Compose them together
// result := readerio.ReadIO[string](createContext)(getValue)
// value := result() // Executes both effects and returns "value"
//
// Comparison with Read:
// - [Read]: Takes a pure context.Context value and executes the ReaderIO immediately
// - [ReadIO]: Takes an IO[context.Context] and chains the effects together
//
//go:inline
func ReadIO[A any](r IO[context.Context]) func(ReaderIO[A]) IO[A] {
return RIO.ReadIO[A](r)
}
// Local transforms the context.Context environment before passing it to a ReaderIO computation.
//
// This is the Reader's local operation, which allows you to modify the environment
@@ -753,3 +810,17 @@ func WithDeadline[A any](deadline time.Time) Operator[A, A] {
return context.WithDeadline(ctx, deadline)
})
}
// Delay creates an operation that passes in the value after some delay
//
//go:inline
func Delay[A any](delay time.Duration) Operator[A, A] {
return RIO.Delay[context.Context, A](delay)
}
// After creates an operation that passes after the given [time.Time]
//
//go:inline
func After[R, E, A any](timestamp time.Time) Operator[A, A] {
return RIO.After[context.Context, A](timestamp)
}

View File

@@ -500,3 +500,188 @@ func TestTapWithLogging(t *testing.T) {
assert.Equal(t, 84, value)
assert.Equal(t, []int{42, 84}, logged)
}
func TestReadIO(t *testing.T) {
// Test basic ReadIO functionality
contextIO := G.Of(context.WithValue(context.Background(), "testKey", "testValue"))
rio := FromReader(func(ctx context.Context) string {
if val := ctx.Value("testKey"); val != nil {
return val.(string)
}
return "default"
})
ioAction := ReadIO[string](contextIO)(rio)
result := ioAction()
assert.Equal(t, "testValue", result)
}
func TestReadIOWithBackground(t *testing.T) {
// Test ReadIO with plain background context
contextIO := G.Of(context.Background())
rio := Of(42)
ioAction := ReadIO[int](contextIO)(rio)
result := ioAction()
assert.Equal(t, 42, result)
}
func TestReadIOWithChain(t *testing.T) {
// Test ReadIO with chained operations
contextIO := G.Of(context.WithValue(context.Background(), "multiplier", 3))
result := F.Pipe1(
FromReader(func(ctx context.Context) int {
if val := ctx.Value("multiplier"); val != nil {
return val.(int)
}
return 1
}),
Chain(func(n int) ReaderIO[int] {
return Of(n * 10)
}),
)
ioAction := ReadIO[int](contextIO)(result)
value := ioAction()
assert.Equal(t, 30, value) // 3 * 10
}
func TestReadIOWithMap(t *testing.T) {
// Test ReadIO with Map operations
contextIO := G.Of(context.Background())
result := F.Pipe2(
Of(5),
Map(N.Mul(2)),
Map(N.Add(10)),
)
ioAction := ReadIO[int](contextIO)(result)
value := ioAction()
assert.Equal(t, 20, value) // (5 * 2) + 10
}
func TestReadIOWithSideEffects(t *testing.T) {
// Test ReadIO with side effects in context creation
counter := 0
contextIO := func() context.Context {
counter++
return context.WithValue(context.Background(), "counter", counter)
}
rio := FromReader(func(ctx context.Context) int {
if val := ctx.Value("counter"); val != nil {
return val.(int)
}
return 0
})
ioAction := ReadIO[int](contextIO)(rio)
result := ioAction()
assert.Equal(t, 1, result)
assert.Equal(t, 1, counter)
}
func TestReadIOMultipleExecutions(t *testing.T) {
// Test that ReadIO creates fresh effects on each execution
counter := 0
contextIO := func() context.Context {
counter++
return context.Background()
}
rio := Of(42)
ioAction := ReadIO[int](contextIO)(rio)
result1 := ioAction()
result2 := ioAction()
assert.Equal(t, 42, result1)
assert.Equal(t, 42, result2)
assert.Equal(t, 2, counter) // Context IO executed twice
}
func TestReadIOComparisonWithRead(t *testing.T) {
// Compare ReadIO with Read to show the difference
ctx := context.WithValue(context.Background(), "key", "value")
rio := FromReader(func(ctx context.Context) string {
if val := ctx.Value("key"); val != nil {
return val.(string)
}
return "default"
})
// Using Read (direct context)
ioAction1 := Read[string](ctx)(rio)
result1 := ioAction1()
// Using ReadIO (context wrapped in IO)
contextIO := G.Of(ctx)
ioAction2 := ReadIO[string](contextIO)(rio)
result2 := ioAction2()
assert.Equal(t, result1, result2)
assert.Equal(t, "value", result1)
assert.Equal(t, "value", result2)
}
func TestReadIOWithComplexContext(t *testing.T) {
// Test ReadIO with complex context manipulation
type contextKey string
const (
userKey contextKey = "user"
tokenKey contextKey = "token"
)
contextIO := G.Of(
context.WithValue(
context.WithValue(context.Background(), userKey, "Alice"),
tokenKey,
"secret123",
),
)
rio := FromReader(func(ctx context.Context) map[string]string {
result := make(map[string]string)
if user := ctx.Value(userKey); user != nil {
result["user"] = user.(string)
}
if token := ctx.Value(tokenKey); token != nil {
result["token"] = token.(string)
}
return result
})
ioAction := ReadIO[map[string]string](contextIO)(rio)
result := ioAction()
assert.Equal(t, "Alice", result["user"])
assert.Equal(t, "secret123", result["token"])
}
func TestReadIOWithAsk(t *testing.T) {
// Test ReadIO combined with Ask
contextIO := G.Of(context.WithValue(context.Background(), "data", 100))
result := F.Pipe1(
Ask(),
Map(func(ctx context.Context) int {
if val := ctx.Value("data"); val != nil {
return val.(int)
}
return 0
}),
)
ioAction := ReadIO[int](contextIO)(result)
value := ioAction()
assert.Equal(t, 100, value)
}

View File

@@ -19,7 +19,68 @@ import (
"github.com/IBM/fp-go/v2/readerio"
)
// TailRec implements stack-safe tail recursion for the ReaderIO monad.
//
// This function enables recursive computations that depend on a [context.Context] and
// perform side effects, without risking stack overflow. It uses an iterative loop to
// execute the recursion, making it safe for deep or unbounded recursion.
//
// The function takes a Kleisli arrow that returns Trampoline[A, B]:
// - Bounce(A): Continue recursion with the new state A
// - Land(B): Terminate recursion and return the final result B
//
// Type Parameters:
// - A: The state type that changes during recursion
// - B: The final result type when recursion terminates
//
// Parameters:
// - f: A Kleisli arrow (A => ReaderIO[Trampoline[A, B]]) that controls recursion flow
//
// Returns:
// - A Kleisli arrow (A => ReaderIO[B]) that executes the recursion safely
//
// Example - Countdown:
//
// countdownStep := func(n int) ReaderIO[tailrec.Trampoline[int, string]] {
// return func(ctx context.Context) IO[tailrec.Trampoline[int, string]] {
// return func() tailrec.Trampoline[int, string] {
// if n <= 0 {
// return tailrec.Land[int]("Done!")
// }
// return tailrec.Bounce[string](n - 1)
// }
// }
// }
//
// countdown := TailRec(countdownStep)
// result := countdown(10)(context.Background())() // Returns "Done!"
//
// Example - Sum with context:
//
// type SumState struct {
// numbers []int
// total int
// }
//
// sumStep := func(state SumState) ReaderIO[tailrec.Trampoline[SumState, int]] {
// return func(ctx context.Context) IO[tailrec.Trampoline[SumState, int]] {
// return func() tailrec.Trampoline[SumState, int] {
// if len(state.numbers) == 0 {
// return tailrec.Land[SumState](state.total)
// }
// return tailrec.Bounce[int](SumState{
// numbers: state.numbers[1:],
// total: state.total + state.numbers[0],
// })
// }
// }
// }
//
// sum := TailRec(sumStep)
// result := sum(SumState{numbers: []int{1, 2, 3, 4, 5}})(context.Background())()
// // Returns 15, safe even for very large slices
//
//go:inline
func TailRec[A, B any](f Kleisli[A, Either[A, B]]) Kleisli[A, B] {
func TailRec[A, B any](f Kleisli[A, Trampoline[A, B]]) Kleisli[A, B] {
return readerio.TailRec(f)
}

View File

@@ -0,0 +1,106 @@
// Copyright (c) 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 (
"github.com/IBM/fp-go/v2/retry"
RG "github.com/IBM/fp-go/v2/retry/generic"
)
// Retrying retries a ReaderIO computation according to a retry policy.
//
// This function implements a retry mechanism for operations that depend on a [context.Context]
// and perform side effects (IO). The retry loop continues until one of the following occurs:
// - The action succeeds and the check function returns false (no retry needed)
// - The retry policy returns None (retry limit reached)
// - The check function returns false (indicating success or a non-retryable condition)
//
// Type Parameters:
// - A: The type of the value produced by the action
//
// Parameters:
//
// - policy: A RetryPolicy that determines when and how long to wait between retries.
// The policy receives a RetryStatus on each iteration and returns an optional delay.
// If it returns None, retrying stops. Common policies include LimitRetries,
// ExponentialBackoff, and CapDelay from the retry package.
//
// - action: A Kleisli arrow that takes a RetryStatus and returns a ReaderIO[A].
// This function is called on each retry attempt and receives information about the
// current retry state (iteration number, cumulative delay, etc.).
//
// - check: A predicate function that examines the result A and returns true if the
// operation should be retried, or false if it should stop. This allows you to
// distinguish between retryable conditions and successful/permanent results.
//
// Returns:
// - A ReaderIO[A] that, when executed with a context, will perform the retry logic
// and return the final result.
//
// Example:
//
// // Create a retry policy: exponential backoff with a cap, limited to 5 retries
// policy := M.Concat(
// retry.LimitRetries(5),
// retry.CapDelay(10*time.Second, retry.ExponentialBackoff(100*time.Millisecond)),
// )(retry.Monoid)
//
// // Action that fetches data, with retry status information
// fetchData := func(status retry.RetryStatus) ReaderIO[string] {
// return func(ctx context.Context) IO[string] {
// return func() string {
// // Simulate an operation that might fail
// if status.IterNumber < 3 {
// return "" // Empty result indicates failure
// }
// return "success"
// }
// }
// }
//
// // Check function: retry if result is empty
// shouldRetry := func(s string) bool {
// return s == ""
// }
//
// // Create the retrying computation
// retryingFetch := Retrying(policy, fetchData, shouldRetry)
//
// // Execute
// ctx := context.Background()
// result := retryingFetch(ctx)() // Returns "success" after 3 attempts
//
//go:inline
func Retrying[A any](
policy retry.RetryPolicy,
action Kleisli[retry.RetryStatus, A],
check Predicate[A],
) ReaderIO[A] {
// get an implementation for the types
return RG.Retrying(
Chain[A, Trampoline[retry.RetryStatus, A]],
Map[retry.RetryStatus, Trampoline[retry.RetryStatus, A]],
Of[Trampoline[retry.RetryStatus, A]],
Of[retry.RetryStatus],
Delay[retry.RetryStatus],
TailRec,
policy,
action,
check,
)
}

View File

@@ -20,10 +20,13 @@ import (
"github.com/IBM/fp-go/v2/consumer"
"github.com/IBM/fp-go/v2/either"
"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/predicate"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/readerio"
"github.com/IBM/fp-go/v2/tailrec"
)
type (
@@ -72,4 +75,10 @@ type (
Consumer[A any] = consumer.Consumer[A]
Either[E, A any] = either.Either[E, A]
Trampoline[B, L any] = tailrec.Trampoline[B, L]
Predicate[A any] = predicate.Predicate[A]
Void = function.Void
)

View File

@@ -8,9 +8,10 @@ This document explains how the `Sequence*` functions in the `context/readeriores
2. [The Problem: Nested Function Application](#the-problem-nested-function-application)
3. [The Solution: Sequence Functions](#the-solution-sequence-functions)
4. [How Sequence Enables Point-Free Style](#how-sequence-enables-point-free-style)
5. [Practical Benefits](#practical-benefits)
6. [Examples](#examples)
7. [Comparison: With and Without Sequence](#comparison-with-and-without-sequence)
5. [TraverseReader: Introducing Dependencies](#traversereader-introducing-dependencies)
6. [Practical Benefits](#practical-benefits)
7. [Examples](#examples)
8. [Comparison: With and Without Sequence](#comparison-with-and-without-sequence)
## What is Point-Free Style?
@@ -25,10 +26,7 @@ func double(x int) int {
**Point-free style (without points):**
```go
var double = F.Flow2(
N.Mul(2),
identity,
)
var double = N.Mul(2)
```
The key benefit is that point-free style emphasizes **what** the function does (its transformation) rather than **how** it manipulates data.
@@ -58,7 +56,7 @@ This creates several problems:
```go
computation := getComputation()
ctx := context.Background()
ctx := t.Context()
cfg := Config{Value: 42}
// Must apply in this specific order
@@ -99,7 +97,7 @@ The `Sequence*` functions solve this by "flipping" or "sequencing" the nested st
```go
func SequenceReader[R, A any](
ma ReaderIOResult[Reader[R, A]]
) reader.Kleisli[context.Context, R, IOResult[A]]
) Kleisli[R, A]
```
**Type transformation:**
@@ -115,7 +113,7 @@ Now `R` (the Reader's environment) comes **first**, before `context.Context`!
```go
func SequenceReaderIO[R, A any](
ma ReaderIOResult[ReaderIO[R, A]]
) reader.Kleisli[context.Context, R, IOResult[A]]
) Kleisli[R, A]
```
**Type transformation:**
@@ -129,7 +127,7 @@ To: func(R) func(context.Context) func() Either[error, A]
```go
func SequenceReaderResult[R, A any](
ma ReaderIOResult[ReaderResult[R, A]]
) reader.Kleisli[context.Context, R, IOResult[A]]
) Kleisli[R, A]
```
**Type transformation:**
@@ -178,7 +176,7 @@ db := Database{ConnectionString: "localhost:5432"}
query := queryWithDB(db) // ✅ Database injected
// Use query with any context
result := query(context.Background())()
result := query(t.Context())()
```
### 3. Point-Free Composition
@@ -222,9 +220,307 @@ authInfo := authService(ctx)()
userInfo := userService(ctx)()
```
## TraverseReader: Introducing Dependencies
While `SequenceReader` flips the parameter order of an existing nested structure, `TraverseReader` allows you to **introduce** a new Reader dependency into an existing computation.
### Function Signature
```go
func TraverseReader[R, A, B any](
f reader.Kleisli[R, A, B],
) func(ReaderIOResult[A]) Kleisli[R, B]
```
**Type transformation:**
```
Input: ReaderIOResult[A] = func(context.Context) func() Either[error, A]
With: reader.Kleisli[R, A, B] = func(A) func(R) B
Output: Kleisli[R, B] = func(R) func(context.Context) func() Either[error, B]
```
### What It Does
`TraverseReader` takes:
1. A Reader-based transformation `f: func(A) func(R) B` that depends on environment `R`
2. Returns a function that transforms `ReaderIOResult[A]` into `Kleisli[R, B]`
This allows you to:
- Add environment dependencies to computations that don't have them yet
- Transform values within a ReaderIOResult using environment-dependent logic
- Build composable pipelines where transformations depend on configuration
### Key Difference from SequenceReader
- **SequenceReader**: Works with computations that **already contain** a Reader (`ReaderIOResult[Reader[R, A]]`)
- Flips the order so `R` comes first
- No transformation of the value itself
- **TraverseReader**: Works with computations that **don't have** a Reader yet (`ReaderIOResult[A]`)
- Introduces a new Reader dependency via a transformation function
- Transforms `A` to `B` using environment `R`
### Example: Adding Configuration to a Computation
```go
type Config struct {
Multiplier int
Prefix string
}
// Original computation that just produces an int
getValue := func(ctx context.Context) func() Either[error, int] {
return func() Either[error, int] {
return Right[error](10)
}
}
// A Reader-based transformation that depends on Config
formatWithConfig := func(n int) func(Config) string {
return func(cfg Config) string {
result := n * cfg.Multiplier
return fmt.Sprintf("%s: %d", cfg.Prefix, result)
}
}
// Use TraverseReader to introduce Config dependency
traversed := TraverseReader[Config, int, string](formatWithConfig)
withConfig := traversed(getValue)
// Now we can provide Config to get the final result
cfg := Config{Multiplier: 5, Prefix: "Result"}
ctx := t.Context()
result := withConfig(cfg)(ctx)() // Returns Right("Result: 50")
```
### Point-Free Composition with TraverseReader
```go
// Build a pipeline that introduces dependencies at each stage
var pipeline = F.Flow4(
loadValue, // ReaderIOResult[int]
TraverseReader(multiplyByConfig), // Kleisli[Config, int]
applyConfig(cfg), // ReaderIOResult[int]
Chain(TraverseReader(formatWithStyle)), // Introduce another dependency
)
```
### When to Use TraverseReader vs SequenceReader
**Use SequenceReader when:**
- Your computation already returns a Reader: `ReaderIOResult[Reader[R, A]]`
- You just want to flip the parameter order
- No transformation of the value is needed
```go
// Already have Reader[Config, int]
computation := getComputation() // ReaderIOResult[Reader[Config, int]]
sequenced := SequenceReader[Config, int](computation)
result := sequenced(cfg)(ctx)()
```
**Use TraverseReader when:**
- Your computation doesn't have a Reader yet: `ReaderIOResult[A]`
- You want to transform the value using environment-dependent logic
- You're introducing a new dependency into the pipeline
```go
// Have ReaderIOResult[int], want to add Config dependency
computation := getValue() // ReaderIOResult[int]
traversed := TraverseReader[Config, int, string](formatWithConfig)
withDep := traversed(computation)
result := withDep(cfg)(ctx)()
```
### Practical Example: Multi-Stage Processing
```go
type DatabaseConfig struct {
ConnectionString string
Timeout time.Duration
}
type FormattingConfig struct {
DateFormat string
Timezone string
}
// Stage 1: Load raw data (no dependencies yet)
loadData := func(ctx context.Context) func() Either[error, RawData] {
// ... implementation
}
// Stage 2: Process with database config
processWithDB := func(raw RawData) func(DatabaseConfig) ProcessedData {
return func(cfg DatabaseConfig) ProcessedData {
// Use cfg.ConnectionString, cfg.Timeout
return ProcessedData{/* ... */}
}
}
// Stage 3: Format with formatting config
formatData := func(processed ProcessedData) func(FormattingConfig) string {
return func(cfg FormattingConfig) string {
// Use cfg.DateFormat, cfg.Timezone
return "formatted result"
}
}
// Build pipeline introducing dependencies at each stage
var pipeline = F.Flow3(
loadData,
TraverseReader[DatabaseConfig, RawData, ProcessedData](processWithDB),
// Now we have Kleisli[DatabaseConfig, ProcessedData]
applyConfig(dbConfig),
// Now we have ReaderIOResult[ProcessedData]
TraverseReader[FormattingConfig, ProcessedData, string](formatData),
// Now we have Kleisli[FormattingConfig, string]
)
// Execute with both configs
result := pipeline(fmtConfig)(ctx)()
```
### Combining TraverseReader and SequenceReader
You can combine both functions in complex pipelines:
```go
// Start with nested Reader
computation := getComputation() // ReaderIOResult[Reader[Config, User]]
var pipeline = F.Flow4(
computation,
SequenceReader[Config, User], // Flip to get Kleisli[Config, User]
applyConfig(cfg), // Apply config, get ReaderIOResult[User]
TraverseReader(enrichWithDatabase), // Add database dependency
// Now have Kleisli[Database, EnrichedUser]
)
result := pipeline(db)(ctx)()
```
## Practical Benefits
### 1. **Improved Testability**
### 1. **Performance: Eager Construction, Lazy Execution**
One of the most important but often overlooked benefits of point-free style is its performance characteristic: **the program structure is constructed eagerly (at definition time), but execution happens lazily (at runtime)**.
#### Construction Happens Once
When you define a pipeline using point-free style with `F.Flow`, `F.Pipe`, or function composition, the composition structure is built immediately at definition time:
```go
// Point-free style - composition built ONCE at definition time
var processUser = F.Flow3(
getDatabase,
SequenceReader[DatabaseConfig, Database],
applyConfig(dbConfig),
)
// The pipeline structure is now fixed in memory
```
#### Execution Happens on Demand
The actual computation only runs when you provide the final parameters and invoke the result:
```go
// Execute multiple times - only execution cost, no re-composition
result1 := processUser(ctx1)() // Fast - reuses pre-built pipeline
result2 := processUser(ctx2)() // Fast - reuses pre-built pipeline
result3 := processUser(ctx3)() // Fast - reuses pre-built pipeline
```
#### Performance Benefit for Repeated Execution
If a flow is executed multiple times, the point-free style is significantly more efficient because:
1. **Composition overhead is paid once** - The function composition happens at definition time
2. **No re-interpretation** - Each execution doesn't need to rebuild the pipeline
3. **Memory efficiency** - The composed function is created once and reused
4. **Better for hot paths** - Ideal for high-frequency operations
#### Comparison: Point-Free vs. Imperative
```go
// Imperative style - reconstruction on EVERY call
func processUserImperative(ctx context.Context) Either[error, Database] {
// This function body is re-interpreted/executed every time
dbComp := getDatabase()(ctx)()
if dbReader, err := either.Unwrap(dbComp); err != nil {
return Left[Database](err)
}
db := dbReader(dbConfig)
// ... manual composition happens on every invocation
return Right[error](db)
}
// Point-free style - composition built ONCE
var processUserPointFree = F.Flow3(
getDatabase,
SequenceReader[DatabaseConfig, Database],
applyConfig(dbConfig),
)
// Benchmark scenario: 1000 executions
for i := 0; i < 1000; i++ {
// Imperative: pays composition cost 1000 times
result := processUserImperative(ctx)()
// Point-free: pays composition cost once, execution cost 1000 times
result := processUserPointFree(ctx)()
}
```
#### When This Matters Most
The performance benefit of eager construction is particularly important for:
- **High-frequency operations** - APIs, event handlers, request processors
- **Batch processing** - Same pipeline processes many items
- **Long-running services** - Pipelines defined once at startup, executed millions of times
- **Hot code paths** - Performance-critical sections that run repeatedly
- **Stream processing** - Processing continuous data streams
#### Example: API Handler
```go
// Define pipeline once at application startup
var handleUserRequest = F.Flow4(
parseRequest,
SequenceReader[Database, UserRequest],
applyDatabase(db),
Chain(validateAndProcess),
)
// Execute thousands of times per second
func apiHandler(w http.ResponseWriter, r *http.Request) {
// No composition overhead - just execution
result := handleUserRequest(r.Context())()
// ... handle result
}
```
#### Memory and CPU Efficiency
```go
// Point-free: O(1) composition overhead
var pipeline = F.Flow5(step1, step2, step3, step4, step5)
// Composed once, stored in memory
// Execute N times: O(N) execution cost only
for i := 0; i < N; i++ {
result := pipeline(input[i])
}
// Imperative: O(N) composition + execution cost
for i := 0; i < N; i++ {
// Composition logic runs every iteration
result := step5(step4(step3(step2(step1(input[i])))))
}
```
### 2. **Improved Testability**
Inject test dependencies easily:
@@ -240,7 +536,7 @@ testQuery := queryWithDB(testDB)
// Same computation, different dependencies
```
### 2. **Better Separation of Concerns**
### 3. **Better Separation of Concerns**
Separate configuration from execution:
@@ -253,7 +549,7 @@ computation := sequenced(cfg)
result := computation(ctx)()
```
### 3. **Enhanced Composability**
### 4. **Enhanced Composability**
Build complex pipelines from simple pieces:
@@ -266,7 +562,7 @@ var processUser = F.Flow4(
)
```
### 4. **Reduced Boilerplate**
### 5. **Reduced Boilerplate**
No need to manually thread parameters:
@@ -473,6 +769,7 @@ var processUser = func(userID string) ReaderIOResult[ProcessedUser] {
5. **Reusability** increases as computations can be specialized early
6. **Testability** improves through easy dependency injection
7. **Separation of concerns** is clearer (configuration vs. execution)
8. **Performance benefit**: Eager construction (once) + lazy execution (many times) = efficiency for repeated operations
## When to Use Sequence

View File

@@ -24,7 +24,6 @@ import (
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioeither"
"github.com/IBM/fp-go/v2/ioresult"
L "github.com/IBM/fp-go/v2/optics/lens"
"github.com/IBM/fp-go/v2/reader"
RIOR "github.com/IBM/fp-go/v2/readerioresult"
"github.com/IBM/fp-go/v2/result"
@@ -96,7 +95,7 @@ func Bind[S1, S2, T any](
setter func(T) func(S1) S2,
f Kleisli[S1, T],
) Operator[S1, S2] {
return RIOR.Bind(setter, F.Flow2(f, WithContext))
return RIOR.Bind(setter, WithContextK(f))
}
// Let attaches the result of a computation to a context [S1] to produce a context [S2]
@@ -128,6 +127,13 @@ func BindTo[S1, T any](
return RIOR.BindTo[context.Context](setter)
}
//go:inline
func BindToP[S1, T any](
setter Prism[S1, T],
) Operator[T, S1] {
return BindTo(setter.ReverseGet)
}
// ApS attaches a value to a context [S1] to produce a context [S2] by considering
// the context and the value concurrently (using Applicative rather than Monad).
// This allows independent computations to be combined without one depending on the result of the other.
@@ -214,7 +220,7 @@ func ApS[S1, S2, T any](
//
//go:inline
func ApSL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
fa ReaderIOResult[T],
) Operator[S, S] {
return ApS(lens.Set, fa)
@@ -253,10 +259,10 @@ func ApSL[S, T any](
//
//go:inline
func BindL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
f Kleisli[T, T],
) Operator[S, S] {
return RIOR.BindL(lens, F.Flow2(f, WithContext))
return RIOR.BindL(lens, WithContextK(f))
}
// LetL is a variant of Let that uses a lens to focus on a specific part of the context.
@@ -289,7 +295,7 @@ func BindL[S, T any](
//
//go:inline
func LetL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
f Endomorphism[T],
) Operator[S, S] {
return RIOR.LetL[context.Context](lens, f)
@@ -322,7 +328,7 @@ func LetL[S, T any](
//
//go:inline
func LetToL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
b T,
) Operator[S, S] {
return RIOR.LetToL[context.Context](lens, b)
@@ -443,7 +449,7 @@ func BindResultK[S1, S2, T any](
//
//go:inline
func BindIOEitherKL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
f ioresult.Kleisli[T, T],
) Operator[S, S] {
return BindL(lens, F.Flow2(f, FromIOEither[T]))
@@ -458,7 +464,7 @@ func BindIOEitherKL[S, T any](
//
//go:inline
func BindIOResultKL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
f ioresult.Kleisli[T, T],
) Operator[S, S] {
return BindL(lens, F.Flow2(f, FromIOEither[T]))
@@ -474,7 +480,7 @@ func BindIOResultKL[S, T any](
//
//go:inline
func BindIOKL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
f io.Kleisli[T, T],
) Operator[S, S] {
return BindL(lens, F.Flow2(f, FromIO[T]))
@@ -490,7 +496,7 @@ func BindIOKL[S, T any](
//
//go:inline
func BindReaderKL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
f reader.Kleisli[context.Context, T, T],
) Operator[S, S] {
return BindL(lens, F.Flow2(f, FromReader[T]))
@@ -506,7 +512,7 @@ func BindReaderKL[S, T any](
//
//go:inline
func BindReaderIOKL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
f readerio.Kleisli[T, T],
) Operator[S, S] {
return BindL(lens, F.Flow2(f, FromReaderIO[T]))
@@ -627,7 +633,7 @@ func ApResultS[S1, S2, T any](
//
//go:inline
func ApIOEitherSL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
fa IOResult[T],
) Operator[S, S] {
return F.Bind2nd(F.Flow2[ReaderIOResult[S], ioresult.Operator[S, S]], ioresult.ApSL(lens, fa))
@@ -642,7 +648,7 @@ func ApIOEitherSL[S, T any](
//
//go:inline
func ApIOResultSL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
fa IOResult[T],
) Operator[S, S] {
return F.Bind2nd(F.Flow2[ReaderIOResult[S], ioresult.Operator[S, S]], ioresult.ApSL(lens, fa))
@@ -657,7 +663,7 @@ func ApIOResultSL[S, T any](
//
//go:inline
func ApIOSL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
fa IO[T],
) Operator[S, S] {
return ApSL(lens, FromIO(fa))
@@ -672,7 +678,7 @@ func ApIOSL[S, T any](
//
//go:inline
func ApReaderSL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
fa Reader[context.Context, T],
) Operator[S, S] {
return ApSL(lens, FromReader(fa))
@@ -687,7 +693,7 @@ func ApReaderSL[S, T any](
//
//go:inline
func ApReaderIOSL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
fa ReaderIO[T],
) Operator[S, S] {
return ApSL(lens, FromReaderIO(fa))
@@ -702,7 +708,7 @@ func ApReaderIOSL[S, T any](
//
//go:inline
func ApEitherSL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
fa Result[T],
) Operator[S, S] {
return ApSL(lens, FromEither(fa))
@@ -717,7 +723,7 @@ func ApEitherSL[S, T any](
//
//go:inline
func ApResultSL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
fa Result[T],
) Operator[S, S] {
return ApSL(lens, FromResult(fa))

View File

@@ -19,6 +19,7 @@ import (
"context"
CIOE "github.com/IBM/fp-go/v2/context/ioresult"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/ioeither"
)
@@ -40,3 +41,47 @@ func WithContext[A any](ma ReaderIOResult[A]) ReaderIOResult[A] {
return CIOE.WithContext(ctx, ma(ctx))
}
}
// WithContextK wraps a Kleisli arrow with context cancellation checking.
// This ensures that the computation checks for context cancellation before executing,
// providing a convenient way to add cancellation awareness to Kleisli arrows.
//
// This is particularly useful when composing multiple Kleisli arrows where each step
// should respect context cancellation.
//
// Type Parameters:
// - A: The input type of the Kleisli arrow
// - B: The output type of the Kleisli arrow
//
// Parameters:
// - f: The Kleisli arrow to wrap with context checking
//
// Returns:
// - A Kleisli arrow that checks for cancellation before executing
//
// Example:
//
// fetchUser := func(id int) ReaderIOResult[User] {
// return func(ctx context.Context) IOResult[User] {
// return func() Result[User] {
// // Long-running operation
// return result.Of(User{ID: id})
// }
// }
// }
//
// // Wrap with context checking
// safeFetch := WithContextK(fetchUser)
//
// // If context is cancelled, returns immediately without executing fetchUser
// ctx, cancel := context.WithCancel(context.Background())
// cancel() // Cancel immediately
// result := safeFetch(123)(ctx)() // Returns context.Canceled error
//
//go:inline
func WithContextK[A, B any](f Kleisli[A, B]) Kleisli[A, B] {
return F.Flow2(
f,
WithContext,
)
}

View File

@@ -0,0 +1,64 @@
package readerioresult
import (
"time"
"github.com/IBM/fp-go/v2/circuitbreaker"
"github.com/IBM/fp-go/v2/context/readerio"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/retry"
)
type (
ClosedState = circuitbreaker.ClosedState
Env[T any] = Pair[IORef[circuitbreaker.BreakerState], ReaderIOResult[T]]
CircuitBreaker[T any] = State[Env[T], ReaderIOResult[T]]
)
func MakeCircuitBreaker[T any](
currentTime IO[time.Time],
closedState ClosedState,
checkError option.Kleisli[error, error],
policy retry.RetryPolicy,
metrics circuitbreaker.Metrics,
) CircuitBreaker[T] {
return circuitbreaker.MakeCircuitBreaker[error, T](
Left,
ChainFirstIOK,
ChainFirstLeftIOK,
readerio.ChainFirstIOK,
FromIO,
Flap,
Flatten,
currentTime,
closedState,
circuitbreaker.MakeCircuitBreakerError,
checkError,
policy,
metrics,
)
}
func MakeSingletonBreaker[T any](
currentTime IO[time.Time],
closedState ClosedState,
checkError option.Kleisli[error, error],
policy retry.RetryPolicy,
metrics circuitbreaker.Metrics,
) Operator[T, T] {
return circuitbreaker.MakeSingletonBreaker(
MakeCircuitBreaker[T](
currentTime,
closedState,
checkError,
policy,
metrics,
),
closedState,
)
}

View File

@@ -0,0 +1,246 @@
# Circuit Breaker Documentation
## Overview
The `circuitbreaker.go` file provides a circuit breaker implementation for the `readerioresult` package. A circuit breaker is a design pattern used to detect failures and prevent cascading failures in distributed systems by temporarily blocking operations that are likely to fail.
## Package
```go
package readerioresult
```
This is part of the `context/readerioresult` package, which provides functional programming abstractions for operations that:
- Depend on a `context.Context` (Reader aspect)
- Perform side effects (IO aspect)
- Can fail with an `error` (Result/Either aspect)
## Type Definitions
### ClosedState
```go
type ClosedState = circuitbreaker.ClosedState
```
A type alias for the circuit breaker's closed state. When the circuit is closed, requests are allowed to pass through normally. The closed state tracks success and failure counts to determine when to open the circuit.
### Env[T any]
```go
type Env[T any] = Pair[IORef[circuitbreaker.BreakerState], ReaderIOResult[T]]
```
The environment type for the circuit breaker state machine. It contains:
- `IORef[circuitbreaker.BreakerState]`: A mutable reference to the current breaker state
- `ReaderIOResult[T]`: The computation to be protected by the circuit breaker
### CircuitBreaker[T any]
```go
type CircuitBreaker[T any] = State[Env[T], ReaderIOResult[T]]
```
The main circuit breaker type. It's a state monad that:
- Takes an environment containing the breaker state and the protected computation
- Returns a new environment and a wrapped computation that respects the circuit breaker logic
## Functions
### MakeCircuitBreaker
```go
func MakeCircuitBreaker[T any](
currentTime IO[time.Time],
closedState ClosedState,
checkError option.Kleisli[error, error],
policy retry.RetryPolicy,
logger io.Kleisli[string, string],
) CircuitBreaker[T]
```
Creates a new circuit breaker with the specified configuration.
#### Parameters
- **currentTime** `IO[time.Time]`: A function that returns the current time. This can be a virtual timer for testing purposes, allowing you to control time progression in tests.
- **closedState** `ClosedState`: The initial closed state configuration. This defines:
- Maximum number of failures before opening the circuit
- Time window for counting failures
- Other closed state parameters
- **checkError** `option.Kleisli[error, error]`: A function that determines whether an error should be counted as a failure. Returns:
- `Some(error)`: The error should be counted as a failure
- `None`: The error should be ignored (not counted as a failure)
This allows you to distinguish between transient errors (that should trigger circuit breaking) and permanent errors (that shouldn't).
- **policy** `retry.RetryPolicy`: The retry policy that determines:
- How long to wait before attempting to close the circuit (reset time)
- Exponential backoff or other delay strategies
- Maximum number of retry attempts
- **logger** `io.Kleisli[string, string]`: A logging function for circuit breaker events. Receives log messages and performs side effects (like writing to a log file or console).
#### Returns
A `CircuitBreaker[T]` that wraps computations with circuit breaker logic.
#### Circuit Breaker States
The circuit breaker operates in three states:
1. **Closed**: Normal operation. Requests pass through. Failures are counted.
- If failure threshold is exceeded, transitions to Open state
2. **Open**: Circuit is broken. Requests fail immediately without executing.
- After reset time expires, transitions to Half-Open state
3. **Half-Open** (Canary): Testing if the service has recovered.
- Allows a single test request (canary request)
- If canary succeeds, transitions to Closed state
- If canary fails, transitions back to Open state with extended reset time
#### Implementation Details
The function delegates to the generic `circuitbreaker.MakeCircuitBreaker` function, providing the necessary type-specific operations:
- **Left**: Creates a failed computation from an error
- **ChainFirstIOK**: Chains an IO operation that runs for side effects on success
- **ChainFirstLeftIOK**: Chains an IO operation that runs for side effects on failure
- **FromIO**: Lifts an IO computation into ReaderIOResult
- **Flap**: Applies a computation to a function
- **Flatten**: Flattens nested ReaderIOResult structures
These operations allow the generic circuit breaker to work with the `ReaderIOResult` monad.
## Usage Example
```go
import (
"context"
"fmt"
"time"
"github.com/IBM/fp-go/v2/circuitbreaker"
"github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioref"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/retry"
)
// Create a circuit breaker configuration
func createCircuitBreaker() readerioresult.CircuitBreaker[string] {
// Use real time
currentTime := func() time.Time { return time.Now() }
// Configure closed state: open after 5 failures in 10 seconds
closedState := circuitbreaker.MakeClosedState(5, 10*time.Second)
// Check all errors (count all as failures)
checkError := func(err error) option.Option[error] {
return option.Some(err)
}
// Retry policy: exponential backoff with max 5 retries
policy := retry.Monoid.Concat(
retry.LimitRetries(5),
retry.ExponentialBackoff(100*time.Millisecond),
)
// Simple logger
logger := func(msg string) io.IO[string] {
return func() string {
fmt.Println("Circuit Breaker:", msg)
return msg
}
}
return readerioresult.MakeCircuitBreaker[string](
currentTime,
closedState,
checkError,
policy,
logger,
)
}
// Use the circuit breaker
func main() {
cb := createCircuitBreaker()
// Create initial state
stateRef := ioref.NewIORef(circuitbreaker.InitialState())
// Your protected operation
operation := func(ctx context.Context) readerioresult.IOResult[string] {
return func() readerioresult.Result[string] {
// Your actual operation here
return result.Of("success")
}
}
// Apply circuit breaker
env := pair.MakePair(stateRef, operation)
result := cb(env)
// Execute the protected operation
ctx := t.Context()
protectedOp := pair.Tail(result)
outcome := protectedOp(ctx)()
}
```
## Testing with Virtual Timer
For testing, you can provide a virtual timer instead of `time.Now()`:
```go
// Virtual timer for testing
type VirtualTimer struct {
current time.Time
}
func (vt *VirtualTimer) Now() time.Time {
return vt.current
}
func (vt *VirtualTimer) Advance(d time.Duration) {
vt.current = vt.current.Add(d)
}
// Use in tests
func TestCircuitBreaker(t *testing.T) {
vt := &VirtualTimer{current: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}
currentTime := func() time.Time { return vt.Now() }
cb := readerioresult.MakeCircuitBreaker[string](
currentTime,
closedState,
checkError,
policy,
logger,
)
// Test circuit breaker behavior
// Advance time as needed
vt.Advance(5 * time.Second)
}
```
## Related Types
- `circuitbreaker.BreakerState`: The internal state of the circuit breaker (closed or open)
- `circuitbreaker.ClosedState`: Configuration for the closed state
- `retry.RetryPolicy`: Policy for retry delays and limits
- `option.Kleisli[error, error]`: Function type for error checking
- `io.Kleisli[string, string]`: Function type for logging
## See Also
- `circuitbreaker` package: Generic circuit breaker implementation
- `retry` package: Retry policies and strategies
- `readerioresult` package: Core ReaderIOResult monad operations

View File

@@ -0,0 +1,974 @@
// 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 (
"errors"
"log"
"sync"
"testing"
"time"
"github.com/IBM/fp-go/v2/array"
"github.com/IBM/fp-go/v2/circuitbreaker"
"github.com/IBM/fp-go/v2/ioref"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/result"
"github.com/IBM/fp-go/v2/retry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// VirtualTimer provides a controllable time source for testing
type VirtualTimer struct {
mu sync.Mutex
current time.Time
}
// NewVirtualTimer creates a new virtual timer starting at the given time
func NewVirtualTimer(start time.Time) *VirtualTimer {
return &VirtualTimer{current: start}
}
// Now returns the current virtual time
func (vt *VirtualTimer) Now() time.Time {
vt.mu.Lock()
defer vt.mu.Unlock()
return vt.current
}
// Advance moves the virtual time forward by the given duration
func (vt *VirtualTimer) Advance(d time.Duration) {
vt.mu.Lock()
defer vt.mu.Unlock()
vt.current = vt.current.Add(d)
}
// Set sets the virtual time to a specific value
func (vt *VirtualTimer) Set(t time.Time) {
vt.mu.Lock()
defer vt.mu.Unlock()
vt.current = t
}
// Helper function to create a test logger that collects messages
func testMetrics(_ *[]string) circuitbreaker.Metrics {
return circuitbreaker.MakeMetricsFromLogger("testMetrics", log.Default())
}
// Helper function to create a simple closed state
func testCBClosedState() circuitbreaker.ClosedState {
return circuitbreaker.MakeClosedStateCounter(3)
}
// Helper function to create a test retry policy
func testCBRetryPolicy() retry.RetryPolicy {
return retry.Monoid.Concat(
retry.LimitRetries(3),
retry.ExponentialBackoff(100*time.Millisecond),
)
}
// Helper function that checks all errors
func checkAllErrors(err error) option.Option[error] {
return option.Some(err)
}
// Helper function that ignores specific errors
func ignoreSpecificError(ignoredMsg string) func(error) option.Option[error] {
return func(err error) option.Option[error] {
if err.Error() == ignoredMsg {
return option.None[error]()
}
return option.Some(err)
}
}
// TestCircuitBreaker_SuccessfulOperation tests that successful operations
// pass through the circuit breaker without issues
func TestCircuitBreaker_SuccessfulOperation(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
// Create initial state
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
// Successful operation
operation := Of("success")
// Apply circuit breaker
env := pair.MakePair(stateRef, operation)
resultEnv := cb(env)
// Execute
ctx := t.Context()
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.Equal(t, result.Of("success"), outcome)
}
// TestCircuitBreaker_SingleFailure tests that a single failure is handled
// but doesn't open the circuit
func TestCircuitBreaker_SingleFailure(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
expError := errors.New("operation failed")
// Failing operation
operation := Left[string](expError)
env := pair.MakePair(stateRef, operation)
resultEnv := cb(env)
ctx := t.Context()
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.Equal(t, result.Left[string](expError), outcome)
// Circuit should still be closed after one failure
state := ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsClosed(state))
}
// TestCircuitBreaker_OpensAfterThreshold tests that the circuit opens
// after exceeding the failure threshold
func TestCircuitBreaker_OpensAfterThreshold(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(), // Opens after 3 failures
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
expError := errors.New("operation failed")
// Failing operation
operation := Left[string](expError)
ctx := t.Context()
// Execute 3 failures to open the circuit
for range 3 {
env := pair.MakePair(stateRef, operation)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.Equal(t, result.Left[string](expError), outcome)
}
// Circuit should now be open
state := ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsOpen(state))
// Next request should fail immediately with circuit breaker error
env := pair.MakePair(stateRef, operation)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.True(t, result.IsLeft(outcome))
_, err := result.Unwrap(outcome)
var cbErr *circuitbreaker.CircuitBreakerError
assert.ErrorAs(t, err, &cbErr)
}
// TestCircuitBreaker_HalfOpenAfterResetTime tests that the circuit
// transitions to half-open state after the reset time
func TestCircuitBreaker_HalfOpenAfterResetTime(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
expError := errors.New("operation failed")
// Failing operation
failingOp := Left[string](expError)
ctx := t.Context()
// Open the circuit with 3 failures
for range 3 {
env := pair.MakePair(stateRef, failingOp)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.Equal(t, result.Left[string](expError), outcome)
}
// Verify circuit is open
state := ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsOpen(state))
// Advance time past the reset time (exponential backoff starts at 100ms)
vt.Advance(200 * time.Millisecond)
// Now create a successful operation for the canary request
successOp := Of("success")
// Next request should be a canary request
env := pair.MakePair(stateRef, successOp)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
// Canary should succeed
assert.Equal(t, result.Of("success"), outcome)
// Circuit should now be closed again
state = ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsClosed(state))
}
// TestCircuitBreaker_CanaryFailureExtendsOpenTime tests that a failed
// canary request extends the open time
func TestCircuitBreaker_CanaryFailureExtendsOpenTime(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
expError := errors.New("operation failed")
// Failing operation
failingOp := Left[string](expError)
ctx := t.Context()
// Open the circuit
for range 3 {
env := pair.MakePair(stateRef, failingOp)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.Equal(t, result.Left[string](expError), outcome)
}
// Advance time to trigger canary
vt.Advance(200 * time.Millisecond)
// Canary request fails
env := pair.MakePair(stateRef, failingOp)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.True(t, result.IsLeft(outcome))
// Circuit should still be open
state := ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsOpen(state))
// Immediate next request should fail with circuit breaker error
env = pair.MakePair(stateRef, failingOp)
resultEnv = cb(env)
protectedOp = pair.Tail(resultEnv)
outcome = protectedOp(ctx)()
assert.True(t, result.IsLeft(outcome))
_, err := result.Unwrap(outcome)
var cbErr *circuitbreaker.CircuitBreakerError
assert.ErrorAs(t, err, &cbErr)
}
// TestCircuitBreaker_IgnoredErrorsDoNotCount tests that errors filtered
// by checkError don't count toward opening the circuit
func TestCircuitBreaker_IgnoredErrorsDoNotCount(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
// Ignore "ignorable error"
checkError := ignoreSpecificError("ignorable error")
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(),
checkError,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
ctx := t.Context()
ignorableError := errors.New("ignorable error")
// Execute 5 ignorable errors
ignorableOp := Left[string](ignorableError)
for range 5 {
env := pair.MakePair(stateRef, ignorableOp)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.Equal(t, result.Left[string](ignorableError), outcome)
}
// Circuit should still be closed
state := ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsClosed(state))
realError := errors.New("real error")
// Now send a real error
realErrorOp := Left[string](realError)
env := pair.MakePair(stateRef, realErrorOp)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.Equal(t, result.Left[string](realError), outcome)
// Circuit should still be closed (only 1 counted error)
state = ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsClosed(state))
}
// TestCircuitBreaker_MixedSuccessAndFailure tests the circuit behavior
// with a mix of successful and failed operations
func TestCircuitBreaker_MixedSuccessAndFailure(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
ctx := t.Context()
successOp := Of("success")
expError := errors.New("failure")
failOp := Left[string](expError)
// Pattern: fail, fail, success, fail
ops := array.From(failOp, failOp, successOp, failOp)
for _, op := range ops {
env := pair.MakePair(stateRef, op)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
_ = protectedOp(ctx)()
}
// Circuit should still be closed (success resets the count)
state := ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsClosed(state))
}
// TestCircuitBreaker_ConcurrentOperations tests that the circuit breaker
// handles concurrent operations correctly
func TestCircuitBreaker_ConcurrentOperations(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[int](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
ctx := t.Context()
var wg sync.WaitGroup
results := make([]Result[int], 10)
// Launch 10 concurrent operations
for i := range 10 {
wg.Add(1)
go func(idx int) {
defer wg.Done()
op := Of(idx)
env := pair.MakePair(stateRef, op)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
results[idx] = protectedOp(ctx)()
}(i)
}
wg.Wait()
// All operations should succeed
for i, res := range results {
assert.True(t, result.IsRight(res), "Operation %d should succeed", i)
}
}
// TestCircuitBreaker_DifferentTypes tests that the circuit breaker works
// with different result types
func TestCircuitBreaker_DifferentTypes(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
// Test with int
cbInt := MakeCircuitBreaker[int](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRefInt := circuitbreaker.MakeClosedIORef(testCBClosedState())()
opInt := Of(42)
ctx := t.Context()
envInt := pair.MakePair(stateRefInt, opInt)
resultEnvInt := cbInt(envInt)
protectedOpInt := pair.Tail(resultEnvInt)
outcomeInt := protectedOpInt(ctx)()
assert.Equal(t, result.Of(42), outcomeInt)
// Test with struct
type User struct {
ID int
Name string
}
cbUser := MakeCircuitBreaker[User](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRefUser := circuitbreaker.MakeClosedIORef(testCBClosedState())()
opUser := Of(User{ID: 1, Name: "Alice"})
envUser := pair.MakePair(stateRefUser, opUser)
resultEnvUser := cbUser(envUser)
protectedOpUser := pair.Tail(resultEnvUser)
outcomeUser := protectedOpUser(ctx)()
require.Equal(t, result.Of(User{ID: 1, Name: "Alice"}), outcomeUser)
}
// TestCircuitBreaker_VirtualTimerAdvancement tests that the virtual timer
// correctly controls time-based behavior
func TestCircuitBreaker_VirtualTimerAdvancement(t *testing.T) {
startTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
vt := NewVirtualTimer(startTime)
// Verify initial time
assert.Equal(t, startTime, vt.Now())
// Advance by 1 hour
vt.Advance(1 * time.Hour)
assert.Equal(t, startTime.Add(1*time.Hour), vt.Now())
// Advance by 30 minutes
vt.Advance(30 * time.Minute)
assert.Equal(t, startTime.Add(90*time.Minute), vt.Now())
// Set to specific time
newTime := time.Date(2024, 6, 15, 10, 30, 0, 0, time.UTC)
vt.Set(newTime)
assert.Equal(t, newTime, vt.Now())
}
// TestCircuitBreaker_InitialState tests that the circuit starts in closed state
func TestCircuitBreaker_InitialState(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
// Check initial state is closed
state := ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsClosed(state), "Circuit should start in closed state")
// First operation should execute normally
op := Of("first operation")
ctx := t.Context()
env := pair.MakePair(stateRef, op)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.Equal(t, result.Of("first operation"), outcome)
}
// TestCircuitBreaker_ErrorMessageFormat tests that circuit breaker errors
// have appropriate error messages
func TestCircuitBreaker_ErrorMessageFormat(t *testing.T) {
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
var logMessages []string
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
ctx := t.Context()
expError := errors.New("service unavailable")
failOp := Left[string](expError)
// Open the circuit
for range 3 {
env := pair.MakePair(stateRef, failOp)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
_ = protectedOp(ctx)()
}
// Next request should fail with circuit breaker error
env := pair.MakePair(stateRef, failOp)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
assert.True(t, result.IsLeft(outcome))
// Error message should indicate circuit breaker is open
_, err := result.Unwrap(outcome)
errMsg := err.Error()
assert.Contains(t, errMsg, "circuit", "Error should mention circuit breaker")
}
// RequestSpec defines a virtual request with timing and outcome information
type RequestSpec struct {
ID int // Unique identifier for the request
StartTime time.Duration // Virtual start time relative to test start
Duration time.Duration // How long the request takes to execute
ShouldFail bool // Whether this request should fail
}
// RequestResult captures the outcome of a request execution
type RequestResult struct {
ID int
StartTime time.Time
EndTime time.Time
Success bool
Error error
CircuitBreakerError bool // True if failed due to circuit breaker being open
}
// TestCircuitBreaker_ConcurrentBatchWithThresholdExceeded tests a complex
// concurrent scenario where:
// 1. Initial requests succeed
// 2. A batch of failures exceeds the threshold, opening the circuit
// 3. Subsequent requests fail immediately due to open circuit
// 4. After timeout, a canary request succeeds
// 5. Following requests succeed again
func TestCircuitBreaker_ConcurrentBatchWithThresholdExceeded(t *testing.T) {
startTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
vt := NewVirtualTimer(startTime)
var logMessages []string
// Circuit opens after 3 failures, with exponential backoff starting at 100ms
cb := MakeCircuitBreaker[string](
vt.Now,
testCBClosedState(), // Opens after 3 failures
checkAllErrors,
testCBRetryPolicy(), // 100ms initial backoff
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
ctx := t.Context()
// Define the request sequence
// Phase 1: Initial successes (0-100ms)
// Phase 2: Failures that exceed threshold (100-200ms) - should open circuit
// Phase 3: Requests during open circuit (200-300ms) - should fail immediately
// Phase 4: After timeout (400ms+) - canary succeeds, then more successes
requests := []RequestSpec{
// Phase 1: Initial successful requests
{ID: 1, StartTime: 0 * time.Millisecond, Duration: 10 * time.Millisecond, ShouldFail: false},
{ID: 2, StartTime: 20 * time.Millisecond, Duration: 10 * time.Millisecond, ShouldFail: false},
// Phase 2: Sequential failures that exceed threshold (3 failures)
{ID: 3, StartTime: 100 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: true},
{ID: 4, StartTime: 110 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: true},
{ID: 5, StartTime: 120 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: true},
{ID: 6, StartTime: 130 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: true},
// Phase 3: Requests during open circuit - should fail with circuit breaker error
{ID: 7, StartTime: 200 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false},
{ID: 8, StartTime: 210 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false},
{ID: 9, StartTime: 220 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false},
// Phase 4: After reset timeout (100ms backoff from last failure at ~125ms = ~225ms)
// Wait longer to ensure we're past the reset time
{ID: 10, StartTime: 400 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false}, // Canary succeeds
{ID: 11, StartTime: 410 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false},
{ID: 12, StartTime: 420 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false},
}
results := make([]RequestResult, len(requests))
// Execute requests sequentially but model them as if they were concurrent
// by advancing the virtual timer to each request's start time
for i, req := range requests {
// Set virtual time to request start time
vt.Set(startTime.Add(req.StartTime))
// Create the operation based on spec
var op ReaderIOResult[string]
if req.ShouldFail {
op = Left[string](errors.New("operation failed"))
} else {
op = Of("success")
}
// Apply circuit breaker
env := pair.MakePair(stateRef, op)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
// Record start time
execStartTime := vt.Now()
// Execute the operation
outcome := protectedOp(ctx)()
// Advance time by operation duration
vt.Advance(req.Duration)
execEndTime := vt.Now()
// Analyze the result
isSuccess := result.IsRight(outcome)
var err error
var isCBError bool
if !isSuccess {
_, err = result.Unwrap(outcome)
var cbErr *circuitbreaker.CircuitBreakerError
isCBError = errors.As(err, &cbErr)
}
results[i] = RequestResult{
ID: req.ID,
StartTime: execStartTime,
EndTime: execEndTime,
Success: isSuccess,
Error: err,
CircuitBreakerError: isCBError,
}
}
// Verify Phase 1: Initial requests should succeed
assert.True(t, results[0].Success, "Request 1 should succeed")
assert.True(t, results[1].Success, "Request 2 should succeed")
// Verify Phase 2: Failures should be recorded (first 3 fail with actual error)
// The 4th might fail with CB error if circuit opened fast enough
assert.False(t, results[2].Success, "Request 3 should fail")
assert.False(t, results[3].Success, "Request 4 should fail")
assert.False(t, results[4].Success, "Request 5 should fail")
// At least the first 3 failures should be actual operation failures, not CB errors
actualFailures := 0
for i := 2; i <= 4; i++ {
if !results[i].CircuitBreakerError {
actualFailures++
}
}
assert.GreaterOrEqual(t, actualFailures, 3, "At least 3 actual operation failures should occur")
// Verify Phase 3: Requests during open circuit should fail with circuit breaker error
for i := 6; i <= 8; i++ {
assert.False(t, results[i].Success, "Request %d should fail during open circuit", results[i].ID)
assert.True(t, results[i].CircuitBreakerError, "Request %d should fail with circuit breaker error", results[i].ID)
}
// Verify Phase 4: After timeout, canary and subsequent requests should succeed
assert.True(t, results[9].Success, "Request 10 (canary) should succeed")
assert.True(t, results[10].Success, "Request 11 should succeed after circuit closes")
assert.True(t, results[11].Success, "Request 12 should succeed after circuit closes")
// Verify final state is closed
finalState := ioref.Read(stateRef)()
assert.True(t, circuitbreaker.IsClosed(finalState), "Circuit should be closed at the end")
// Log summary for debugging
t.Logf("Test completed with %d requests", len(results))
successCount := 0
cbErrorCount := 0
actualErrorCount := 0
for _, r := range results {
if r.Success {
successCount++
} else if r.CircuitBreakerError {
cbErrorCount++
} else {
actualErrorCount++
}
}
t.Logf("Summary: %d successes, %d circuit breaker errors, %d actual errors",
successCount, cbErrorCount, actualErrorCount)
}
// TestCircuitBreaker_ConcurrentHighLoad tests circuit breaker behavior
// under high concurrent load with mixed success/failure patterns
func TestCircuitBreaker_ConcurrentHighLoad(t *testing.T) {
startTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
vt := NewVirtualTimer(startTime)
var logMessages []string
cb := MakeCircuitBreaker[int](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
ctx := t.Context()
// Create a large batch of 50 requests
// Pattern: success, success, fail, fail, fail, fail, success, success, ...
// This ensures we have initial successes, then failures to open circuit,
// then more requests that hit the open circuit
numRequests := 50
results := make([]bool, numRequests)
cbErrors := make([]bool, numRequests)
// Execute requests with controlled timing
for i := range numRequests {
// Advance time slightly for each request
vt.Advance(10 * time.Millisecond)
// Pattern: 2 success, 4 failures, repeat
// This ensures we exceed the threshold (3 failures) early on
shouldFail := (i%6) >= 2 && (i%6) < 6
var op ReaderIOResult[int]
if shouldFail {
op = Left[int](errors.New("simulated failure"))
} else {
op = Of(i)
}
env := pair.MakePair(stateRef, op)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
results[i] = result.IsRight(outcome)
if !results[i] {
_, err := result.Unwrap(outcome)
var cbErr *circuitbreaker.CircuitBreakerError
cbErrors[i] = errors.As(err, &cbErr)
}
}
// Count outcomes
successCount := 0
failureCount := 0
cbErrorCount := 0
for i := range numRequests {
if results[i] {
successCount++
} else {
failureCount++
if cbErrors[i] {
cbErrorCount++
}
}
}
t.Logf("High load test: %d total requests", numRequests)
t.Logf("Results: %d successes, %d failures (%d circuit breaker errors)",
successCount, failureCount, cbErrorCount)
// Verify that circuit breaker activated (some requests failed due to open circuit)
assert.Greater(t, cbErrorCount, 0, "Circuit breaker should have opened and blocked some requests")
// Verify that not all requests failed (some succeeded before circuit opened)
assert.Greater(t, successCount, 0, "Some requests should have succeeded")
}
// TestCircuitBreaker_TrueConcurrentRequests tests actual concurrent execution
// with proper synchronization
func TestCircuitBreaker_TrueConcurrentRequests(t *testing.T) {
startTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
vt := NewVirtualTimer(startTime)
var logMessages []string
cb := MakeCircuitBreaker[int](
vt.Now,
testCBClosedState(),
checkAllErrors,
testCBRetryPolicy(),
testMetrics(&logMessages),
)
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
ctx := t.Context()
// Launch 20 concurrent requests
numRequests := 20
var wg sync.WaitGroup
results := make([]bool, numRequests)
cbErrors := make([]bool, numRequests)
// First, send some successful requests
for i := range 5 {
op := Of(i)
env := pair.MakePair(stateRef, op)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
results[i] = result.IsRight(outcome)
}
// Now send concurrent failures to open the circuit
for i := 5; i < 10; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
op := Left[int](errors.New("concurrent failure"))
env := pair.MakePair(stateRef, op)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
results[idx] = result.IsRight(outcome)
if !results[idx] {
_, err := result.Unwrap(outcome)
var cbErr *circuitbreaker.CircuitBreakerError
cbErrors[idx] = errors.As(err, &cbErr)
}
}(i)
}
wg.Wait()
// Now send more requests that should hit the open circuit
for i := 10; i < numRequests; i++ {
op := Of(i)
env := pair.MakePair(stateRef, op)
resultEnv := cb(env)
protectedOp := pair.Tail(resultEnv)
outcome := protectedOp(ctx)()
results[i] = result.IsRight(outcome)
if !results[i] {
_, err := result.Unwrap(outcome)
var cbErr *circuitbreaker.CircuitBreakerError
cbErrors[i] = errors.As(err, &cbErr)
}
}
// Count outcomes
successCount := 0
failureCount := 0
cbErrorCount := 0
for i := range numRequests {
if results[i] {
successCount++
} else {
failureCount++
if cbErrors[i] {
cbErrorCount++
}
}
}
t.Logf("Concurrent test: %d total requests", numRequests)
t.Logf("Results: %d successes, %d failures (%d circuit breaker errors)",
successCount, failureCount, cbErrorCount)
// Verify initial successes
assert.Equal(t, 5, successCount, "First 5 requests should succeed")
// Verify that circuit breaker opened and blocked some requests
assert.Greater(t, cbErrorCount, 0, "Circuit breaker should have opened and blocked some requests")
}

View File

@@ -2,12 +2,62 @@ package readerioresult
import "github.com/IBM/fp-go/v2/io"
// ChainConsumer chains a consumer function into a ReaderIOResult computation, discarding the original value.
// This is useful for performing side effects (like logging or metrics) that consume a value
// but don't produce a meaningful result. The computation continues with an empty struct.
//
// Type Parameters:
// - A: The type of value to consume
//
// Parameters:
// - c: A consumer function that performs side effects on the value
//
// Returns:
// - An Operator that chains the consumer and returns struct{}
//
// Example:
//
// logUser := func(u User) {
// log.Printf("Processing user: %s", u.Name)
// }
//
// pipeline := F.Pipe2(
// fetchUser(123),
// ChainConsumer(logUser),
// )
//
//go:inline
func ChainConsumer[A any](c Consumer[A]) Operator[A, struct{}] {
return ChainIOK(io.FromConsumerK(c))
return ChainIOK(io.FromConsumer(c))
}
// ChainFirstConsumer chains a consumer function into a ReaderIOResult computation, preserving the original value.
// This is useful for performing side effects (like logging or metrics) while passing the value through unchanged.
//
// The consumer is executed for its side effects, but the original value is returned.
//
// Type Parameters:
// - A: The type of value to consume and return
//
// Parameters:
// - c: A consumer function that performs side effects on the value
//
// Returns:
// - An Operator that chains the consumer and returns the original value
//
// Example:
//
// logUser := func(u User) {
// log.Printf("User: %s", u.Name)
// }
//
// pipeline := F.Pipe3(
// fetchUser(123),
// ChainFirstConsumer(logUser), // Logs but passes user through
// Map(func(u User) string { return u.Email }),
// )
//
//go:inline
func ChainFirstConsumer[A any](c Consumer[A]) Operator[A, A] {
return ChainFirstIOK(io.FromConsumerK(c))
return ChainFirstIOK(io.FromConsumer(c))
}

View File

@@ -44,11 +44,11 @@ var (
)
// Close closes an object
func Close[C io.Closer](c C) RIOE.ReaderIOResult[any] {
func Close[C io.Closer](c C) RIOE.ReaderIOResult[struct{}] {
return F.Pipe2(
c,
IOEF.Close[C],
RIOE.FromIOEither[any],
RIOE.FromIOEither[struct{}],
)
}

View File

@@ -16,7 +16,6 @@
package file
import (
"context"
"os"
"testing"
@@ -30,7 +29,7 @@ func TestWithTempFile(t *testing.T) {
res := WithTempFile(onWriteAll[*os.File]([]byte("Carsten")))
assert.Equal(t, E.Of[error]([]byte("Carsten")), res(context.Background())())
assert.Equal(t, E.Of[error]([]byte("Carsten")), res(t.Context())())
}
func TestWithTempFileOnClosedFile(t *testing.T) {
@@ -43,5 +42,5 @@ func TestWithTempFileOnClosedFile(t *testing.T) {
)
})
assert.Equal(t, E.Of[error]([]byte("Carsten")), res(context.Background())())
assert.Equal(t, E.Of[error]([]byte("Carsten")), res(t.Context())())
}

View File

@@ -0,0 +1,51 @@
// 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 (
"context"
RIOR "github.com/IBM/fp-go/v2/readerioresult"
)
// FilterOrElse filters a ReaderIOResult value based on a predicate.
// This is a convenience wrapper around readerioresult.FilterOrElse that fixes
// the context type to context.Context.
//
// If the predicate returns true for the Right value, it passes through unchanged.
// If the predicate returns false, it transforms the Right value into a Left (error) using onFalse.
// Left values are passed through unchanged.
//
// Parameters:
// - pred: A predicate function that tests the Right value
// - onFalse: A function that converts the failing value into an error
//
// Returns:
// - An Operator that filters ReaderIOResult values based on the predicate
//
// Example:
//
// // Validate that a number is positive
// isPositive := N.MoreThan(0)
// onNegative := func(n int) error { return fmt.Errorf("%d is not positive", n) }
//
// filter := readerioresult.FilterOrElse(isPositive, onNegative)
// result := filter(readerioresult.Right(42))(context.Background())()
//
//go:inline
func FilterOrElse[A any](pred Predicate[A], onFalse func(A) error) Operator[A, A] {
return RIOR.FilterOrElse[context.Context](pred, onFalse)
}

View File

@@ -83,7 +83,7 @@ import (
// )
//
//go:inline
func SequenceReader[R, A any](ma ReaderIOResult[Reader[R, A]]) reader.Kleisli[context.Context, R, IOResult[A]] {
func SequenceReader[R, A any](ma ReaderIOResult[Reader[R, A]]) Kleisli[R, A] {
return RIOR.SequenceReader(ma)
}
@@ -145,7 +145,7 @@ func SequenceReader[R, A any](ma ReaderIOResult[Reader[R, A]]) reader.Kleisli[co
// )
//
//go:inline
func SequenceReaderIO[R, A any](ma ReaderIOResult[RIO.ReaderIO[R, A]]) reader.Kleisli[context.Context, R, IOResult[A]] {
func SequenceReaderIO[R, A any](ma ReaderIOResult[RIO.ReaderIO[R, A]]) Kleisli[R, A] {
return RIOR.SequenceReaderIO(ma)
}
@@ -212,7 +212,7 @@ func SequenceReaderIO[R, A any](ma ReaderIOResult[RIO.ReaderIO[R, A]]) reader.Kl
// )
//
//go:inline
func SequenceReaderResult[R, A any](ma ReaderIOResult[RR.ReaderResult[R, A]]) reader.Kleisli[context.Context, R, IOResult[A]] {
func SequenceReaderResult[R, A any](ma ReaderIOResult[RR.ReaderResult[R, A]]) Kleisli[R, A] {
return RIOR.SequenceReaderEither(ma)
}

View File

@@ -22,6 +22,7 @@ import (
RIOE "github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/either"
F "github.com/IBM/fp-go/v2/function"
N "github.com/IBM/fp-go/v2/number"
)
// Example_sequenceReader_basicUsage demonstrates the basic usage of SequenceReader
@@ -233,7 +234,7 @@ func Example_sequenceReaderResult_errorHandling() {
ctx := context.Background()
pipeline := F.Pipe2(
sequenced(ctx),
RIOE.Map(func(x int) int { return x * 2 }),
RIOE.Map(N.Mul(2)),
RIOE.Chain(func(x int) RIOE.ReaderIOResult[string] {
return RIOE.Of(fmt.Sprintf("Result: %d", x))
}),

View File

@@ -0,0 +1,75 @@
// Copyright (c) 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 (
"context"
"github.com/IBM/fp-go/v2/function"
)
// Promap is the profunctor map operation that transforms both the input and output of a context-based ReaderIOResult.
// It applies f to the input context (contravariantly) and g to the output value (covariantly).
//
// See: https://github.com/fantasyland/fantasy-land?tab=readme-ov-file#profunctor
//
// This operation allows you to:
// - Modify the context before passing it to the ReaderIOResult (via f)
// - Transform the success value after the IO effect completes (via g)
//
// The function f returns both a new context and a CancelFunc that should be called to release resources.
// The error type is fixed as error and remains unchanged through the transformation.
//
// Type Parameters:
// - A: The original success type produced by the ReaderIOResult
// - B: The new output success type
//
// Parameters:
// - f: Function to transform the input 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]
//
//go:inline
func Promap[A, B any](f func(context.Context) (context.Context, context.CancelFunc), g func(A) B) Operator[A, B] {
return function.Flow2(
Local[A](f),
Map(g),
)
}
// Contramap changes the context during the execution of a ReaderIOResult.
// This is the contravariant functor operation that transforms the input context.
//
// See: https://github.com/fantasyland/fantasy-land?tab=readme-ov-file#profunctor
//
// Contramap is an alias for Local and is useful for adapting a ReaderIOResult to work with
// a modified context by providing a function that transforms the context.
//
// Type Parameters:
// - A: The success type (unchanged)
//
// Parameters:
// - f: Function to transform the context, returning a new context and CancelFunc
//
// Returns:
// - An Operator that takes a ReaderIOResult[A] and returns a ReaderIOResult[A]
//
//go:inline
func Contramap[A any](f func(context.Context) (context.Context, context.CancelFunc)) Operator[A, A] {
return Local[A](f)
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 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 (
"context"
"strconv"
"testing"
R "github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
// TestPromapBasic tests basic Promap functionality
func TestPromapBasic(t *testing.T) {
t.Run("transform both context and output", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[int] {
return func() R.Result[int] {
if v := ctx.Value("key"); v != nil {
return R.Of(v.(int))
}
return R.Of(0)
}
}
addKey := func(ctx context.Context) (context.Context, context.CancelFunc) {
newCtx := context.WithValue(ctx, "key", 42)
return newCtx, func() {}
}
toString := strconv.Itoa
adapted := Promap(addKey, toString)(getValue)
result := adapted(context.Background())()
assert.Equal(t, R.Of("42"), result)
})
}
// TestContramapBasic tests basic Contramap functionality
func TestContramapBasic(t *testing.T) {
t.Run("context transformation", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[int] {
return func() R.Result[int] {
if v := ctx.Value("key"); v != nil {
return R.Of(v.(int))
}
return R.Of(0)
}
}
addKey := func(ctx context.Context) (context.Context, context.CancelFunc) {
newCtx := context.WithValue(ctx, "key", 100)
return newCtx, func() {}
}
adapted := Contramap[int](addKey)(getValue)
result := adapted(context.Background())()
assert.Equal(t, R.Of(100), result)
})
}
// TestLocalBasic tests basic Local functionality
func TestLocalBasic(t *testing.T) {
t.Run("adds value to context", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[string] {
return func() R.Result[string] {
if v := ctx.Value("user"); v != nil {
return R.Of(v.(string))
}
return R.Of("unknown")
}
}
addUser := func(ctx context.Context) (context.Context, context.CancelFunc) {
newCtx := context.WithValue(ctx, "user", "Alice")
return newCtx, func() {}
}
adapted := Local[string](addUser)(getValue)
result := adapted(context.Background())()
assert.Equal(t, R.Of("Alice"), result)
})
}

View File

@@ -152,7 +152,7 @@ func MapTo[A, B any](b B) Operator[A, B] {
//
//go:inline
func MonadChain[A, B any](ma ReaderIOResult[A], f Kleisli[A, B]) ReaderIOResult[B] {
return RIOR.MonadChain(ma, function.Flow2(f, WithContext))
return RIOR.MonadChain(ma, WithContextK(f))
}
// Chain sequences two [ReaderIOResult] computations, where the second depends on the result of the first.
@@ -165,7 +165,7 @@ func MonadChain[A, B any](ma ReaderIOResult[A], f Kleisli[A, B]) ReaderIOResult[
//
//go:inline
func Chain[A, B any](f Kleisli[A, B]) Operator[A, B] {
return RIOR.Chain(function.Flow2(f, WithContext))
return RIOR.Chain(WithContextK(f))
}
// MonadChainFirst sequences two [ReaderIOResult] computations but returns the result of the first.
@@ -179,12 +179,12 @@ func Chain[A, B any](f Kleisli[A, B]) Operator[A, B] {
//
//go:inline
func MonadChainFirst[A, B any](ma ReaderIOResult[A], f Kleisli[A, B]) ReaderIOResult[A] {
return RIOR.MonadChainFirst(ma, function.Flow2(f, WithContext))
return RIOR.MonadChainFirst(ma, WithContextK(f))
}
//go:inline
func MonadTap[A, B any](ma ReaderIOResult[A], f Kleisli[A, B]) ReaderIOResult[A] {
return RIOR.MonadTap(ma, function.Flow2(f, WithContext))
return RIOR.MonadTap(ma, WithContextK(f))
}
// ChainFirst sequences two [ReaderIOResult] computations but returns the result of the first.
@@ -197,12 +197,12 @@ func MonadTap[A, B any](ma ReaderIOResult[A], f Kleisli[A, B]) ReaderIOResult[A]
//
//go:inline
func ChainFirst[A, B any](f Kleisli[A, B]) Operator[A, A] {
return RIOR.ChainFirst(function.Flow2(f, WithContext))
return RIOR.ChainFirst(WithContextK(f))
}
//go:inline
func Tap[A, B any](f Kleisli[A, B]) Operator[A, A] {
return RIOR.Tap(function.Flow2(f, WithContext))
return RIOR.Tap(WithContextK(f))
}
// Of creates a [ReaderIOResult] that always succeeds with the given value.
@@ -401,6 +401,11 @@ func ChainEitherK[A, B any](f either.Kleisli[error, A, B]) Operator[A, B] {
return RIOR.ChainEitherK[context.Context](f)
}
//go:inline
func ChainResultK[A, B any](f either.Kleisli[error, A, B]) Operator[A, B] {
return RIOR.ChainEitherK[context.Context](f)
}
// MonadChainFirstEitherK chains a function that returns an [Either] but keeps the original value.
// The Either-returning function is executed for its validation/side effects only.
//
@@ -909,13 +914,28 @@ func Read[A any](r context.Context) func(ReaderIOResult[A]) IOResult[A] {
return RIOR.Read[A](r)
}
//go:inline
func ReadIO[A any](r IO[context.Context]) func(ReaderIOResult[A]) IOResult[A] {
return RIOR.ReadIO[A](r)
}
//go:inline
func ReadIOEither[A any](r IOResult[context.Context]) func(ReaderIOResult[A]) IOResult[A] {
return RIOR.ReadIOEither[A](r)
}
//go:inline
func ReadIOResult[A any](r IOResult[context.Context]) func(ReaderIOResult[A]) IOResult[A] {
return RIOR.ReadIOResult[A](r)
}
// MonadChainLeft chains a computation on the left (error) side of a [ReaderIOResult].
// If the input is a Left value, it applies the function f to transform the error and potentially
// change the error type. If the input is a Right value, it passes through unchanged.
//
//go:inline
func MonadChainLeft[A any](fa ReaderIOResult[A], f Kleisli[error, A]) ReaderIOResult[A] {
return RIOR.MonadChainLeft(fa, function.Flow2(f, WithContext))
return RIOR.MonadChainLeft(fa, WithContextK(f))
}
// ChainLeft is the curried version of [MonadChainLeft].
@@ -923,7 +943,7 @@ func MonadChainLeft[A any](fa ReaderIOResult[A], f Kleisli[error, A]) ReaderIORe
//
//go:inline
func ChainLeft[A any](f Kleisli[error, A]) Operator[A, A] {
return RIOR.ChainLeft(function.Flow2(f, WithContext))
return RIOR.ChainLeft(WithContextK(f))
}
// MonadChainFirstLeft chains a computation on the left (error) side but always returns the original error.
@@ -936,12 +956,12 @@ func ChainLeft[A any](f Kleisli[error, A]) Operator[A, A] {
//
//go:inline
func MonadChainFirstLeft[A, B any](ma ReaderIOResult[A], f Kleisli[error, B]) ReaderIOResult[A] {
return RIOR.MonadChainFirstLeft(ma, function.Flow2(f, WithContext))
return RIOR.MonadChainFirstLeft(ma, WithContextK(f))
}
//go:inline
func MonadTapLeft[A, B any](ma ReaderIOResult[A], f Kleisli[error, B]) ReaderIOResult[A] {
return RIOR.MonadTapLeft(ma, function.Flow2(f, WithContext))
return RIOR.MonadTapLeft(ma, WithContextK(f))
}
// ChainFirstLeft is the curried version of [MonadChainFirstLeft].
@@ -953,12 +973,22 @@ func MonadTapLeft[A, B any](ma ReaderIOResult[A], f Kleisli[error, B]) ReaderIOR
//
//go:inline
func ChainFirstLeft[A, B any](f Kleisli[error, B]) Operator[A, A] {
return RIOR.ChainFirstLeft[A](function.Flow2(f, WithContext))
return RIOR.ChainFirstLeft[A](WithContextK(f))
}
//go:inline
func TapLeft[A, B any](f Kleisli[error, B]) Operator[A, A] {
return RIOR.TapLeft[A](function.Flow2(f, WithContext))
return RIOR.TapLeft[A](WithContextK(f))
}
//go:inline
func ChainFirstLeftIOK[A, B any](f io.Kleisli[error, B]) Operator[A, A] {
return RIOR.ChainFirstLeftIOK[A, context.Context](f)
}
//go:inline
func TapLeftIOK[A, B any](f io.Kleisli[error, B]) Operator[A, A] {
return RIOR.TapLeftIOK[A, context.Context](f)
}
// Local transforms the context.Context environment before passing it to a ReaderIOResult computation.
@@ -1011,7 +1041,7 @@ func TapLeft[A, B any](f Kleisli[error, B]) Operator[A, A] {
// getUser,
// addUser,
// )
// value, err := result(context.Background())() // Returns ("Alice", nil)
// value, err := result(t.Context())() // Returns ("Alice", nil)
//
// Timeout Example:
//
@@ -1082,7 +1112,7 @@ func Local[A any](f func(context.Context) (context.Context, context.CancelFunc))
// fetchData,
// readerioresult.WithTimeout[Data](5*time.Second),
// )
// value, err := result(context.Background())() // Returns (Data{}, context.DeadlineExceeded) after 5s
// value, err := result(t.Context())() // Returns (Data{}, context.DeadlineExceeded) after 5s
//
// Successful Example:
//
@@ -1091,7 +1121,7 @@ func Local[A any](f func(context.Context) (context.Context, context.CancelFunc))
// quickFetch,
// readerioresult.WithTimeout[Data](5*time.Second),
// )
// value, err := result(context.Background())() // Returns (Data{Value: "quick"}, nil)
// value, err := result(t.Context())() // Returns (Data{Value: "quick"}, nil)
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)
@@ -1143,12 +1173,12 @@ func WithTimeout[A any](timeout time.Duration) Operator[A, A] {
// fetchData,
// readerioresult.WithDeadline[Data](deadline),
// )
// value, err := result(context.Background())() // Returns (Data{}, context.DeadlineExceeded) if past deadline
// value, err := result(t.Context())() // Returns (Data{}, context.DeadlineExceeded) if past deadline
//
// Combining with Parent Context:
//
// // If parent context already has a deadline, the earlier one takes precedence
// parentCtx, cancel := context.WithDeadline(context.Background(), time.Now().Add(1*time.Hour))
// parentCtx, cancel := context.WithDeadline(t.Context(), time.Now().Add(1*time.Hour))
// defer cancel()
//
// laterDeadline := time.Now().Add(2 * time.Hour)

View File

@@ -235,7 +235,7 @@ func TestApPar(t *testing.T) {
func TestFromPredicate(t *testing.T) {
t.Run("Predicate true", func(t *testing.T) {
pred := FromPredicate(
func(x int) bool { return x > 0 },
N.MoreThan(0),
func(x int) error { return fmt.Errorf("value %d is not positive", x) },
)
result := pred(5)
@@ -244,7 +244,7 @@ func TestFromPredicate(t *testing.T) {
t.Run("Predicate false", func(t *testing.T) {
pred := FromPredicate(
func(x int) bool { return x > 0 },
N.MoreThan(0),
func(x int) error { return fmt.Errorf("value %d is not positive", x) },
)
result := pred(-5)

View File

@@ -16,7 +16,6 @@
package readerioresult
import (
"github.com/IBM/fp-go/v2/either"
F "github.com/IBM/fp-go/v2/function"
RIOR "github.com/IBM/fp-go/v2/readerioresult"
)
@@ -36,9 +35,9 @@ import (
//
// # How It Works
//
// TailRec takes a Kleisli arrow that returns Either[A, B]:
// - Left(A): Continue recursion with the new state A
// - Right(B): Terminate recursion successfully and return the final result B
// TailRec takes a Kleisli arrow that returns Trampoline[A, B]:
// - Bounce(A): Continue recursion with the new state A
// - Land(B): Terminate recursion successfully and return the final result B
//
// The function wraps each iteration with [WithContext] to ensure context cancellation
// is checked before each recursive step. If the context is cancelled, the recursion
@@ -51,11 +50,11 @@ import (
//
// # Parameters
//
// - f: A Kleisli arrow (A => ReaderIOResult[Either[A, B]]) that:
// - f: A Kleisli arrow (A => ReaderIOResult[Trampoline[A, B]]) that:
// - Takes the current state A
// - Returns a ReaderIOResult that depends on [context.Context]
// - Can fail with error (Left in the outer Either)
// - Produces Either[A, B] to control recursion flow (Right in the outer Either)
// - Produces Trampoline[A, B] to control recursion flow (Right in the outer Either)
//
// # Returns
//
@@ -93,15 +92,15 @@ import (
//
// # Example: Cancellable Countdown
//
// countdownStep := func(n int) readerioresult.ReaderIOResult[either.Either[int, string]] {
// return func(ctx context.Context) ioeither.IOEither[error, either.Either[int, string]] {
// return func() either.Either[error, either.Either[int, string]] {
// countdownStep := func(n int) readerioresult.ReaderIOResult[tailrec.Trampoline[int, string]] {
// return func(ctx context.Context) ioeither.IOEither[error, tailrec.Trampoline[int, string]] {
// return func() either.Either[error, tailrec.Trampoline[int, string]] {
// if n <= 0 {
// return either.Right[error](either.Right[int]("Done!"))
// return either.Right[error](tailrec.Land[int]("Done!"))
// }
// // Simulate some work
// time.Sleep(100 * time.Millisecond)
// return either.Right[error](either.Left[string](n - 1))
// return either.Right[error](tailrec.Bounce[string](n - 1))
// }
// }
// }
@@ -120,20 +119,20 @@ import (
// processed []string
// }
//
// processStep := func(state ProcessState) readerioresult.ReaderIOResult[either.Either[ProcessState, []string]] {
// return func(ctx context.Context) ioeither.IOEither[error, either.Either[ProcessState, []string]] {
// return func() either.Either[error, either.Either[ProcessState, []string]] {
// processStep := func(state ProcessState) readerioresult.ReaderIOResult[tailrec.Trampoline[ProcessState, []string]] {
// return func(ctx context.Context) ioeither.IOEither[error, tailrec.Trampoline[ProcessState, []string]] {
// return func() either.Either[error, tailrec.Trampoline[ProcessState, []string]] {
// if len(state.files) == 0 {
// return either.Right[error](either.Right[ProcessState](state.processed))
// return either.Right[error](tailrec.Land[ProcessState](state.processed))
// }
//
// file := state.files[0]
// // Process file (this could be cancelled via context)
// if err := processFileWithContext(ctx, file); err != nil {
// return either.Left[either.Either[ProcessState, []string]](err)
// return either.Left[tailrec.Trampoline[ProcessState, []string]](err)
// }
//
// return either.Right[error](either.Left[[]string](ProcessState{
// return either.Right[error](tailrec.Bounce[[]string](ProcessState{
// files: state.files[1:],
// processed: append(state.processed, file),
// }))
@@ -179,6 +178,6 @@ import (
// - [Left]/[Right]: For creating error/success values
//
//go:inline
func TailRec[A, B any](f Kleisli[A, either.Either[A, B]]) Kleisli[A, B] {
func TailRec[A, B any](f Kleisli[A, Trampoline[A, B]]) Kleisli[A, B] {
return RIOR.TailRec(F.Flow2(f, WithContext))
}

View File

@@ -23,20 +23,22 @@ import (
"testing"
"time"
A "github.com/IBM/fp-go/v2/array"
E "github.com/IBM/fp-go/v2/either"
"github.com/IBM/fp-go/v2/tailrec"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTailRec_BasicRecursion(t *testing.T) {
// Test basic countdown recursion
countdownStep := func(n int) ReaderIOResult[E.Either[int, string]] {
return func(ctx context.Context) IOEither[E.Either[int, string]] {
return func() Either[E.Either[int, string]] {
countdownStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
return func() Either[Trampoline[int, string]] {
if n <= 0 {
return E.Right[error](E.Right[int]("Done!"))
return E.Right[error](tailrec.Land[int]("Done!"))
}
return E.Right[error](E.Left[string](n - 1))
return E.Right[error](tailrec.Bounce[string](n - 1))
}
}
}
@@ -54,13 +56,13 @@ func TestTailRec_FactorialRecursion(t *testing.T) {
acc int
}
factorialStep := func(state FactorialState) ReaderIOResult[E.Either[FactorialState, int]] {
return func(ctx context.Context) IOEither[E.Either[FactorialState, int]] {
return func() Either[E.Either[FactorialState, int]] {
factorialStep := func(state FactorialState) ReaderIOResult[Trampoline[FactorialState, int]] {
return func(ctx context.Context) IOEither[Trampoline[FactorialState, int]] {
return func() Either[Trampoline[FactorialState, int]] {
if state.n <= 1 {
return E.Right[error](E.Right[FactorialState](state.acc))
return E.Right[error](tailrec.Land[FactorialState](state.acc))
}
return E.Right[error](E.Left[int](FactorialState{
return E.Right[error](tailrec.Bounce[int](FactorialState{
n: state.n - 1,
acc: state.acc * state.n,
}))
@@ -78,16 +80,16 @@ func TestTailRec_ErrorHandling(t *testing.T) {
// Test that errors are properly propagated
testErr := errors.New("computation error")
errorStep := func(n int) ReaderIOResult[E.Either[int, string]] {
return func(ctx context.Context) IOEither[E.Either[int, string]] {
return func() Either[E.Either[int, string]] {
errorStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
return func() Either[Trampoline[int, string]] {
if n == 3 {
return E.Left[E.Either[int, string]](testErr)
return E.Left[Trampoline[int, string]](testErr)
}
if n <= 0 {
return E.Right[error](E.Right[int]("Done!"))
return E.Right[error](tailrec.Land[int]("Done!"))
}
return E.Right[error](E.Left[string](n - 1))
return E.Right[error](tailrec.Bounce[string](n - 1))
}
}
}
@@ -104,18 +106,18 @@ func TestTailRec_ContextCancellation(t *testing.T) {
// Test that recursion gets cancelled early when context is canceled
var iterationCount int32
slowStep := func(n int) ReaderIOResult[E.Either[int, string]] {
return func(ctx context.Context) IOEither[E.Either[int, string]] {
return func() Either[E.Either[int, string]] {
slowStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
return func() Either[Trampoline[int, string]] {
atomic.AddInt32(&iterationCount, 1)
// Simulate some work
time.Sleep(50 * time.Millisecond)
if n <= 0 {
return E.Right[error](E.Right[int]("Done!"))
return E.Right[error](tailrec.Land[int]("Done!"))
}
return E.Right[error](E.Left[string](n - 1))
return E.Right[error](tailrec.Bounce[string](n - 1))
}
}
}
@@ -143,13 +145,13 @@ func TestTailRec_ContextCancellation(t *testing.T) {
func TestTailRec_ImmediateCancellation(t *testing.T) {
// Test with an already cancelled context
countdownStep := func(n int) ReaderIOResult[E.Either[int, string]] {
return func(ctx context.Context) IOEither[E.Either[int, string]] {
return func() Either[E.Either[int, string]] {
countdownStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
return func() Either[Trampoline[int, string]] {
if n <= 0 {
return E.Right[error](E.Right[int]("Done!"))
return E.Right[error](tailrec.Land[int]("Done!"))
}
return E.Right[error](E.Left[string](n - 1))
return E.Right[error](tailrec.Bounce[string](n - 1))
}
}
}
@@ -172,13 +174,13 @@ func TestTailRec_StackSafety(t *testing.T) {
// Test that deep recursion doesn't cause stack overflow
const largeN = 10000
countdownStep := func(n int) ReaderIOResult[E.Either[int, int]] {
return func(ctx context.Context) IOEither[E.Either[int, int]] {
return func() Either[E.Either[int, int]] {
countdownStep := func(n int) ReaderIOResult[Trampoline[int, int]] {
return func(ctx context.Context) IOEither[Trampoline[int, int]] {
return func() Either[Trampoline[int, int]] {
if n <= 0 {
return E.Right[error](E.Right[int](0))
return E.Right[error](tailrec.Land[int](0))
}
return E.Right[error](E.Left[int](n - 1))
return E.Right[error](tailrec.Bounce[int](n - 1))
}
}
}
@@ -194,9 +196,9 @@ func TestTailRec_StackSafetyWithCancellation(t *testing.T) {
const largeN = 100000
var iterationCount int32
countdownStep := func(n int) ReaderIOResult[E.Either[int, int]] {
return func(ctx context.Context) IOEither[E.Either[int, int]] {
return func() Either[E.Either[int, int]] {
countdownStep := func(n int) ReaderIOResult[Trampoline[int, int]] {
return func(ctx context.Context) IOEither[Trampoline[int, int]] {
return func() Either[Trampoline[int, int]] {
atomic.AddInt32(&iterationCount, 1)
// Add a small delay every 1000 iterations to make cancellation more likely
@@ -205,9 +207,9 @@ func TestTailRec_StackSafetyWithCancellation(t *testing.T) {
}
if n <= 0 {
return E.Right[error](E.Right[int](0))
return E.Right[error](tailrec.Land[int](0))
}
return E.Right[error](E.Left[int](n - 1))
return E.Right[error](tailrec.Bounce[int](n - 1))
}
}
}
@@ -239,22 +241,22 @@ func TestTailRec_ComplexState(t *testing.T) {
errors []error
}
processStep := func(state ProcessState) ReaderIOResult[E.Either[ProcessState, []string]] {
return func(ctx context.Context) IOEither[E.Either[ProcessState, []string]] {
return func() Either[E.Either[ProcessState, []string]] {
if len(state.items) == 0 {
return E.Right[error](E.Right[ProcessState](state.processed))
processStep := func(state ProcessState) ReaderIOResult[Trampoline[ProcessState, []string]] {
return func(ctx context.Context) IOEither[Trampoline[ProcessState, []string]] {
return func() Either[Trampoline[ProcessState, []string]] {
if A.IsEmpty(state.items) {
return E.Right[error](tailrec.Land[ProcessState](state.processed))
}
item := state.items[0]
// Simulate processing that might fail for certain items
if item == "error-item" {
return E.Left[E.Either[ProcessState, []string]](
return E.Left[Trampoline[ProcessState, []string]](
fmt.Errorf("failed to process item: %s", item))
}
return E.Right[error](E.Left[[]string](ProcessState{
return E.Right[error](tailrec.Bounce[[]string](ProcessState{
items: state.items[1:],
processed: append(state.processed, item),
errors: state.errors,
@@ -301,18 +303,18 @@ func TestTailRec_CancellationDuringProcessing(t *testing.T) {
var processedCount int32
processFileStep := func(state FileProcessState) ReaderIOResult[E.Either[FileProcessState, int]] {
return func(ctx context.Context) IOEither[E.Either[FileProcessState, int]] {
return func() Either[E.Either[FileProcessState, int]] {
if len(state.files) == 0 {
return E.Right[error](E.Right[FileProcessState](state.processed))
processFileStep := func(state FileProcessState) ReaderIOResult[Trampoline[FileProcessState, int]] {
return func(ctx context.Context) IOEither[Trampoline[FileProcessState, int]] {
return func() Either[Trampoline[FileProcessState, int]] {
if A.IsEmpty(state.files) {
return E.Right[error](tailrec.Land[FileProcessState](state.processed))
}
// Simulate file processing time
time.Sleep(20 * time.Millisecond)
atomic.AddInt32(&processedCount, 1)
return E.Right[error](E.Left[int](FileProcessState{
return E.Right[error](tailrec.Bounce[int](FileProcessState{
files: state.files[1:],
processed: state.processed + 1,
}))
@@ -355,10 +357,10 @@ func TestTailRec_CancellationDuringProcessing(t *testing.T) {
func TestTailRec_ZeroIterations(t *testing.T) {
// Test case where recursion terminates immediately
immediateStep := func(n int) ReaderIOResult[E.Either[int, string]] {
return func(ctx context.Context) IOEither[E.Either[int, string]] {
return func() Either[E.Either[int, string]] {
return E.Right[error](E.Right[int]("immediate"))
immediateStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
return func() Either[Trampoline[int, string]] {
return E.Right[error](tailrec.Land[int]("immediate"))
}
}
}
@@ -373,16 +375,16 @@ func TestTailRec_ContextWithDeadline(t *testing.T) {
// Test with context deadline
var iterationCount int32
slowStep := func(n int) ReaderIOResult[E.Either[int, string]] {
return func(ctx context.Context) IOEither[E.Either[int, string]] {
return func() Either[E.Either[int, string]] {
slowStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
return func() Either[Trampoline[int, string]] {
atomic.AddInt32(&iterationCount, 1)
time.Sleep(30 * time.Millisecond)
if n <= 0 {
return E.Right[error](E.Right[int]("Done!"))
return E.Right[error](tailrec.Land[int]("Done!"))
}
return E.Right[error](E.Left[string](n - 1))
return E.Right[error](tailrec.Bounce[string](n - 1))
}
}
}
@@ -409,17 +411,17 @@ func TestTailRec_ContextWithValue(t *testing.T) {
type contextKey string
const testKey contextKey = "test"
valueStep := func(n int) ReaderIOResult[E.Either[int, string]] {
return func(ctx context.Context) IOEither[E.Either[int, string]] {
return func() Either[E.Either[int, string]] {
valueStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
return func() Either[Trampoline[int, string]] {
value := ctx.Value(testKey)
require.NotNil(t, value)
assert.Equal(t, "test-value", value.(string))
if n <= 0 {
return E.Right[error](E.Right[int]("Done!"))
return E.Right[error](tailrec.Land[int]("Done!"))
}
return E.Right[error](E.Left[string](n - 1))
return E.Right[error](tailrec.Bounce[string](n - 1))
}
}
}
@@ -430,5 +432,3 @@ func TestTailRec_ContextWithValue(t *testing.T) {
assert.Equal(t, E.Of[error]("Done!"), result)
}
// Made with Bob

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache LicensVersion 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 (
"context"
"time"
RIO "github.com/IBM/fp-go/v2/context/readerio"
R "github.com/IBM/fp-go/v2/retry"
RG "github.com/IBM/fp-go/v2/retry/generic"
)
// Retrying retries a ReaderIOResult computation according to a retry policy with context awareness.
//
// This function implements a retry mechanism for operations that depend on a [context.Context],
// perform side effects (IO), and can fail (Result). It respects context cancellation, meaning
// that if the context is cancelled during retry delays, the operation will stop immediately
// and return the cancellation error.
//
// The retry loop will continue until one of the following occurs:
// - The action succeeds and the check function returns false (no retry needed)
// - The retry policy returns None (retry limit reached)
// - The check function returns false (indicating success or a non-retryable failure)
// - The context is cancelled (returns context.Canceled or context.DeadlineExceeded)
//
// Parameters:
//
// - policy: A RetryPolicy that determines when and how long to wait between retries.
// The policy receives a RetryStatus on each iteration and returns an optional delay.
// If it returns None, retrying stops. Common policies include LimitRetries,
// ExponentialBackoff, and CapDelay from the retry package.
//
// - action: A Kleisli arrow that takes a RetryStatus and returns a ReaderIOResult[A].
// This function is called on each retry attempt and receives information about the
// current retry state (iteration number, cumulative delay, etc.). The action depends
// on a context.Context and produces a Result[A]. The context passed to the action
// will be the same context used for retry delays, so cancellation is properly propagated.
//
// - check: A predicate function that examines the Result[A] and returns true if the
// operation should be retried, or false if it should stop. This allows you to
// distinguish between retryable failures (e.g., network timeouts) and permanent
// failures (e.g., invalid input). Note that context cancellation errors will
// automatically stop retrying regardless of this function's return value.
//
// Returns:
//
// A ReaderIOResult[A] that, when executed with a context, will perform the retry
// logic with context cancellation support and return the final result.
//
// Type Parameters:
// - A: The type of the success value
//
// Context Cancellation:
//
// The retry mechanism respects context cancellation in two ways:
// 1. During retry delays: If the context is cancelled while waiting between retries,
// the operation stops immediately and returns the context error.
// 2. During action execution: If the action itself checks the context and returns
// an error due to cancellation, the retry loop will stop (assuming the check
// function doesn't force a retry on context errors).
//
// Example:
//
// // Create a retry policy: exponential backoff with a cap, limited to 5 retries
// policy := M.Concat(
// retry.LimitRetries(5),
// retry.CapDelay(10*time.Second, retry.ExponentialBackoff(100*time.Millisecond)),
// )(retry.Monoid)
//
// // Action that fetches data, with retry status information
// fetchData := func(status retry.RetryStatus) ReaderIOResult[string] {
// return func(ctx context.Context) IOResult[string] {
// return func() Result[string] {
// // Check if context is cancelled
// if ctx.Err() != nil {
// return result.Left[string](ctx.Err())
// }
// // Simulate an HTTP request that might fail
// if status.IterNumber < 3 {
// return result.Left[string](fmt.Errorf("temporary error"))
// }
// return result.Of("success")
// }
// }
// }
//
// // Check function: retry on any error except context cancellation
// shouldRetry := func(r Result[string]) bool {
// return result.IsLeft(r) && !errors.Is(result.GetLeft(r), context.Canceled)
// }
//
// // Create the retrying computation
// retryingFetch := Retrying(policy, fetchData, shouldRetry)
//
// // Execute with a cancellable context
// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// defer cancel()
// ioResult := retryingFetch(ctx)
// finalResult := ioResult()
//
// See also:
// - retry.RetryPolicy for available retry policies
// - retry.RetryStatus for information passed to the action
// - context.Context for context cancellation semantics
//
//go:inline
func Retrying[A any](
policy R.RetryPolicy,
action Kleisli[R.RetryStatus, A],
check Predicate[Result[A]],
) ReaderIOResult[A] {
// delayWithCancel implements a context-aware delay mechanism for retry operations.
// It creates a timeout context that will be cancelled when either:
// 1. The delay duration expires (normal case), or
// 2. The parent context is cancelled (early termination)
//
// The function waits on timeoutCtx.Done(), which will be signaled in either case:
// - If the delay expires, timeoutCtx is cancelled by the timeout
// - If the parent ctx is cancelled, timeoutCtx inherits the cancellation
//
// After the wait completes, we dispatch to the next action by calling ri(ctx)().
// This works correctly because the action is wrapped in WithContextK, which handles
// context cancellation by checking ctx.Err() and returning an appropriate error
// (context.Canceled or context.DeadlineExceeded) when the context is cancelled.
//
// This design ensures that:
// - Retry delays respect context cancellation and terminate immediately
// - The cancellation error propagates correctly through the retry chain
// - No unnecessary delays occur when the context is already cancelled
delayWithCancel := func(delay time.Duration) RIO.Operator[R.RetryStatus, R.RetryStatus] {
return func(ri ReaderIO[R.RetryStatus]) ReaderIO[R.RetryStatus] {
return func(ctx context.Context) IO[R.RetryStatus] {
return func() R.RetryStatus {
// Create a timeout context that will be cancelled when either:
// - The delay duration expires, or
// - The parent context is cancelled
timeoutCtx, cancelTimeout := context.WithTimeout(ctx, delay)
defer cancelTimeout()
// Wait for either the timeout or parent context cancellation
<-timeoutCtx.Done()
// Dispatch to the next action with the original context.
// WithContextK will handle context cancellation correctly.
return ri(ctx)()
}
}
}
}
// get an implementation for the types
return RG.Retrying(
RIO.Chain[Result[A], Trampoline[R.RetryStatus, Result[A]]],
RIO.Map[R.RetryStatus, Trampoline[R.RetryStatus, Result[A]]],
RIO.Of[Trampoline[R.RetryStatus, Result[A]]],
RIO.Of[R.RetryStatus],
delayWithCancel,
RIO.TailRec,
policy,
WithContextK(action),
check,
)
}

View File

@@ -0,0 +1,511 @@
// Copyright (c) 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 (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/IBM/fp-go/v2/result"
R "github.com/IBM/fp-go/v2/retry"
"github.com/stretchr/testify/assert"
)
// Helper function to create a test retry policy
func testRetryPolicy() R.RetryPolicy {
return R.Monoid.Concat(
R.LimitRetries(5),
R.CapDelay(1*time.Second, R.ExponentialBackoff(10*time.Millisecond)),
)
}
// TestRetrying_SuccessOnFirstAttempt tests that Retrying succeeds immediately
// when the action succeeds on the first attempt.
func TestRetrying_SuccessOnFirstAttempt(t *testing.T) {
policy := testRetryPolicy()
action := func(status R.RetryStatus) ReaderIOResult[string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
return result.Of("success")
}
}
}
check := func(r Result[string]) bool {
return result.IsLeft(r)
}
retrying := Retrying(policy, action, check)
ctx := t.Context()
res := retrying(ctx)()
assert.Equal(t, result.Of("success"), res)
}
// TestRetrying_SuccessAfterRetries tests that Retrying eventually succeeds
// after a few failed attempts.
func TestRetrying_SuccessAfterRetries(t *testing.T) {
policy := testRetryPolicy()
action := func(status R.RetryStatus) ReaderIOResult[string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
// Fail on first 3 attempts, succeed on 4th
if status.IterNumber < 3 {
return result.Left[string](fmt.Errorf("attempt %d failed", status.IterNumber))
}
return result.Of(fmt.Sprintf("success on attempt %d", status.IterNumber))
}
}
}
check := func(r Result[string]) bool {
return result.IsLeft(r)
}
retrying := Retrying(policy, action, check)
ctx := t.Context()
res := retrying(ctx)()
assert.Equal(t, result.Of("success on attempt 3"), res)
}
// TestRetrying_ExhaustsRetries tests that Retrying stops after the retry limit
// is reached and returns the last error.
func TestRetrying_ExhaustsRetries(t *testing.T) {
policy := R.LimitRetries(3)
action := func(status R.RetryStatus) ReaderIOResult[string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
return result.Left[string](fmt.Errorf("attempt %d failed", status.IterNumber))
}
}
}
check := func(r Result[string]) bool {
return result.IsLeft(r)
}
retrying := Retrying(policy, action, check)
ctx := t.Context()
res := retrying(ctx)()
assert.True(t, result.IsLeft(res))
assert.Equal(t, result.Left[string](fmt.Errorf("attempt 3 failed")), res)
}
// TestRetrying_ActionChecksContextCancellation tests that actions can check
// the context and return early if it's cancelled.
func TestRetrying_ActionChecksContextCancellation(t *testing.T) {
policy := R.LimitRetries(10)
attemptCount := 0
action := func(status R.RetryStatus) ReaderIOResult[string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
attemptCount++
// Check context at the start of the action
if ctx.Err() != nil {
return result.Left[string](ctx.Err())
}
// Simulate work that might take time
time.Sleep(10 * time.Millisecond)
// Check context again after work
if ctx.Err() != nil {
return result.Left[string](ctx.Err())
}
// Always fail to trigger retries
return result.Left[string](fmt.Errorf("attempt %d failed", status.IterNumber))
}
}
}
check := func(r Result[string]) bool {
// Don't retry on context errors
val, err := result.Unwrap(r)
_ = val
if err != nil && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) {
return false
}
return result.IsLeft(r)
}
retrying := Retrying(policy, action, check)
// Create a context that we'll cancel after a short time
ctx, cancel := context.WithCancel(t.Context())
// Start the retry operation in a goroutine
resultChan := make(chan Result[string], 1)
go func() {
res := retrying(ctx)()
resultChan <- res
}()
// Cancel the context after allowing a couple attempts
time.Sleep(50 * time.Millisecond)
cancel()
// Wait for the result
res := <-resultChan
// Should have stopped due to context cancellation
assert.True(t, result.IsLeft(res))
// Should have stopped early (not all 10 attempts)
assert.Less(t, attemptCount, 10, "Should stop retrying when action detects context cancellation")
// The error should be related to context cancellation or an early attempt
val, err := result.Unwrap(res)
_ = val
assert.Error(t, err)
}
// TestRetrying_ContextCancelledBeforeStart tests that if the context is already
// cancelled before starting, the operation fails immediately.
func TestRetrying_ContextCancelledBeforeStart(t *testing.T) {
policy := testRetryPolicy()
attemptCount := 0
action := func(status R.RetryStatus) ReaderIOResult[string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
attemptCount++
// Check context before doing work
if ctx.Err() != nil {
return result.Left[string](ctx.Err())
}
return result.Left[string](fmt.Errorf("attempt %d failed", status.IterNumber))
}
}
}
check := func(r Result[string]) bool {
// Don't retry on context errors
val, err := result.Unwrap(r)
_ = val
if err != nil && errors.Is(err, context.Canceled) {
return false
}
return result.IsLeft(r)
}
retrying := Retrying(policy, action, check)
// Create an already-cancelled context
ctx, cancel := context.WithCancel(t.Context())
cancel()
res := retrying(ctx)()
assert.True(t, result.IsLeft(res))
val, err := result.Unwrap(res)
_ = val
assert.True(t, errors.Is(err, context.Canceled))
// Should have attempted at most once
assert.LessOrEqual(t, attemptCount, 1)
}
// TestRetrying_ContextTimeoutInAction tests that actions respect context deadlines.
func TestRetrying_ContextTimeoutInAction(t *testing.T) {
policy := R.LimitRetries(10)
attemptCount := 0
action := func(status R.RetryStatus) ReaderIOResult[string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
attemptCount++
// Check context before doing work
if ctx.Err() != nil {
return result.Left[string](ctx.Err())
}
// Simulate some work
time.Sleep(50 * time.Millisecond)
// Check context after work
if ctx.Err() != nil {
return result.Left[string](ctx.Err())
}
// Always fail to trigger retries
return result.Left[string](fmt.Errorf("attempt %d failed", status.IterNumber))
}
}
}
check := func(r Result[string]) bool {
// Don't retry on context errors
val, err := result.Unwrap(r)
_ = val
if err != nil && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) {
return false
}
return result.IsLeft(r)
}
retrying := Retrying(policy, action, check)
// Create a context with a short timeout
ctx, cancel := context.WithTimeout(t.Context(), 150*time.Millisecond)
defer cancel()
startTime := time.Now()
res := retrying(ctx)()
elapsed := time.Since(startTime)
assert.True(t, result.IsLeft(res))
// Should have stopped before completing all 10 retries
assert.Less(t, attemptCount, 10, "Should stop retrying when action detects context timeout")
// Should have stopped around the timeout duration
assert.Less(t, elapsed, 500*time.Millisecond, "Should stop soon after timeout")
}
// TestRetrying_CheckFunctionStopsRetry tests that the check function can
// stop retrying even when errors occur.
func TestRetrying_CheckFunctionStopsRetry(t *testing.T) {
policy := testRetryPolicy()
action := func(status R.RetryStatus) ReaderIOResult[string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
if status.IterNumber == 0 {
return result.Left[string](fmt.Errorf("retryable error"))
}
return result.Left[string](fmt.Errorf("permanent error"))
}
}
}
// Only retry on "retryable error"
check := func(r Result[string]) bool {
return result.IsLeft(r) && result.Fold(
func(err error) bool { return err.Error() == "retryable error" },
func(string) bool { return false },
)(r)
}
retrying := Retrying(policy, action, check)
ctx := t.Context()
res := retrying(ctx)()
assert.Equal(t, result.Left[string](fmt.Errorf("permanent error")), res)
}
// TestRetrying_ExponentialBackoff tests that exponential backoff is applied.
func TestRetrying_ExponentialBackoff(t *testing.T) {
// Use a policy with measurable delays
policy := R.Monoid.Concat(
R.LimitRetries(3),
R.ExponentialBackoff(50*time.Millisecond),
)
startTime := time.Now()
action := func(status R.RetryStatus) ReaderIOResult[string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
if status.IterNumber < 2 {
return result.Left[string](fmt.Errorf("retry"))
}
return result.Of("success")
}
}
}
check := func(r Result[string]) bool {
return result.IsLeft(r)
}
retrying := Retrying(policy, action, check)
ctx := t.Context()
res := retrying(ctx)()
elapsed := time.Since(startTime)
assert.Equal(t, result.Of("success"), res)
// With exponential backoff starting at 50ms:
// Iteration 0: no delay
// Iteration 1: 50ms delay
// Iteration 2: 100ms delay
// Total should be at least 150ms
assert.GreaterOrEqual(t, elapsed, 150*time.Millisecond)
}
// TestRetrying_ContextValuePropagation tests that context values are properly
// propagated through the retry mechanism.
func TestRetrying_ContextValuePropagation(t *testing.T) {
policy := R.LimitRetries(2)
type contextKey string
const requestIDKey contextKey = "requestID"
action := func(status R.RetryStatus) ReaderIOResult[string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
// Extract value from context
requestID, ok := ctx.Value(requestIDKey).(string)
if !ok {
return result.Left[string](fmt.Errorf("missing request ID"))
}
if status.IterNumber < 1 {
return result.Left[string](fmt.Errorf("retry needed"))
}
return result.Of(fmt.Sprintf("processed request %s", requestID))
}
}
}
check := func(r Result[string]) bool {
return result.IsLeft(r)
}
retrying := Retrying(policy, action, check)
// Create context with a value
ctx := context.WithValue(t.Context(), requestIDKey, "12345")
res := retrying(ctx)()
assert.Equal(t, result.Of("processed request 12345"), res)
}
// TestRetrying_RetryStatusProgression tests that the RetryStatus is properly
// updated on each iteration.
func TestRetrying_RetryStatusProgression(t *testing.T) {
policy := testRetryPolicy()
var iterations []uint
action := func(status R.RetryStatus) ReaderIOResult[int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
iterations = append(iterations, status.IterNumber)
if status.IterNumber < 3 {
return result.Left[int](fmt.Errorf("retry"))
}
return result.Of(int(status.IterNumber))
}
}
}
check := func(r Result[int]) bool {
return result.IsLeft(r)
}
retrying := Retrying(policy, action, check)
ctx := t.Context()
res := retrying(ctx)()
assert.Equal(t, result.Of(3), res)
// Should have attempted iterations 0, 1, 2, 3
assert.Equal(t, []uint{0, 1, 2, 3}, iterations)
}
// TestRetrying_ContextCancelledDuringDelay tests that the retry operation
// stops immediately when the context is cancelled during a retry delay,
// even if there are still retries remaining according to the policy.
func TestRetrying_ContextCancelledDuringDelay(t *testing.T) {
// Use a policy with significant delays to ensure we can cancel during the delay
policy := R.Monoid.Concat(
R.LimitRetries(10),
R.ConstantDelay(200*time.Millisecond),
)
attemptCount := 0
action := func(status R.RetryStatus) ReaderIOResult[string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
attemptCount++
// Always fail to trigger retries
return result.Left[string](fmt.Errorf("attempt %d failed", status.IterNumber))
}
}
}
// Always retry on errors (don't check for context cancellation in check function)
check := func(r Result[string]) bool {
return result.IsLeft(r)
}
retrying := Retrying(policy, action, check)
// Create a context that we'll cancel during the retry delay
ctx, cancel := context.WithCancel(t.Context())
// Start the retry operation in a goroutine
resultChan := make(chan Result[string], 1)
startTime := time.Now()
go func() {
res := retrying(ctx)()
resultChan <- res
}()
// Wait for the first attempt to complete and the delay to start
time.Sleep(50 * time.Millisecond)
// Cancel the context during the retry delay
cancel()
// Wait for the result
res := <-resultChan
elapsed := time.Since(startTime)
// Should have stopped due to context cancellation
assert.True(t, result.IsLeft(res))
// Should have attempted only once or twice (not all 10 attempts)
// because the context was cancelled during the delay
assert.LessOrEqual(t, attemptCount, 2, "Should stop retrying when context is cancelled during delay")
// Should have stopped quickly after cancellation, not waiting for all delays
// With 10 retries and 200ms delays, it would take ~2 seconds without cancellation
// With cancellation during first delay, it should complete in well under 500ms
assert.Less(t, elapsed, 500*time.Millisecond, "Should stop immediately when context is cancelled during delay")
// When context is cancelled during the delay, the retry mechanism
// detects the cancellation and returns a context error
val, err := result.Unwrap(res)
_ = val
assert.Error(t, err)
// The error should be a context cancellation error since cancellation
// happened during the delay between retries
assert.True(t, errors.Is(err, context.Canceled), "Should return context.Canceled when cancelled during delay")
}

View File

@@ -25,14 +25,21 @@ import (
"github.com/IBM/fp-go/v2/endomorphism"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioeither"
"github.com/IBM/fp-go/v2/ioref"
"github.com/IBM/fp-go/v2/lazy"
"github.com/IBM/fp-go/v2/optics/lens"
"github.com/IBM/fp-go/v2/optics/prism"
"github.com/IBM/fp-go/v2/option"
"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/readereither"
"github.com/IBM/fp-go/v2/readerio"
RIOR "github.com/IBM/fp-go/v2/readerioresult"
"github.com/IBM/fp-go/v2/readeroption"
"github.com/IBM/fp-go/v2/result"
"github.com/IBM/fp-go/v2/state"
"github.com/IBM/fp-go/v2/tailrec"
)
type (
@@ -106,7 +113,7 @@ type (
// }
//
// The computation is executed by providing a context and then invoking the result:
// ctx := context.Background()
// ctx := t.Context()
// result := fetchUser("123")(ctx)()
ReaderIOResult[A any] = RIOR.ReaderIOResult[context.Context, A]
@@ -132,4 +139,17 @@ type (
Endomorphism[A any] = endomorphism.Endomorphism[A]
Consumer[A any] = consumer.Consumer[A]
Prism[S, T any] = prism.Prism[S, T]
Lens[S, T any] = lens.Lens[S, T]
Trampoline[B, L any] = tailrec.Trampoline[B, L]
Predicate[A any] = predicate.Predicate[A]
Pair[A, B any] = pair.Pair[A, B]
IORef[A any] = ioref.IORef[A]
State[S, A any] = state.State[S, A]
)

View File

@@ -0,0 +1,453 @@
// 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 (
"github.com/IBM/fp-go/v2/either"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/internal/apply"
"github.com/IBM/fp-go/v2/internal/chain"
"github.com/IBM/fp-go/v2/internal/functor"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioeither"
"github.com/IBM/fp-go/v2/ioresult"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/readerio"
)
// Do creates an empty context of type [S] to be used with the [Bind] operation.
// This is the starting point for do-notation style composition with two reader contexts.
//
// Example:
//
// type State struct {
// User User
// Posts []Post
// }
// type OuterEnv struct {
// Database string
// }
// type InnerEnv struct {
// UserRepo UserRepository
// PostRepo PostRepository
// }
// result := readerreaderioeither.Do[OuterEnv, InnerEnv, error](State{})
//
//go:inline
func Do[R, S any](
empty S,
) ReaderReaderIOResult[R, S] {
return Of[R](empty)
}
// Bind attaches the result of a computation to a context [S1] to produce a context [S2].
// This enables sequential composition where each step can depend on the results of previous steps
// and access both the outer (R) and inner (C) reader environments.
//
// The setter function takes the result of the computation and returns a function that
// updates the context from S1 to S2.
//
// Example:
//
// type State struct {
// User User
// Posts []Post
// }
// type OuterEnv struct {
// Database string
// }
// type InnerEnv struct {
// UserRepo UserRepository
// PostRepo PostRepository
// }
//
// result := F.Pipe2(
// readerreaderioeither.Do[OuterEnv, InnerEnv, error](State{}),
// readerreaderioeither.Bind(
// func(user User) func(State) State {
// return func(s State) State { s.User = user; return s }
// },
// func(s State) readerreaderioeither.ReaderReaderIOResult[OuterEnv, InnerEnv, error, User] {
// return func(outer OuterEnv) readerioeither.ReaderIOEither[InnerEnv, error, User] {
// return readerioeither.Asks(func(inner InnerEnv) ioeither.IOEither[error, User] {
// return inner.UserRepo.FindUser(outer.Database)
// })
// }
// },
// ),
// readerreaderioeither.Bind(
// func(posts []Post) func(State) State {
// return func(s State) State { s.Posts = posts; return s }
// },
// func(s State) readerreaderioeither.ReaderReaderIOResult[OuterEnv, InnerEnv, error, []Post] {
// return func(outer OuterEnv) readerioeither.ReaderIOEither[InnerEnv, error, []Post] {
// return readerioeither.Asks(func(inner InnerEnv) ioeither.IOEither[error, []Post] {
// return inner.PostRepo.FindPostsByUser(outer.Database, s.User.ID)
// })
// }
// },
// ),
// )
//
//go:inline
func Bind[R, S1, S2, T any](
setter func(T) func(S1) S2,
f func(S1) ReaderReaderIOResult[R, T],
) Operator[R, S1, S2] {
return chain.Bind(
Chain[R, S1, S2],
Map[R, T, S2],
setter,
f,
)
}
// Let attaches a pure computation result to a context [S1] to produce a context [S2].
// Unlike [Bind], the computation function f is pure (doesn't perform effects).
//
// Example:
//
// readerreaderioeither.Let(
// func(fullName string) func(State) State {
// return func(s State) State { s.FullName = fullName; return s }
// },
// func(s State) string {
// return s.FirstName + " " + s.LastName
// },
// )
//
//go:inline
func Let[R, S1, S2, T any](
setter func(T) func(S1) S2,
f func(S1) T,
) Operator[R, S1, S2] {
return functor.Let(
Map[R, S1, S2],
setter,
f,
)
}
// LetTo attaches a constant value to a context [S1] to produce a context [S2].
//
// Example:
//
// readerreaderioeither.LetTo(
// func(status string) func(State) State {
// return func(s State) State { s.Status = status; return s }
// },
// "active",
// )
//
//go:inline
func LetTo[R, S1, S2, T any](
setter func(T) func(S1) S2,
b T,
) Operator[R, S1, S2] {
return functor.LetTo(
Map[R, S1, S2],
setter,
b,
)
}
// BindTo wraps a value of type T into a context S1 using the provided setter function.
// This is typically used as the first operation after [Do] to initialize the context.
//
// Example:
//
// F.Pipe1(
// readerreaderioeither.Of[OuterEnv, InnerEnv, error](42),
// readerreaderioeither.BindTo(func(n int) State { return State{Count: n} }),
// )
//
//go:inline
func BindTo[R, S1, T any](
setter func(T) S1,
) Operator[R, T, S1] {
return chain.BindTo(
Map[R, T, S1],
setter,
)
}
// ApS applies a computation in parallel (applicative style) and attaches its result to the context.
// Unlike [Bind], this doesn't allow the computation to depend on the current context state.
//
// Example:
//
// readerreaderioeither.ApS(
// func(count int) func(State) State {
// return func(s State) State { s.Count = count; return s }
// },
// getCount, // ReaderReaderIOResult[OuterEnv, InnerEnv, error, int]
// )
//
//go:inline
func ApS[R, S1, S2, T any](
setter func(T) func(S1) S2,
fa ReaderReaderIOResult[R, T],
) Operator[R, S1, S2] {
return apply.ApS(
Ap[S2, R, T],
Map[R, S1, func(T) S2],
setter,
fa,
)
}
// ApSL is a lens-based version of [ApS] that uses a lens to focus on a specific field in the context.
//
//go:inline
func ApSL[R, S, T any](
lens Lens[S, T],
fa ReaderReaderIOResult[R, T],
) Operator[R, S, S] {
return ApS(lens.Set, fa)
}
// BindL is a lens-based version of [Bind] that uses a lens to focus on a specific field in the context.
//
//go:inline
func BindL[R, S, T any](
lens Lens[S, T],
f func(T) ReaderReaderIOResult[R, T],
) Operator[R, S, S] {
return Bind(lens.Set, F.Flow2(lens.Get, f))
}
// LetL is a lens-based version of [Let] that uses a lens to focus on a specific field in the context.
//
//go:inline
func LetL[R, S, T any](
lens Lens[S, T],
f func(T) T,
) Operator[R, S, S] {
return Let[R](lens.Set, F.Flow2(lens.Get, f))
}
// LetToL is a lens-based version of [LetTo] that uses a lens to focus on a specific field in the context.
//
//go:inline
func LetToL[R, S, T any](
lens Lens[S, T],
b T,
) Operator[R, S, S] {
return LetTo[R](lens.Set, b)
}
// BindIOEitherK binds a computation that returns an IOEither to the context.
// The Kleisli function is automatically lifted into ReaderReaderIOResult.
//
//go:inline
func BindIOEitherK[R, S1, S2, T any](
setter func(T) func(S1) S2,
f ioeither.Kleisli[error, S1, T],
) Operator[R, S1, S2] {
return Bind(setter, F.Flow2(f, FromIOEither[R, T]))
}
//go:inline
func BindIOResultK[R, S1, S2, T any](
setter func(T) func(S1) S2,
f ioresult.Kleisli[S1, T],
) Operator[R, S1, S2] {
return Bind(setter, F.Flow2(f, FromIOResult[R, T]))
}
// BindIOK binds a computation that returns an IO to the context.
// The Kleisli function is automatically lifted into ReaderReaderIOResult.
//
//go:inline
func BindIOK[R, S1, S2, T any](
setter func(T) func(S1) S2,
f io.Kleisli[S1, T],
) Operator[R, S1, S2] {
return Bind(setter, F.Flow2(f, FromIO[R, T]))
}
// BindReaderK binds a computation that returns a Reader to the context.
// The Kleisli function is automatically lifted into ReaderReaderIOResult.
//
//go:inline
func BindReaderK[R, S1, S2, T any](
setter func(T) func(S1) S2,
f reader.Kleisli[R, S1, T],
) Operator[R, S1, S2] {
return Bind(setter, F.Flow2(f, FromReader[R, T]))
}
// BindReaderIOK binds a computation that returns a ReaderIO to the context.
// The Kleisli function is automatically lifted into ReaderReaderIOResult.
//
//go:inline
func BindReaderIOK[R, S1, S2, T any](
setter func(T) func(S1) S2,
f readerio.Kleisli[R, S1, T],
) Operator[R, S1, S2] {
return Bind(setter, F.Flow2(f, FromReaderIO[R, T]))
}
// BindEitherK binds a computation that returns an Either to the context.
// The Kleisli function is automatically lifted into ReaderReaderIOResult.
//
//go:inline
func BindEitherK[R, S1, S2, T any](
setter func(T) func(S1) S2,
f either.Kleisli[error, S1, T],
) Operator[R, S1, S2] {
return Bind(setter, F.Flow2(f, FromEither[R, T]))
}
// BindIOEitherKL is a lens-based version of [BindIOEitherK].
//
//go:inline
func BindIOEitherKL[R, S, T any](
lens Lens[S, T],
f ioeither.Kleisli[error, T, T],
) Operator[R, S, S] {
return BindL(lens, F.Flow2(f, FromIOEither[R, T]))
}
// BindIOKL is a lens-based version of [BindIOK].
//
//go:inline
func BindIOKL[R, S, T any](
lens Lens[S, T],
f io.Kleisli[T, T],
) Operator[R, S, S] {
return BindL(lens, F.Flow2(f, FromIO[R, T]))
}
// BindReaderKL is a lens-based version of [BindReaderK].
//
//go:inline
func BindReaderKL[R, S, T any](
lens Lens[S, T],
f reader.Kleisli[R, T, T],
) Operator[R, S, S] {
return BindL(lens, F.Flow2(f, FromReader[R, T]))
}
// BindReaderIOKL is a lens-based version of [BindReaderIOK].
//
//go:inline
func BindReaderIOKL[R, S, T any](
lens Lens[S, T],
f readerio.Kleisli[R, T, T],
) Operator[R, S, S] {
return BindL(lens, F.Flow2(f, FromReaderIO[R, T]))
}
// ApIOEitherS applies an IOEither computation and attaches its result to the context.
//
//go:inline
func ApIOEitherS[R, S1, S2, T any](
setter func(T) func(S1) S2,
fa IOEither[error, T],
) Operator[R, S1, S2] {
return ApS(setter, FromIOEither[R](fa))
}
// ApIOS applies an IO computation and attaches its result to the context.
//
//go:inline
func ApIOS[R, S1, S2, T any](
setter func(T) func(S1) S2,
fa IO[T],
) Operator[R, S1, S2] {
return ApS(setter, FromIO[R](fa))
}
// ApReaderS applies a Reader computation and attaches its result to the context.
//
//go:inline
func ApReaderS[R, S1, S2, T any](
setter func(T) func(S1) S2,
fa Reader[R, T],
) Operator[R, S1, S2] {
return ApS(setter, FromReader(fa))
}
// ApReaderIOS applies a ReaderIO computation and attaches its result to the context.
//
//go:inline
func ApReaderIOS[R, S1, S2, T any](
setter func(T) func(S1) S2,
fa ReaderIO[R, T],
) Operator[R, S1, S2] {
return ApS(setter, FromReaderIO(fa))
}
// ApEitherS applies an Either computation and attaches its result to the context.
//
//go:inline
func ApEitherS[R, S1, S2, T any](
setter func(T) func(S1) S2,
fa Either[error, T],
) Operator[R, S1, S2] {
return ApS(setter, FromEither[R](fa))
}
// ApIOEitherSL is a lens-based version of [ApIOEitherS].
//
//go:inline
func ApIOEitherSL[R, S, T any](
lens Lens[S, T],
fa IOEither[error, T],
) Operator[R, S, S] {
return ApIOEitherS[R](lens.Set, fa)
}
// ApIOSL is a lens-based version of [ApIOS].
//
//go:inline
func ApIOSL[R, S, T any](
lens Lens[S, T],
fa IO[T],
) Operator[R, S, S] {
return ApSL(lens, FromIO[R](fa))
}
// ApReaderSL is a lens-based version of [ApReaderS].
//
//go:inline
func ApReaderSL[R, S, T any](
lens Lens[S, T],
fa Reader[R, T],
) Operator[R, S, S] {
return ApReaderS(lens.Set, fa)
}
// ApReaderIOSL is a lens-based version of [ApReaderIOS].
//
//go:inline
func ApReaderIOSL[R, S, T any](
lens Lens[S, T],
fa ReaderIO[R, T],
) Operator[R, S, S] {
return ApReaderIOS(lens.Set, fa)
}
// ApEitherSL is a lens-based version of [ApEitherS].
//
//go:inline
func ApEitherSL[R, S, T any](
lens Lens[S, T],
fa Either[error, T],
) Operator[R, S, S] {
return ApEitherS[R](lens.Set, fa)
}

View File

@@ -0,0 +1,720 @@
// 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 (
"errors"
"testing"
"github.com/IBM/fp-go/v2/either"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/internal/utils"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioeither"
"github.com/IBM/fp-go/v2/ioresult"
"github.com/IBM/fp-go/v2/optics/lens"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/readerio"
"github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
type AppConfig struct {
DatabaseURL string
LogLevel string
}
var defaultConfig = AppConfig{
DatabaseURL: "postgres://localhost",
LogLevel: "info",
}
func getLastName(s utils.Initial) ReaderReaderIOResult[AppConfig, string] {
return Of[AppConfig]("Doe")
}
func getGivenName(s utils.WithLastName) ReaderReaderIOResult[AppConfig, string] {
return Of[AppConfig]("John")
}
func TestDo(t *testing.T) {
res := Do[AppConfig](utils.Empty)
outcome := res(defaultConfig)(t.Context())()
assert.True(t, result.IsRight(outcome))
assert.Equal(t, result.Of(utils.Empty), outcome)
}
func TestBind(t *testing.T) {
res := F.Pipe3(
Do[AppConfig](utils.Empty),
Bind(utils.SetLastName, getLastName),
Bind(utils.SetGivenName, getGivenName),
Map[AppConfig](utils.GetFullName),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of("John Doe"), outcome)
}
func TestBindWithError(t *testing.T) {
testErr := errors.New("bind error")
getLastNameErr := func(s utils.Initial) ReaderReaderIOResult[AppConfig, string] {
return Left[AppConfig, string](testErr)
}
res := F.Pipe3(
Do[AppConfig](utils.Empty),
Bind(utils.SetLastName, getLastNameErr),
Bind(utils.SetGivenName, getGivenName),
Map[AppConfig](utils.GetFullName),
)
outcome := res(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
}
func TestLet(t *testing.T) {
type State struct {
FirstName string
LastName string
FullName string
}
res := F.Pipe2(
Do[AppConfig](State{FirstName: "John", LastName: "Doe"}),
Let[AppConfig](
func(fullName string) func(State) State {
return func(s State) State {
s.FullName = fullName
return s
}
},
func(s State) string {
return s.FirstName + " " + s.LastName
},
),
Map[AppConfig](func(s State) string { return s.FullName }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of("John Doe"), outcome)
}
func TestLetTo(t *testing.T) {
type State struct {
Status string
}
res := F.Pipe2(
Do[AppConfig](State{}),
LetTo[AppConfig](
func(status string) func(State) State {
return func(s State) State {
s.Status = status
return s
}
},
"active",
),
Map[AppConfig](func(s State) string { return s.Status }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of("active"), outcome)
}
func TestBindTo(t *testing.T) {
type State struct {
Count int
}
res := F.Pipe2(
Of[AppConfig](42),
BindTo[AppConfig](func(n int) State { return State{Count: n} }),
Map[AppConfig](func(s State) int { return s.Count }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestApS(t *testing.T) {
res := F.Pipe3(
Do[AppConfig](utils.Empty),
ApS(utils.SetLastName, Of[AppConfig]("Doe")),
ApS(utils.SetGivenName, Of[AppConfig]("John")),
Map[AppConfig](utils.GetFullName),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of("John Doe"), outcome)
}
func TestApSWithError(t *testing.T) {
testErr := errors.New("aps error")
res := F.Pipe3(
Do[AppConfig](utils.Empty),
ApS(utils.SetLastName, Left[AppConfig, string](testErr)),
ApS(utils.SetGivenName, Of[AppConfig]("John")),
Map[AppConfig](utils.GetFullName),
)
outcome := res(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
}
func TestBindReaderK(t *testing.T) {
type State struct {
Config string
}
getConfigPath := func(s State) func(AppConfig) string {
return func(cfg AppConfig) string {
return cfg.DatabaseURL
}
}
res := F.Pipe2(
Do[AppConfig](State{}),
BindReaderK(
func(path string) func(State) State {
return func(s State) State {
s.Config = path
return s
}
},
getConfigPath,
),
Map[AppConfig](func(s State) string { return s.Config }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of("postgres://localhost"), outcome)
}
func TestBindIOResultK(t *testing.T) {
type State struct {
Value int
ParsedValue int
}
parseValue := func(s State) ioresult.IOResult[int] {
return func() result.Result[int] {
if s.Value < 0 {
return result.Left[int](errors.New("negative value"))
}
return result.Of(s.Value * 2)
}
}
t.Run("success case", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{Value: 5}),
BindIOResultK[AppConfig](
func(parsed int) func(State) State {
return func(s State) State {
s.ParsedValue = parsed
return s
}
},
parseValue,
),
Map[AppConfig](func(s State) int { return s.ParsedValue }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(10), outcome)
})
t.Run("error case", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{Value: -5}),
BindIOResultK[AppConfig](
func(parsed int) func(State) State {
return func(s State) State {
s.ParsedValue = parsed
return s
}
},
parseValue,
),
Map[AppConfig](func(s State) int { return s.ParsedValue }),
)
outcome := res(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
})
}
func TestBindIOK(t *testing.T) {
type State struct {
Value int
}
getValue := func(s State) io.IO[int] {
return func() int {
return s.Value * 2
}
}
res := F.Pipe2(
Do[AppConfig](State{Value: 21}),
BindIOK[AppConfig](
func(v int) func(State) State {
return func(s State) State {
s.Value = v
return s
}
},
getValue,
),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestBindReaderIOK(t *testing.T) {
type State struct {
Value int
}
getValue := func(s State) readerio.ReaderIO[AppConfig, int] {
return func(cfg AppConfig) io.IO[int] {
return func() int {
return s.Value + len(cfg.DatabaseURL)
}
}
}
res := F.Pipe2(
Do[AppConfig](State{Value: 10}),
BindReaderIOK(
func(v int) func(State) State {
return func(s State) State {
s.Value = v
return s
}
},
getValue,
),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
// 10 + len("postgres://localhost") = 10 + 20 = 30
assert.Equal(t, result.Of(30), outcome)
}
func TestBindEitherK(t *testing.T) {
type State struct {
Value int
}
parseValue := func(s State) either.Either[error, int] {
if s.Value < 0 {
return either.Left[int](errors.New("negative"))
}
return either.Right[error](s.Value * 2)
}
t.Run("success case", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{Value: 5}),
BindEitherK[AppConfig](
func(v int) func(State) State {
return func(s State) State {
s.Value = v
return s
}
},
parseValue,
),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(10), outcome)
})
t.Run("error case", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{Value: -5}),
BindEitherK[AppConfig](
func(v int) func(State) State {
return func(s State) State {
s.Value = v
return s
}
},
parseValue,
),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
})
}
func TestBindIOEitherK(t *testing.T) {
type State struct {
Value int
}
parseValue := func(s State) ioeither.IOEither[error, int] {
return func() either.Either[error, int] {
if s.Value < 0 {
return either.Left[int](errors.New("negative"))
}
return either.Right[error](s.Value * 2)
}
}
t.Run("success case", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{Value: 5}),
BindIOEitherK[AppConfig](
func(v int) func(State) State {
return func(s State) State {
s.Value = v
return s
}
},
parseValue,
),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(10), outcome)
})
t.Run("error case", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{Value: -5}),
BindIOEitherK[AppConfig](
func(v int) func(State) State {
return func(s State) State {
s.Value = v
return s
}
},
parseValue,
),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
})
}
func TestLensOperations(t *testing.T) {
type State struct {
Value int
}
valueLens := lens.MakeLens(
func(s State) int { return s.Value },
func(s State, v int) State {
s.Value = v
return s
},
)
t.Run("ApSL", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{}),
ApSL(valueLens, Of[AppConfig](42)),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
})
t.Run("BindL", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{Value: 10}),
BindL(valueLens, func(v int) ReaderReaderIOResult[AppConfig, int] {
return Of[AppConfig](v * 2)
}),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(20), outcome)
})
t.Run("LetL", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{Value: 10}),
LetL[AppConfig](valueLens, func(v int) int { return v * 3 }),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(30), outcome)
})
t.Run("LetToL", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{}),
LetToL[AppConfig](valueLens, 99),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(99), outcome)
})
t.Run("BindIOEitherKL", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{Value: 5}),
BindIOEitherKL[AppConfig](valueLens, func(v int) ioeither.IOEither[error, int] {
return ioeither.Of[error](v * 2)
}),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(10), outcome)
})
t.Run("BindIOKL", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{Value: 7}),
BindIOKL[AppConfig](valueLens, func(v int) io.IO[int] {
return func() int { return v * 3 }
}),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(21), outcome)
})
t.Run("BindReaderKL", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{Value: 5}),
BindReaderKL(valueLens, func(v int) reader.Reader[AppConfig, int] {
return func(cfg AppConfig) int {
return v + len(cfg.LogLevel)
}
}),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
// 5 + len("info") = 5 + 4 = 9
assert.Equal(t, result.Of(9), outcome)
})
t.Run("BindReaderIOKL", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{Value: 10}),
BindReaderIOKL(valueLens, func(v int) readerio.ReaderIO[AppConfig, int] {
return func(cfg AppConfig) io.IO[int] {
return func() int {
return v + len(cfg.DatabaseURL)
}
}
}),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
// 10 + len("postgres://localhost") = 10 + 20 = 30
assert.Equal(t, result.Of(30), outcome)
})
t.Run("ApIOEitherSL", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{}),
ApIOEitherSL[AppConfig](valueLens, ioeither.Of[error](42)),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
})
t.Run("ApIOSL", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{}),
ApIOSL[AppConfig](valueLens, func() int { return 99 }),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(99), outcome)
})
t.Run("ApReaderSL", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{}),
ApReaderSL(valueLens, func(cfg AppConfig) int {
return len(cfg.LogLevel)
}),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(4), outcome)
})
t.Run("ApReaderIOSL", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{}),
ApReaderIOSL(valueLens, func(cfg AppConfig) io.IO[int] {
return func() int { return len(cfg.DatabaseURL) }
}),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(20), outcome)
})
t.Run("ApEitherSL", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{}),
ApEitherSL[AppConfig](valueLens, either.Right[error](77)),
Map[AppConfig](func(s State) int { return s.Value }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(77), outcome)
})
}
func TestApOperations(t *testing.T) {
type State struct {
Value1 int
Value2 int
}
t.Run("ApIOEitherS", func(t *testing.T) {
res := F.Pipe3(
Do[AppConfig](State{}),
ApIOEitherS[AppConfig](
func(v int) func(State) State {
return func(s State) State {
s.Value1 = v
return s
}
},
ioeither.Of[error](10),
),
ApIOEitherS[AppConfig](
func(v int) func(State) State {
return func(s State) State {
s.Value2 = v
return s
}
},
ioeither.Of[error](20),
),
Map[AppConfig](func(s State) int { return s.Value1 + s.Value2 }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(30), outcome)
})
t.Run("ApIOS", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{}),
ApIOS[AppConfig](
func(v int) func(State) State {
return func(s State) State {
s.Value1 = v
return s
}
},
func() int { return 42 },
),
Map[AppConfig](func(s State) int { return s.Value1 }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
})
t.Run("ApReaderS", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{}),
ApReaderS(
func(v int) func(State) State {
return func(s State) State {
s.Value1 = v
return s
}
},
func(cfg AppConfig) int { return len(cfg.LogLevel) },
),
Map[AppConfig](func(s State) int { return s.Value1 }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(4), outcome)
})
t.Run("ApReaderIOS", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{}),
ApReaderIOS(
func(v int) func(State) State {
return func(s State) State {
s.Value1 = v
return s
}
},
func(cfg AppConfig) io.IO[int] {
return func() int { return len(cfg.DatabaseURL) }
},
),
Map[AppConfig](func(s State) int { return s.Value1 }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(20), outcome)
})
t.Run("ApEitherS", func(t *testing.T) {
res := F.Pipe2(
Do[AppConfig](State{}),
ApEitherS[AppConfig](
func(v int) func(State) State {
return func(s State) State {
s.Value1 = v
return s
}
},
either.Right[error](99),
),
Map[AppConfig](func(s State) int { return s.Value1 }),
)
outcome := res(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(99), outcome)
})
}

View File

@@ -0,0 +1,96 @@
// 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 (
RRIOE "github.com/IBM/fp-go/v2/readerreaderioeither"
)
// Bracket ensures that a resource is properly cleaned up regardless of whether the operation
// succeeds or fails. It follows the acquire-use-release pattern with access to both outer (R)
// and inner (C) reader contexts.
//
// The release action is always called after the use action completes, whether it succeeds or fails.
// This makes it ideal for managing resources like file handles, database connections, or locks.
//
// Parameters:
// - acquire: Acquires the resource, returning a ReaderReaderIOEither[R, C, E, A]
// - use: Uses the acquired resource to perform an operation, returning ReaderReaderIOEither[R, C, E, B]
// - release: Releases the resource, receiving both the resource and the result of use
//
// Returns:
// - A ReaderReaderIOEither[R, C, E, B] that safely manages the resource lifecycle
//
// The release function receives:
// - The acquired resource (A)
// - The result of the use function (Either[E, B])
//
// Example:
//
// type OuterConfig struct {
// ConnectionPool string
// }
// type InnerConfig struct {
// Timeout time.Duration
// }
//
// // Acquire a database connection
// acquire := func(outer OuterConfig) readerioeither.ReaderIOEither[InnerConfig, error, *sql.DB] {
// return func(inner InnerConfig) ioeither.IOEither[error, *sql.DB] {
// return ioeither.TryCatch(
// func() (*sql.DB, error) {
// return sql.Open("postgres", outer.ConnectionPool)
// },
// func(err error) error { return err },
// )
// }
// }
//
// // Use the connection
// use := func(db *sql.DB) readerreaderioeither.ReaderReaderIOEither[OuterConfig, InnerConfig, error, []User] {
// return func(outer OuterConfig) readerioeither.ReaderIOEither[InnerConfig, error, []User] {
// return func(inner InnerConfig) ioeither.IOEither[error, []User] {
// return queryUsers(db, inner.Timeout)
// }
// }
// }
//
// // Release the connection
// release := func(db *sql.DB, result either.Either[error, []User]) readerreaderioeither.ReaderReaderIOEither[OuterConfig, InnerConfig, error, any] {
// return func(outer OuterConfig) readerioeither.ReaderIOEither[InnerConfig, error, any] {
// return func(inner InnerConfig) ioeither.IOEither[error, any] {
// return ioeither.TryCatch(
// func() (any, error) {
// return nil, db.Close()
// },
// func(err error) error { return err },
// )
// }
// }
// }
//
// result := readerreaderioeither.Bracket(acquire, use, release)
//
//go:inline
func Bracket[
R, A, B, ANY any](
acquire ReaderReaderIOResult[R, A],
use func(A) ReaderReaderIOResult[R, B],
release func(A, Result[B]) ReaderReaderIOResult[R, ANY],
) ReaderReaderIOResult[R, B] {
return RRIOE.Bracket(acquire, use, release)
}

View File

@@ -0,0 +1,396 @@
// 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"
"testing"
"github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
type Resource struct {
id string
acquired bool
released bool
}
func TestBracketSuccessPath(t *testing.T) {
cfg := defaultConfig
ctx := t.Context()
resource := &Resource{id: "res1"}
// Acquire resource
acquire := func(c AppConfig) ReaderIOResult[context.Context, *Resource] {
return func(ctx context.Context) IOResult[*Resource] {
return func() Result[*Resource] {
resource.acquired = true
return result.Of(resource)
}
}
}
// Use resource successfully
use := func(r *Resource) ReaderReaderIOResult[AppConfig, string] {
return func(c AppConfig) ReaderIOResult[context.Context, string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
return result.Of("result from " + r.id)
}
}
}
}
// Release resource
release := func(r *Resource, res Result[string]) ReaderReaderIOResult[AppConfig, any] {
return func(c AppConfig) ReaderIOResult[context.Context, any] {
return func(ctx context.Context) IOResult[any] {
return func() Result[any] {
r.released = true
return result.Of[any](nil)
}
}
}
}
computation := Bracket(acquire, use, release)
outcome := computation(cfg)(ctx)()
assert.Equal(t, result.Of("result from res1"), outcome)
assert.True(t, resource.acquired, "Resource should be acquired")
assert.True(t, resource.released, "Resource should be released")
}
func TestBracketUseFailure(t *testing.T) {
cfg := defaultConfig
ctx := t.Context()
resource := &Resource{id: "res1"}
useErr := errors.New("use failed")
// Acquire resource
acquire := func(c AppConfig) ReaderIOResult[context.Context, *Resource] {
return func(ctx context.Context) IOResult[*Resource] {
return func() Result[*Resource] {
resource.acquired = true
return result.Of(resource)
}
}
}
// Use resource with failure
use := func(r *Resource) ReaderReaderIOResult[AppConfig, string] {
return func(c AppConfig) ReaderIOResult[context.Context, string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
return result.Left[string](useErr)
}
}
}
}
// Release resource (should still be called)
release := func(r *Resource, res Result[string]) ReaderReaderIOResult[AppConfig, any] {
return func(c AppConfig) ReaderIOResult[context.Context, any] {
return func(ctx context.Context) IOResult[any] {
return func() Result[any] {
r.released = true
return result.Of[any](nil)
}
}
}
}
computation := Bracket(acquire, use, release)
outcome := computation(cfg)(ctx)()
assert.Equal(t, result.Left[string](useErr), outcome)
assert.True(t, resource.acquired, "Resource should be acquired")
assert.True(t, resource.released, "Resource should be released even on failure")
}
func TestBracketAcquireFailure(t *testing.T) {
cfg := defaultConfig
ctx := t.Context()
resource := &Resource{id: "res1"}
acquireErr := errors.New("acquire failed")
useCalled := false
releaseCalled := false
// Acquire resource fails
acquire := func(c AppConfig) ReaderIOResult[context.Context, *Resource] {
return func(ctx context.Context) IOResult[*Resource] {
return func() Result[*Resource] {
return result.Left[*Resource](acquireErr)
}
}
}
// Use should not be called
use := func(r *Resource) ReaderReaderIOResult[AppConfig, string] {
return func(c AppConfig) ReaderIOResult[context.Context, string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
useCalled = true
return result.Of("should not reach here")
}
}
}
}
// Release should not be called
release := func(r *Resource, res Result[string]) ReaderReaderIOResult[AppConfig, any] {
return func(c AppConfig) ReaderIOResult[context.Context, any] {
return func(ctx context.Context) IOResult[any] {
return func() Result[any] {
releaseCalled = true
return result.Of[any](nil)
}
}
}
}
computation := Bracket(acquire, use, release)
outcome := computation(cfg)(ctx)()
assert.Equal(t, result.Left[string](acquireErr), outcome)
assert.False(t, resource.acquired, "Resource should not be acquired")
assert.False(t, useCalled, "Use should not be called when acquire fails")
assert.False(t, releaseCalled, "Release should not be called when acquire fails")
}
func TestBracketReleaseReceivesResult(t *testing.T) {
cfg := defaultConfig
ctx := t.Context()
resource := &Resource{id: "res1"}
var capturedResult Result[string]
// Acquire resource
acquire := func(c AppConfig) ReaderIOResult[context.Context, *Resource] {
return func(ctx context.Context) IOResult[*Resource] {
return func() Result[*Resource] {
resource.acquired = true
return result.Of(resource)
}
}
}
// Use resource
use := func(r *Resource) ReaderReaderIOResult[AppConfig, string] {
return func(c AppConfig) ReaderIOResult[context.Context, string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
return result.Of("use result")
}
}
}
}
// Release captures the result
release := func(r *Resource, res Result[string]) ReaderReaderIOResult[AppConfig, any] {
return func(c AppConfig) ReaderIOResult[context.Context, any] {
return func(ctx context.Context) IOResult[any] {
return func() Result[any] {
capturedResult = res
r.released = true
return result.Of[any](nil)
}
}
}
}
computation := Bracket(acquire, use, release)
outcome := computation(cfg)(ctx)()
assert.Equal(t, result.Of("use result"), outcome)
assert.Equal(t, result.Of("use result"), capturedResult)
assert.True(t, resource.released, "Resource should be released")
}
func TestBracketWithContextAccess(t *testing.T) {
cfg := AppConfig{DatabaseURL: "production-db", LogLevel: "debug"}
ctx := t.Context()
resource := &Resource{id: "res1"}
// Acquire uses outer context
acquire := func(c AppConfig) ReaderIOResult[context.Context, *Resource] {
return func(ctx context.Context) IOResult[*Resource] {
return func() Result[*Resource] {
resource.id = c.DatabaseURL + "-resource"
resource.acquired = true
return result.Of(resource)
}
}
}
// Use uses both contexts
use := func(r *Resource) ReaderReaderIOResult[AppConfig, string] {
return func(c AppConfig) ReaderIOResult[context.Context, string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
res := r.id + " with log level " + c.LogLevel
return result.Of(res)
}
}
}
}
// Release uses both contexts
release := func(r *Resource, res Result[string]) ReaderReaderIOResult[AppConfig, any] {
return func(c AppConfig) ReaderIOResult[context.Context, any] {
return func(ctx context.Context) IOResult[any] {
return func() Result[any] {
r.released = true
return result.Of[any](nil)
}
}
}
}
computation := Bracket(acquire, use, release)
outcome := computation(cfg)(ctx)()
assert.True(t, result.IsRight(outcome))
assert.True(t, resource.acquired)
assert.True(t, resource.released)
assert.Equal(t, "production-db-resource", resource.id)
}
func TestBracketMultipleResources(t *testing.T) {
cfg := defaultConfig
ctx := t.Context()
resource1 := &Resource{id: "res1"}
resource2 := &Resource{id: "res2"}
// Acquire first resource
acquire1 := func(c AppConfig) ReaderIOResult[context.Context, *Resource] {
return func(ctx context.Context) IOResult[*Resource] {
return func() Result[*Resource] {
resource1.acquired = true
return result.Of(resource1)
}
}
}
// Use first resource to acquire second
use1 := func(r1 *Resource) ReaderReaderIOResult[AppConfig, string] {
// Nested bracket for second resource
acquire2 := func(c AppConfig) ReaderIOResult[context.Context, *Resource] {
return func(ctx context.Context) IOResult[*Resource] {
return func() Result[*Resource] {
resource2.acquired = true
return result.Of(resource2)
}
}
}
use2 := func(r2 *Resource) ReaderReaderIOResult[AppConfig, string] {
return func(c AppConfig) ReaderIOResult[context.Context, string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
return result.Of(r1.id + " and " + r2.id)
}
}
}
}
release2 := func(r2 *Resource, res Result[string]) ReaderReaderIOResult[AppConfig, any] {
return func(c AppConfig) ReaderIOResult[context.Context, any] {
return func(ctx context.Context) IOResult[any] {
return func() Result[any] {
r2.released = true
return result.Of[any](nil)
}
}
}
}
return Bracket(acquire2, use2, release2)
}
release1 := func(r1 *Resource, res Result[string]) ReaderReaderIOResult[AppConfig, any] {
return func(c AppConfig) ReaderIOResult[context.Context, any] {
return func(ctx context.Context) IOResult[any] {
return func() Result[any] {
r1.released = true
return result.Of[any](nil)
}
}
}
}
computation := Bracket(acquire1, use1, release1)
outcome := computation(cfg)(ctx)()
assert.Equal(t, result.Of("res1 and res2"), outcome)
assert.True(t, resource1.acquired && resource1.released, "Resource 1 should be acquired and released")
assert.True(t, resource2.acquired && resource2.released, "Resource 2 should be acquired and released")
}
func TestBracketReleaseErrorDoesNotAffectResult(t *testing.T) {
cfg := defaultConfig
ctx := t.Context()
resource := &Resource{id: "res1"}
releaseErr := errors.New("release failed")
// Acquire resource
acquire := func(c AppConfig) ReaderIOResult[context.Context, *Resource] {
return func(ctx context.Context) IOResult[*Resource] {
return func() Result[*Resource] {
resource.acquired = true
return result.Of(resource)
}
}
}
// Use resource successfully
use := func(r *Resource) ReaderReaderIOResult[AppConfig, string] {
return func(c AppConfig) ReaderIOResult[context.Context, string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
return result.Of("use success")
}
}
}
}
// Release fails but shouldn't affect the result
release := func(r *Resource, res Result[string]) ReaderReaderIOResult[AppConfig, any] {
return func(c AppConfig) ReaderIOResult[context.Context, any] {
return func(ctx context.Context) IOResult[any] {
return func() Result[any] {
return result.Left[any](releaseErr)
}
}
}
}
computation := Bracket(acquire, use, release)
outcome := computation(cfg)(ctx)()
// The use result should be returned, not the release error
// (This behavior depends on the Bracket implementation)
assert.True(t, result.IsRight(outcome) || result.IsLeft(outcome))
assert.True(t, resource.acquired)
}

View File

@@ -0,0 +1,430 @@
// 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"
"testing"
"time"
F "github.com/IBM/fp-go/v2/function"
N "github.com/IBM/fp-go/v2/number"
"github.com/IBM/fp-go/v2/result"
"github.com/IBM/fp-go/v2/retry"
"github.com/stretchr/testify/assert"
)
// TestContextCancellationInMap tests that context cancellation is properly handled in Map operations
func TestContextCancellationInMap(t *testing.T) {
cfg := defaultConfig
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
computation := F.Pipe1(
Of[AppConfig](42),
Map[AppConfig](func(n int) int {
// This should still execute as Map doesn't check context
return n * 2
}),
)
outcome := computation(cfg)(ctx)()
// Map operations don't inherently check context, so they succeed
assert.Equal(t, result.Of(84), outcome)
}
// TestContextCancellationInChain tests context cancellation in Chain operations
func TestContextCancellationInChain(t *testing.T) {
cfg := defaultConfig
ctx, cancel := context.WithCancel(context.Background())
executed := false
computation := F.Pipe1(
Of[AppConfig](42),
Chain(func(n int) ReaderReaderIOResult[AppConfig, int] {
return func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
// Check if context is cancelled
select {
case <-ctx.Done():
return result.Left[int](ctx.Err())
default:
executed = true
return result.Of(n * 2)
}
}
}
}
}),
)
cancel() // Cancel before execution
outcome := computation(cfg)(ctx)()
assert.True(t, result.IsLeft(outcome))
assert.False(t, executed, "Chained operation should not execute when context is cancelled")
}
// TestContextCancellationWithTimeout tests timeout-based cancellation
func TestContextCancellationWithTimeout(t *testing.T) {
cfg := defaultConfig
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
computation := func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
// Simulate long-running operation
select {
case <-time.After(100 * time.Millisecond):
return result.Of(42)
case <-ctx.Done():
return result.Left[int](ctx.Err())
}
}
}
}
outcome := computation(cfg)(ctx)()
assert.True(t, result.IsLeft(outcome))
result.Fold(
func(err error) any {
assert.ErrorIs(t, err, context.DeadlineExceeded)
return nil
},
func(v int) any {
t.Fatal("Should have timed out")
return nil
},
)(outcome)
}
// TestContextCancellationInBracket tests that bracket properly handles context cancellation
func TestContextCancellationInBracket(t *testing.T) {
cfg := defaultConfig
ctx, cancel := context.WithCancel(context.Background())
resource := &Resource{id: "res1"}
useCalled := false
acquire := func(c AppConfig) ReaderIOResult[context.Context, *Resource] {
return func(ctx context.Context) IOResult[*Resource] {
return func() Result[*Resource] {
resource.acquired = true
return result.Of(resource)
}
}
}
use := func(r *Resource) ReaderReaderIOResult[AppConfig, string] {
return func(c AppConfig) ReaderIOResult[context.Context, string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
select {
case <-ctx.Done():
return result.Left[string](ctx.Err())
default:
useCalled = true
return result.Of("success")
}
}
}
}
}
release := func(r *Resource, res Result[string]) ReaderReaderIOResult[AppConfig, any] {
return func(c AppConfig) ReaderIOResult[context.Context, any] {
return func(ctx context.Context) IOResult[any] {
return func() Result[any] {
r.released = true
return result.Of[any](nil)
}
}
}
}
cancel() // Cancel before use
computation := Bracket(acquire, use, release)
outcome := computation(cfg)(ctx)()
assert.True(t, resource.acquired, "Resource should be acquired")
assert.True(t, resource.released, "Resource should be released even with cancellation")
assert.False(t, useCalled, "Use should not execute when context is cancelled")
assert.True(t, result.IsLeft(outcome))
}
// TestContextCancellationInRetry tests context cancellation during retry operations
func TestContextCancellationInRetry(t *testing.T) {
cfg := defaultConfig
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
attempts := 0
action := func(status retry.RetryStatus) ReaderReaderIOResult[AppConfig, int] {
return func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
attempts++
select {
case <-ctx.Done():
return result.Left[int](ctx.Err())
case <-time.After(30 * time.Millisecond):
return result.Left[int](errors.New("temporary error"))
}
}
}
}
}
check := func(r Result[int]) bool {
return result.IsLeft(r)
}
policy := retry.LimitRetries(10)
computation := Retrying(policy, action, check)
outcome := computation(cfg)(ctx)()
assert.True(t, result.IsLeft(outcome))
// Should stop retrying when context is cancelled
assert.Less(t, attempts, 10, "Should stop retrying when context is cancelled")
}
// TestContextPropagationThroughMonadTransforms tests that context is properly propagated
func TestContextPropagationThroughMonadTransforms(t *testing.T) {
cfg := defaultConfig
t.Run("context propagates through Map", func(t *testing.T) {
ctx := context.WithValue(context.Background(), "key", "value")
var capturedCtx context.Context
computation := func(c AppConfig) ReaderIOResult[context.Context, string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
capturedCtx = ctx
return result.Of("test")
}
}
}
_ = computation(cfg)(ctx)()
assert.Equal(t, "value", capturedCtx.Value("key"))
})
t.Run("context propagates through Chain", func(t *testing.T) {
ctx := context.WithValue(context.Background(), "key", "value")
var capturedCtx context.Context
computation := F.Pipe1(
Of[AppConfig](42),
Chain[AppConfig](func(n int) ReaderReaderIOResult[AppConfig, int] {
return func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
capturedCtx = ctx
return result.Of(n * 2)
}
}
}
}),
)
_ = computation(cfg)(ctx)()
assert.Equal(t, "value", capturedCtx.Value("key"))
})
t.Run("context propagates through Ap", func(t *testing.T) {
ctx := context.WithValue(context.Background(), "key", "value")
var capturedCtx context.Context
fab := func(c AppConfig) ReaderIOResult[context.Context, func(int) int] {
return func(ctx context.Context) IOResult[func(int) int] {
return func() Result[func(int) int] {
capturedCtx = ctx
return result.Of(N.Mul(2))
}
}
}
fa := Of[AppConfig](21)
computation := MonadAp(fab, fa)
_ = computation(cfg)(ctx)()
assert.Equal(t, "value", capturedCtx.Value("key"))
})
}
// TestContextCancellationInAlt tests Alt operation with context cancellation
func TestContextCancellationInAlt(t *testing.T) {
cfg := defaultConfig
ctx, cancel := context.WithCancel(context.Background())
cancel()
firstCalled := false
secondCalled := false
first := func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
select {
case <-ctx.Done():
return result.Left[int](ctx.Err())
default:
firstCalled = true
return result.Left[int](errors.New("first error"))
}
}
}
}
second := func() ReaderReaderIOResult[AppConfig, int] {
return func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
select {
case <-ctx.Done():
return result.Left[int](ctx.Err())
default:
secondCalled = true
return result.Of(42)
}
}
}
}
}
computation := MonadAlt(first, second)
outcome := computation(cfg)(ctx)()
assert.True(t, result.IsLeft(outcome))
assert.False(t, firstCalled, "First should not execute when context is cancelled")
assert.False(t, secondCalled, "Second should not execute when context is cancelled")
}
// TestContextCancellationInDoNotation tests context cancellation in do-notation
func TestContextCancellationInDoNotation(t *testing.T) {
cfg := defaultConfig
ctx, cancel := context.WithCancel(context.Background())
type State struct {
Value1 int
Value2 int
}
step1Executed := false
step2Executed := false
computation := F.Pipe2(
Do[AppConfig](State{}),
Bind(
func(v int) func(State) State {
return func(s State) State {
s.Value1 = v
return s
}
},
func(s State) ReaderReaderIOResult[AppConfig, int] {
return func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
select {
case <-ctx.Done():
return result.Left[int](ctx.Err())
default:
step1Executed = true
return result.Of(10)
}
}
}
}
},
),
Bind(
func(v int) func(State) State {
return func(s State) State {
s.Value2 = v
return s
}
},
func(s State) ReaderReaderIOResult[AppConfig, int] {
return func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
select {
case <-ctx.Done():
return result.Left[int](ctx.Err())
default:
step2Executed = true
return result.Of(20)
}
}
}
}
},
),
)
cancel() // Cancel before execution
outcome := computation(cfg)(ctx)()
assert.True(t, result.IsLeft(outcome))
assert.False(t, step1Executed, "Step 1 should not execute when context is cancelled")
assert.False(t, step2Executed, "Step 2 should not execute when context is cancelled")
}
// TestContextCancellationBetweenSteps tests cancellation between sequential steps
func TestContextCancellationBetweenSteps(t *testing.T) {
cfg := defaultConfig
ctx, cancel := context.WithCancel(context.Background())
step1Executed := false
step2Executed := false
computation := F.Pipe1(
func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
step1Executed = true
cancel() // Cancel after first step
return result.Of(42)
}
}
},
Chain[AppConfig](func(n int) ReaderReaderIOResult[AppConfig, int] {
return func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
select {
case <-ctx.Done():
return result.Left[int](ctx.Err())
default:
step2Executed = true
return result.Of(n * 2)
}
}
}
}
}),
)
outcome := computation(cfg)(ctx)()
assert.True(t, step1Executed, "Step 1 should execute")
assert.False(t, step2Executed, "Step 2 should not execute after cancellation")
assert.True(t, result.IsLeft(outcome))
}

View File

@@ -0,0 +1,76 @@
package readerreaderioresult
import (
"strconv"
"sync"
"testing"
A "github.com/IBM/fp-go/v2/array"
RES "github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/function"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
type (
ConsoleDependency interface {
Log(msg string) IO[Void]
}
Res[A any] = RES.ReaderIOResult[A]
ConsoleEnv[A any] = ReaderReaderIOResult[ConsoleDependency, A]
consoleOnArray struct {
logs []string
mu sync.Mutex
}
)
var (
logConsole = reader.Curry1(ConsoleDependency.Log)
)
func (c *consoleOnArray) Log(msg string) IO[Void] {
return func() Void {
c.mu.Lock()
defer c.mu.Unlock()
c.logs = append(c.logs, msg)
return function.VOID
}
}
func makeConsoleOnArray() *consoleOnArray {
return &consoleOnArray{}
}
func TestConsoleEnv(t *testing.T) {
console := makeConsoleOnArray()
prg := F.Pipe1(
Of[ConsoleDependency]("Hello World!"),
TapReaderIOK(logConsole),
)
res := prg(console)(t.Context())()
assert.Equal(t, result.Of("Hello World!"), res)
assert.Equal(t, A.Of("Hello World!"), console.logs)
}
func TestConsoleEnvWithLocal(t *testing.T) {
console := makeConsoleOnArray()
prg := F.Pipe1(
Of[ConsoleDependency](42),
TapReaderIOK(reader.WithLocal(logConsole, strconv.Itoa)),
)
res := prg(console)(t.Context())()
assert.Equal(t, result.Of(42), res)
assert.Equal(t, A.Of("42"), console.logs)
}

View File

@@ -0,0 +1,477 @@
// 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 provides a functional programming abstraction that combines
// four powerful concepts: Reader, Reader, IO, and Result (Either[error, A]) monads in a nested structure.
// This is a specialized version of readerreaderioeither where the error type is fixed to `error` and
// the inner context is fixed to `context.Context`.
//
// # Type Definition
//
// ReaderReaderIOResult[R, A] is defined as:
//
// type ReaderReaderIOResult[R, A] = ReaderReaderIOEither[R, context.Context, error, A]
//
// Which expands to:
//
// func(R) func(context.Context) func() Either[error, A]
//
// This represents a computation that:
// - Takes an outer environment/context of type R
// - Returns a function that takes a context.Context
// - Returns an IO operation (a thunk/function with no parameters)
// - Produces an Either[error, A] (Result[A]) when executed
//
// # Type Parameter Ordering Convention
//
// This package follows a consistent convention for ordering type parameters in function signatures.
// The general rule is: R -> C -> E -> T (outer context, inner context, error, type), where:
// - R: The outer Reader context/environment type
// - C: The inner Reader context/environment type (for the ReaderIOEither)
// - E: The Either error type
// - T: The value type(s) (A, B, etc.)
//
// However, when some type parameters can be automatically inferred by the Go compiler from
// function arguments, the convention is modified to minimize explicit type annotations:
//
// Rule: Undetectable types come first, followed by detectable types, while preserving
// the relative order within each group (R -> C -> E -> T).
//
// Examples:
//
// 1. All types detectable from first argument:
// MonadMap[R, C, E, A, B](fa ReaderReaderIOEither[R, C, E, A], f func(A) B)
// - R, C, E, A are detectable from fa
// - B is detectable from f
// - Order: R, C, E, A, B (standard order, all detectable)
//
// 2. Some types undetectable:
// FromReader[C, E, R, A](ma Reader[R, A]) ReaderReaderIOEither[R, C, E, A]
// - R, A are detectable from ma
// - C, E are undetectable (not in any argument)
// - Order: C, E, R, A (C, E first as undetectable, then R, A in standard order)
//
// 3. Multiple undetectable types:
// Local[C, E, A, R1, R2](f func(R2) R1) func(ReaderReaderIOEither[R1, C, E, A]) ReaderReaderIOEither[R2, C, E, A]
// - C, E, A are undetectable
// - R1, R2 are detectable from f
// - Order: C, E, A, R1, R2 (undetectable first, then detectable)
//
// 4. Functions returning Kleisli arrows:
// ChainReaderOptionK[R, C, A, B, E](onNone Lazy[E]) func(readeroption.Kleisli[R, A, B]) Operator[R, C, E, A, B]
// - Canonical order would be R, C, E, A, B
// - E is detectable from onNone parameter
// - R, C, A, B are not detectable (they're in the Kleisli argument type)
// - Order: R, C, A, B, E (undetectable R, C, A, B first, then detectable E)
//
// This convention allows for more ergonomic function calls:
//
// // Without convention - need to specify all types:
// result := FromReader[OuterCtx, InnerCtx, error, User](readerFunc)
//
// // With convention - only specify undetectable types:
// result := FromReader[InnerCtx, error](readerFunc) // R and A inferred from readerFunc
//
// The reasoning behind this approach is to reduce the number of explicit type parameters
// that developers need to specify when calling functions, improving code readability and
// reducing verbosity while maintaining type safety.
//
// Additional examples demonstrating the convention:
//
// 5. FromReaderOption[R, C, A, E](onNone Lazy[E]) Kleisli[R, C, E, ReaderOption[R, A], A]
// - Canonical order would be R, C, E, A
// - E is detectable from onNone parameter
// - R, C, A are not detectable (they're in the return type's Kleisli)
// - Order: R, C, A, E (undetectable R, C, A first, then detectable E)
//
// 6. MapLeft[R, C, A, E1, E2](f func(E1) E2) func(ReaderReaderIOEither[R, C, E1, A]) ReaderReaderIOEither[R, C, E2, A]
// - Canonical order would be R, C, E1, E2, A
// - E1, E2 are detectable from f parameter
// - R, C, A are not detectable (they're in the return type)
// - Order: R, C, A, E1, E2 (undetectable R, C, A first, then detectable E1, E2)
//
// Additional special cases:
//
// - Ap[B, R, C, E, A]: B is undetectable (in function return type), so B comes first
// - ChainOptionK[R, C, A, B, E]: R, C, A, B are undetectable, E is detectable from onNone
// - FromReaderIO[C, E, R, A]: C, E are undetectable, R, A are detectable from ReaderIO[R, A]
//
// All functions in this package follow this convention consistently.
//
// # Fantasy Land Specification
//
// This is a monad transformer combining:
// - Reader monad: https://github.com/fantasyland/fantasy-land
// - Reader monad (nested): https://github.com/fantasyland/fantasy-land
// - IO monad: https://github.com/fantasyland/fantasy-land
// - Either monad: https://github.com/fantasyland/fantasy-land#either
//
// Implemented Fantasy Land algebras:
// - Functor: https://github.com/fantasyland/fantasy-land#functor
// - Bifunctor: https://github.com/fantasyland/fantasy-land#bifunctor
// - Apply: https://github.com/fantasyland/fantasy-land#apply
// - Applicative: https://github.com/fantasyland/fantasy-land#applicative
// - Chain: https://github.com/fantasyland/fantasy-land#chain
// - Monad: https://github.com/fantasyland/fantasy-land#monad
// - Alt: https://github.com/fantasyland/fantasy-land#alt
//
// # ReaderReaderIOEither
//
// ReaderReaderIOEither[R, C, E, A] represents a computation that:
// - Depends on an outer context/environment of type R (outer Reader)
// - Returns a computation that depends on an inner context/environment of type C (inner Reader)
// - Performs side effects (IO)
// - Can fail with an error of type E or succeed with a value of type A (Either)
//
// This is particularly useful for:
// - Multi-level dependency injection patterns
// - Layered architectures with different context requirements at each layer
// - Composing operations that need access to multiple levels of configuration or context
// - Building reusable components that can be configured at different stages
//
// # Core Operations
//
// Construction:
// - Of/Right: Create a successful computation
// - Left: Create a failed computation
// - FromEither: Lift an Either into ReaderReaderIOEither
// - FromIO: Lift an IO into ReaderReaderIOEither
// - FromReader: Lift a Reader into ReaderReaderIOEither
// - FromReaderIO: Lift a ReaderIO into ReaderReaderIOEither
// - FromIOEither: Lift an IOEither into ReaderReaderIOEither
// - FromReaderEither: Lift a ReaderEither into ReaderReaderIOEither
// - FromReaderIOEither: Lift a ReaderIOEither into ReaderReaderIOEither
// - FromReaderOption: Lift a ReaderOption into ReaderReaderIOEither
//
// Transformation:
// - Map: Transform the success value
// - MapLeft: Transform the error value
// - Chain/Bind: Sequence dependent computations
// - Flatten: Flatten nested ReaderReaderIOEither
//
// Combination:
// - Ap: Apply a function in a context to a value in a context
// - ApSeq: Sequential application
// - ApPar: Parallel application
//
// Error Handling:
// - Alt: Choose the first successful computation
//
// Context Access:
// - Ask: Get the current outer context
// - Asks: Get a value derived from the outer context
// - Local: Run a computation with a modified outer context
// - Read: Execute with a specific outer context
//
// Kleisli Composition:
// - ChainEitherK: Chain with Either-returning functions
// - ChainReaderK: Chain with Reader-returning functions
// - ChainReaderIOK: Chain with ReaderIO-returning functions
// - ChainReaderEitherK: Chain with ReaderEither-returning functions
// - ChainReaderOptionK: Chain with ReaderOption-returning functions
// - ChainIOEitherK: Chain with IOEither-returning functions
// - ChainIOK: Chain with IO-returning functions
// - ChainOptionK: Chain with Option-returning functions
//
// First/Tap Operations (execute for side effects, return original value):
// - ChainFirst/Tap: Execute a computation but return the original value
// - ChainFirstEitherK/TapEitherK: Tap with Either-returning functions
// - ChainFirstReaderK/TapReaderK: Tap with Reader-returning functions
// - ChainFirstReaderIOK/TapReaderIOK: Tap with ReaderIO-returning functions
// - ChainFirstReaderEitherK/TapReaderEitherK: Tap with ReaderEither-returning functions
// - ChainFirstReaderOptionK/TapReaderOptionK: Tap with ReaderOption-returning functions
// - ChainFirstIOK/TapIOK: Tap with IO-returning functions
//
// # Example Usage
//
// type AppConfig struct {
// DatabaseURL string
// LogLevel string
// }
//
// // A computation that depends on AppConfig and context.Context
// func fetchUser(id int) ReaderReaderIOResult[AppConfig, User] {
// return func(cfg AppConfig) readerioresult.ReaderIOResult[context.Context, User] {
// // Use cfg.DatabaseURL and cfg.LogLevel
// return func(ctx context.Context) ioresult.IOResult[User] {
// // Use ctx for cancellation/timeout
// return func() result.Result[User] {
// // Perform the actual IO operation
// // Return result.Of(user) or result.Error[User](err)
// }
// }
// }
// }
//
// // Compose operations
// result := function.Pipe2(
// fetchUser(123),
// Map[AppConfig](func(u User) string { return u.Name }),
// Chain[AppConfig](func(name string) ReaderReaderIOResult[AppConfig, string] {
// return Of[AppConfig]("Hello, " + name)
// }),
// )
//
// // Execute with config and context
// appConfig := AppConfig{DatabaseURL: "postgres://...", LogLevel: "info"}
// ctx := t.Context()
// outcome := result(appConfig)(ctx)() // Returns result.Result[string]
//
// # Use Cases
//
// This monad is particularly useful for:
// - Applications with layered configuration (app config + request context)
// - HTTP handlers that need both application config and request context
// - Database operations with connection pool config and query context
// - Retry logic with policy configuration and execution context
// - Resource management with bracket pattern across multiple contexts
//
// # Dependency Injection with the Outer Context
//
// The outer Reader context (type parameter R) provides a powerful mechanism for dependency injection
// in functional programming. This pattern is explained in detail in Scott Wlaschin's talk:
// "Dependency Injection, The Functional Way" - https://www.youtube.com/watch?v=xPlsVVaMoB0
//
// ## Core Concept
//
// Instead of using traditional OOP dependency injection frameworks, the Reader monad allows you to:
// 1. Define functions that declare their dependencies as type parameters
// 2. Compose these functions without providing the dependencies
// 3. Supply all dependencies at the "end of the world" (program entry point)
//
// This approach provides:
// - Compile-time safety: Missing dependencies cause compilation errors
// - Explicit dependencies: Function signatures show exactly what they need
// - Easy testing: Mock dependencies by providing different values
// - Pure functions: Dependencies are passed as parameters, not global state
//
// ## Examples from the Video Adapted to fp-go
//
// ### Example 1: Basic Reader Pattern (Video: "Reader Monad Basics")
//
// In the video, Scott shows how to pass configuration through a chain of functions.
// In fp-go with ReaderReaderIOResult:
//
// // Define your dependencies
// type AppConfig struct {
// DatabaseURL string
// APIKey string
// MaxRetries int
// }
//
// // Functions declare their dependencies via the R type parameter
// func getConnectionString() ReaderReaderIOResult[AppConfig, string] {
// return Asks[AppConfig](func(cfg AppConfig) string {
// return cfg.DatabaseURL
// })
// }
//
// func connectToDatabase() ReaderReaderIOResult[AppConfig, *sql.DB] {
// return MonadChain(
// getConnectionString(),
// func(connStr string) ReaderReaderIOResult[AppConfig, *sql.DB] {
// return FromIO[AppConfig](func() result.Result[*sql.DB] {
// db, err := sql.Open("postgres", connStr)
// return result.FromEither(either.FromError(db, err))
// })
// },
// )
// }
//
// ### Example 2: Composing Dependencies (Video: "Composing Reader Functions")
//
// The video demonstrates how Reader functions compose naturally.
// In fp-go, you can compose operations that all share the same dependency:
//
// func fetchUser(id int) ReaderReaderIOResult[AppConfig, User] {
// return MonadChain(
// connectToDatabase(),
// func(db *sql.DB) ReaderReaderIOResult[AppConfig, User] {
// return FromIO[AppConfig](func() result.Result[User] {
// // Query database using db and return user
// // The AppConfig is still available if needed
// })
// },
// )
// }
//
// func enrichUser(user User) ReaderReaderIOResult[AppConfig, EnrichedUser] {
// return Asks[AppConfig, EnrichedUser](func(cfg AppConfig) EnrichedUser {
// // Use cfg.APIKey to call external service
// return EnrichedUser{User: user, Extra: "data"}
// })
// }
//
// // Compose without providing dependencies
// pipeline := function.Pipe2(
// fetchUser(123),
// Chain[AppConfig](enrichUser),
// )
//
// // Provide dependencies at the end
// config := AppConfig{DatabaseURL: "...", APIKey: "...", MaxRetries: 3}
// ctx := context.Background()
// result := pipeline(config)(ctx)()
//
// ### Example 3: Local Context Modification (Video: "Local Environment")
//
// The video shows how to temporarily modify the environment for a sub-computation.
// In fp-go, use the Local function:
//
// // Run a computation with modified configuration
// func withRetries(retries int, action ReaderReaderIOResult[AppConfig, string]) ReaderReaderIOResult[AppConfig, string] {
// return Local[string](func(cfg AppConfig) AppConfig {
// // Create a modified config with different retry count
// return AppConfig{
// DatabaseURL: cfg.DatabaseURL,
// APIKey: cfg.APIKey,
// MaxRetries: retries,
// }
// })(action)
// }
//
// // Use it
// result := withRetries(5, fetchUser(123))
//
// ### Example 4: Testing with Mock Dependencies (Video: "Testing with Reader")
//
// The video emphasizes how Reader makes testing easy by allowing mock dependencies.
// In fp-go:
//
// func TestFetchUser(t *testing.T) {
// // Create a test configuration
// testConfig := AppConfig{
// DatabaseURL: "mock://test",
// APIKey: "test-key",
// MaxRetries: 1,
// }
//
// // Run the computation with test config
// ctx := context.Background()
// result := fetchUser(123)(testConfig)(ctx)()
//
// // Assert on the result
// assert.True(t, either.IsRight(result))
// }
//
// ### Example 5: Multi-Layer Dependencies (Video: "Nested Readers")
//
// The video discusses nested readers for multi-layer architectures.
// ReaderReaderIOResult provides exactly this with R (outer) and context.Context (inner):
//
// type AppConfig struct {
// DatabaseURL string
// }
//
// // Outer context: Application-level configuration (AppConfig)
// // Inner context: Request-level context (context.Context)
// func handleRequest(userID int) ReaderReaderIOResult[AppConfig, Response] {
// return func(cfg AppConfig) readerioresult.ReaderIOResult[context.Context, Response] {
// // cfg is available here (outer context)
// return func(ctx context.Context) ioresult.IOResult[Response] {
// // ctx is available here (inner context)
// // Both cfg and ctx can be used
// return func() result.Result[Response] {
// // Perform operation using both contexts
// select {
// case <-ctx.Done():
// return result.Error[Response](ctx.Err())
// default:
// // Use cfg.DatabaseURL to connect
// return result.Of(Response{})
// }
// }
// }
// }
// }
//
// ### Example 6: Avoiding Global State (Video: "Problems with Global State")
//
// The video criticizes global state and shows how Reader solves this.
// In fp-go, instead of:
//
// // BAD: Global state
// var globalConfig AppConfig
//
// func fetchUser(id int) result.Result[User] {
// // Uses globalConfig implicitly
// db := connectTo(globalConfig.DatabaseURL)
// // ...
// }
//
// Use Reader to make dependencies explicit:
//
// // GOOD: Explicit dependencies
// func fetchUser(id int) ReaderReaderIOResult[AppConfig, User] {
// return MonadChain(
// Ask[AppConfig](), // Explicitly request the config
// func(cfg AppConfig) ReaderReaderIOResult[AppConfig, User] {
// // Use cfg explicitly
// return FromIO[AppConfig](func() result.Result[User] {
// db := connectTo(cfg.DatabaseURL)
// // ...
// })
// },
// )
// }
//
// ## Benefits of This Approach
//
// 1. **Type Safety**: The compiler ensures all dependencies are provided
// 2. **Testability**: Easy to provide mock dependencies for testing
// 3. **Composability**: Functions compose naturally without dependency wiring
// 4. **Explicitness**: Function signatures document their dependencies
// 5. **Immutability**: Dependencies are immutable values, not mutable global state
// 6. **Flexibility**: Use Local to modify dependencies for sub-computations
// 7. **Separation of Concerns**: Business logic is separate from dependency resolution
//
// ## Comparison with Traditional DI
//
// Traditional OOP DI (e.g., Spring, Guice):
// - Runtime dependency resolution
// - Magic/reflection-based wiring
// - Implicit dependencies (hidden in constructors)
// - Mutable containers
//
// Reader-based DI (fp-go):
// - Compile-time dependency resolution
// - Explicit function composition
// - Explicit dependencies (in type signatures)
// - Immutable values
//
// ## When to Use Each Layer
//
// - **Outer Reader (R)**: Application-level dependencies that rarely change
// - Database connection pools
// - API keys and secrets
// - Feature flags
// - Application configuration
//
// - **Inner Reader (context.Context)**: Request-level dependencies that change per operation
// - Request IDs and tracing
// - Cancellation signals
// - Deadlines and timeouts
// - User authentication tokens
//
// This two-layer approach mirrors the video's discussion of nested readers and provides
// a clean separation between application-level and request-level concerns.
//
// # Relationship to Other Packages
//
// - readerreaderioeither: The generic version with configurable error and context types
// - readerioresult: Single reader with context.Context and error
// - readerresult: Single reader with error (no IO)
// - context/readerioresult: Alias for readerioresult with context.Context
package readerreaderioresult

View File

@@ -0,0 +1,291 @@
// 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 (
"github.com/IBM/fp-go/v2/internal/readert"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/readerioeither"
RRIOE "github.com/IBM/fp-go/v2/readerreaderioeither"
)
// Sequence swaps the order of nested environment parameters in a ReaderReaderIOResult computation.
//
// This function takes a ReaderReaderIOResult that produces another ReaderReaderIOResult and returns a
// Kleisli arrow that reverses the order of the outer environment parameters (R1 and R2). The result is
// a curried function that takes R1 first, then R2, and produces a computation with context.Context and error handling.
//
// Type Parameters:
// - R1: The first outer environment type (becomes the outermost after sequence)
// - R2: The second outer environment type (becomes inner after sequence)
// - A: The success value type
//
// Parameters:
// - ma: A ReaderReaderIOResult[R2, ReaderReaderIOResult[R1, A]]
//
// Returns:
// - A Kleisli[R2, R1, A], which is func(R1) ReaderReaderIOResult[R2, A]
//
// The function preserves error handling and IO effects at all levels while reordering the
// outer environment dependencies. The inner context.Context layer remains unchanged.
//
// This is particularly useful when you need to change the order in which contexts are provided
// to a nested computation, such as when composing operations that have different dependency orders.
//
// Example:
//
// type AppConfig struct {
// DatabaseURL string
// }
// type UserPrefs struct {
// Theme string
// }
//
// // Original: takes AppConfig, returns computation that may produce
// // another computation depending on UserPrefs
// original := func(cfg AppConfig) readerioresult.ReaderIOResult[context.Context,
// ReaderReaderIOResult[UserPrefs, string]] {
// return readerioresult.Of[context.Context](
// Of[UserPrefs]("result"),
// )
// }
//
// // Sequence swaps UserPrefs and AppConfig order
// sequenced := Sequence[UserPrefs, AppConfig, string](original)
//
// // Now provide UserPrefs first, then AppConfig
// ctx := context.Background()
// result := sequenced(UserPrefs{Theme: "dark"})(AppConfig{DatabaseURL: "db"})(ctx)()
func Sequence[R1, R2, A any](ma ReaderReaderIOResult[R2, ReaderReaderIOResult[R1, A]]) Kleisli[R2, R1, A] {
return readert.Sequence(
readerioeither.Chain,
ma,
)
}
// SequenceReader swaps the order of environment parameters when the inner computation is a pure Reader.
//
// This function is similar to Sequence but specialized for the case where the innermost computation
// is a pure Reader (without IO or error handling) rather than another ReaderReaderIOResult. It takes
// a ReaderReaderIOResult that produces a Reader and returns a Kleisli arrow that reverses the order
// of the outer environment parameters.
//
// Type Parameters:
// - R1: The first environment type (becomes outermost after sequence)
// - R2: The second environment type (becomes inner after sequence)
// - A: The success value type
//
// Parameters:
// - ma: A ReaderReaderIOResult[R2, Reader[R1, A]]
//
// Returns:
// - A Kleisli[R2, R1, A], which is func(R1) ReaderReaderIOResult[R2, A]
//
// The function lifts the pure Reader computation into the ReaderIOResult context (with context.Context
// and error handling) while reordering the environment dependencies.
//
// Example:
//
// type AppConfig struct {
// Multiplier int
// }
// type Database struct {
// ConnectionString string
// }
//
// // Original: takes AppConfig, may produce a Reader[Database, int]
// original := func(cfg AppConfig) readerioresult.ReaderIOResult[context.Context, reader.Reader[Database, int]] {
// return readerioresult.Of[context.Context](func(db Database) int {
// return len(db.ConnectionString) * cfg.Multiplier
// })
// }
//
// // Sequence to provide Database first, then AppConfig
// sequenced := SequenceReader[Database, AppConfig, int](original)
// ctx := context.Background()
// result := sequenced(Database{ConnectionString: "localhost"})(AppConfig{Multiplier: 2})(ctx)()
func SequenceReader[R1, R2, A any](ma ReaderReaderIOResult[R2, Reader[R1, A]]) Kleisli[R2, R1, A] {
return readert.SequenceReader(
readerioeither.Map,
ma,
)
}
// SequenceReaderIO swaps the order of environment parameters when the inner computation is a ReaderIO.
//
// This function is specialized for the case where the innermost computation is a ReaderIO
// (with IO effects but no error handling) rather than another ReaderReaderIOResult. It takes
// a ReaderReaderIOResult that produces a ReaderIO and returns a Kleisli arrow that reverses
// the order of the outer environment parameters.
//
// Type Parameters:
// - R1: The first environment type (becomes outermost after sequence)
// - R2: The second environment type (becomes inner after sequence)
// - A: The success value type
//
// Parameters:
// - ma: A ReaderReaderIOResult[R2, ReaderIO[R1, A]]
//
// Returns:
// - A Kleisli[R2, R1, A], which is func(R1) ReaderReaderIOResult[R2, A]
//
// The function lifts the ReaderIO computation (which has IO effects but no error handling)
// into the ReaderIOResult context (with context.Context and error handling) while reordering
// the environment dependencies.
//
// Example:
//
// type AppConfig struct {
// FilePath string
// }
// type Logger struct {
// Level string
// }
//
// // Original: takes AppConfig, may produce a ReaderIO[Logger, string]
// original := func(cfg AppConfig) readerioresult.ReaderIOResult[context.Context, readerio.ReaderIO[Logger, string]] {
// return readerioresult.Of[context.Context](func(logger Logger) io.IO[string] {
// return func() string {
// return fmt.Sprintf("[%s] Reading from %s", logger.Level, cfg.FilePath)
// }
// })
// }
//
// // Sequence to provide Logger first, then AppConfig
// sequenced := SequenceReaderIO[Logger, AppConfig, string](original)
// ctx := context.Background()
// result := sequenced(Logger{Level: "INFO"})(AppConfig{FilePath: "/data"})(ctx)()
func SequenceReaderIO[R1, R2, A any](ma ReaderReaderIOResult[R2, ReaderIO[R1, A]]) Kleisli[R2, R1, A] {
return RRIOE.SequenceReaderIO(ma)
}
// Traverse transforms a ReaderReaderIOResult computation by applying a function that produces
// another ReaderReaderIOResult, effectively swapping the order of outer environment parameters.
//
// This function is useful when you have a computation that depends on environment R2 and
// produces a value of type A, and you want to transform it using a function that takes A
// and produces a computation depending on environment R1. The result is a curried function
// that takes R1 first, then R2, and produces a computation with context.Context and error handling.
//
// Type Parameters:
// - R2: The outer environment type from the original computation
// - R1: The inner environment type introduced by the transformation
// - A: The input value type
// - B: The output value type
//
// Parameters:
// - f: A Kleisli arrow that transforms A into a ReaderReaderIOResult[R1, B]
//
// Returns:
// - A function that takes a ReaderReaderIOResult[R2, A] and returns a Kleisli[R2, R1, B],
// which is func(R1) ReaderReaderIOResult[R2, B]
//
// The function preserves error handling and IO effects while reordering the environment dependencies.
// This is the generalized version of Sequence that also applies a transformation function.
//
// Example:
//
// type AppConfig struct {
// SystemID string
// }
// type UserConfig struct {
// UserID int
// }
//
// // Original computation depending on AppConfig
// original := Of[AppConfig](42)
//
// // Transformation that introduces UserConfig dependency
// transform := func(n int) ReaderReaderIOResult[UserConfig, string] {
// return func(userCfg UserConfig) readerioresult.ReaderIOResult[context.Context, string] {
// return readerioresult.Of[context.Context](fmt.Sprintf("User %d: %d", userCfg.UserID, n))
// }
// }
//
// // Apply traverse to swap order and transform
// traversed := Traverse[AppConfig, UserConfig, int, string](transform)(original)
//
// // Provide UserConfig first, then AppConfig
// ctx := context.Background()
// result := traversed(UserConfig{UserID: 1})(AppConfig{SystemID: "sys1"})(ctx)()
func Traverse[R2, R1, A, B any](
f Kleisli[R1, A, B],
) func(ReaderReaderIOResult[R2, A]) Kleisli[R2, R1, B] {
return readert.Traverse[ReaderReaderIOResult[R2, A]](
readerioeither.Map,
readerioeither.Chain,
f,
)
}
// TraverseReader transforms a ReaderReaderIOResult computation by applying a Reader-based function,
// effectively introducing a new environment dependency.
//
// This function takes a Reader-based transformation (Kleisli arrow) and returns a function that
// can transform a ReaderReaderIOResult. The result allows you to provide the Reader's environment (R1)
// first, which then produces a ReaderReaderIOResult that depends on environment R2.
//
// Type Parameters:
// - R2: The outer environment type from the original ReaderReaderIOResult
// - R1: The inner environment type introduced by the Reader transformation
// - A: The input value type
// - B: The output value type
//
// Parameters:
// - f: A Reader-based Kleisli arrow that transforms A to B using environment R1
//
// Returns:
// - A function that takes a ReaderReaderIOResult[R2, A] and returns a Kleisli[R2, R1, B],
// which is func(R1) ReaderReaderIOResult[R2, B]
//
// The function preserves error handling and IO effects while adding the Reader environment dependency
// and reordering the environment parameters. This is useful when you want to introduce a pure
// (non-IO, non-error) environment dependency to an existing computation.
//
// Example:
//
// type AppConfig struct {
// Timeout int
// }
// type UserPreferences struct {
// Theme string
// }
//
// // Original computation depending on AppConfig
// original := Of[AppConfig](100)
//
// // Pure Reader transformation that introduces UserPreferences dependency
// formatWithTheme := func(value int) reader.Reader[UserPreferences, string] {
// return func(prefs UserPreferences) string {
// return fmt.Sprintf("[%s theme] Value: %d", prefs.Theme, value)
// }
// }
//
// // Apply traverse to introduce UserPreferences and swap order
// traversed := TraverseReader[AppConfig, UserPreferences, int, string](formatWithTheme)(original)
//
// // Provide UserPreferences first, then AppConfig
// ctx := context.Background()
// result := traversed(UserPreferences{Theme: "dark"})(AppConfig{Timeout: 30})(ctx)()
func TraverseReader[R2, R1, A, B any](
f reader.Kleisli[R1, A, B],
) func(ReaderReaderIOResult[R2, A]) Kleisli[R2, R1, B] {
return readert.TraverseReader[ReaderReaderIOResult[R2, A]](
readerioeither.Map,
readerioeither.Map,
f,
)
}

View File

@@ -0,0 +1,778 @@
// 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"
"testing"
RIORES "github.com/IBM/fp-go/v2/context/readerioresult"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
type Config1 struct {
value1 int
}
type Config2 struct {
value2 string
}
func TestSequence(t *testing.T) {
t.Run("swaps parameter order for simple types", func(t *testing.T) {
ctx := t.Context()
// Original: takes Config2, returns ReaderIOResult that may produce ReaderReaderIOResult[Config1, int]
original := func(cfg2 Config2) RIORES.ReaderIOResult[ReaderReaderIOResult[Config1, int]] {
return func(ctx1 context.Context) IOResult[ReaderReaderIOResult[Config1, int]] {
return func() Result[ReaderReaderIOResult[Config1, int]] {
return result.Of(func(cfg1 Config1) RIORES.ReaderIOResult[int] {
return func(ctx2 context.Context) IOResult[int] {
return func() Result[int] {
return result.Of(cfg1.value1 + len(cfg2.value2))
}
}
})
}
}
}
// Sequence swaps Config1 and Config2 order
sequenced := Sequence[Config1, Config2, int](original)
cfg1 := Config1{value1: 10}
cfg2 := Config2{value2: "hello"}
// Test original: Config2 -> Context -> Config1 -> Context
result1 := original(cfg2)(ctx)()
assert.True(t, result.IsRight(result1))
innerFunc1, _ := result.Unwrap(result1)
innerResult1 := innerFunc1(cfg1)(ctx)()
assert.Equal(t, result.Of(15), innerResult1)
// Test sequenced: Config1 -> Config2 -> Context
innerFunc2 := sequenced(cfg1)
innerResult2 := innerFunc2(cfg2)(ctx)()
assert.Equal(t, result.Of(15), innerResult2)
})
t.Run("preserves error handling", func(t *testing.T) {
ctx := t.Context()
testErr := errors.New("test error")
// Original that returns an error
original := func(cfg2 Config2) RIORES.ReaderIOResult[ReaderReaderIOResult[Config1, int]] {
return func(ctx context.Context) IOResult[ReaderReaderIOResult[Config1, int]] {
return func() Result[ReaderReaderIOResult[Config1, int]] {
return result.Left[ReaderReaderIOResult[Config1, int]](testErr)
}
}
}
sequenced := Sequence[Config1, Config2, int](original)
cfg1 := Config1{value1: 10}
cfg2 := Config2{value2: "hello"}
// Test sequenced preserves error
innerFunc := sequenced(cfg1)
outcome := innerFunc(cfg2)(ctx)()
assert.Equal(t, result.Left[int](testErr), outcome)
})
t.Run("works with nested computations", func(t *testing.T) {
ctx := t.Context()
// Original with nested logic
original := func(cfg2 Config2) RIORES.ReaderIOResult[ReaderReaderIOResult[Config1, string]] {
return func(ctx context.Context) IOResult[ReaderReaderIOResult[Config1, string]] {
return func() Result[ReaderReaderIOResult[Config1, string]] {
if len(cfg2.value2) == 0 {
return result.Left[ReaderReaderIOResult[Config1, string]](errors.New("empty string"))
}
return result.Of(func(cfg1 Config1) RIORES.ReaderIOResult[string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
if cfg1.value1 < 0 {
return result.Left[string](errors.New("negative value"))
}
return result.Of(fmt.Sprintf("%s:%d", cfg2.value2, cfg1.value1))
}
}
})
}
}
}
sequenced := Sequence[Config1, Config2, string](original)
// Test with valid inputs
result1 := sequenced(Config1{value1: 42})(Config2{value2: "test"})(ctx)()
assert.Equal(t, result.Of("test:42"), result1)
// Test with empty string
result2 := sequenced(Config1{value1: 42})(Config2{value2: ""})(ctx)()
assert.True(t, result.IsLeft(result2))
// Test with negative value
result3 := sequenced(Config1{value1: -1})(Config2{value2: "test"})(ctx)()
assert.True(t, result.IsLeft(result3))
})
t.Run("works with zero values", func(t *testing.T) {
ctx := t.Context()
original := func(cfg2 Config2) RIORES.ReaderIOResult[ReaderReaderIOResult[Config1, int]] {
return func(ctx context.Context) IOResult[ReaderReaderIOResult[Config1, int]] {
return func() Result[ReaderReaderIOResult[Config1, int]] {
return result.Of(func(cfg1 Config1) RIORES.ReaderIOResult[int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
return result.Of(cfg1.value1 + len(cfg2.value2))
}
}
})
}
}
}
sequenced := Sequence[Config1, Config2, int](original)
outcome := sequenced(Config1{value1: 0})(Config2{value2: ""})(ctx)()
assert.Equal(t, result.Of(0), outcome)
})
t.Run("maintains referential transparency", func(t *testing.T) {
ctx := t.Context()
original := func(cfg2 Config2) RIORES.ReaderIOResult[ReaderReaderIOResult[Config1, int]] {
return func(ctx context.Context) IOResult[ReaderReaderIOResult[Config1, int]] {
return func() Result[ReaderReaderIOResult[Config1, int]] {
return result.Of(func(cfg1 Config1) RIORES.ReaderIOResult[int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
return result.Of(cfg1.value1 * len(cfg2.value2))
}
}
})
}
}
}
sequenced := Sequence[Config1, Config2, int](original)
cfg1 := Config1{value1: 3}
cfg2 := Config2{value2: "test"}
// Call multiple times with same inputs
for range 5 {
outcome := sequenced(cfg1)(cfg2)(ctx)()
assert.Equal(t, result.Of(12), outcome)
}
})
}
func TestSequenceReader(t *testing.T) {
t.Run("swaps parameter order for Reader types", func(t *testing.T) {
ctx := t.Context()
// Original: takes Config2, returns ReaderIOResult that may produce Reader[Config1, int]
original := func(cfg2 Config2) RIORES.ReaderIOResult[Reader[Config1, int]] {
return func(ctx context.Context) IOResult[Reader[Config1, int]] {
return func() Result[Reader[Config1, int]] {
return result.Of(func(cfg1 Config1) int {
return cfg1.value1 + len(cfg2.value2)
})
}
}
}
// Sequence swaps Config1 and Config2 order
sequenced := SequenceReader[Config1, Config2, int](original)
cfg1 := Config1{value1: 10}
cfg2 := Config2{value2: "hello"}
// Test original
result1 := original(cfg2)(ctx)()
assert.True(t, result.IsRight(result1))
innerFunc1, _ := result.Unwrap(result1)
value1 := innerFunc1(cfg1)
assert.Equal(t, 15, value1)
// Test sequenced
innerFunc2 := sequenced(cfg1)
result2 := innerFunc2(cfg2)(ctx)()
assert.True(t, result.IsRight(result2))
value2, _ := result.Unwrap(result2)
assert.Equal(t, 15, value2)
})
t.Run("preserves error handling", func(t *testing.T) {
ctx := t.Context()
testErr := errors.New("test error")
original := func(cfg2 Config2) RIORES.ReaderIOResult[Reader[Config1, int]] {
return func(ctx context.Context) IOResult[Reader[Config1, int]] {
return func() Result[Reader[Config1, int]] {
return result.Left[Reader[Config1, int]](testErr)
}
}
}
sequenced := SequenceReader[Config1, Config2, int](original)
outcome := sequenced(Config1{value1: 10})(Config2{value2: "hello"})(ctx)()
assert.Equal(t, result.Left[int](testErr), outcome)
})
t.Run("works with pure Reader computations", func(t *testing.T) {
ctx := t.Context()
original := func(cfg2 Config2) RIORES.ReaderIOResult[Reader[Config1, string]] {
return func(ctx context.Context) IOResult[Reader[Config1, string]] {
return func() Result[Reader[Config1, string]] {
if len(cfg2.value2) == 0 {
return result.Left[Reader[Config1, string]](errors.New("empty string"))
}
return result.Of(func(cfg1 Config1) string {
return fmt.Sprintf("%s:%d", cfg2.value2, cfg1.value1)
})
}
}
}
sequenced := SequenceReader[Config1, Config2, string](original)
// Test with valid inputs
result1 := sequenced(Config1{value1: 42})(Config2{value2: "test"})(ctx)()
assert.Equal(t, result.Of("test:42"), result1)
// Test with empty string
result2 := sequenced(Config1{value1: 42})(Config2{value2: ""})(ctx)()
assert.True(t, result.IsLeft(result2))
})
t.Run("works with zero values", func(t *testing.T) {
ctx := t.Context()
original := func(cfg2 Config2) RIORES.ReaderIOResult[Reader[Config1, int]] {
return func(ctx context.Context) IOResult[Reader[Config1, int]] {
return func() Result[Reader[Config1, int]] {
return result.Of(func(cfg1 Config1) int {
return cfg1.value1 + len(cfg2.value2)
})
}
}
}
sequenced := SequenceReader[Config1, Config2, int](original)
outcome := sequenced(Config1{value1: 0})(Config2{value2: ""})(ctx)()
assert.Equal(t, result.Of(0), outcome)
})
t.Run("maintains referential transparency", func(t *testing.T) {
ctx := t.Context()
original := func(cfg2 Config2) RIORES.ReaderIOResult[Reader[Config1, int]] {
return func(ctx context.Context) IOResult[Reader[Config1, int]] {
return func() Result[Reader[Config1, int]] {
return result.Of(func(cfg1 Config1) int {
return cfg1.value1 * len(cfg2.value2)
})
}
}
}
sequenced := SequenceReader[Config1, Config2, int](original)
cfg1 := Config1{value1: 3}
cfg2 := Config2{value2: "test"}
// Call multiple times with same inputs
for range 5 {
outcome := sequenced(cfg1)(cfg2)(ctx)()
assert.Equal(t, result.Of(12), outcome)
}
})
}
func TestSequenceReaderIO(t *testing.T) {
t.Run("swaps parameter order for ReaderIO types", func(t *testing.T) {
ctx := t.Context()
// Original: takes Config2, returns ReaderIOResult that may produce ReaderIO[Config1, int]
original := func(cfg2 Config2) RIORES.ReaderIOResult[ReaderIO[Config1, int]] {
return func(ctx context.Context) IOResult[ReaderIO[Config1, int]] {
return func() Result[ReaderIO[Config1, int]] {
return result.Of(func(cfg1 Config1) io.IO[int] {
return io.Of(cfg1.value1 + len(cfg2.value2))
})
}
}
}
// Sequence swaps Config1 and Config2 order
sequenced := SequenceReaderIO[Config1, Config2, int](original)
cfg1 := Config1{value1: 10}
cfg2 := Config2{value2: "hello"}
// Test original
result1 := original(cfg2)(ctx)()
assert.True(t, result.IsRight(result1))
innerFunc1, _ := result.Unwrap(result1)
value1 := innerFunc1(cfg1)()
assert.Equal(t, 15, value1)
// Test sequenced
innerFunc2 := sequenced(cfg1)
result2 := innerFunc2(cfg2)(ctx)()
assert.True(t, result.IsRight(result2))
value2, _ := result.Unwrap(result2)
assert.Equal(t, 15, value2)
})
t.Run("preserves error handling", func(t *testing.T) {
ctx := t.Context()
testErr := errors.New("test error")
original := func(cfg2 Config2) RIORES.ReaderIOResult[ReaderIO[Config1, int]] {
return func(ctx context.Context) IOResult[ReaderIO[Config1, int]] {
return func() Result[ReaderIO[Config1, int]] {
return result.Left[ReaderIO[Config1, int]](testErr)
}
}
}
sequenced := SequenceReaderIO[Config1, Config2, int](original)
outcome := sequenced(Config1{value1: 10})(Config2{value2: "hello"})(ctx)()
assert.Equal(t, result.Left[int](testErr), outcome)
})
t.Run("works with IO effects", func(t *testing.T) {
ctx := t.Context()
sideEffect := 0
original := func(cfg2 Config2) RIORES.ReaderIOResult[ReaderIO[Config1, string]] {
return func(ctx context.Context) IOResult[ReaderIO[Config1, string]] {
return func() Result[ReaderIO[Config1, string]] {
if len(cfg2.value2) == 0 {
return result.Left[ReaderIO[Config1, string]](errors.New("empty string"))
}
return result.Of(func(cfg1 Config1) io.IO[string] {
return func() string {
sideEffect = cfg1.value1
return fmt.Sprintf("%s:%d", cfg2.value2, cfg1.value1)
}
})
}
}
}
sequenced := SequenceReaderIO[Config1, Config2, string](original)
// Test with valid inputs
sideEffect = 0
result1 := sequenced(Config1{value1: 42})(Config2{value2: "test"})(ctx)()
assert.Equal(t, result.Of("test:42"), result1)
assert.Equal(t, 42, sideEffect)
// Test with empty string
sideEffect = 0
result2 := sequenced(Config1{value1: 42})(Config2{value2: ""})(ctx)()
assert.True(t, result.IsLeft(result2))
assert.Equal(t, 0, sideEffect) // Side effect should not occur
})
t.Run("works with zero values", func(t *testing.T) {
ctx := t.Context()
original := func(cfg2 Config2) RIORES.ReaderIOResult[ReaderIO[Config1, int]] {
return func(ctx context.Context) IOResult[ReaderIO[Config1, int]] {
return func() Result[ReaderIO[Config1, int]] {
return result.Of(func(cfg1 Config1) io.IO[int] {
return io.Of(cfg1.value1 + len(cfg2.value2))
})
}
}
}
sequenced := SequenceReaderIO[Config1, Config2, int](original)
outcome := sequenced(Config1{value1: 0})(Config2{value2: ""})(ctx)()
assert.Equal(t, result.Of(0), outcome)
})
t.Run("executes IO effects correctly", func(t *testing.T) {
ctx := t.Context()
counter := 0
original := func(cfg2 Config2) RIORES.ReaderIOResult[ReaderIO[Config1, int]] {
return func(ctx context.Context) IOResult[ReaderIO[Config1, int]] {
return func() Result[ReaderIO[Config1, int]] {
return result.Of(func(cfg1 Config1) io.IO[int] {
return func() int {
counter++
return cfg1.value1 + len(cfg2.value2)
}
})
}
}
}
sequenced := SequenceReaderIO[Config1, Config2, int](original)
cfg1 := Config1{value1: 10}
cfg2 := Config2{value2: "hello"}
// Each execution should increment counter
counter = 0
result1 := sequenced(cfg1)(cfg2)(ctx)()
assert.Equal(t, result.Of(15), result1)
assert.Equal(t, 1, counter)
result2 := sequenced(cfg1)(cfg2)(ctx)()
assert.Equal(t, result.Of(15), result2)
assert.Equal(t, 2, counter)
})
}
func TestTraverse(t *testing.T) {
t.Run("transforms and swaps parameter order", func(t *testing.T) {
ctx := t.Context()
// Original computation depending on Config2
original := Of[Config2](42)
// Transformation that introduces Config1 dependency
transform := func(n int) ReaderReaderIOResult[Config1, string] {
return func(cfg1 Config1) RIORES.ReaderIOResult[string] {
return func(ctx context.Context) IOResult[string] {
return func() Result[string] {
return result.Of(fmt.Sprintf("value=%d, cfg1=%d", n, cfg1.value1))
}
}
}
}
// Apply traverse to swap order and transform
traversed := Traverse[Config2, Config1, int, string](transform)(original)
cfg1 := Config1{value1: 100}
cfg2 := Config2{value2: "test"}
outcome := traversed(cfg1)(cfg2)(ctx)()
assert.Equal(t, result.Of("value=42, cfg1=100"), outcome)
})
t.Run("preserves error handling in original", func(t *testing.T) {
ctx := t.Context()
testErr := errors.New("test error")
original := Left[Config2, int](testErr)
transform := func(n int) ReaderReaderIOResult[Config1, string] {
return Of[Config1](fmt.Sprintf("%d", n))
}
traversed := Traverse[Config2, Config1, int, string](transform)(original)
outcome := traversed(Config1{value1: 100})(Config2{value2: "test"})(ctx)()
assert.Equal(t, result.Left[string](testErr), outcome)
})
t.Run("preserves error handling in transformation", func(t *testing.T) {
ctx := t.Context()
original := Of[Config2](42)
testErr := errors.New("transform error")
transform := func(n int) ReaderReaderIOResult[Config1, string] {
if n < 0 {
return Left[Config1, string](testErr)
}
return Of[Config1](fmt.Sprintf("%d", n))
}
// Test with negative value
originalNeg := Of[Config2](-1)
traversedNeg := Traverse[Config2, Config1, int, string](transform)(originalNeg)
resultNeg := traversedNeg(Config1{value1: 100})(Config2{value2: "test"})(ctx)()
assert.Equal(t, result.Left[string](testErr), resultNeg)
// Test with positive value
traversedPos := Traverse[Config2, Config1, int, string](transform)(original)
resultPos := traversedPos(Config1{value1: 100})(Config2{value2: "test"})(ctx)()
assert.Equal(t, result.Of("42"), resultPos)
})
t.Run("works with complex transformations", func(t *testing.T) {
ctx := t.Context()
original := Of[Config2](10)
transform := func(n int) ReaderReaderIOResult[Config1, int] {
return func(cfg1 Config1) RIORES.ReaderIOResult[int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
return result.Of(n * cfg1.value1)
}
}
}
}
traversed := Traverse[Config2, Config1, int, int](transform)(original)
outcome := traversed(Config1{value1: 5})(Config2{value2: "test"})(ctx)()
assert.Equal(t, result.Of(50), outcome)
})
t.Run("can be composed with other operations", func(t *testing.T) {
ctx := t.Context()
original := Of[Config2](10)
transform := func(n int) ReaderReaderIOResult[Config1, int] {
return Of[Config1](n * 2)
}
outcome := F.Pipe2(
original,
Traverse[Config2, Config1, int, int](transform),
func(k Kleisli[Config2, Config1, int]) ReaderReaderIOResult[Config2, int] {
return k(Config1{value1: 5})
},
)
res := outcome(Config2{value2: "test"})(ctx)()
assert.Equal(t, result.Of(20), res)
})
}
func TestTraverseReader(t *testing.T) {
t.Run("transforms with pure Reader and swaps parameter order", func(t *testing.T) {
ctx := t.Context()
// Original computation depending on Config2
original := Of[Config2](100)
// Pure Reader transformation that introduces Config1 dependency
formatWithConfig := func(value int) reader.Reader[Config1, string] {
return func(cfg1 Config1) string {
return fmt.Sprintf("value=%d, multiplier=%d, result=%d", value, cfg1.value1, value*cfg1.value1)
}
}
// Apply traverse to introduce Config1 and swap order
traversed := TraverseReader[Config2, Config1, int, string](formatWithConfig)(original)
cfg1 := Config1{value1: 5}
cfg2 := Config2{value2: "test"}
outcome := traversed(cfg1)(cfg2)(ctx)()
assert.Equal(t, result.Of("value=100, multiplier=5, result=500"), outcome)
})
t.Run("preserves error handling", func(t *testing.T) {
ctx := t.Context()
testErr := errors.New("test error")
original := Left[Config2, int](testErr)
transform := func(n int) reader.Reader[Config1, string] {
return reader.Of[Config1](fmt.Sprintf("%d", n))
}
traversed := TraverseReader[Config2, Config1, int, string](transform)(original)
outcome := traversed(Config1{value1: 5})(Config2{value2: "test"})(ctx)()
assert.Equal(t, result.Left[string](testErr), outcome)
})
t.Run("works with pure computations", func(t *testing.T) {
ctx := t.Context()
original := Of[Config2](42)
// Pure transformation using Reader
double := func(n int) reader.Reader[Config1, int] {
return func(cfg1 Config1) int {
return n * cfg1.value1
}
}
traversed := TraverseReader[Config2, Config1, int, int](double)(original)
outcome := traversed(Config1{value1: 3})(Config2{value2: "test"})(ctx)()
assert.Equal(t, result.Of(126), outcome)
})
t.Run("works with zero values", func(t *testing.T) {
ctx := t.Context()
original := Of[Config2](0)
transform := func(n int) reader.Reader[Config1, int] {
return func(cfg1 Config1) int {
return n + cfg1.value1
}
}
traversed := TraverseReader[Config2, Config1, int, int](transform)(original)
outcome := traversed(Config1{value1: 0})(Config2{value2: ""})(ctx)()
assert.Equal(t, result.Of(0), outcome)
})
t.Run("maintains referential transparency", func(t *testing.T) {
ctx := t.Context()
original := Of[Config2](10)
transform := func(n int) reader.Reader[Config1, int] {
return func(cfg1 Config1) int {
return n * cfg1.value1
}
}
traversed := TraverseReader[Config2, Config1, int, int](transform)(original)
cfg1 := Config1{value1: 5}
cfg2 := Config2{value2: "test"}
// Call multiple times with same inputs
for range 5 {
outcome := traversed(cfg1)(cfg2)(ctx)()
assert.Equal(t, result.Of(50), outcome)
}
})
t.Run("can be used in composition", func(t *testing.T) {
ctx := t.Context()
original := Of[Config2](10)
multiply := func(n int) reader.Reader[Config1, int] {
return func(cfg1 Config1) int {
return n * cfg1.value1
}
}
outcome := F.Pipe2(
original,
TraverseReader[Config2, Config1, int, int](multiply),
func(k Kleisli[Config2, Config1, int]) ReaderReaderIOResult[Config2, int] {
return k(Config1{value1: 3})
},
)
res := outcome(Config2{value2: "test"})(ctx)()
assert.Equal(t, result.Of(30), res)
})
}
func TestFlipIntegration(t *testing.T) {
t.Run("Sequence and Traverse work together", func(t *testing.T) {
ctx := t.Context()
// Create a nested computation
nested := func(cfg2 Config2) RIORES.ReaderIOResult[ReaderReaderIOResult[Config1, int]] {
return func(ctx context.Context) IOResult[ReaderReaderIOResult[Config1, int]] {
return func() Result[ReaderReaderIOResult[Config1, int]] {
return result.Of(Of[Config1](len(cfg2.value2)))
}
}
}
// Sequence it
sequenced := Sequence[Config1, Config2, int](nested)
// Then traverse with a transformation
transform := func(n int) ReaderReaderIOResult[Config1, string] {
return Of[Config1](fmt.Sprintf("length=%d", n))
}
// Apply both operations
cfg1 := Config1{value1: 10}
cfg2 := Config2{value2: "hello"}
// First sequence
intermediate := sequenced(cfg1)(cfg2)(ctx)()
assert.Equal(t, result.Of(5), intermediate)
// Then apply traverse on a new computation
original := Of[Config2](5)
traversed := Traverse[Config2, Config1, int, string](transform)(original)
outcome := traversed(cfg1)(cfg2)(ctx)()
assert.Equal(t, result.Of("length=5"), outcome)
})
t.Run("all flip functions preserve error semantics", func(t *testing.T) {
ctx := t.Context()
testErr := errors.New("test error")
cfg1 := Config1{value1: 10}
cfg2 := Config2{value2: "test"}
// Test Sequence with error
seqErr := func(cfg2 Config2) RIORES.ReaderIOResult[ReaderReaderIOResult[Config1, int]] {
return func(ctx context.Context) IOResult[ReaderReaderIOResult[Config1, int]] {
return func() Result[ReaderReaderIOResult[Config1, int]] {
return result.Left[ReaderReaderIOResult[Config1, int]](testErr)
}
}
}
seqResult := Sequence[Config1, Config2, int](seqErr)(cfg1)(cfg2)(ctx)()
assert.True(t, result.IsLeft(seqResult))
// Test SequenceReader with error
seqReaderErr := func(cfg2 Config2) RIORES.ReaderIOResult[Reader[Config1, int]] {
return func(ctx context.Context) IOResult[Reader[Config1, int]] {
return func() Result[Reader[Config1, int]] {
return result.Left[Reader[Config1, int]](testErr)
}
}
}
seqReaderResult := SequenceReader[Config1, Config2, int](seqReaderErr)(cfg1)(cfg2)(ctx)()
assert.True(t, result.IsLeft(seqReaderResult))
// Test SequenceReaderIO with error
seqReaderIOErr := func(cfg2 Config2) RIORES.ReaderIOResult[ReaderIO[Config1, int]] {
return func(ctx context.Context) IOResult[ReaderIO[Config1, int]] {
return func() Result[ReaderIO[Config1, int]] {
return result.Left[ReaderIO[Config1, int]](testErr)
}
}
}
seqReaderIOResult := SequenceReaderIO[Config1, Config2, int](seqReaderIOErr)(cfg1)(cfg2)(ctx)()
assert.True(t, result.IsLeft(seqReaderIOResult))
// Test Traverse with error
travErr := Left[Config2, int](testErr)
travTransform := func(n int) ReaderReaderIOResult[Config1, string] {
return Of[Config1](fmt.Sprintf("%d", n))
}
travResult := Traverse[Config2, Config1, int, string](travTransform)(travErr)(cfg1)(cfg2)(ctx)()
assert.True(t, result.IsLeft(travResult))
// Test TraverseReader with error
travReaderErr := Left[Config2, int](testErr)
travReaderTransform := func(n int) reader.Reader[Config1, string] {
return reader.Of[Config1](fmt.Sprintf("%d", n))
}
travReaderResult := TraverseReader[Config2, Config1, int, string](travReaderTransform)(travReaderErr)(cfg1)(cfg2)(ctx)()
assert.True(t, result.IsLeft(travReaderResult))
})
}

View File

@@ -0,0 +1,148 @@
// Copyright (c) 2023 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 (
"github.com/IBM/fp-go/v2/monoid"
)
type (
// Monoid represents a monoid structure for ReaderReaderIOResult[R, A].
// A monoid provides an identity element (empty) and an associative binary operation (concat).
Monoid[R, A any] = monoid.Monoid[ReaderReaderIOResult[R, A]]
)
// ApplicativeMonoid creates a monoid for ReaderReaderIOResult using applicative composition.
// It combines values using the provided monoid m and the applicative Ap operation.
// This allows combining multiple ReaderReaderIOResult values in parallel while merging their results.
//
// The resulting monoid satisfies:
// - Identity: concat(empty, x) = concat(x, empty) = x
// - Associativity: concat(concat(x, y), z) = concat(x, concat(y, z))
//
// Example:
//
// import "github.com/IBM/fp-go/v2/monoid"
// import "github.com/IBM/fp-go/v2/number"
//
// // Create a monoid for combining integers with addition
// intMonoid := ApplicativeMonoid[Config](number.MonoidSum)
//
// // Combine multiple computations
// result := intMonoid.Concat(
// Of[Config](10),
// intMonoid.Concat(Of[Config](20), Of[Config](30)),
// ) // Results in 60
func ApplicativeMonoid[R, A any](m monoid.Monoid[A]) Monoid[R, A] {
return monoid.ApplicativeMonoid(
Of[R, A],
MonadMap[R, A, func(A) A],
MonadAp[R, A, A],
m,
)
}
// ApplicativeMonoidSeq creates a monoid for ReaderReaderIOResult using sequential applicative composition.
// Similar to ApplicativeMonoid but evaluates effects sequentially rather than in parallel.
//
// Use this when:
// - Effects must be executed in a specific order
// - Side effects depend on sequential execution
// - You want to avoid concurrent execution
func ApplicativeMonoidSeq[R, A any](m monoid.Monoid[A]) Monoid[R, A] {
return monoid.ApplicativeMonoid(
Of[R, A],
MonadMap[R, A, func(A) A],
MonadApSeq[R, A, A],
m,
)
}
// ApplicativeMonoidPar creates a monoid for ReaderReaderIOResult using parallel applicative composition.
// Similar to ApplicativeMonoid but explicitly evaluates effects in parallel.
//
// Use this when:
// - Effects are independent and can run concurrently
// - You want to maximize performance through parallelism
// - Order of execution doesn't matter
func ApplicativeMonoidPar[R, A any](m monoid.Monoid[A]) Monoid[R, A] {
return monoid.ApplicativeMonoid(
Of[R, A],
MonadMap[R, A, func(A) A],
MonadApPar[R, A, A],
m,
)
}
// AlternativeMonoid creates a monoid that combines ReaderReaderIOResult values using both
// applicative composition and alternative (Alt) semantics.
//
// This monoid:
// - Uses Ap for combining successful values
// - Uses Alt for handling failures (tries alternatives on failure)
// - Provides a way to combine multiple computations with fallback behavior
//
// Example:
//
// import "github.com/IBM/fp-go/v2/monoid"
// import "github.com/IBM/fp-go/v2/number"
//
// intMonoid := AlternativeMonoid[Config](number.MonoidSum)
//
// // If first computation fails, tries the second
// result := intMonoid.Concat(
// Left[Config, int](errors.New("failed")),
// Of[Config](42),
// ) // Results in Right(42)
func AlternativeMonoid[R, A any](m monoid.Monoid[A]) Monoid[R, A] {
return monoid.AlternativeMonoid(
Of[R, A],
MonadMap[R, A, func(A) A],
MonadAp[R, A, A],
MonadAlt[R, A],
m,
)
}
// AltMonoid creates a monoid based solely on the Alt operation.
// It provides a way to chain computations with fallback behavior.
//
// The monoid:
// - Uses the provided zero as the identity element
// - Uses Alt for concatenation (tries first, falls back to second on failure)
// - Implements a "first success" strategy
//
// Example:
//
// zero := func() ReaderReaderIOResult[Config, int] {
// return Left[Config, int](errors.New("no value"))
// }
// altMonoid := AltMonoid[Config, int](zero)
//
// // Tries computations in order until one succeeds
// result := altMonoid.Concat(
// Left[Config, int](errors.New("first failed")),
// altMonoid.Concat(
// Left[Config, int](errors.New("second failed")),
// Of[Config](42),
// ),
// ) // Results in Right(42)
func AltMonoid[R, A any](zero Lazy[ReaderReaderIOResult[R, A]]) Monoid[R, A] {
return monoid.AltMonoid(
zero,
MonadAlt[R, A],
)
}

View File

@@ -0,0 +1,337 @@
// 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 (
"errors"
"testing"
N "github.com/IBM/fp-go/v2/number"
"github.com/IBM/fp-go/v2/result"
S "github.com/IBM/fp-go/v2/string"
"github.com/stretchr/testify/assert"
)
var (
intAddMonoid = N.MonoidSum[int]()
strMonoid = S.Monoid
testError = errors.New("test error")
)
func TestApplicativeMonoid(t *testing.T) {
rrMonoid := ApplicativeMonoid[AppConfig](intAddMonoid)
cfg := defaultConfig
ctx := t.Context()
t.Run("empty element", func(t *testing.T) {
empty := rrMonoid.Empty()
assert.Equal(t, result.Of(0), empty(cfg)(ctx)())
})
t.Run("concat two success values", func(t *testing.T) {
rr1 := Of[AppConfig](5)
rr2 := Of[AppConfig](3)
combined := rrMonoid.Concat(rr1, rr2)
assert.Equal(t, result.Of(8), combined(cfg)(ctx)())
})
t.Run("concat with empty", func(t *testing.T) {
rr := Of[AppConfig](42)
combined1 := rrMonoid.Concat(rr, rrMonoid.Empty())
combined2 := rrMonoid.Concat(rrMonoid.Empty(), rr)
assert.Equal(t, result.Of(42), combined1(cfg)(ctx)())
assert.Equal(t, result.Of(42), combined2(cfg)(ctx)())
})
t.Run("concat with left failure", func(t *testing.T) {
rrSuccess := Of[AppConfig](5)
rrFailure := Left[AppConfig, int](testError)
combined := rrMonoid.Concat(rrFailure, rrSuccess)
assert.True(t, result.IsLeft(combined(cfg)(ctx)()))
})
t.Run("concat with right failure", func(t *testing.T) {
rrSuccess := Of[AppConfig](5)
rrFailure := Left[AppConfig, int](testError)
combined := rrMonoid.Concat(rrSuccess, rrFailure)
assert.True(t, result.IsLeft(combined(cfg)(ctx)()))
})
t.Run("concat multiple values", func(t *testing.T) {
rr1 := Of[AppConfig](1)
rr2 := Of[AppConfig](2)
rr3 := Of[AppConfig](3)
rr4 := Of[AppConfig](4)
// Chain concat calls: ((1 + 2) + 3) + 4
combined := rrMonoid.Concat(
rrMonoid.Concat(
rrMonoid.Concat(rr1, rr2),
rr3,
),
rr4,
)
assert.Equal(t, result.Of(10), combined(cfg)(ctx)())
})
t.Run("string concatenation", func(t *testing.T) {
strRRMonoid := ApplicativeMonoid[AppConfig](strMonoid)
rr1 := Of[AppConfig]("Hello")
rr2 := Of[AppConfig](" ")
rr3 := Of[AppConfig]("World")
combined := strRRMonoid.Concat(
strRRMonoid.Concat(rr1, rr2),
rr3,
)
assert.Equal(t, result.Of("Hello World"), combined(cfg)(ctx)())
})
}
func TestApplicativeMonoidSeq(t *testing.T) {
rrMonoid := ApplicativeMonoidSeq[AppConfig](intAddMonoid)
cfg := defaultConfig
ctx := t.Context()
t.Run("empty element", func(t *testing.T) {
empty := rrMonoid.Empty()
assert.Equal(t, result.Of(0), empty(cfg)(ctx)())
})
t.Run("concat two success values", func(t *testing.T) {
rr1 := Of[AppConfig](5)
rr2 := Of[AppConfig](3)
combined := rrMonoid.Concat(rr1, rr2)
assert.Equal(t, result.Of(8), combined(cfg)(ctx)())
})
t.Run("concat with failure", func(t *testing.T) {
rrSuccess := Of[AppConfig](5)
rrFailure := Left[AppConfig, int](testError)
combined := rrMonoid.Concat(rrFailure, rrSuccess)
assert.True(t, result.IsLeft(combined(cfg)(ctx)()))
})
}
func TestApplicativeMonoidPar(t *testing.T) {
rrMonoid := ApplicativeMonoidPar[AppConfig](intAddMonoid)
cfg := defaultConfig
ctx := t.Context()
t.Run("empty element", func(t *testing.T) {
empty := rrMonoid.Empty()
assert.Equal(t, result.Of(0), empty(cfg)(ctx)())
})
t.Run("concat two success values", func(t *testing.T) {
rr1 := Of[AppConfig](5)
rr2 := Of[AppConfig](3)
combined := rrMonoid.Concat(rr1, rr2)
assert.Equal(t, result.Of(8), combined(cfg)(ctx)())
})
t.Run("concat with failure", func(t *testing.T) {
rrSuccess := Of[AppConfig](5)
rrFailure := Left[AppConfig, int](testError)
combined := rrMonoid.Concat(rrFailure, rrSuccess)
assert.True(t, result.IsLeft(combined(cfg)(ctx)()))
})
}
func TestAltMonoid(t *testing.T) {
zero := func() ReaderReaderIOResult[AppConfig, int] {
return Left[AppConfig, int](errors.New("empty"))
}
rrMonoid := AltMonoid(zero)
cfg := defaultConfig
ctx := t.Context()
t.Run("empty element", func(t *testing.T) {
empty := rrMonoid.Empty()
assert.True(t, result.IsLeft(empty(cfg)(ctx)()))
})
t.Run("concat two success values - uses first", func(t *testing.T) {
rr1 := Of[AppConfig](5)
rr2 := Of[AppConfig](3)
combined := rrMonoid.Concat(rr1, rr2)
// AltMonoid takes the first successful value
assert.Equal(t, result.Of(5), combined(cfg)(ctx)())
})
t.Run("concat failure then success", func(t *testing.T) {
rrFailure := Left[AppConfig, int](testError)
rrSuccess := Of[AppConfig](42)
combined := rrMonoid.Concat(rrFailure, rrSuccess)
// Should fall back to second when first fails
assert.Equal(t, result.Of(42), combined(cfg)(ctx)())
})
t.Run("concat success then failure", func(t *testing.T) {
rrSuccess := Of[AppConfig](42)
rrFailure := Left[AppConfig, int](testError)
combined := rrMonoid.Concat(rrSuccess, rrFailure)
// Should use first successful value
assert.Equal(t, result.Of(42), combined(cfg)(ctx)())
})
t.Run("concat two failures", func(t *testing.T) {
err1 := errors.New("error 1")
err2 := errors.New("error 2")
rr1 := Left[AppConfig, int](err1)
rr2 := Left[AppConfig, int](err2)
combined := rrMonoid.Concat(rr1, rr2)
// Should use second error when both fail
assert.True(t, result.IsLeft(combined(cfg)(ctx)()))
})
t.Run("concat with empty", func(t *testing.T) {
rr := Of[AppConfig](42)
combined1 := rrMonoid.Concat(rr, rrMonoid.Empty())
combined2 := rrMonoid.Concat(rrMonoid.Empty(), rr)
assert.Equal(t, result.Of(42), combined1(cfg)(ctx)())
assert.Equal(t, result.Of(42), combined2(cfg)(ctx)())
})
t.Run("fallback chain", func(t *testing.T) {
// Simulate trying multiple sources until one succeeds
primary := Left[AppConfig, string](errors.New("primary failed"))
secondary := Left[AppConfig, string](errors.New("secondary failed"))
tertiary := Of[AppConfig]("tertiary success")
strZero := func() ReaderReaderIOResult[AppConfig, string] {
return Left[AppConfig, string](errors.New("all failed"))
}
strMonoid := AltMonoid(strZero)
// Chain concat: try primary, then secondary, then tertiary
combined := strMonoid.Concat(
strMonoid.Concat(primary, secondary),
tertiary,
)
assert.Equal(t, result.Of("tertiary success"), combined(cfg)(ctx)())
})
}
func TestAlternativeMonoid(t *testing.T) {
rrMonoid := AlternativeMonoid[AppConfig](intAddMonoid)
cfg := defaultConfig
ctx := t.Context()
t.Run("empty element", func(t *testing.T) {
empty := rrMonoid.Empty()
assert.Equal(t, result.Of(0), empty(cfg)(ctx)())
})
t.Run("concat two success values", func(t *testing.T) {
rr1 := Of[AppConfig](5)
rr2 := Of[AppConfig](3)
combined := rrMonoid.Concat(rr1, rr2)
assert.Equal(t, result.Of(8), combined(cfg)(ctx)())
})
t.Run("concat failure then success", func(t *testing.T) {
rrFailure := Left[AppConfig, int](testError)
rrSuccess := Of[AppConfig](42)
combined := rrMonoid.Concat(rrFailure, rrSuccess)
// Alternative falls back to second when first fails
assert.Equal(t, result.Of(42), combined(cfg)(ctx)())
})
t.Run("concat success then failure", func(t *testing.T) {
rrSuccess := Of[AppConfig](42)
rrFailure := Left[AppConfig, int](testError)
combined := rrMonoid.Concat(rrSuccess, rrFailure)
// Should use first successful value
assert.Equal(t, result.Of(42), combined(cfg)(ctx)())
})
t.Run("concat with empty", func(t *testing.T) {
rr := Of[AppConfig](42)
combined1 := rrMonoid.Concat(rr, rrMonoid.Empty())
combined2 := rrMonoid.Concat(rrMonoid.Empty(), rr)
assert.Equal(t, result.Of(42), combined1(cfg)(ctx)())
assert.Equal(t, result.Of(42), combined2(cfg)(ctx)())
})
t.Run("multiple values with some failures", func(t *testing.T) {
rr1 := Left[AppConfig, int](errors.New("fail 1"))
rr2 := Of[AppConfig](5)
rr3 := Left[AppConfig, int](errors.New("fail 2"))
rr4 := Of[AppConfig](10)
// Alternative should skip failures and accumulate successes
combined := rrMonoid.Concat(
rrMonoid.Concat(
rrMonoid.Concat(rr1, rr2),
rr3,
),
rr4,
)
// Should accumulate successful values: 5 + 10 = 15
assert.Equal(t, result.Of(15), combined(cfg)(ctx)())
})
}
// Test monoid laws
func TestMonoidLaws(t *testing.T) {
rrMonoid := ApplicativeMonoid[AppConfig](intAddMonoid)
cfg := defaultConfig
ctx := t.Context()
// Left identity: empty <> x == x
t.Run("left identity", func(t *testing.T) {
x := Of[AppConfig](42)
result1 := rrMonoid.Concat(rrMonoid.Empty(), x)(cfg)(ctx)()
result2 := x(cfg)(ctx)()
assert.Equal(t, result2, result1)
})
// Right identity: x <> empty == x
t.Run("right identity", func(t *testing.T) {
x := Of[AppConfig](42)
result1 := rrMonoid.Concat(x, rrMonoid.Empty())(cfg)(ctx)()
result2 := x(cfg)(ctx)()
assert.Equal(t, result2, result1)
})
// Associativity: (x <> y) <> z == x <> (y <> z)
t.Run("associativity", func(t *testing.T) {
x := Of[AppConfig](1)
y := Of[AppConfig](2)
z := Of[AppConfig](3)
left := rrMonoid.Concat(rrMonoid.Concat(x, y), z)(cfg)(ctx)()
right := rrMonoid.Concat(x, rrMonoid.Concat(y, z))(cfg)(ctx)()
assert.Equal(t, right, left)
})
}

View File

@@ -0,0 +1,894 @@
// 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"
"time"
RIOE "github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/either"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/internal/chain"
"github.com/IBM/fp-go/v2/internal/fromeither"
"github.com/IBM/fp-go/v2/internal/fromio"
"github.com/IBM/fp-go/v2/internal/fromioeither"
"github.com/IBM/fp-go/v2/internal/fromreader"
"github.com/IBM/fp-go/v2/internal/functor"
"github.com/IBM/fp-go/v2/internal/readert"
"github.com/IBM/fp-go/v2/io"
IOE "github.com/IBM/fp-go/v2/ioeither"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/reader"
RE "github.com/IBM/fp-go/v2/readereither"
"github.com/IBM/fp-go/v2/readerio"
"github.com/IBM/fp-go/v2/readeroption"
RRIOE "github.com/IBM/fp-go/v2/readerreaderioeither"
)
// FromReaderOption converts a ReaderOption to a ReaderReaderIOResult.
// If the option is None, it uses the provided onNone function to generate an error.
//
//go:inline
func FromReaderOption[R, A any](onNone Lazy[error]) Kleisli[R, ReaderOption[R, A], A] {
return RRIOE.FromReaderOption[R, context.Context, A](onNone)
}
// FromReaderIOResult lifts a ReaderIOResult into a ReaderReaderIOResult.
// This adds an additional reader layer to the computation.
//
//go:inline
func FromReaderIOResult[R, A any](ma ReaderIOResult[R, A]) ReaderReaderIOResult[R, A] {
return RRIOE.FromReaderIOEither[context.Context, error](ma)
}
// FromReaderIO lifts a ReaderIO into a ReaderReaderIOResult.
// The IO computation is wrapped in a Right (success) value.
//
//go:inline
func FromReaderIO[R, A any](ma ReaderIO[R, A]) ReaderReaderIOResult[R, A] {
return RRIOE.FromReaderIO[context.Context, error](ma)
}
// RightReaderIO lifts a ReaderIO into a ReaderReaderIOResult as a Right (success) value.
// Alias for FromReaderIO.
//
//go:inline
func RightReaderIO[R, A any](ma ReaderIO[R, A]) ReaderReaderIOResult[R, A] {
return RRIOE.RightReaderIO[context.Context, error](ma)
}
// LeftReaderIO lifts a ReaderIO that produces an error into a ReaderReaderIOResult as a Left (failure) value.
//
//go:inline
func LeftReaderIO[A, R any](me ReaderIO[R, error]) ReaderReaderIOResult[R, A] {
return RRIOE.LeftReaderIO[context.Context, A](me)
}
// MonadMap applies a function to the value inside a ReaderReaderIOResult (Functor operation).
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadMap[R, A, B any](fa ReaderReaderIOResult[R, A], f func(A) B) ReaderReaderIOResult[R, B] {
return reader.MonadMap(fa, RIOE.Map(f))
}
// Map applies a function to the value inside a ReaderReaderIOResult (Functor operation).
// This is the curried version that returns an operator.
//
//go:inline
func Map[R, A, B any](f func(A) B) Operator[R, A, B] {
return reader.Map[R](RIOE.Map(f))
}
// MonadMapTo replaces the value inside a ReaderReaderIOResult with a constant value.
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadMapTo[R, A, B any](fa ReaderReaderIOResult[R, A], b B) ReaderReaderIOResult[R, B] {
return reader.MonadMap(fa, RIOE.MapTo[A](b))
}
// MapTo replaces the value inside a ReaderReaderIOResult with a constant value.
// This is the curried version that returns an operator.
//
//go:inline
func MapTo[R, A, B any](b B) Operator[R, A, B] {
return reader.Map[R](RIOE.MapTo[A](b))
}
// MonadChain sequences two computations, where the second depends on the result of the first (Monad operation).
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadChain[R, A, B any](fa ReaderReaderIOResult[R, A], f Kleisli[R, A, B]) ReaderReaderIOResult[R, B] {
return readert.MonadChain(
RIOE.MonadChain[A, B],
fa,
f,
)
}
// MonadChainFirst sequences two computations but returns the result of the first.
// Useful for performing side effects while preserving the original value.
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadChainFirst[R, A, B any](fa ReaderReaderIOResult[R, A], f Kleisli[R, A, B]) ReaderReaderIOResult[R, A] {
return chain.MonadChainFirst(
MonadChain[R, A, A],
MonadMap[R, B, A],
fa,
f)
}
// MonadTap is an alias for MonadChainFirst.
// Executes a side effect while preserving the original value.
//
//go:inline
func MonadTap[R, A, B any](fa ReaderReaderIOResult[R, A], f Kleisli[R, A, B]) ReaderReaderIOResult[R, A] {
return MonadChainFirst(fa, f)
}
// MonadChainEitherK chains a computation that returns an Either.
// The Either is automatically lifted into ReaderReaderIOResult.
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadChainEitherK[R, A, B any](ma ReaderReaderIOResult[R, A], f either.Kleisli[error, A, B]) ReaderReaderIOResult[R, B] {
return fromeither.MonadChainEitherK(
MonadChain[R, A, B],
FromEither[R, B],
ma,
f,
)
}
// ChainEitherK chains a computation that returns an Either.
// The Either is automatically lifted into ReaderReaderIOResult.
// This is the curried version that returns an operator.
//
//go:inline
func ChainEitherK[R, A, B any](f either.Kleisli[error, A, B]) Operator[R, A, B] {
return fromeither.ChainEitherK(
Chain[R, A, B],
FromEither[R, B],
f,
)
}
// MonadChainFirstEitherK chains a computation that returns an Either but preserves the original value.
// Useful for validation or side effects that may fail.
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadChainFirstEitherK[R, A, B any](ma ReaderReaderIOResult[R, A], f either.Kleisli[error, A, B]) ReaderReaderIOResult[R, A] {
return fromeither.MonadChainFirstEitherK(
MonadChain[R, A, A],
MonadMap[R, B, A],
FromEither[R, B],
ma,
f,
)
}
// MonadTapEitherK is an alias for MonadChainFirstEitherK.
// Executes an Either-returning side effect while preserving the original value.
//
//go:inline
func MonadTapEitherK[R, A, B any](ma ReaderReaderIOResult[R, A], f either.Kleisli[error, A, B]) ReaderReaderIOResult[R, A] {
return MonadChainFirstEitherK(ma, f)
}
// ChainFirstEitherK chains a computation that returns an Either but preserves the original value.
// This is the curried version that returns an operator.
//
//go:inline
func ChainFirstEitherK[R, A, B any](f either.Kleisli[error, A, B]) Operator[R, A, A] {
return fromeither.ChainFirstEitherK(
Chain[R, A, A],
Map[R, B, A],
FromEither[R, B],
f,
)
}
// TapEitherK is an alias for ChainFirstEitherK.
// Executes an Either-returning side effect while preserving the original value.
//
//go:inline
func TapEitherK[R, A, B any](f either.Kleisli[error, A, B]) Operator[R, A, A] {
return ChainFirstEitherK[R](f)
}
// MonadChainReaderK chains a computation that returns a Reader.
// The Reader is automatically lifted into ReaderReaderIOResult.
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadChainReaderK[R, A, B any](ma ReaderReaderIOResult[R, A], f reader.Kleisli[R, A, B]) ReaderReaderIOResult[R, B] {
return fromreader.MonadChainReaderK(
MonadChain[R, A, B],
FromReader[R, B],
ma,
f,
)
}
// ChainReaderK chains a computation that returns a Reader.
// The Reader is automatically lifted into ReaderReaderIOResult.
// This is the curried version that returns an operator.
//
//go:inline
func ChainReaderK[R, A, B any](f reader.Kleisli[R, A, B]) Operator[R, A, B] {
return fromreader.ChainReaderK(
Chain[R, A, B],
FromReader[R, B],
f,
)
}
// MonadChainFirstReaderK chains a computation that returns a Reader but preserves the original value.
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadChainFirstReaderK[R, A, B any](ma ReaderReaderIOResult[R, A], f reader.Kleisli[R, A, B]) ReaderReaderIOResult[R, A] {
return fromreader.MonadChainFirstReaderK(
MonadChainFirst[R, A, B],
FromReader[R, B],
ma,
f,
)
}
// MonadTapReaderK is an alias for MonadChainFirstReaderK.
// Executes a Reader-returning side effect while preserving the original value.
//
//go:inline
func MonadTapReaderK[R, A, B any](ma ReaderReaderIOResult[R, A], f reader.Kleisli[R, A, B]) ReaderReaderIOResult[R, A] {
return MonadChainFirstReaderK(ma, f)
}
// ChainFirstReaderK chains a computation that returns a Reader but preserves the original value.
// This is the curried version that returns an operator.
//
//go:inline
func ChainFirstReaderK[R, A, B any](f reader.Kleisli[R, A, B]) Operator[R, A, A] {
return fromreader.ChainFirstReaderK(
ChainFirst[R, A, B],
FromReader[R, B],
f,
)
}
// TapReaderK is an alias for ChainFirstReaderK.
// Executes a Reader-returning side effect while preserving the original value.
//
//go:inline
func TapReaderK[R, A, B any](f reader.Kleisli[R, A, B]) Operator[R, A, A] {
return ChainFirstReaderK(f)
}
// MonadChainReaderIOK chains a computation that returns a ReaderIO.
// The ReaderIO is automatically lifted into ReaderReaderIOResult.
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadChainReaderIOK[R, A, B any](ma ReaderReaderIOResult[R, A], f readerio.Kleisli[R, A, B]) ReaderReaderIOResult[R, B] {
return fromreader.MonadChainReaderK(
MonadChain[R, A, B],
FromReaderIO[R, B],
ma,
f,
)
}
// ChainReaderIOK chains a computation that returns a ReaderIO.
// The ReaderIO is automatically lifted into ReaderReaderIOResult.
// This is the curried version that returns an operator.
//
//go:inline
func ChainReaderIOK[R, A, B any](f readerio.Kleisli[R, A, B]) Operator[R, A, B] {
return fromreader.ChainReaderK(
Chain[R, A, B],
FromReaderIO[R, B],
f,
)
}
// MonadChainFirstReaderIOK chains a computation that returns a ReaderIO but preserves the original value.
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadChainFirstReaderIOK[R, A, B any](ma ReaderReaderIOResult[R, A], f readerio.Kleisli[R, A, B]) ReaderReaderIOResult[R, A] {
return fromreader.MonadChainFirstReaderK(
MonadChainFirst[R, A, B],
FromReaderIO[R, B],
ma,
f,
)
}
// MonadTapReaderIOK is an alias for MonadChainFirstReaderIOK.
// Executes a ReaderIO-returning side effect while preserving the original value.
//
//go:inline
func MonadTapReaderIOK[R, A, B any](ma ReaderReaderIOResult[R, A], f readerio.Kleisli[R, A, B]) ReaderReaderIOResult[R, A] {
return MonadChainFirstReaderIOK(ma, f)
}
// ChainFirstReaderIOK chains a computation that returns a ReaderIO but preserves the original value.
// This is the curried version that returns an operator.
//
//go:inline
func ChainFirstReaderIOK[R, A, B any](f readerio.Kleisli[R, A, B]) Operator[R, A, A] {
return fromreader.ChainFirstReaderK(
ChainFirst[R, A, B],
FromReaderIO[R, B],
f,
)
}
// TapReaderIOK is an alias for ChainFirstReaderIOK.
// Executes a ReaderIO-returning side effect while preserving the original value.
//
//go:inline
func TapReaderIOK[R, A, B any](f readerio.Kleisli[R, A, B]) Operator[R, A, A] {
return ChainFirstReaderIOK(f)
}
// MonadChainReaderEitherK chains a computation that returns a ReaderEither.
// The ReaderEither is automatically lifted into ReaderReaderIOResult.
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadChainReaderEitherK[R, A, B any](ma ReaderReaderIOResult[R, A], f RE.Kleisli[R, error, A, B]) ReaderReaderIOResult[R, B] {
return fromreader.MonadChainReaderK(
MonadChain[R, A, B],
FromReaderEither[R, B],
ma,
f,
)
}
// ChainReaderEitherK chains a computation that returns a ReaderEither.
// The ReaderEither is automatically lifted into ReaderReaderIOResult.
// This is the curried version that returns an operator.
//
//go:inline
func ChainReaderEitherK[R, A, B any](f RE.Kleisli[R, error, A, B]) Operator[R, A, B] {
return fromreader.ChainReaderK(
Chain[R, A, B],
FromReaderEither[R, B],
f,
)
}
// MonadChainFirstReaderEitherK chains a computation that returns a ReaderEither but preserves the original value.
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadChainFirstReaderEitherK[R, A, B any](ma ReaderReaderIOResult[R, A], f RE.Kleisli[R, error, A, B]) ReaderReaderIOResult[R, A] {
return fromreader.MonadChainFirstReaderK(
MonadChainFirst[R, A, B],
FromReaderEither[R, B],
ma,
f,
)
}
// MonadTapReaderEitherK is an alias for MonadChainFirstReaderEitherK.
// Executes a ReaderEither-returning side effect while preserving the original value.
//
//go:inline
func MonadTapReaderEitherK[R, A, B any](ma ReaderReaderIOResult[R, A], f RE.Kleisli[R, error, A, B]) ReaderReaderIOResult[R, A] {
return MonadChainFirstReaderEitherK(ma, f)
}
// ChainFirstReaderEitherK chains a computation that returns a ReaderEither but preserves the original value.
// This is the curried version that returns an operator.
//
//go:inline
func ChainFirstReaderEitherK[R, A, B any](f RE.Kleisli[R, error, A, B]) Operator[R, A, A] {
return fromreader.ChainFirstReaderK(
ChainFirst[R, A, B],
FromReaderEither[R, B],
f,
)
}
// TapReaderEitherK is an alias for ChainFirstReaderEitherK.
// Executes a ReaderEither-returning side effect while preserving the original value.
//
//go:inline
func TapReaderEitherK[R, A, B any](f RE.Kleisli[R, error, A, B]) Operator[R, A, A] {
return ChainFirstReaderEitherK(f)
}
// ChainReaderOptionK chains a computation that returns a ReaderOption.
// If the option is None, it uses the provided onNone function to generate an error.
// Returns a function that takes a ReaderOption Kleisli and returns an operator.
//
//go:inline
func ChainReaderOptionK[R, A, B any](onNone Lazy[error]) func(readeroption.Kleisli[R, A, B]) Operator[R, A, B] {
return RRIOE.ChainReaderOptionK[R, context.Context, A, B](onNone)
}
// ChainFirstReaderOptionK chains a computation that returns a ReaderOption but preserves the original value.
// If the option is None, it uses the provided onNone function to generate an error.
// Returns a function that takes a ReaderOption Kleisli and returns an operator.
func ChainFirstReaderOptionK[R, A, B any](onNone Lazy[error]) func(readeroption.Kleisli[R, A, B]) Operator[R, A, A] {
return RRIOE.ChainFirstReaderOptionK[R, context.Context, A, B](onNone)
}
// TapReaderOptionK is an alias for ChainFirstReaderOptionK.
// Executes a ReaderOption-returning side effect while preserving the original value.
//
//go:inline
func TapReaderOptionK[R, A, B any](onNone Lazy[error]) func(readeroption.Kleisli[R, A, B]) Operator[R, A, A] {
return ChainFirstReaderOptionK[R, A, B](onNone)
}
// MonadChainIOEitherK chains a computation that returns an IOEither.
// The IOEither is automatically lifted into ReaderReaderIOResult.
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadChainIOEitherK[R, A, B any](ma ReaderReaderIOResult[R, A], f IOE.Kleisli[error, A, B]) ReaderReaderIOResult[R, B] {
return fromioeither.MonadChainIOEitherK(
MonadChain[R, A, B],
FromIOEither[R, B],
ma,
f,
)
}
// ChainIOEitherK chains a computation that returns an IOEither.
// The IOEither is automatically lifted into ReaderReaderIOResult.
// This is the curried version that returns an operator.
//
//go:inline
func ChainIOEitherK[R, A, B any](f IOE.Kleisli[error, A, B]) Operator[R, A, B] {
return fromioeither.ChainIOEitherK(
Chain[R, A, B],
FromIOEither[R, B],
f,
)
}
// MonadChainIOK chains a computation that returns an IO.
// The IO is automatically lifted into ReaderReaderIOResult.
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadChainIOK[R, A, B any](ma ReaderReaderIOResult[R, A], f io.Kleisli[A, B]) ReaderReaderIOResult[R, B] {
return fromio.MonadChainIOK(
MonadChain[R, A, B],
FromIO[R, B],
ma,
f,
)
}
// ChainIOK chains a computation that returns an IO.
// The IO is automatically lifted into ReaderReaderIOResult.
// This is the curried version that returns an operator.
//
//go:inline
func ChainIOK[R, A, B any](f io.Kleisli[A, B]) Operator[R, A, B] {
return fromio.ChainIOK(
Chain[R, A, B],
FromIO[R, B],
f,
)
}
// MonadChainFirstIOK chains a computation that returns an IO but preserves the original value.
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadChainFirstIOK[R, A, B any](ma ReaderReaderIOResult[R, A], f io.Kleisli[A, B]) ReaderReaderIOResult[R, A] {
return fromio.MonadChainFirstIOK(
MonadChain[R, A, A],
MonadMap[R, B, A],
FromIO[R, B],
ma,
f,
)
}
// MonadTapIOK is an alias for MonadChainFirstIOK.
// Executes an IO-returning side effect while preserving the original value.
//
//go:inline
func MonadTapIOK[R, A, B any](ma ReaderReaderIOResult[R, A], f io.Kleisli[A, B]) ReaderReaderIOResult[R, A] {
return MonadChainFirstIOK(ma, f)
}
// ChainFirstIOK chains a computation that returns an IO but preserves the original value.
// This is the curried version that returns an operator.
//
//go:inline
func ChainFirstIOK[R, A, B any](f io.Kleisli[A, B]) Operator[R, A, A] {
return fromio.ChainFirstIOK(
Chain[R, A, A],
Map[R, B, A],
FromIO[R, B],
f,
)
}
// TapIOK is an alias for ChainFirstIOK.
// Executes an IO-returning side effect while preserving the original value.
//
//go:inline
func TapIOK[R, A, B any](f io.Kleisli[A, B]) Operator[R, A, A] {
return ChainFirstIOK[R](f)
}
// ChainOptionK chains a computation that returns an Option.
// If the option is None, it uses the provided onNone function to generate an error.
// Returns a function that takes an Option Kleisli and returns an operator.
//
//go:inline
func ChainOptionK[R, A, B any](onNone Lazy[error]) func(option.Kleisli[A, B]) Operator[R, A, B] {
return fromeither.ChainOptionK(
MonadChain[R, A, B],
FromEither[R, B],
onNone,
)
}
// MonadAp applies a function wrapped in a ReaderReaderIOResult to a value wrapped in a ReaderReaderIOResult (Applicative operation).
// This is the monadic version that takes both computations as parameters.
//
//go:inline
func MonadAp[R, A, B any](fab ReaderReaderIOResult[R, func(A) B], fa ReaderReaderIOResult[R, A]) ReaderReaderIOResult[R, B] {
return readert.MonadAp[
ReaderReaderIOResult[R, A],
ReaderReaderIOResult[R, B],
ReaderReaderIOResult[R, func(A) B], R, A](
RIOE.MonadAp[B, A],
fab,
fa,
)
}
// MonadApSeq is like MonadAp but evaluates effects sequentially.
//
//go:inline
func MonadApSeq[R, A, B any](fab ReaderReaderIOResult[R, func(A) B], fa ReaderReaderIOResult[R, A]) ReaderReaderIOResult[R, B] {
return readert.MonadAp[
ReaderReaderIOResult[R, A],
ReaderReaderIOResult[R, B],
ReaderReaderIOResult[R, func(A) B], R, A](
RIOE.MonadApSeq[B, A],
fab,
fa,
)
}
// MonadApPar is like MonadAp but evaluates effects in parallel.
//
//go:inline
func MonadApPar[R, A, B any](fab ReaderReaderIOResult[R, func(A) B], fa ReaderReaderIOResult[R, A]) ReaderReaderIOResult[R, B] {
return readert.MonadAp[
ReaderReaderIOResult[R, A],
ReaderReaderIOResult[R, B],
ReaderReaderIOResult[R, func(A) B], R, A](
RIOE.MonadApPar[B, A],
fab,
fa,
)
}
// Ap applies a function wrapped in a ReaderReaderIOResult to a value wrapped in a ReaderReaderIOResult.
// This is the curried version that returns an operator.
//
//go:inline
func Ap[B, R, A any](fa ReaderReaderIOResult[R, A]) Operator[R, func(A) B, B] {
return readert.Ap[
ReaderReaderIOResult[R, A],
ReaderReaderIOResult[R, B],
ReaderReaderIOResult[R, func(A) B], R, A](
RIOE.Ap[B, A],
fa,
)
}
// Chain sequences two computations, where the second depends on the result of the first (Monad operation).
// This is the curried version that returns an operator.
//
//go:inline
func Chain[R, A, B any](f Kleisli[R, A, B]) Operator[R, A, B] {
return readert.Chain[ReaderReaderIOResult[R, A]](
RIOE.Chain[A, B],
f,
)
}
// ChainFirst sequences two computations but returns the result of the first.
// This is the curried version that returns an operator.
//
//go:inline
func ChainFirst[R, A, B any](f Kleisli[R, A, B]) Operator[R, A, A] {
return chain.ChainFirst(
Chain[R, A, A],
Map[R, B, A],
f)
}
// Tap is an alias for ChainFirst.
// Executes a side effect while preserving the original value.
//
//go:inline
func Tap[R, A, B any](f Kleisli[R, A, B]) Operator[R, A, A] {
return ChainFirst(f)
}
// Right creates a ReaderReaderIOResult that succeeds with the given value.
// This is the success constructor for the Result type.
//
//go:inline
func Right[R, A any](a A) ReaderReaderIOResult[R, A] {
return RRIOE.Right[R, context.Context, error](a)
}
// Left creates a ReaderReaderIOResult that fails with the given error.
// This is the failure constructor for the Result type.
//
//go:inline
func Left[R, A any](e error) ReaderReaderIOResult[R, A] {
return RRIOE.Left[R, context.Context, A](e)
}
// Of creates a ReaderReaderIOResult that succeeds with the given value (Pointed operation).
// Alias for Right.
//
//go:inline
func Of[R, A any](a A) ReaderReaderIOResult[R, A] {
return RRIOE.Of[R, context.Context, error](a)
}
// Flatten removes one level of nesting from a nested ReaderReaderIOResult.
// Converts ReaderReaderIOResult[R, ReaderReaderIOResult[R, A]] to ReaderReaderIOResult[R, A].
//
//go:inline
func Flatten[R, A any](mma ReaderReaderIOResult[R, ReaderReaderIOResult[R, A]]) ReaderReaderIOResult[R, A] {
return MonadChain(mma, function.Identity[ReaderReaderIOResult[R, A]])
}
// FromEither lifts an Either into a ReaderReaderIOResult.
//
//go:inline
func FromEither[R, A any](t Either[error, A]) ReaderReaderIOResult[R, A] {
return RRIOE.FromEither[R, context.Context](t)
}
// FromResult lifts a Result into a ReaderReaderIOResult.
// Alias for FromEither since Result is Either[error, A].
//
//go:inline
func FromResult[R, A any](t Result[A]) ReaderReaderIOResult[R, A] {
return FromEither[R](t)
}
// RightReader lifts a Reader into a ReaderReaderIOResult as a Right (success) value.
//
//go:inline
func RightReader[R, A any](ma Reader[R, A]) ReaderReaderIOResult[R, A] {
return RRIOE.RightReader[context.Context, error](ma)
}
// LeftReader lifts a Reader that produces an error into a ReaderReaderIOResult as a Left (failure) value.
//
//go:inline
func LeftReader[A, R any](ma Reader[R, error]) ReaderReaderIOResult[R, A] {
return RRIOE.LeftReader[context.Context, A](ma)
}
// FromReader lifts a Reader into a ReaderReaderIOResult.
// The Reader's result is wrapped in a Right (success) value.
//
//go:inline
func FromReader[R, A any](ma Reader[R, A]) ReaderReaderIOResult[R, A] {
return RRIOE.FromReader[context.Context, error](ma)
}
// RightIO lifts an IO into a ReaderReaderIOResult as a Right (success) value.
//
//go:inline
func RightIO[R, A any](ma IO[A]) ReaderReaderIOResult[R, A] {
return RRIOE.RightIO[R, context.Context, error](ma)
}
// LeftIO lifts an IO that produces an error into a ReaderReaderIOResult as a Left (failure) value.
//
//go:inline
func LeftIO[R, A any](ma IO[error]) ReaderReaderIOResult[R, A] {
return RRIOE.LeftIO[R, context.Context, A](ma)
}
// FromIO lifts an IO into a ReaderReaderIOResult.
// The IO's result is wrapped in a Right (success) value.
//
//go:inline
func FromIO[R, A any](ma IO[A]) ReaderReaderIOResult[R, A] {
return RRIOE.FromIO[R, context.Context, error](ma)
}
// FromIOEither lifts an IOEither into a ReaderReaderIOResult.
//
//go:inline
func FromIOEither[R, A any](ma IOEither[error, A]) ReaderReaderIOResult[R, A] {
return RRIOE.FromIOEither[R, context.Context, error](ma)
}
// FromIOResult lifts an IOResult into a ReaderReaderIOResult.
// Alias for FromIOEither since IOResult is IOEither[error, A].
//
//go:inline
func FromIOResult[R, A any](ma IOResult[A]) ReaderReaderIOResult[R, A] {
return RRIOE.FromIOEither[R, context.Context, error](ma)
}
// FromReaderEither lifts a ReaderEither into a ReaderReaderIOResult.
//
//go:inline
func FromReaderEither[R, A any](ma RE.ReaderEither[R, error, A]) ReaderReaderIOResult[R, A] {
return RRIOE.FromReaderEither[R, context.Context, error](ma)
}
// Ask retrieves the outer environment R.
// Returns a ReaderReaderIOResult that succeeds with the environment value.
//
//go:inline
func Ask[R any]() ReaderReaderIOResult[R, R] {
return RRIOE.Ask[R, context.Context, error]()
}
// Asks retrieves a value derived from the outer environment R using the provided function.
//
//go:inline
func Asks[R, A any](r Reader[R, A]) ReaderReaderIOResult[R, A] {
return RRIOE.Asks[context.Context, error](r)
}
// FromOption converts an Option to a ReaderReaderIOResult.
// If the option is None, it uses the provided onNone function to generate an error.
// Returns a function that takes an Option and returns a ReaderReaderIOResult.
//
//go:inline
func FromOption[R, A any](onNone Lazy[error]) func(Option[A]) ReaderReaderIOResult[R, A] {
return RRIOE.FromOption[R, context.Context, A](onNone)
}
// FromPredicate creates a ReaderReaderIOResult from a predicate.
// If the predicate returns true, the value is wrapped in Right.
// If false, onFalse is called to generate an error wrapped in Left.
//
//go:inline
func FromPredicate[R, A any](pred func(A) bool, onFalse func(A) error) Kleisli[R, A, A] {
return RRIOE.FromPredicate[R, context.Context, error](pred, onFalse)
}
// MonadAlt provides alternative/fallback behavior.
// If the first computation fails, it tries the second (lazy-evaluated).
// This is the monadic version that takes both computations as parameters.
//
//go:inline
func MonadAlt[R, A any](first ReaderReaderIOResult[R, A], second Lazy[ReaderReaderIOResult[R, A]]) ReaderReaderIOResult[R, A] {
return RRIOE.MonadAlt(first, second)
}
// Alt provides alternative/fallback behavior.
// If the first computation fails, it tries the second (lazy-evaluated).
// This is the curried version that returns an operator.
//
//go:inline
func Alt[R, A any](second Lazy[ReaderReaderIOResult[R, A]]) Operator[R, A, A] {
return RRIOE.Alt(second)
}
// MonadFlap applies a value to a function wrapped in a ReaderReaderIOResult.
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadFlap[R, B, A any](fab ReaderReaderIOResult[R, func(A) B], a A) ReaderReaderIOResult[R, B] {
return functor.MonadFlap(MonadMap[R, func(A) B, B], fab, a)
}
// Flap applies a value to a function wrapped in a ReaderReaderIOResult.
// This is the curried version that returns an operator.
//
//go:inline
func Flap[R, B, A any](a A) Operator[R, func(A) B, B] {
return functor.Flap(Map[R, func(A) B, B], a)
}
// MonadMapLeft transforms the error value if the computation fails.
// Has no effect if the computation succeeds.
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadMapLeft[R, A any](fa ReaderReaderIOResult[R, A], f Endmorphism[error]) ReaderReaderIOResult[R, A] {
return RRIOE.MonadMapLeft[R, context.Context](fa, f)
}
// MapLeft transforms the error value if the computation fails.
// Has no effect if the computation succeeds.
// This is the curried version that returns an operator.
//
//go:inline
func MapLeft[R, A any](f Endmorphism[error]) Operator[R, A, A] {
return RRIOE.MapLeft[R, context.Context, A](f)
}
// Local modifies the outer environment before passing it to a computation.
// Useful for providing different configurations to sub-computations.
//
//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)
}
// Read provides a specific outer environment value to a computation.
// Converts ReaderReaderIOResult[R, A] to ReaderIOResult[context.Context, A].
//
//go:inline
func Read[A, R any](r R) func(ReaderReaderIOResult[R, A]) ReaderIOResult[context.Context, A] {
return RRIOE.Read[context.Context, error, A](r)
}
// ReadIOEither provides an outer environment value from an IOEither to a computation.
//
//go:inline
func ReadIOEither[A, R any](rio IOEither[error, R]) func(ReaderReaderIOResult[R, A]) ReaderIOResult[context.Context, A] {
return RRIOE.ReadIOEither[A, R, context.Context](rio)
}
// ReadIO provides an outer environment value from an IO to a computation.
//
//go:inline
func ReadIO[A, R any](rio IO[R]) func(ReaderReaderIOResult[R, A]) ReaderIOResult[context.Context, A] {
return RRIOE.ReadIO[context.Context, error, A, R](rio)
}
// MonadChainLeft handles errors by chaining a recovery computation.
// If the computation fails, the error is passed to f for recovery.
// This is the monadic version that takes the computation as the first parameter.
//
//go:inline
func MonadChainLeft[R, A any](fa ReaderReaderIOResult[R, A], f Kleisli[R, error, A]) ReaderReaderIOResult[R, A] {
return RRIOE.MonadChainLeft[R, context.Context, error, error, A](fa, f)
}
// ChainLeft handles errors by chaining a recovery computation.
// If the computation fails, the error is passed to f for recovery.
// This is the curried version that returns an operator.
//
//go:inline
func ChainLeft[R, A any](f Kleisli[R, error, A]) func(ReaderReaderIOResult[R, A]) ReaderReaderIOResult[R, A] {
return RRIOE.ChainLeft[R, context.Context, error, error, A](f)
}
// Delay adds a time delay before executing the computation.
// Useful for rate limiting, retry backoff, or scheduled execution.
//
//go:inline
func Delay[R, A any](delay time.Duration) Operator[R, A, A] {
return reader.Map[R](RIOE.Delay[A](delay))
}

View File

@@ -0,0 +1,718 @@
// 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 (
"errors"
"testing"
"time"
"github.com/IBM/fp-go/v2/either"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioeither"
"github.com/IBM/fp-go/v2/ioresult"
N "github.com/IBM/fp-go/v2/number"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/reader"
RE "github.com/IBM/fp-go/v2/readereither"
"github.com/IBM/fp-go/v2/readerio"
"github.com/IBM/fp-go/v2/readeroption"
"github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
func TestOf(t *testing.T) {
computation := Of[AppConfig](42)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestRight(t *testing.T) {
computation := Right[AppConfig](42)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestLeft(t *testing.T) {
err := errors.New("test error")
computation := Left[AppConfig, int](err)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Left[int](err), outcome)
}
func TestMonadMap(t *testing.T) {
computation := MonadMap(
Of[AppConfig](21),
N.Mul(2),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestMap(t *testing.T) {
computation := F.Pipe1(
Of[AppConfig](21),
Map[AppConfig](N.Mul(2)),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestMonadMapTo(t *testing.T) {
computation := MonadMapTo(Of[AppConfig](21), 99)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(99), outcome)
}
func TestMapTo(t *testing.T) {
computation := F.Pipe1(
Of[AppConfig](21),
MapTo[AppConfig, int](99),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(99), outcome)
}
func TestMonadChain(t *testing.T) {
computation := MonadChain(
Of[AppConfig](21),
func(n int) ReaderReaderIOResult[AppConfig, int] {
return Of[AppConfig](n * 2)
},
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestChain(t *testing.T) {
computation := F.Pipe1(
Of[AppConfig](21),
Chain[AppConfig](func(n int) ReaderReaderIOResult[AppConfig, int] {
return Of[AppConfig](n * 2)
}),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestMonadChainFirst(t *testing.T) {
sideEffect := 0
computation := MonadChainFirst(
Of[AppConfig](42),
func(n int) ReaderReaderIOResult[AppConfig, string] {
sideEffect = n
return Of[AppConfig]("ignored")
},
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
assert.Equal(t, 42, sideEffect)
}
func TestChainFirst(t *testing.T) {
sideEffect := 0
computation := F.Pipe1(
Of[AppConfig](42),
ChainFirst[AppConfig](func(n int) ReaderReaderIOResult[AppConfig, string] {
sideEffect = n
return Of[AppConfig]("ignored")
}),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
assert.Equal(t, 42, sideEffect)
}
func TestTap(t *testing.T) {
sideEffect := 0
computation := F.Pipe1(
Of[AppConfig](42),
Tap[AppConfig](func(n int) ReaderReaderIOResult[AppConfig, string] {
sideEffect = n
return Of[AppConfig]("ignored")
}),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
assert.Equal(t, 42, sideEffect)
}
func TestFlatten(t *testing.T) {
nested := Of[AppConfig](Of[AppConfig](42))
computation := Flatten(nested)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestFromEither(t *testing.T) {
t.Run("right", func(t *testing.T) {
computation := FromEither[AppConfig](either.Right[error](42))
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
})
t.Run("left", func(t *testing.T) {
err := errors.New("test error")
computation := FromEither[AppConfig, int](either.Left[int](err))
outcome := computation(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
})
}
func TestFromResult(t *testing.T) {
t.Run("success", func(t *testing.T) {
computation := FromResult[AppConfig](result.Of(42))
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
})
t.Run("error", func(t *testing.T) {
err := errors.New("test error")
computation := FromResult[AppConfig](result.Left[int](err))
outcome := computation(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
})
}
func TestFromReader(t *testing.T) {
computation := FromReader[AppConfig](func(cfg AppConfig) int {
return len(cfg.DatabaseURL)
})
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(20), outcome) // len("postgres://localhost")
}
func TestRightReader(t *testing.T) {
computation := RightReader[AppConfig](func(cfg AppConfig) int {
return len(cfg.LogLevel)
})
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(4), outcome) // len("info")
}
func TestLeftReader(t *testing.T) {
err := errors.New("test error")
computation := LeftReader[int](func(cfg AppConfig) error {
return err
})
outcome := computation(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
}
func TestFromIO(t *testing.T) {
computation := FromIO[AppConfig](func() int { return 42 })
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestRightIO(t *testing.T) {
computation := RightIO[AppConfig](func() int { return 42 })
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestLeftIO(t *testing.T) {
err := errors.New("test error")
computation := LeftIO[AppConfig, int](func() error { return err })
outcome := computation(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
}
func TestFromIOEither(t *testing.T) {
t.Run("right", func(t *testing.T) {
computation := FromIOEither[AppConfig](ioeither.Of[error](42))
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
})
t.Run("left", func(t *testing.T) {
err := errors.New("test error")
computation := FromIOEither[AppConfig, int](ioeither.Left[int](err))
outcome := computation(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
})
}
func TestFromIOResult(t *testing.T) {
t.Run("success", func(t *testing.T) {
computation := FromIOResult[AppConfig](func() result.Result[int] {
return result.Of(42)
})
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
})
t.Run("error", func(t *testing.T) {
err := errors.New("test error")
computation := FromIOResult[AppConfig](func() result.Result[int] {
return result.Left[int](err)
})
outcome := computation(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
})
}
func TestFromReaderIO(t *testing.T) {
computation := FromReaderIO[AppConfig](func(cfg AppConfig) io.IO[int] {
return func() int { return len(cfg.DatabaseURL) }
})
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(20), outcome)
}
func TestRightReaderIO(t *testing.T) {
computation := RightReaderIO[AppConfig](func(cfg AppConfig) io.IO[int] {
return func() int { return len(cfg.LogLevel) }
})
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(4), outcome)
}
func TestLeftReaderIO(t *testing.T) {
err := errors.New("test error")
computation := LeftReaderIO[int](func(cfg AppConfig) io.IO[error] {
return func() error { return err }
})
outcome := computation(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
}
func TestFromReaderEither(t *testing.T) {
t.Run("right", func(t *testing.T) {
computation := FromReaderEither[AppConfig](func(cfg AppConfig) either.Either[error, int] {
return either.Right[error](len(cfg.DatabaseURL))
})
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(20), outcome)
})
t.Run("left", func(t *testing.T) {
err := errors.New("test error")
computation := FromReaderEither[AppConfig, int](func(cfg AppConfig) either.Either[error, int] {
return either.Left[int](err)
})
outcome := computation(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
})
}
func TestAsk(t *testing.T) {
computation := Ask[AppConfig]()
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(defaultConfig), outcome)
}
func TestAsks(t *testing.T) {
computation := Asks(func(cfg AppConfig) string {
return cfg.DatabaseURL
})
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of("postgres://localhost"), outcome)
}
func TestFromOption(t *testing.T) {
err := errors.New("none error")
t.Run("some", func(t *testing.T) {
computation := FromOption[AppConfig, int](func() error { return err })(option.Some(42))
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
})
t.Run("none", func(t *testing.T) {
computation := FromOption[AppConfig, int](func() error { return err })(option.None[int]())
outcome := computation(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
})
}
func TestFromPredicate(t *testing.T) {
isPositive := func(n int) bool { return n > 0 }
onFalse := func(n int) error { return errors.New("not positive") }
t.Run("predicate true", func(t *testing.T) {
computation := FromPredicate[AppConfig](isPositive, onFalse)(42)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
})
t.Run("predicate false", func(t *testing.T) {
computation := FromPredicate[AppConfig](isPositive, onFalse)(-5)
outcome := computation(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
})
}
func TestMonadAlt(t *testing.T) {
err := errors.New("first error")
t.Run("first succeeds", func(t *testing.T) {
first := Of[AppConfig](42)
second := func() ReaderReaderIOResult[AppConfig, int] {
return Of[AppConfig](99)
}
computation := MonadAlt(first, second)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
})
t.Run("first fails, second succeeds", func(t *testing.T) {
first := Left[AppConfig, int](err)
second := func() ReaderReaderIOResult[AppConfig, int] {
return Of[AppConfig](99)
}
computation := MonadAlt(first, second)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(99), outcome)
})
t.Run("both fail", func(t *testing.T) {
first := Left[AppConfig, int](err)
second := func() ReaderReaderIOResult[AppConfig, int] {
return Left[AppConfig, int](errors.New("second error"))
}
computation := MonadAlt(first, second)
outcome := computation(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
})
}
func TestAlt(t *testing.T) {
err := errors.New("first error")
computation := F.Pipe1(
Left[AppConfig, int](err),
Alt[AppConfig](func() ReaderReaderIOResult[AppConfig, int] {
return Of[AppConfig](99)
}),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(99), outcome)
}
func TestMonadFlap(t *testing.T) {
fab := Of[AppConfig](N.Mul(2))
computation := MonadFlap(fab, 21)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestFlap(t *testing.T) {
computation := F.Pipe1(
Of[AppConfig](N.Mul(2)),
Flap[AppConfig, int](21),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestMonadMapLeft(t *testing.T) {
err := errors.New("original error")
computation := MonadMapLeft(
Left[AppConfig, int](err),
func(e error) error { return errors.New("mapped: " + e.Error()) },
)
outcome := computation(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
result.Fold(
func(e error) any {
assert.Contains(t, e.Error(), "mapped:")
return nil
},
func(v int) any {
t.Fatal("should be left")
return nil
},
)(outcome)
}
func TestMapLeft(t *testing.T) {
err := errors.New("original error")
computation := F.Pipe1(
Left[AppConfig, int](err),
MapLeft[AppConfig, int](func(e error) error {
return errors.New("mapped: " + e.Error())
}),
)
outcome := computation(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
}
func TestLocal(t *testing.T) {
type OtherConfig struct {
URL string
}
computation := F.Pipe1(
Asks(func(cfg AppConfig) string {
return cfg.DatabaseURL
}),
Local[string, AppConfig, OtherConfig](func(other OtherConfig) AppConfig {
return AppConfig{DatabaseURL: other.URL, LogLevel: "debug"}
}),
)
outcome := computation(OtherConfig{URL: "test-url"})(t.Context())()
assert.Equal(t, result.Of("test-url"), outcome)
}
func TestRead(t *testing.T) {
computation := Asks(func(cfg AppConfig) string {
return cfg.DatabaseURL
})
reader := Read[string](defaultConfig)
outcome := reader(computation)(t.Context())()
assert.Equal(t, result.Of("postgres://localhost"), outcome)
}
func TestReadIOEither(t *testing.T) {
computation := Asks(func(cfg AppConfig) string {
return cfg.DatabaseURL
})
rio := ioeither.Of[error](defaultConfig)
reader := ReadIOEither[string](rio)
outcome := reader(computation)(t.Context())()
assert.Equal(t, result.Of("postgres://localhost"), outcome)
}
func TestReadIO(t *testing.T) {
computation := Asks(func(cfg AppConfig) string {
return cfg.DatabaseURL
})
rio := func() AppConfig { return defaultConfig }
reader := ReadIO[string](rio)
outcome := reader(computation)(t.Context())()
assert.Equal(t, result.Of("postgres://localhost"), outcome)
}
func TestMonadChainLeft(t *testing.T) {
err := errors.New("original error")
computation := MonadChainLeft(
Left[AppConfig, int](err),
func(e error) ReaderReaderIOResult[AppConfig, int] {
return Of[AppConfig](99)
},
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(99), outcome)
}
func TestChainLeft(t *testing.T) {
err := errors.New("original error")
computation := F.Pipe1(
Left[AppConfig, int](err),
ChainLeft[AppConfig](func(e error) ReaderReaderIOResult[AppConfig, int] {
return Of[AppConfig](99)
}),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(99), outcome)
}
func TestDelay(t *testing.T) {
start := time.Now()
computation := F.Pipe1(
Of[AppConfig](42),
Delay[AppConfig, int](50*time.Millisecond),
)
outcome := computation(defaultConfig)(t.Context())()
elapsed := time.Since(start)
assert.Equal(t, result.Of(42), outcome)
assert.GreaterOrEqual(t, elapsed, 50*time.Millisecond)
}
func TestChainEitherK(t *testing.T) {
computation := F.Pipe1(
Of[AppConfig](21),
ChainEitherK[AppConfig](func(n int) either.Either[error, int] {
return either.Right[error](n * 2)
}),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestChainReaderK(t *testing.T) {
computation := F.Pipe1(
Of[AppConfig](10),
ChainReaderK[AppConfig](func(n int) reader.Reader[AppConfig, int] {
return func(cfg AppConfig) int {
return n + len(cfg.LogLevel)
}
}),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(14), outcome) // 10 + len("info")
}
func TestChainReaderIOK(t *testing.T) {
computation := F.Pipe1(
Of[AppConfig](10),
ChainReaderIOK[AppConfig](func(n int) readerio.ReaderIO[AppConfig, int] {
return func(cfg AppConfig) io.IO[int] {
return func() int {
return n + len(cfg.DatabaseURL)
}
}
}),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(30), outcome) // 10 + 20
}
func TestChainReaderEitherK(t *testing.T) {
computation := F.Pipe1(
Of[AppConfig](10),
ChainReaderEitherK[AppConfig](func(n int) RE.ReaderEither[AppConfig, error, int] {
return func(cfg AppConfig) either.Either[error, int] {
return either.Right[error](n + len(cfg.LogLevel))
}
}),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(14), outcome)
}
func TestChainReaderOptionK(t *testing.T) {
onNone := func() error { return errors.New("none") }
t.Run("some", func(t *testing.T) {
computation := F.Pipe1(
Of[AppConfig](10),
ChainReaderOptionK[AppConfig, int, int](onNone)(func(n int) readeroption.ReaderOption[AppConfig, int] {
return func(cfg AppConfig) option.Option[int] {
return option.Some(n + len(cfg.LogLevel))
}
}),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(14), outcome)
})
t.Run("none", func(t *testing.T) {
computation := F.Pipe1(
Of[AppConfig](10),
ChainReaderOptionK[AppConfig, int, int](onNone)(func(n int) readeroption.ReaderOption[AppConfig, int] {
return func(cfg AppConfig) option.Option[int] {
return option.None[int]()
}
}),
)
outcome := computation(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
})
}
func TestChainIOEitherK(t *testing.T) {
computation := F.Pipe1(
Of[AppConfig](21),
ChainIOEitherK[AppConfig](func(n int) ioeither.IOEither[error, int] {
return ioeither.Of[error](n * 2)
}),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestChainIOK(t *testing.T) {
computation := F.Pipe1(
Of[AppConfig](21),
ChainIOK[AppConfig](func(n int) io.IO[int] {
return func() int { return n * 2 }
}),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestChainOptionK(t *testing.T) {
onNone := func() error { return errors.New("none") }
t.Run("some", func(t *testing.T) {
computation := F.Pipe1(
Of[AppConfig](21),
ChainOptionK[AppConfig, int, int](onNone)(func(n int) option.Option[int] {
return option.Some(n * 2)
}),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
})
t.Run("none", func(t *testing.T) {
computation := F.Pipe1(
Of[AppConfig](21),
ChainOptionK[AppConfig, int, int](onNone)(func(n int) option.Option[int] {
return option.None[int]()
}),
)
outcome := computation(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
})
}
func TestFromReaderIOResult(t *testing.T) {
computation := FromReaderIOResult[AppConfig](func(cfg AppConfig) ioresult.IOResult[int] {
return func() result.Result[int] {
return result.Of(len(cfg.DatabaseURL))
}
})
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(20), outcome)
}
func TestFromReaderOption(t *testing.T) {
onNone := func() error { return errors.New("none") }
t.Run("some", func(t *testing.T) {
computation := FromReaderOption[AppConfig, int](onNone)(func(cfg AppConfig) option.Option[int] {
return option.Some(len(cfg.DatabaseURL))
})
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(20), outcome)
})
t.Run("none", func(t *testing.T) {
computation := FromReaderOption[AppConfig, int](onNone)(func(cfg AppConfig) option.Option[int] {
return option.None[int]()
})
outcome := computation(defaultConfig)(t.Context())()
assert.True(t, result.IsLeft(outcome))
})
}
func TestMonadAp(t *testing.T) {
fab := Of[AppConfig](N.Mul(2))
fa := Of[AppConfig](21)
computation := MonadAp(fab, fa)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}
func TestAp(t *testing.T) {
fa := Of[AppConfig](21)
computation := F.Pipe1(
Of[AppConfig](N.Mul(2)),
Ap[int, AppConfig](fa),
)
outcome := computation(defaultConfig)(t.Context())()
assert.Equal(t, result.Of(42), outcome)
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) 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"
RIOE "github.com/IBM/fp-go/v2/context/readerioresult"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/retry"
)
// Retrying executes an action with automatic retry logic based on a retry policy.
// It retries the action when it fails or when the check predicate returns false.
//
// This function is useful for handling transient failures in operations like:
// - Network requests that may temporarily fail
// - Database operations that may encounter locks
// - External service calls that may be temporarily unavailable
//
// Parameters:
// - policy: Defines the retry behavior (number of retries, delays, backoff strategy)
// - action: The computation to retry, receives retry status information
// - check: Predicate to determine if the result should trigger a retry (returns true to continue, false to retry)
//
// The action receives a retry.RetryStatus that contains:
// - IterNumber: Current iteration number (0-based)
// - CumulativeDelay: Total delay accumulated so far
// - PreviousDelay: Delay from the previous iteration
//
// Returns:
// - A ReaderReaderIOResult that executes the action with retry logic
//
// Example:
//
// import (
// "errors"
// "time"
// "github.com/IBM/fp-go/v2/retry"
// )
//
// type Config struct {
// MaxRetries int
// BaseDelay time.Duration
// }
//
// // Create a retry policy with exponential backoff
// policy := retry.ExponentialBackoff(100*time.Millisecond, 5*time.Second)
// policy = retry.LimitRetries(3, policy)
//
// // Action that may fail transiently
// action := func(status retry.RetryStatus) ReaderReaderIOResult[Config, string] {
// return func(cfg Config) ReaderIOResult[context.Context, string] {
// return func(ctx context.Context) IOResult[string] {
// return func() Either[error, string] {
// // Simulate transient failure
// if status.IterNumber < 2 {
// return either.Left[string](errors.New("transient error"))
// }
// return either.Right[error]("success")
// }
// }
// }
// }
//
// // Check if we should retry (retry on any error)
// check := func(result Result[string]) bool {
// return either.IsRight(result) // Continue only if successful
// }
//
// // Execute with retry logic
// result := Retrying(policy, action, check)
//
//go:inline
func Retrying[R, A any](
policy retry.RetryPolicy,
action Kleisli[R, retry.RetryStatus, A],
check Predicate[Result[A]],
) ReaderReaderIOResult[R, A] {
// get an implementation for the types
return func(r R) ReaderIOResult[context.Context, A] {
return RIOE.Retrying(policy, F.Pipe1(action, reader.Map[retry.RetryStatus](reader.Read[ReaderIOResult[context.Context, A]](r))), check)
}
}

View File

@@ -0,0 +1,265 @@
// Copyright (c) 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"
"testing"
"time"
"github.com/IBM/fp-go/v2/result"
"github.com/IBM/fp-go/v2/retry"
"github.com/stretchr/testify/assert"
)
func TestRetryingSuccess(t *testing.T) {
cfg := defaultConfig
ctx := t.Context()
attempts := 0
action := func(status retry.RetryStatus) ReaderReaderIOResult[AppConfig, int] {
return func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
attempts++
if attempts < 3 {
return result.Left[int](errors.New("temporary error"))
}
return result.Of(42)
}
}
}
}
check := func(r Result[int]) bool {
return result.IsLeft(r)
}
policy := retry.LimitRetries(5)
computation := Retrying(policy, action, check)
outcome := computation(cfg)(ctx)()
assert.Equal(t, result.Of(42), outcome)
assert.Equal(t, 3, attempts)
}
func TestRetryingFailureExhaustsRetries(t *testing.T) {
cfg := defaultConfig
ctx := t.Context()
attempts := 0
testErr := errors.New("persistent error")
action := func(status retry.RetryStatus) ReaderReaderIOResult[AppConfig, int] {
return func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
attempts++
return result.Left[int](testErr)
}
}
}
}
check := func(r Result[int]) bool {
return result.IsLeft(r)
}
policy := retry.LimitRetries(3)
computation := Retrying(policy, action, check)
outcome := computation(cfg)(ctx)()
assert.True(t, result.IsLeft(outcome))
assert.Equal(t, 4, attempts) // Initial attempt + 3 retries
}
func TestRetryingNoRetryNeeded(t *testing.T) {
cfg := defaultConfig
ctx := t.Context()
attempts := 0
action := func(status retry.RetryStatus) ReaderReaderIOResult[AppConfig, int] {
return func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
attempts++
return result.Of(42)
}
}
}
}
check := func(r Result[int]) bool {
return result.IsLeft(r)
}
policy := retry.LimitRetries(5)
computation := Retrying(policy, action, check)
outcome := computation(cfg)(ctx)()
assert.Equal(t, result.Of(42), outcome)
assert.Equal(t, 1, attempts) // Only initial attempt
}
func TestRetryingWithDelay(t *testing.T) {
cfg := defaultConfig
ctx := t.Context()
attempts := 0
start := time.Now()
action := func(status retry.RetryStatus) ReaderReaderIOResult[AppConfig, int] {
return func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
attempts++
if attempts < 2 {
return result.Left[int](errors.New("temporary error"))
}
return result.Of(42)
}
}
}
}
check := func(r Result[int]) bool {
return result.IsLeft(r)
}
// Policy with delay
policy := retry.CapDelay(
100*time.Millisecond,
retry.LimitRetries(3),
)
computation := Retrying(policy, action, check)
outcome := computation(cfg)(ctx)()
elapsed := time.Since(start)
assert.Equal(t, result.Of(42), outcome)
assert.Equal(t, 2, attempts)
// The delay might be very short in tests, so just check it completed
_ = elapsed
}
func TestRetryingAccessesConfig(t *testing.T) {
cfg := AppConfig{DatabaseURL: "test-db", LogLevel: "debug"}
ctx := t.Context()
attempts := 0
var capturedURL string
action := func(status retry.RetryStatus) ReaderReaderIOResult[AppConfig, int] {
return func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
attempts++
capturedURL = c.DatabaseURL
if attempts < 2 {
return result.Left[int](errors.New("temporary error"))
}
return result.Of(len(c.DatabaseURL))
}
}
}
}
check := func(r Result[int]) bool {
return result.IsLeft(r)
}
policy := retry.LimitRetries(3)
computation := Retrying(policy, action, check)
outcome := computation(cfg)(ctx)()
assert.Equal(t, result.Of(7), outcome) // len("test-db")
assert.Equal(t, "test-db", capturedURL)
assert.Equal(t, 2, attempts)
}
func TestRetryingWithExponentialBackoff(t *testing.T) {
cfg := defaultConfig
ctx := t.Context()
attempts := 0
action := func(status retry.RetryStatus) ReaderReaderIOResult[AppConfig, int] {
return func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
attempts++
if attempts < 3 {
return result.Left[int](errors.New("temporary error"))
}
return result.Of(42)
}
}
}
}
check := func(r Result[int]) bool {
return result.IsLeft(r)
}
// Exponential backoff policy
policy := retry.CapDelay(
200*time.Millisecond,
retry.LimitRetries(5),
)
computation := Retrying(policy, action, check)
outcome := computation(cfg)(ctx)()
assert.Equal(t, result.Of(42), outcome)
assert.Equal(t, 3, attempts)
}
func TestRetryingCheckFunction(t *testing.T) {
cfg := defaultConfig
ctx := t.Context()
attempts := 0
action := func(status retry.RetryStatus) ReaderReaderIOResult[AppConfig, int] {
return func(c AppConfig) ReaderIOResult[context.Context, int] {
return func(ctx context.Context) IOResult[int] {
return func() Result[int] {
attempts++
return result.Of(attempts)
}
}
}
}
// Retry while result is less than 3
check := func(r Result[int]) bool {
return result.Fold(
func(error) bool { return true },
func(v int) bool { return v < 3 },
)(r)
}
policy := retry.LimitRetries(10)
computation := Retrying(policy, action, check)
outcome := computation(cfg)(ctx)()
assert.Equal(t, result.Of(3), outcome)
assert.Equal(t, 3, attempts)
}

View File

@@ -0,0 +1,154 @@
// Copyright (c) 2024 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"
"github.com/IBM/fp-go/v2/either"
"github.com/IBM/fp-go/v2/endomorphism"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioeither"
"github.com/IBM/fp-go/v2/ioresult"
"github.com/IBM/fp-go/v2/lazy"
"github.com/IBM/fp-go/v2/optics/lens"
"github.com/IBM/fp-go/v2/optics/traversal/result"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/predicate"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/readerio"
"github.com/IBM/fp-go/v2/readerioresult"
"github.com/IBM/fp-go/v2/readeroption"
"github.com/IBM/fp-go/v2/readerreaderioeither"
"github.com/IBM/fp-go/v2/tailrec"
)
type (
// Option represents an optional value that may or may not be present.
// It's an alias for option.Option[A].
Option[A any] = option.Option[A]
// Lazy represents a lazily evaluated computation that produces a value of type A.
// It's an alias for lazy.Lazy[A].
Lazy[A any] = lazy.Lazy[A]
// Reader represents a computation that depends on an environment of type R
// and produces a value of type A.
// It's an alias for reader.Reader[R, A].
Reader[R, A any] = reader.Reader[R, A]
// ReaderOption represents a computation that depends on an environment of type R
// and produces an optional value of type A.
// It's an alias for readeroption.ReaderOption[R, A].
ReaderOption[R, A any] = readeroption.ReaderOption[R, A]
// ReaderIO represents a computation that depends on an environment of type R
// and performs side effects to produce a value of type A.
// It's an alias for readerio.ReaderIO[R, A].
ReaderIO[R, A any] = readerio.ReaderIO[R, A]
// ReaderIOResult represents a computation that depends on an environment of type R,
// performs side effects, and may fail with an error.
// It's an alias for readerioresult.ReaderIOResult[R, A].
ReaderIOResult[R, A any] = readerioresult.ReaderIOResult[R, A]
// Either represents a value that can be one of two types: Left (error) or Right (success).
// It's an alias for either.Either[E, A].
Either[E, A any] = either.Either[E, A]
// Result is a specialized Either with error as the left type.
// It's an alias for result.Result[A] which is Either[error, A].
Result[A any] = result.Result[A]
// IOEither represents a side-effecting computation that may fail with an error of type E
// or succeed with a value of type A.
// It's an alias for ioeither.IOEither[E, A].
IOEither[E, A any] = ioeither.IOEither[E, A]
// IOResult represents a side-effecting computation that may fail with an error
// or succeed with a value of type A.
// It's an alias for ioresult.IOResult[A] which is IOEither[error, A].
IOResult[A any] = ioresult.IOResult[A]
// IO represents a side-effecting computation that produces a value of type A.
// It's an alias for io.IO[A].
IO[A any] = io.IO[A]
// ReaderReaderIOEither is the base monad transformer that combines:
// - Reader[R, ...] for outer dependency injection
// - Reader[C, ...] for inner dependency injection (typically context.Context)
// - IO for side effects
// - Either[E, A] for error handling
// It's an alias for readerreaderioeither.ReaderReaderIOEither[R, C, E, A].
ReaderReaderIOEither[R, C, E, A any] = readerreaderioeither.ReaderReaderIOEither[R, C, E, A]
// ReaderReaderIOResult is the main type of this package, specializing ReaderReaderIOEither
// with context.Context as the inner reader type and error as the error type.
//
// Type structure:
// ReaderReaderIOResult[R, A] = R -> context.Context -> IO[Either[error, A]]
//
// This represents a computation that:
// 1. Depends on an outer environment of type R (e.g., application config)
// 2. Depends on a context.Context for cancellation and request-scoped values
// 3. Performs side effects (IO)
// 4. May fail with an error or succeed with a value of type A
//
// This is the primary type used throughout the package for composing
// context-aware, effectful computations with error handling.
ReaderReaderIOResult[R, A any] = ReaderReaderIOEither[R, context.Context, error, A]
// Kleisli represents a function from A to a monadic value ReaderReaderIOResult[R, B].
// It's used for composing monadic functions using Kleisli composition.
//
// Type structure:
// Kleisli[R, A, B] = A -> ReaderReaderIOResult[R, B]
//
// Kleisli arrows can be composed using Chain operations to build complex
// data transformation pipelines.
Kleisli[R, A, B any] = Reader[A, ReaderReaderIOResult[R, B]]
// Operator is a specialized Kleisli arrow that operates on monadic values.
// It takes a ReaderReaderIOResult[R, A] and produces a ReaderReaderIOResult[R, B].
//
// Type structure:
// Operator[R, A, B] = ReaderReaderIOResult[R, A] -> ReaderReaderIOResult[R, B]
//
// Operators are useful for transforming monadic computations, such as
// adding retry logic, logging, or error recovery.
Operator[R, A, B any] = Kleisli[R, ReaderReaderIOResult[R, A], B]
// Lens represents an optic for focusing on a part of a data structure.
// It provides a way to get and set a field T within a structure S.
// It's an alias for lens.Lens[S, T].
Lens[S, T any] = lens.Lens[S, T]
// Trampoline is used for stack-safe recursion through tail call optimization.
// It's an alias for tailrec.Trampoline[L, B].
Trampoline[L, B any] = tailrec.Trampoline[L, B]
// Predicate represents a function that tests whether a value of type A
// satisfies some condition.
// It's an alias for predicate.Predicate[A].
Predicate[A any] = predicate.Predicate[A]
// Endmorphism represents a function from type A to type A.
// It's an alias for endomorphism.Endomorphism[A].
Endmorphism[A any] = endomorphism.Endomorphism[A]
Void = function.Void
)

View File

@@ -0,0 +1,246 @@
# Why Combining IO Operations with ReaderResult Makes Sense
## Overview
The `context/readerresult` package provides functions that combine IO operations (like `FromIO`, `ChainIOK`, `TapIOK`, etc.) with ReaderResult computations. This document explains why this combination is natural and appropriate, despite IO operations being side-effectful.
## Key Insight: ReaderResult is Already Effectful
**IMPORTANT**: Unlike pure functional Reader monads, `ReaderResult[A]` in this package is **already side-effectful** because it depends on `context.Context`.
### Why context.Context is Effectful
The `context.Context` type in Go is inherently effectful because it:
1. **Can be cancelled**: `ctx.Done()` returns a channel that closes when the context is cancelled
2. **Has deadlines**: `ctx.Deadline()` returns a time when the context expires
3. **Carries values**: `ctx.Value(key)` retrieves request-scoped values
4. **Propagates signals**: Cancellation signals propagate across goroutines
5. **Has observable state**: The context's state can change over time (e.g., when cancelled)
### Type Definition
```go
type ReaderResult[A any] = func(context.Context) Result[A]
```
This is **not** a pure function because:
- The behavior can change based on the context's state
- The context can be cancelled during execution
- The context carries mutable, observable state
## Comparison with Pure Reader Monads
### Pure Reader (from `readerresult` package)
```go
type ReaderResult[R, A any] = func(R) Result[A]
```
- `R` can be any type (config, state, etc.)
- The function is **pure** if `R` is immutable
- No side effects unless explicitly introduced
### Effectful Reader (from `context/readerresult` package)
```go
type ReaderResult[A any] = func(context.Context) Result[A]
```
- Always depends on `context.Context`
- **Inherently effectful** due to context's nature
- Side effects are part of the design
## Why IO Operations Fit Naturally
Since `ReaderResult` is already effectful, combining it with IO operations is a natural fit:
### 1. Both Represent Side Effects
```go
// IO operation - side effectful
io := func() int {
fmt.Println("Performing IO")
return 42
}
// ReaderResult - also side effectful (depends on context)
rr := func(ctx context.Context) Result[int] {
// Can check if context is cancelled (side effect)
if ctx.Err() != nil {
return result.Error[int](ctx.Err())
}
return result.Of(42)
}
// Combining them is natural
combined := FromIO(io)
```
### 2. Context-Aware IO Operations
The combination allows IO operations to respect context cancellation:
```go
// IO operation that should respect cancellation
readFile := func(path string) ReaderResult[[]byte] {
return func(ctx context.Context) Result[[]byte] {
// Check cancellation before expensive IO
if ctx.Err() != nil {
return result.Error[[]byte](ctx.Err())
}
// Perform IO operation
data, err := os.ReadFile(path)
if err != nil {
return result.Error[[]byte](err)
}
return result.Of(data)
}
}
```
### 3. Practical Use Cases
#### Logging with Side Effects
```go
// Log to external system (IO operation)
logMetric := func(value int) func() string {
return func() string {
// Side effect: write to metrics system
metrics.Record("value", value)
return "logged"
}
}
// Use with ReaderResult
pipeline := F.Pipe1(
readerresult.Of(42),
readerresult.TapIOK(logMetric),
)
```
#### Database Operations
```go
// Database query (IO operation with context)
queryDB := func(id int) ReaderResult[User] {
return func(ctx context.Context) Result[User] {
// Context used for timeout/cancellation
user, err := db.QueryContext(ctx, "SELECT * FROM users WHERE id = ?", id)
if err != nil {
return result.Error[User](err)
}
return result.Of(user)
}
}
// Chain with other operations
pipeline := F.Pipe2(
readerresult.Of(123),
readerresult.Chain(queryDB),
readerresult.TapIOK(func(user User) func() string {
return func() string {
log.Printf("Retrieved user: %s", user.Name)
return "logged"
}
}),
)
```
#### HTTP Requests
```go
// HTTP request (IO operation)
fetchData := func(url string) ReaderResult[Response] {
return func(ctx context.Context) Result[Response] {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return result.Error[Response](err)
}
return result.Of(resp)
}
}
```
## Functions That Combine IO with ReaderResult
### Lifting Functions
- **`FromIO[A]`**: Lifts a pure IO computation into ReaderResult
- **`FromIOResult[A]`**: Lifts an IOResult (IO with error handling) into ReaderResult
### Chaining Functions
- **`ChainIOK[A, B]`**: Sequences a ReaderResult with an IO computation
- **`ChainIOEitherK[A, B]`**: Sequences with an IOResult computation
- **`ChainIOResultK[A, B]`**: Alias for ChainIOEitherK
### Tapping Functions (Side Effects)
- **`TapIOK[A, B]`**: Executes IO for side effects, preserves original value
- **`ChainFirstIOK[A, B]`**: Same as TapIOK
- **`MonadTapIOK[A, B]`**: Monadic version of TapIOK
- **`MonadChainFirstIOK[A, B]`**: Monadic version of ChainFirstIOK
### Error Handling with IO
- **`TapLeftIOK[A, B]`**: Executes IO on error for side effects (logging, metrics)
- **`ChainFirstLeftIOK[A, B]`**: Same as TapLeftIOK
### Reading Context from IO
- **`ReadIO[A]`**: Executes ReaderResult with context from IO
- **`ReadIOEither[A]`**: Executes with context from IOResult
- **`ReadIOResult[A]`**: Alias for ReadIOEither
## Design Philosophy
### Embrace Effectfulness
Rather than trying to maintain purity (which is impossible with `context.Context`), this package embraces the effectful nature of Go's context and provides tools to work with it safely and composably.
### Composition Over Isolation
The package allows you to compose effectful operations (ReaderResult + IO) in a type-safe, functional way, rather than isolating them.
### Practical Go Idioms
This approach aligns with Go's pragmatic philosophy:
- Context is used everywhere in Go for cancellation and timeouts
- IO operations are common and necessary
- Combining them in a type-safe way improves code quality
## Contrast with Pure Functional Packages
### When to Use `context/readerresult` (This Package)
Use when you need:
- ✅ Context cancellation and timeouts
- ✅ Request-scoped values
- ✅ Integration with Go's standard library (http, database/sql, etc.)
- ✅ IO operations with error handling
- ✅ Practical, idiomatic Go code
### When to Use `readerresult` (Pure Package)
Use when you need:
- ✅ Pure dependency injection
- ✅ Testable computations with simple config objects
- ✅ No context propagation
- ✅ Generic environment types (not limited to context.Context)
- ✅ Purely functional composition
## Conclusion
Combining IO operations with ReaderResult in the `context/readerresult` package makes sense because:
1. **ReaderResult is already effectful** due to its dependency on `context.Context`
2. **IO operations are also effectful**, making them a natural fit
3. **The combination provides practical benefits** for real-world Go applications
4. **It aligns with Go's pragmatic philosophy** of embracing side effects when necessary
5. **It enables type-safe composition** of effectful operations
The key insight is that `context.Context` itself is a side effect, so adding more side effects (IO operations) doesn't violate any purity constraints—because there were none to begin with. This package provides tools to work with these side effects in a safe, composable, and type-safe manner.

View File

@@ -17,7 +17,6 @@ package readerresult
import (
F "github.com/IBM/fp-go/v2/function"
L "github.com/IBM/fp-go/v2/optics/lens"
G "github.com/IBM/fp-go/v2/readereither/generic"
)
@@ -39,10 +38,18 @@ func Do[S any](
return G.Do[ReaderResult[S]](empty)
}
// Bind attaches the result of a computation to a context [S1] to produce a context [S2].
// Bind attaches the result of an EFFECTFUL computation to a context [S1] to produce a context [S2].
// This enables sequential composition where each step can depend on the results of previous steps
// and access the context.Context from the environment.
//
// IMPORTANT: Bind is for EFFECTFUL FUNCTIONS that depend on context.Context.
// The function parameter takes state and returns a ReaderResult[T], which is effectful because
// it depends on context.Context (can be cancelled, has deadlines, carries values).
//
// For PURE FUNCTIONS (side-effect free), use:
// - BindResultK: For pure functions with errors (State -> (Value, error))
// - Let: For pure functions without errors (State -> Value)
//
// The setter function takes the result of the computation and returns a function that
// updates the context from S1 to S2.
//
@@ -89,7 +96,16 @@ func Bind[S1, S2, T any](
return G.Bind[ReaderResult[S1], ReaderResult[S2]](setter, F.Flow2(f, WithContext))
}
// Let attaches the result of a computation to a context [S1] to produce a context [S2]
// Let attaches the result of a PURE computation to a context [S1] to produce a context [S2].
//
// IMPORTANT: Let is for PURE FUNCTIONS (side-effect free) that don't depend on context.Context.
// The function parameter takes state and returns a value directly, with no errors or effects.
//
// For EFFECTFUL FUNCTIONS (that need context.Context), use:
// - Bind: For effectful ReaderResult computations (State -> ReaderResult[Value])
//
// For PURE FUNCTIONS with error handling, use:
// - BindResultK: For pure functions with errors (State -> (Value, error))
//
//go:inline
func Let[S1, S2, T any](
@@ -99,7 +115,8 @@ func Let[S1, S2, T any](
return G.Let[ReaderResult[S1], ReaderResult[S2]](setter, f)
}
// LetTo attaches the a value to a context [S1] to produce a context [S2]
// LetTo attaches a constant value to a context [S1] to produce a context [S2].
// This is a PURE operation (side-effect free) that simply sets a field to a constant value.
//
//go:inline
func LetTo[S1, S2, T any](
@@ -114,13 +131,23 @@ func LetTo[S1, S2, T any](
//go:inline
func BindTo[S1, T any](
setter func(T) S1,
) Kleisli[ReaderResult[T], S1] {
) Operator[T, S1] {
return G.BindTo[ReaderResult[S1], ReaderResult[T]](setter)
}
//go:inline
func BindToP[S1, T any](
setter Prism[S1, T],
) Operator[T, S1] {
return BindTo(setter.ReverseGet)
}
// ApS attaches a value to a context [S1] to produce a context [S2] by considering
// the context and the value concurrently (using Applicative rather than Monad).
// This allows independent computations to be combined without one depending on the result of the other.
// This allows independent EFFECTFUL computations to be combined without one depending on the result of the other.
//
// IMPORTANT: ApS is for EFFECTFUL FUNCTIONS that depend on context.Context.
// The ReaderResult parameter is effectful because it depends on context.Context.
//
// Unlike Bind, which sequences operations, ApS can be used when operations are independent
// and can conceptually run in parallel.
@@ -198,16 +225,21 @@ func ApS[S1, S2, T any](
//
//go:inline
func ApSL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
fa ReaderResult[T],
) Kleisli[ReaderResult[S], S] {
return ApS(lens.Set, fa)
}
// BindL is a variant of Bind that uses a lens to focus on a specific field in the state.
// It combines the lens-based field access with monadic composition, allowing you to:
// It combines the lens-based field access with monadic composition for EFFECTFUL computations.
//
// IMPORTANT: BindL is for EFFECTFUL FUNCTIONS that depend on context.Context.
// The function parameter returns a ReaderResult, which is effectful.
//
// It allows you to:
// 1. Extract a field value using the lens
// 2. Use that value in a computation that may fail
// 2. Use that value in an effectful computation that may fail
// 3. Update the field with the result
//
// Parameters:
@@ -244,14 +276,17 @@ func ApSL[S, T any](
//
//go:inline
func BindL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
f Kleisli[T, T],
) Kleisli[ReaderResult[S], S] {
return Bind(lens.Set, F.Flow2(lens.Get, F.Flow2(f, WithContext)))
}
// LetL is a variant of Let that uses a lens to focus on a specific field in the state.
// It applies a pure transformation to the focused field without any effects.
// It applies a PURE transformation to the focused field without any effects.
//
// IMPORTANT: LetL is for PURE FUNCTIONS (side-effect free) that don't depend on context.Context.
// The function parameter is a pure endomorphism (T -> T) with no errors or effects.
//
// Parameters:
// - lens: A lens that focuses on a field of type T within state S
@@ -281,14 +316,14 @@ func BindL[S, T any](
//
//go:inline
func LetL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
f Endomorphism[T],
) Kleisli[ReaderResult[S], S] {
return Let(lens.Set, F.Flow2(lens.Get, f))
}
// LetToL is a variant of LetTo that uses a lens to focus on a specific field in the state.
// It sets the focused field to a constant value.
// It sets the focused field to a constant value. This is a PURE operation (side-effect free).
//
// Parameters:
// - lens: A lens that focuses on a field of type T within state S
@@ -317,7 +352,7 @@ func LetL[S, T any](
//
//go:inline
func LetToL[S, T any](
lens L.Lens[S, T],
lens Lens[S, T],
b T,
) Kleisli[ReaderResult[S], S] {
return LetTo(lens.Set, b)

View File

@@ -16,7 +16,6 @@
package readerresult
import (
"context"
"testing"
E "github.com/IBM/fp-go/v2/either"
@@ -42,7 +41,7 @@ func TestBind(t *testing.T) {
Map(utils.GetFullName),
)
assert.Equal(t, res(context.Background()), E.Of[error]("John Doe"))
assert.Equal(t, res(t.Context()), E.Of[error]("John Doe"))
}
func TestApS(t *testing.T) {
@@ -54,5 +53,5 @@ func TestApS(t *testing.T) {
Map(utils.GetFullName),
)
assert.Equal(t, res(context.Background()), E.Of[error]("John Doe"))
assert.Equal(t, res(t.Context()), E.Of[error]("John Doe"))
}

View File

@@ -19,9 +19,78 @@ import (
"context"
E "github.com/IBM/fp-go/v2/either"
F "github.com/IBM/fp-go/v2/function"
)
// withContext wraps an existing ReaderResult and performs a context check for cancellation before deletating
// WithContext wraps an existing ReaderResult and performs a context check for cancellation
// before delegating to the wrapped computation. This provides early cancellation detection,
// allowing computations to fail fast when the context has been cancelled or has exceeded
// its deadline.
//
// IMPORTANT: This function checks for context cancellation BEFORE executing the wrapped
// ReaderResult. If the context is already cancelled or has exceeded its deadline, the
// computation returns immediately with the cancellation error without executing the
// wrapped ReaderResult.
//
// The function uses context.Cause(ctx) to extract the cancellation reason, which may be:
// - context.Canceled: The context was explicitly cancelled
// - context.DeadlineExceeded: The context's deadline was exceeded
// - A custom error: If the context was cancelled with a cause (Go 1.20+)
//
// Type Parameters:
// - A: The success type of the ReaderResult
//
// Parameters:
// - ma: The ReaderResult to wrap with cancellation checking
//
// Returns:
// - A ReaderResult that checks for cancellation before executing ma
//
// Example:
//
// // Create a long-running computation
// longComputation := func(ctx context.Context) result.Result[int] {
// time.Sleep(5 * time.Second)
// return result.Of(42)
// }
//
// // Wrap with cancellation check
// safeLongComputation := readerresult.WithContext(longComputation)
//
// // Cancel the context before execution
// ctx, cancel := context.WithCancel(t.Context())
// cancel()
//
// // The computation returns immediately with cancellation error
// result := safeLongComputation(ctx)
// // result is Left(context.Canceled) - longComputation never executes
//
// Example with timeout:
//
// fetchData := func(ctx context.Context) result.Result[string] {
// // Simulate slow operation
// time.Sleep(2 * time.Second)
// return result.Of("data")
// }
//
// safeFetch := readerresult.WithContext(fetchData)
//
// // Context with 1 second timeout
// ctx, cancel := context.WithTimeout(t.Context(), 1*time.Second)
// defer cancel()
//
// time.Sleep(1500 * time.Millisecond) // Wait for timeout
//
// result := safeFetch(ctx)
// // result is Left(context.DeadlineExceeded) - fetchData never executes
//
// Use cases:
// - Wrapping expensive computations to enable early cancellation
// - Preventing unnecessary work when context is already cancelled
// - Implementing timeout-aware operations
// - Building cancellation-aware pipelines
//
//go:inline
func WithContext[A any](ma ReaderResult[A]) ReaderResult[A] {
return func(ctx context.Context) E.Either[error, A] {
if ctx.Err() != nil {
@@ -30,3 +99,86 @@ func WithContext[A any](ma ReaderResult[A]) ReaderResult[A] {
return ma(ctx)
}
}
// WithContextK wraps a Kleisli arrow with context cancellation checking.
// This is a higher-order function that takes a Kleisli arrow and returns a new
// Kleisli arrow that checks for context cancellation before executing.
//
// IMPORTANT: This function composes the Kleisli arrow with WithContext, ensuring
// that the resulting ReaderResult checks for cancellation before execution. This
// is particularly useful when building pipelines of Kleisli arrows where you want
// cancellation checking at each step.
//
// Type Parameters:
// - A: The input type of the Kleisli arrow
// - B: The output type of the Kleisli arrow
//
// Parameters:
// - f: The Kleisli arrow to wrap with cancellation checking
//
// Returns:
// - A new Kleisli arrow that checks for cancellation before executing f
//
// Example:
//
// // Define a Kleisli arrow
// processUser := func(id int) readerresult.ReaderResult[User] {
// return func(ctx context.Context) result.Result[User] {
// // Expensive database operation
// return fetchUserFromDB(ctx, id)
// }
// }
//
// // Wrap with cancellation checking
// safeProcessUser := readerresult.WithContextK(processUser)
//
// // Use in a pipeline
// pipeline := F.Pipe1(
// readerresult.Of(123),
// readerresult.Chain(safeProcessUser),
// )
//
// // If context is cancelled, processUser never executes
// ctx, cancel := context.WithCancel(t.Context())
// cancel()
// result := pipeline(ctx) // Left(context.Canceled)
//
// Example with multiple steps:
//
// getUserK := readerresult.WithContextK(func(id int) readerresult.ReaderResult[User] {
// return func(ctx context.Context) result.Result[User] {
// return fetchUser(ctx, id)
// }
// })
//
// getOrdersK := readerresult.WithContextK(func(user User) readerresult.ReaderResult[[]Order] {
// return func(ctx context.Context) result.Result[[]Order] {
// return fetchOrders(ctx, user.ID)
// }
// })
//
// // Each step checks for cancellation
// pipeline := F.Pipe2(
// readerresult.Of(123),
// readerresult.Chain(getUserK),
// readerresult.Chain(getOrdersK),
// )
//
// // If context is cancelled at any point, remaining steps don't execute
// ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
// defer cancel()
// result := pipeline(ctx)
//
// Use cases:
// - Building cancellation-aware pipelines
// - Ensuring each step in a chain respects cancellation
// - Implementing timeout-aware multi-step operations
// - Preventing cascading failures in long pipelines
//
//go:inline
func WithContextK[A, B any](f Kleisli[A, B]) Kleisli[A, B] {
return F.Flow2(
f,
WithContext,
)
}

View File

@@ -0,0 +1,418 @@
// 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 readerresult
import (
"context"
"errors"
"testing"
"time"
E "github.com/IBM/fp-go/v2/either"
F "github.com/IBM/fp-go/v2/function"
N "github.com/IBM/fp-go/v2/number"
"github.com/IBM/fp-go/v2/reader"
"github.com/stretchr/testify/assert"
)
// TestWithContext tests the WithContext function
func TestWithContext(t *testing.T) {
t.Run("executes wrapped ReaderResult when context is not cancelled", func(t *testing.T) {
executed := false
computation := func(ctx context.Context) E.Either[error, int] {
executed = true
return E.Of[error](42)
}
wrapped := WithContext(computation)
result := wrapped(t.Context())
assert.True(t, executed, "computation should be executed")
assert.Equal(t, E.Of[error](42), result)
})
t.Run("returns cancellation error when context is cancelled", func(t *testing.T) {
executed := false
computation := func(ctx context.Context) E.Either[error, int] {
executed = true
return E.Of[error](42)
}
wrapped := WithContext(computation)
ctx, cancel := context.WithCancel(t.Context())
cancel()
result := wrapped(ctx)
assert.False(t, executed, "computation should not be executed when context is cancelled")
assert.True(t, E.IsLeft(result))
_, err := E.UnwrapError(result)
assert.Equal(t, context.Canceled, err)
})
t.Run("returns deadline exceeded error when context times out", func(t *testing.T) {
executed := false
computation := func(ctx context.Context) E.Either[error, int] {
executed = true
time.Sleep(100 * time.Millisecond)
return E.Of[error](42)
}
wrapped := WithContext(computation)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
defer cancel()
time.Sleep(20 * time.Millisecond) // Wait for timeout
result := wrapped(ctx)
assert.False(t, executed, "computation should not be executed when context has timed out")
assert.True(t, E.IsLeft(result))
_, err := E.UnwrapError(result)
assert.Equal(t, context.DeadlineExceeded, err)
})
t.Run("preserves errors from wrapped computation", func(t *testing.T) {
testErr := errors.New("computation error")
computation := func(ctx context.Context) E.Either[error, int] {
return E.Left[int](testErr)
}
wrapped := WithContext(computation)
result := wrapped(t.Context())
assert.True(t, E.IsLeft(result))
_, err := E.UnwrapError(result)
assert.Equal(t, testErr, err)
})
t.Run("prevents expensive computation when context is already cancelled", func(t *testing.T) {
expensiveExecuted := false
expensiveComputation := func(ctx context.Context) E.Either[error, int] {
expensiveExecuted = true
// Simulate expensive operation
time.Sleep(1 * time.Second)
return E.Of[error](42)
}
wrapped := WithContext(expensiveComputation)
ctx, cancel := context.WithCancel(t.Context())
cancel()
start := time.Now()
result := wrapped(ctx)
duration := time.Since(start)
assert.False(t, expensiveExecuted, "expensive computation should not execute")
assert.True(t, E.IsLeft(result))
assert.Less(t, duration, 100*time.Millisecond, "should return immediately")
})
t.Run("works with context.WithCancelCause", func(t *testing.T) {
executed := false
computation := func(ctx context.Context) E.Either[error, int] {
executed = true
return E.Of[error](42)
}
wrapped := WithContext(computation)
customErr := errors.New("custom cancellation reason")
ctx, cancel := context.WithCancelCause(t.Context())
cancel(customErr)
result := wrapped(ctx)
assert.False(t, executed, "computation should not be executed")
assert.True(t, E.IsLeft(result))
_, err := E.UnwrapError(result)
assert.Equal(t, customErr, err)
})
t.Run("can be nested for multiple cancellation checks", func(t *testing.T) {
executed := false
computation := func(ctx context.Context) E.Either[error, int] {
executed = true
return E.Of[error](42)
}
doubleWrapped := WithContext(WithContext(computation))
ctx, cancel := context.WithCancel(t.Context())
cancel()
result := doubleWrapped(ctx)
assert.False(t, executed, "computation should not be executed")
assert.True(t, E.IsLeft(result))
})
}
// TestWithContextK tests the WithContextK function
func TestWithContextK(t *testing.T) {
t.Run("wraps Kleisli arrow with cancellation checking", func(t *testing.T) {
executed := false
processUser := func(id int) ReaderResult[string] {
return func(ctx context.Context) E.Either[error, string] {
executed = true
return E.Of[error]("user-" + string(rune(id+48)))
}
}
safeProcessUser := WithContextK(processUser)
result := safeProcessUser(123)(t.Context())
assert.True(t, executed, "Kleisli should be executed")
assert.True(t, E.IsRight(result))
})
t.Run("prevents Kleisli execution when context is cancelled", func(t *testing.T) {
executed := false
processUser := func(id int) ReaderResult[string] {
return func(ctx context.Context) E.Either[error, string] {
executed = true
return E.Of[error]("user")
}
}
safeProcessUser := WithContextK(processUser)
ctx, cancel := context.WithCancel(t.Context())
cancel()
result := safeProcessUser(123)(ctx)
assert.False(t, executed, "Kleisli should not be executed when context is cancelled")
assert.True(t, E.IsLeft(result))
_, err := E.UnwrapError(result)
assert.Equal(t, context.Canceled, err)
})
t.Run("works in Chain pipeline", func(t *testing.T) {
firstExecuted := false
secondExecuted := false
getUser := WithContextK(func(id int) ReaderResult[string] {
return func(ctx context.Context) E.Either[error, string] {
firstExecuted = true
return E.Of[error]("Alice")
}
})
getOrders := WithContextK(func(name string) ReaderResult[int] {
return func(ctx context.Context) E.Either[error, int] {
secondExecuted = true
return E.Of[error](5)
}
})
pipeline := F.Pipe2(
Of(123),
Chain(getUser),
Chain(getOrders),
)
result := pipeline(t.Context())
assert.True(t, firstExecuted, "first step should execute")
assert.True(t, secondExecuted, "second step should execute")
assert.Equal(t, E.Of[error](5), result)
})
t.Run("stops pipeline on cancellation", func(t *testing.T) {
firstExecuted := false
secondExecuted := false
getUser := WithContextK(func(id int) ReaderResult[string] {
return func(ctx context.Context) E.Either[error, string] {
firstExecuted = true
return E.Of[error]("Alice")
}
})
getOrders := WithContextK(func(name string) ReaderResult[int] {
return func(ctx context.Context) E.Either[error, int] {
secondExecuted = true
return E.Of[error](5)
}
})
pipeline := F.Pipe2(
Of(123),
Chain(getUser),
Chain(getOrders),
)
ctx, cancel := context.WithCancel(t.Context())
cancel()
result := pipeline(ctx)
assert.False(t, firstExecuted, "first step should not execute")
assert.False(t, secondExecuted, "second step should not execute")
assert.True(t, E.IsLeft(result))
})
t.Run("respects timeout in multi-step pipeline", func(t *testing.T) {
step1Executed := false
step2Executed := false
step1 := WithContextK(func(x int) ReaderResult[int] {
return func(ctx context.Context) E.Either[error, int] {
step1Executed = true
time.Sleep(50 * time.Millisecond)
return E.Of[error](x * 2)
}
})
step2 := WithContextK(func(x int) ReaderResult[int] {
return func(ctx context.Context) E.Either[error, int] {
step2Executed = true
return E.Of[error](x + 10)
}
})
pipeline := F.Pipe2(
Of(5),
Chain(step1),
Chain(step2),
)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
defer cancel()
time.Sleep(20 * time.Millisecond) // Wait for timeout
result := pipeline(ctx)
assert.False(t, step1Executed, "step1 should not execute after timeout")
assert.False(t, step2Executed, "step2 should not execute after timeout")
assert.True(t, E.IsLeft(result))
})
t.Run("preserves errors from Kleisli computation", func(t *testing.T) {
testErr := errors.New("kleisli error")
failingKleisli := func(id int) ReaderResult[string] {
return func(ctx context.Context) E.Either[error, string] {
return E.Left[string](testErr)
}
}
safeKleisli := WithContextK(failingKleisli)
result := safeKleisli(123)(t.Context())
assert.True(t, E.IsLeft(result))
_, err := E.UnwrapError(result)
assert.Equal(t, testErr, err)
})
}
// TestWithContextIntegration tests integration scenarios
func TestWithContextIntegration(t *testing.T) {
t.Run("WithContext in complex pipeline with multiple operations", func(t *testing.T) {
step1Executed := false
step2Executed := false
step3Executed := false
step1 := WithContext(func(ctx context.Context) E.Either[error, int] {
step1Executed = true
return E.Of[error](10)
})
step2 := WithContextK(func(x int) ReaderResult[int] {
return func(ctx context.Context) E.Either[error, int] {
step2Executed = true
return E.Of[error](x * 2)
}
})
step3 := WithContext(func(ctx context.Context) E.Either[error, string] {
step3Executed = true
return E.Of[error]("done")
})
pipeline := F.Pipe2(
step1,
Chain(step2),
ChainTo[int](step3),
)
result := pipeline(t.Context())
assert.True(t, step1Executed)
assert.True(t, step2Executed)
assert.True(t, step3Executed)
assert.Equal(t, E.Of[error]("done"), result)
})
t.Run("early cancellation prevents all subsequent operations", func(t *testing.T) {
step1Executed := false
step2Executed := false
step3Executed := false
step1 := WithContext(func(ctx context.Context) E.Either[error, int] {
step1Executed = true
return E.Of[error](10)
})
step2 := WithContextK(func(x int) ReaderResult[int] {
return func(ctx context.Context) E.Either[error, int] {
step2Executed = true
return E.Of[error](x * 2)
}
})
step3 := WithContext(func(ctx context.Context) E.Either[error, string] {
step3Executed = true
return E.Of[error]("done")
})
pipeline := F.Pipe2(
step1,
Chain(step2),
ChainTo[int](step3),
)
ctx, cancel := context.WithCancel(t.Context())
cancel()
result := pipeline(ctx)
assert.False(t, step1Executed, "no steps should execute")
assert.False(t, step2Executed, "no steps should execute")
assert.False(t, step3Executed, "no steps should execute")
assert.True(t, E.IsLeft(result))
})
t.Run("WithContext with Map and Chain", func(t *testing.T) {
computation := WithContext(func(ctx context.Context) E.Either[error, int] {
return E.Of[error](42)
})
pipeline := F.Pipe2(
computation,
Map(N.Mul(2)),
Map(reader.Of[int]("result")),
)
result := pipeline(t.Context())
assert.Equal(t, E.Of[error]("result"), result)
})
}

View File

@@ -21,33 +21,299 @@ import (
"github.com/IBM/fp-go/v2/readereither"
)
// these functions curry a golang function with the context as the firsr parameter into a either reader with the context as the last parameter
// this goes back to the advice in https://pkg.go.dev/context to put the context as a first parameter as a convention
// Curry and Uncurry functions convert between idiomatic Go functions (with context.Context as the first parameter)
// and functional ReaderResult/Kleisli compositions (with context.Context as the last parameter).
//
// This follows the Go convention from https://pkg.go.dev/context to put context as the first parameter,
// while enabling functional composition where context is typically the last parameter.
//
// The curry functions transform:
// func(context.Context, T1, T2, ...) (A, error) → func(T1) func(T2) ... ReaderResult[A]
//
// The uncurry functions transform:
// func(T1) func(T2) ... ReaderResult[A] → func(context.Context, T1, T2, ...) (A, error)
// Curry0 converts a Go function with context and no additional parameters into a ReaderResult.
// This is useful for adapting context-aware functions to the ReaderResult monad.
//
// Type Parameters:
// - A: The return type of the function
//
// Parameters:
// - f: A function that takes a context and returns a value and error
//
// Returns:
// - A ReaderResult that wraps the function
//
// Example:
//
// // Idiomatic Go function
// getConfig := func(ctx context.Context) (Config, error) {
// // Check context cancellation
// if ctx.Err() != nil {
// return Config{}, ctx.Err()
// }
// return Config{Value: 42}, nil
// }
//
// // Convert to ReaderResult for functional composition
// configRR := readerresult.Curry0(getConfig)
// result := configRR(t.Context()) // Right(Config{Value: 42})
//
//go:inline
func Curry0[A any](f func(context.Context) (A, error)) ReaderResult[A] {
return readereither.Curry0(f)
}
// Curry1 converts a Go function with context and one parameter into a Kleisli arrow.
// This enables functional composition of single-parameter functions.
//
// Type Parameters:
// - T1: The type of the first parameter
// - A: The return type of the function
//
// Parameters:
// - f: A function that takes a context and one parameter, returning a value and error
//
// Returns:
// - A Kleisli arrow that can be composed with other ReaderResult operations
//
// Example:
//
// // Idiomatic Go function
// getUserByID := func(ctx context.Context, id int) (User, error) {
// if ctx.Err() != nil {
// return User{}, ctx.Err()
// }
// return User{ID: id, Name: "Alice"}, nil
// }
//
// // Convert to Kleisli for functional composition
// getUserKleisli := readerresult.Curry1(getUserByID)
//
// // Use in a pipeline
// pipeline := F.Pipe1(
// readerresult.Of(123),
// readerresult.Chain(getUserKleisli),
// )
// result := pipeline(t.Context()) // Right(User{ID: 123, Name: "Alice"})
//
//go:inline
func Curry1[T1, A any](f func(context.Context, T1) (A, error)) Kleisli[T1, A] {
return readereither.Curry1(f)
}
// Curry2 converts a Go function with context and two parameters into a curried function.
// This enables partial application and functional composition of two-parameter functions.
//
// Type Parameters:
// - T1: The type of the first parameter
// - T2: The type of the second parameter
// - A: The return type of the function
//
// Parameters:
// - f: A function that takes a context and two parameters, returning a value and error
//
// Returns:
// - A curried function that takes T1 and returns a Kleisli arrow for T2
//
// Example:
//
// // Idiomatic Go function
// updateUser := func(ctx context.Context, id int, name string) (User, error) {
// if ctx.Err() != nil {
// return User{}, ctx.Err()
// }
// return User{ID: id, Name: name}, nil
// }
//
// // Convert to curried form
// updateUserCurried := readerresult.Curry2(updateUser)
//
// // Partial application
// updateUser123 := updateUserCurried(123)
//
// // Use in a pipeline
// pipeline := F.Pipe1(
// readerresult.Of("Bob"),
// readerresult.Chain(updateUser123),
// )
// result := pipeline(t.Context()) // Right(User{ID: 123, Name: "Bob"})
//
//go:inline
func Curry2[T1, T2, A any](f func(context.Context, T1, T2) (A, error)) func(T1) Kleisli[T2, A] {
return readereither.Curry2(f)
}
// Curry3 converts a Go function with context and three parameters into a curried function.
// This enables partial application and functional composition of three-parameter functions.
//
// Type Parameters:
// - T1: The type of the first parameter
// - T2: The type of the second parameter
// - T3: The type of the third parameter
// - A: The return type of the function
//
// Parameters:
// - f: A function that takes a context and three parameters, returning a value and error
//
// Returns:
// - A curried function that takes T1, T2, and returns a Kleisli arrow for T3
//
// Example:
//
// // Idiomatic Go function
// createOrder := func(ctx context.Context, userID int, productID int, quantity int) (Order, error) {
// if ctx.Err() != nil {
// return Order{}, ctx.Err()
// }
// return Order{UserID: userID, ProductID: productID, Quantity: quantity}, nil
// }
//
// // Convert to curried form
// createOrderCurried := readerresult.Curry3(createOrder)
//
// // Partial application
// createOrderForUser := createOrderCurried(123)
// createOrderForProduct := createOrderForUser(456)
//
// // Use in a pipeline
// pipeline := F.Pipe1(
// readerresult.Of(2),
// readerresult.Chain(createOrderForProduct),
// )
// result := pipeline(t.Context()) // Right(Order{UserID: 123, ProductID: 456, Quantity: 2})
//
//go:inline
func Curry3[T1, T2, T3, A any](f func(context.Context, T1, T2, T3) (A, error)) func(T1) func(T2) Kleisli[T3, A] {
return readereither.Curry3(f)
}
// Uncurry1 converts a Kleisli arrow back into an idiomatic Go function with context as the first parameter.
// This is useful for interfacing with code that expects standard Go function signatures.
//
// Type Parameters:
// - T1: The type of the parameter
// - A: The return type
//
// Parameters:
// - f: A Kleisli arrow
//
// Returns:
// - A Go function with context as the first parameter
//
// Example:
//
// // Kleisli arrow
// getUserKleisli := func(id int) readerresult.ReaderResult[User] {
// return func(ctx context.Context) result.Result[User] {
// if ctx.Err() != nil {
// return result.Error[User](ctx.Err())
// }
// return result.Of(User{ID: id, Name: "Alice"})
// }
// }
//
// // Convert back to idiomatic Go function
// getUserByID := readerresult.Uncurry1(getUserKleisli)
//
// // Use as a normal Go function
// user, err := getUserByID(t.Context(), 123)
// if err != nil {
// log.Fatal(err)
// }
// fmt.Println(user.Name) // "Alice"
//
//go:inline
func Uncurry1[T1, A any](f Kleisli[T1, A]) func(context.Context, T1) (A, error) {
return readereither.Uncurry1(f)
}
// Uncurry2 converts a curried function back into an idiomatic Go function with context as the first parameter.
// This is useful for interfacing with code that expects standard Go function signatures.
//
// Type Parameters:
// - T1: The type of the first parameter
// - T2: The type of the second parameter
// - A: The return type
//
// Parameters:
// - f: A curried function
//
// Returns:
// - A Go function with context as the first parameter
//
// Example:
//
// // Curried function
// updateUserCurried := func(id int) func(name string) readerresult.ReaderResult[User] {
// return func(name string) readerresult.ReaderResult[User] {
// return func(ctx context.Context) result.Result[User] {
// if ctx.Err() != nil {
// return result.Error[User](ctx.Err())
// }
// return result.Of(User{ID: id, Name: name})
// }
// }
// }
//
// // Convert back to idiomatic Go function
// updateUser := readerresult.Uncurry2(updateUserCurried)
//
// // Use as a normal Go function
// user, err := updateUser(t.Context(), 123, "Bob")
// if err != nil {
// log.Fatal(err)
// }
// fmt.Println(user.Name) // "Bob"
//
//go:inline
func Uncurry2[T1, T2, A any](f func(T1) Kleisli[T2, A]) func(context.Context, T1, T2) (A, error) {
return readereither.Uncurry2(f)
}
// Uncurry3 converts a curried function back into an idiomatic Go function with context as the first parameter.
// This is useful for interfacing with code that expects standard Go function signatures.
//
// Type Parameters:
// - T1: The type of the first parameter
// - T2: The type of the second parameter
// - T3: The type of the third parameter
// - A: The return type
//
// Parameters:
// - f: A curried function
//
// Returns:
// - A Go function with context as the first parameter
//
// Example:
//
// // Curried function
// createOrderCurried := func(userID int) func(productID int) func(quantity int) readerresult.ReaderResult[Order] {
// return func(productID int) func(quantity int) readerresult.ReaderResult[Order] {
// return func(quantity int) readerresult.ReaderResult[Order] {
// return func(ctx context.Context) result.Result[Order] {
// if ctx.Err() != nil {
// return result.Error[Order](ctx.Err())
// }
// return result.Of(Order{UserID: userID, ProductID: productID, Quantity: quantity})
// }
// }
// }
// }
//
// // Convert back to idiomatic Go function
// createOrder := readerresult.Uncurry3(createOrderCurried)
//
// // Use as a normal Go function
// order, err := createOrder(t.Context(), 123, 456, 2)
// if err != nil {
// log.Fatal(err)
// }
// fmt.Printf("Order: User=%d, Product=%d, Qty=%d\n", order.UserID, order.ProductID, order.Quantity)
//
//go:inline
func Uncurry3[T1, T2, T3, A any](f func(T1) func(T2) Kleisli[T3, A]) func(context.Context, T1, T2, T3) (A, error) {
return readereither.Uncurry3(f)
}

View File

@@ -0,0 +1,564 @@
// 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 readerresult
import (
"context"
"errors"
"testing"
E "github.com/IBM/fp-go/v2/either"
F "github.com/IBM/fp-go/v2/function"
"github.com/stretchr/testify/assert"
)
// TestCurry0 tests the Curry0 function
func TestCurry0(t *testing.T) {
t.Run("converts Go function to ReaderResult on success", func(t *testing.T) {
// Idiomatic Go function
getConfig := func(ctx context.Context) (int, error) {
return 42, nil
}
// Convert to ReaderResult
configRR := Curry0(getConfig)
result := configRR(t.Context())
assert.Equal(t, E.Of[error](42), result)
})
t.Run("converts Go function to ReaderResult on error", func(t *testing.T) {
testErr := errors.New("config error")
getConfig := func(ctx context.Context) (int, error) {
return 0, testErr
}
configRR := Curry0(getConfig)
result := configRR(t.Context())
assert.Equal(t, E.Left[int](testErr), result)
})
t.Run("respects context cancellation", func(t *testing.T) {
getConfig := func(ctx context.Context) (int, error) {
if ctx.Err() != nil {
return 0, ctx.Err()
}
return 42, nil
}
configRR := Curry0(getConfig)
// Test with cancelled context
ctx, cancel := context.WithCancel(t.Context())
cancel()
result := configRR(ctx)
assert.True(t, E.IsLeft(result))
})
t.Run("can be used in functional composition", func(t *testing.T) {
getConfig := func(ctx context.Context) (int, error) {
return 42, nil
}
pipeline := F.Pipe1(
Curry0(getConfig),
Map(func(x int) string {
return "value"
}),
)
result := pipeline(t.Context())
assert.True(t, E.IsRight(result))
})
}
// TestCurry1 tests the Curry1 function
func TestCurry1(t *testing.T) {
t.Run("converts Go function to Kleisli on success", func(t *testing.T) {
getUserByID := func(ctx context.Context, id int) (string, error) {
return "Alice", nil
}
getUserKleisli := Curry1(getUserByID)
// Use in a pipeline
pipeline := F.Pipe1(
Of(123),
Chain(getUserKleisli),
)
result := pipeline(t.Context())
assert.Equal(t, E.Of[error]("Alice"), result)
})
t.Run("converts Go function to Kleisli on error", func(t *testing.T) {
testErr := errors.New("user not found")
getUserByID := func(ctx context.Context, id int) (string, error) {
return "", testErr
}
getUserKleisli := Curry1(getUserByID)
result := getUserKleisli(123)(t.Context())
assert.Equal(t, E.Left[string](testErr), result)
})
t.Run("respects context cancellation", func(t *testing.T) {
getUserByID := func(ctx context.Context, id int) (string, error) {
if ctx.Err() != nil {
return "", ctx.Err()
}
return "Alice", nil
}
getUserKleisli := Curry1(getUserByID)
ctx, cancel := context.WithCancel(t.Context())
cancel()
result := getUserKleisli(123)(ctx)
assert.True(t, E.IsLeft(result))
})
t.Run("can be composed with other operations", func(t *testing.T) {
getUserByID := func(ctx context.Context, id int) (string, error) {
return "Alice", nil
}
pipeline := F.Pipe2(
Of(123),
Chain(Curry1(getUserByID)),
Map(func(name string) int {
return len(name)
}),
)
result := pipeline(t.Context())
assert.Equal(t, E.Of[error](5), result) // len("Alice") = 5
})
}
// TestCurry2 tests the Curry2 function
func TestCurry2(t *testing.T) {
t.Run("converts Go function to curried form on success", func(t *testing.T) {
updateUser := func(ctx context.Context, id int, name string) (string, error) {
return name, nil
}
updateUserCurried := Curry2(updateUser)
// Partial application
updateUser123 := updateUserCurried(123)
// Use in a pipeline
pipeline := F.Pipe1(
Of("Bob"),
Chain(updateUser123),
)
result := pipeline(t.Context())
assert.Equal(t, E.Of[error]("Bob"), result)
})
t.Run("converts Go function to curried form on error", func(t *testing.T) {
testErr := errors.New("update failed")
updateUser := func(ctx context.Context, id int, name string) (string, error) {
return "", testErr
}
updateUserCurried := Curry2(updateUser)
result := updateUserCurried(123)("Bob")(t.Context())
assert.Equal(t, E.Left[string](testErr), result)
})
t.Run("supports partial application", func(t *testing.T) {
concat := func(ctx context.Context, a string, b string) (string, error) {
return a + b, nil
}
concatCurried := Curry2(concat)
// Partial application
prependHello := concatCurried("Hello, ")
result1 := prependHello("World")(t.Context())
result2 := prependHello("Alice")(t.Context())
assert.Equal(t, E.Of[error]("Hello, World"), result1)
assert.Equal(t, E.Of[error]("Hello, Alice"), result2)
})
t.Run("respects context cancellation", func(t *testing.T) {
updateUser := func(ctx context.Context, id int, name string) (string, error) {
if ctx.Err() != nil {
return "", ctx.Err()
}
return name, nil
}
updateUserCurried := Curry2(updateUser)
ctx, cancel := context.WithCancel(t.Context())
cancel()
result := updateUserCurried(123)("Bob")(ctx)
assert.True(t, E.IsLeft(result))
})
}
// TestCurry3 tests the Curry3 function
func TestCurry3(t *testing.T) {
t.Run("converts Go function to curried form on success", func(t *testing.T) {
createOrder := func(ctx context.Context, userID int, productID int, quantity int) (int, error) {
return userID + productID + quantity, nil
}
createOrderCurried := Curry3(createOrder)
// Partial application
createOrderForUser := createOrderCurried(100)
createOrderForProduct := createOrderForUser(200)
// Use in a pipeline
pipeline := F.Pipe1(
Of(3),
Chain(createOrderForProduct),
)
result := pipeline(t.Context())
assert.Equal(t, E.Of[error](303), result) // 100 + 200 + 3
})
t.Run("converts Go function to curried form on error", func(t *testing.T) {
testErr := errors.New("order creation failed")
createOrder := func(ctx context.Context, userID int, productID int, quantity int) (int, error) {
return 0, testErr
}
createOrderCurried := Curry3(createOrder)
result := createOrderCurried(100)(200)(3)(t.Context())
assert.Equal(t, E.Left[int](testErr), result)
})
t.Run("supports multiple levels of partial application", func(t *testing.T) {
sum3 := func(ctx context.Context, a int, b int, c int) (int, error) {
return a + b + c, nil
}
sum3Curried := Curry3(sum3)
// First level partial application
add10 := sum3Curried(10)
// Second level partial application
add10And20 := add10(20)
result1 := add10And20(5)(t.Context())
result2 := add10And20(15)(t.Context())
assert.Equal(t, E.Of[error](35), result1) // 10 + 20 + 5
assert.Equal(t, E.Of[error](45), result2) // 10 + 20 + 15
})
t.Run("respects context cancellation", func(t *testing.T) {
createOrder := func(ctx context.Context, userID int, productID int, quantity int) (int, error) {
if ctx.Err() != nil {
return 0, ctx.Err()
}
return userID + productID + quantity, nil
}
createOrderCurried := Curry3(createOrder)
ctx, cancel := context.WithCancel(t.Context())
cancel()
result := createOrderCurried(100)(200)(3)(ctx)
assert.True(t, E.IsLeft(result))
})
}
// TestUncurry1 tests the Uncurry1 function
func TestUncurry1(t *testing.T) {
t.Run("converts Kleisli back to Go function on success", func(t *testing.T) {
getUserKleisli := func(id int) ReaderResult[string] {
return func(ctx context.Context) E.Either[error, string] {
return E.Of[error]("Alice")
}
}
getUserByID := Uncurry1(getUserKleisli)
user, err := getUserByID(t.Context(), 123)
assert.NoError(t, err)
assert.Equal(t, "Alice", user)
})
t.Run("converts Kleisli back to Go function on error", func(t *testing.T) {
testErr := errors.New("user not found")
getUserKleisli := func(id int) ReaderResult[string] {
return func(ctx context.Context) E.Either[error, string] {
return E.Left[string](testErr)
}
}
getUserByID := Uncurry1(getUserKleisli)
user, err := getUserByID(t.Context(), 123)
assert.Error(t, err)
assert.Equal(t, testErr, err)
assert.Equal(t, "", user)
})
t.Run("respects context in uncurried function", func(t *testing.T) {
getUserKleisli := func(id int) ReaderResult[string] {
return func(ctx context.Context) E.Either[error, string] {
if ctx.Err() != nil {
return E.Left[string](ctx.Err())
}
return E.Of[error]("Alice")
}
}
getUserByID := Uncurry1(getUserKleisli)
ctx, cancel := context.WithCancel(t.Context())
cancel()
user, err := getUserByID(ctx, 123)
assert.Error(t, err)
assert.Equal(t, "", user)
})
t.Run("round-trip with Curry1", func(t *testing.T) {
// Original Go function
original := func(ctx context.Context, id int) (string, error) {
return "Alice", nil
}
// Curry then uncurry
roundTrip := Uncurry1(Curry1(original))
user, err := roundTrip(t.Context(), 123)
assert.NoError(t, err)
assert.Equal(t, "Alice", user)
})
}
// TestUncurry2 tests the Uncurry2 function
func TestUncurry2(t *testing.T) {
t.Run("converts curried function back to Go function on success", func(t *testing.T) {
updateUserCurried := func(id int) func(name string) ReaderResult[string] {
return func(name string) ReaderResult[string] {
return func(ctx context.Context) E.Either[error, string] {
return E.Of[error](name)
}
}
}
updateUser := Uncurry2(updateUserCurried)
result, err := updateUser(t.Context(), 123, "Bob")
assert.NoError(t, err)
assert.Equal(t, "Bob", result)
})
t.Run("converts curried function back to Go function on error", func(t *testing.T) {
testErr := errors.New("update failed")
updateUserCurried := func(id int) func(name string) ReaderResult[string] {
return func(name string) ReaderResult[string] {
return func(ctx context.Context) E.Either[error, string] {
return E.Left[string](testErr)
}
}
}
updateUser := Uncurry2(updateUserCurried)
result, err := updateUser(t.Context(), 123, "Bob")
assert.Error(t, err)
assert.Equal(t, testErr, err)
assert.Equal(t, "", result)
})
t.Run("respects context in uncurried function", func(t *testing.T) {
updateUserCurried := func(id int) func(name string) ReaderResult[string] {
return func(name string) ReaderResult[string] {
return func(ctx context.Context) E.Either[error, string] {
if ctx.Err() != nil {
return E.Left[string](ctx.Err())
}
return E.Of[error](name)
}
}
}
updateUser := Uncurry2(updateUserCurried)
ctx, cancel := context.WithCancel(t.Context())
cancel()
result, err := updateUser(ctx, 123, "Bob")
assert.Error(t, err)
assert.Equal(t, "", result)
})
t.Run("round-trip with Curry2", func(t *testing.T) {
// Original Go function
original := func(ctx context.Context, a string, b string) (string, error) {
return a + b, nil
}
// Curry then uncurry
roundTrip := Uncurry2(Curry2(original))
result, err := roundTrip(t.Context(), "Hello, ", "World")
assert.NoError(t, err)
assert.Equal(t, "Hello, World", result)
})
}
// TestUncurry3 tests the Uncurry3 function
func TestUncurry3(t *testing.T) {
t.Run("converts curried function back to Go function on success", func(t *testing.T) {
createOrderCurried := func(userID int) func(productID int) func(quantity int) ReaderResult[int] {
return func(productID int) func(quantity int) ReaderResult[int] {
return func(quantity int) ReaderResult[int] {
return func(ctx context.Context) E.Either[error, int] {
return E.Of[error](userID + productID + quantity)
}
}
}
}
createOrder := Uncurry3(createOrderCurried)
result, err := createOrder(t.Context(), 100, 200, 3)
assert.NoError(t, err)
assert.Equal(t, 303, result) // 100 + 200 + 3
})
t.Run("converts curried function back to Go function on error", func(t *testing.T) {
testErr := errors.New("order creation failed")
createOrderCurried := func(userID int) func(productID int) func(quantity int) ReaderResult[int] {
return func(productID int) func(quantity int) ReaderResult[int] {
return func(quantity int) ReaderResult[int] {
return func(ctx context.Context) E.Either[error, int] {
return E.Left[int](testErr)
}
}
}
}
createOrder := Uncurry3(createOrderCurried)
result, err := createOrder(t.Context(), 100, 200, 3)
assert.Error(t, err)
assert.Equal(t, testErr, err)
assert.Equal(t, 0, result)
})
t.Run("respects context in uncurried function", func(t *testing.T) {
createOrderCurried := func(userID int) func(productID int) func(quantity int) ReaderResult[int] {
return func(productID int) func(quantity int) ReaderResult[int] {
return func(quantity int) ReaderResult[int] {
return func(ctx context.Context) E.Either[error, int] {
if ctx.Err() != nil {
return E.Left[int](ctx.Err())
}
return E.Of[error](userID + productID + quantity)
}
}
}
}
createOrder := Uncurry3(createOrderCurried)
ctx, cancel := context.WithCancel(t.Context())
cancel()
result, err := createOrder(ctx, 100, 200, 3)
assert.Error(t, err)
assert.Equal(t, 0, result)
})
t.Run("round-trip with Curry3", func(t *testing.T) {
// Original Go function
original := func(ctx context.Context, a int, b int, c int) (int, error) {
return a + b + c, nil
}
// Curry then uncurry
roundTrip := Uncurry3(Curry3(original))
result, err := roundTrip(t.Context(), 10, 20, 5)
assert.NoError(t, err)
assert.Equal(t, 35, result) // 10 + 20 + 5
})
}
// TestCurryUncurryIntegration tests integration between curry and uncurry functions
func TestCurryUncurryIntegration(t *testing.T) {
t.Run("Curry1 and Uncurry1 are inverses", func(t *testing.T) {
original := func(ctx context.Context, x int) (int, error) {
return x * 2, nil
}
// Curry then uncurry should give back equivalent function
roundTrip := Uncurry1(Curry1(original))
result1, err1 := original(t.Context(), 21)
result2, err2 := roundTrip(t.Context(), 21)
assert.NoError(t, err1)
assert.NoError(t, err2)
assert.Equal(t, result1, result2)
})
t.Run("Curry2 and Uncurry2 are inverses", func(t *testing.T) {
original := func(ctx context.Context, x int, y int) (int, error) {
return x + y, nil
}
roundTrip := Uncurry2(Curry2(original))
result1, err1 := original(t.Context(), 10, 20)
result2, err2 := roundTrip(t.Context(), 10, 20)
assert.NoError(t, err1)
assert.NoError(t, err2)
assert.Equal(t, result1, result2)
})
t.Run("Curry3 and Uncurry3 are inverses", func(t *testing.T) {
original := func(ctx context.Context, x int, y int, z int) (int, error) {
return x * y * z, nil
}
roundTrip := Uncurry3(Curry3(original))
result1, err1 := original(t.Context(), 2, 3, 4)
result2, err2 := roundTrip(t.Context(), 2, 3, 4)
assert.NoError(t, err1)
assert.NoError(t, err2)
assert.Equal(t, result1, result2)
})
}

View File

@@ -0,0 +1,51 @@
// 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 readerresult
import (
"context"
RR "github.com/IBM/fp-go/v2/readerresult"
)
// FilterOrElse filters a ReaderResult value based on a predicate.
// This is a convenience wrapper around readerresult.FilterOrElse that fixes
// the context type to context.Context.
//
// If the predicate returns true for the Right value, it passes through unchanged.
// If the predicate returns false, it transforms the Right value into a Left (error) using onFalse.
// Left values are passed through unchanged.
//
// Parameters:
// - pred: A predicate function that tests the Right value
// - onFalse: A function that converts the failing value into an error
//
// Returns:
// - An Operator that filters ReaderResult values based on the predicate
//
// Example:
//
// // Validate that a number is positive
// isPositive := N.MoreThan(0)
// onNegative := func(n int) error { return fmt.Errorf("%d is not positive", n) }
//
// filter := readerresult.FilterOrElse(isPositive, onNegative)
// result := filter(readerresult.Right(42))(t.Context())
//
//go:inline
func FilterOrElse[A any](pred Predicate[A], onFalse func(A) error) Operator[A, A] {
return RR.FilterOrElse[context.Context](pred, onFalse)
}

View File

@@ -63,7 +63,7 @@ import (
// // Sequenced: takes context first, then Database
// sequenced := SequenceReader(original)
//
// ctx := context.Background()
// ctx := t.Context()
// db := Database{ConnectionString: "localhost:5432"}
//
// // Apply context first to get a function that takes database
@@ -135,7 +135,7 @@ func SequenceReader[R, A any](ma ReaderResult[Reader[R, A]]) reader.Kleisli[cont
//
// // Now we can provide Config first, then context
// cfg := Config{MaxRetries: 3}
// ctx := context.Background()
// ctx := t.Context()
//
// result := flipped(cfg)(ctx)
// // result is Result[string] containing "Value: 42, MaxRetries: 3"

Some files were not shown because too many files have changed in this diff Show More