1
0
mirror of https://github.com/IBM/fp-go.git synced 2026-03-08 13:29:18 +02:00

Compare commits

...

3 Commits

Author SHA1 Message Date
Dr. Carsten Leue
6834f72856 fix: make signature of Local for context more generic, but backwards compatible
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-07 21:02:24 +01:00
Dr. Carsten Leue
8cfb7ef659 fix: logging implementation for context sensitive operations
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-06 23:54:42 +01:00
Dr. Carsten Leue
622c87d734 fix: logging implementation for context sensitive operations
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2026-03-06 23:50:54 +01:00
21 changed files with 2056 additions and 292 deletions

View File

@@ -1,6 +1,7 @@
package readerio
import (
"github.com/IBM/fp-go/v2/function"
RIO "github.com/IBM/fp-go/v2/readerio"
)
@@ -73,3 +74,117 @@ func Bracket[
) ReaderIO[B] {
return RIO.Bracket(acquire, use, release)
}
// WithResource creates a higher-order function that manages a resource lifecycle for any operation.
// It returns a Kleisli arrow that takes a use function and automatically handles resource
// acquisition and cleanup using the bracket pattern.
//
// This is a more composable alternative to Bracket, allowing you to define resource management
// once and reuse it with different use functions. The resource is acquired when the returned
// Kleisli arrow is invoked, used by the provided function, and then released regardless of
// success or failure.
//
// Type Parameters:
// - A: The type of the resource to be managed
// - B: The type of the result produced by the use function
// - ANY: The type returned by the release function (typically ignored)
//
// Parameters:
// - onCreate: A ReaderIO that acquires/creates the resource
// - onRelease: A Kleisli arrow that releases/cleans up the resource
//
// Returns:
// - A Kleisli arrow that takes a use function and returns a ReaderIO managing the full lifecycle
//
// Example with database connection:
//
// // Define resource management once
// withDB := WithResource(
// // Acquire connection
// func(ctx context.Context) IO[*sql.DB] {
// return func() *sql.DB {
// db, _ := sql.Open("postgres", "connection-string")
// return db
// }
// },
// // Release connection
// func(db *sql.DB) ReaderIO[any] {
// return func(ctx context.Context) IO[any] {
// return func() any {
// db.Close()
// return nil
// }
// }
// },
// )
//
// // Reuse with different operations
// queryUsers := withDB(func(db *sql.DB) ReaderIO[[]User] {
// return func(ctx context.Context) IO[[]User] {
// return func() []User {
// // Query users from db
// return users
// }
// }
// })
//
// insertUser := withDB(func(db *sql.DB) ReaderIO[int64] {
// return func(ctx context.Context) IO[int64] {
// return func() int64 {
// // Insert user into db
// return userID
// }
// }
// })
//
// Example with file handling:
//
// withFile := WithResource(
// func(ctx context.Context) IO[*os.File] {
// return func() *os.File {
// f, _ := os.Open("data.txt")
// return f
// }
// },
// func(f *os.File) ReaderIO[any] {
// return func(ctx context.Context) IO[any] {
// return func() any {
// f.Close()
// return nil
// }
// }
// },
// )
//
// // Use for reading
// readContent := withFile(func(f *os.File) ReaderIO[string] {
// return func(ctx context.Context) IO[string] {
// return func() string {
// data, _ := io.ReadAll(f)
// return string(data)
// }
// }
// })
//
// // Use for getting file info
// getSize := withFile(func(f *os.File) ReaderIO[int64] {
// return func(ctx context.Context) IO[int64] {
// return func() int64 {
// info, _ := f.Stat()
// return info.Size()
// }
// }
// })
//
// Use Cases:
// - Database connections: Acquire connection, execute queries, close connection
// - File handles: Open file, read/write, close file
// - Network connections: Establish connection, transfer data, close connection
// - Locks: Acquire lock, perform critical section, release lock
// - Temporary resources: Create temp file/directory, use it, clean up
//
//go:inline
func WithResource[A, B, ANY any](
onCreate ReaderIO[A], onRelease Kleisli[A, ANY]) Kleisli[Kleisli[A, B], B] {
return function.Bind13of3(Bracket[A, B, ANY])(onCreate, function.Ignore2of2[B](onRelease))
}

View File

@@ -0,0 +1,456 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package readerio
import (
"context"
"errors"
"testing"
"github.com/IBM/fp-go/v2/io"
"github.com/stretchr/testify/assert"
)
// mockResource simulates a resource that tracks its lifecycle
type mockResource struct {
id int
acquired bool
released bool
used bool
}
// TestBracket_Success tests that Bracket properly manages resource lifecycle on success
func TestBracket_Success(t *testing.T) {
resource := &mockResource{id: 1}
// Acquire resource
acquire := func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource {
resource.acquired = true
return resource
}
}
// Use resource
use := func(r *mockResource) ReaderIO[string] {
return func(ctx context.Context) io.IO[string] {
return func() string {
r.used = true
return "success"
}
}
}
// Release resource
release := func(r *mockResource, result string) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
r.released = true
return nil
}
}
}
// Execute bracket
operation := Bracket(acquire, use, release)
result := operation(context.Background())()
// Verify lifecycle
assert.True(t, resource.acquired, "Resource should be acquired")
assert.True(t, resource.used, "Resource should be used")
assert.True(t, resource.released, "Resource should be released")
assert.Equal(t, "success", result)
}
// TestBracket_MultipleResources tests managing multiple resources
func TestBracket_MultipleResources(t *testing.T) {
resource1 := &mockResource{id: 1}
resource2 := &mockResource{id: 2}
acquire1 := func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource {
resource1.acquired = true
return resource1
}
}
use1 := func(r1 *mockResource) ReaderIO[*mockResource] {
return func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource {
r1.used = true
resource2.acquired = true
return resource2
}
}
}
release1 := func(r1 *mockResource, result string) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
r1.released = true
return nil
}
}
}
// Nested bracket for second resource
use2 := func(r2 *mockResource) ReaderIO[string] {
return func(ctx context.Context) io.IO[string] {
return func() string {
r2.used = true
return "both used"
}
}
}
release2 := func(r2 *mockResource, result string) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
r2.released = true
return nil
}
}
}
// Compose brackets
operation := Bracket(acquire1, func(r1 *mockResource) ReaderIO[string] {
return func(ctx context.Context) io.IO[string] {
r2 := use1(r1)(ctx)()
return Bracket(
func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource { return r2 }
},
use2,
release2,
)(ctx)
}
}, release1)
result := operation(context.Background())()
assert.True(t, resource1.acquired)
assert.True(t, resource1.used)
assert.True(t, resource1.released)
assert.True(t, resource2.acquired)
assert.True(t, resource2.used)
assert.True(t, resource2.released)
assert.Equal(t, "both used", result)
}
// TestWithResource_Success tests WithResource with successful operation
func TestWithResource_Success(t *testing.T) {
resource := &mockResource{id: 1}
// Define resource management
withResource := WithResource[*mockResource, string, any](
func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource {
resource.acquired = true
return resource
}
},
func(r *mockResource) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
r.released = true
return nil
}
}
},
)
// Use resource
operation := withResource(func(r *mockResource) ReaderIO[string] {
return func(ctx context.Context) io.IO[string] {
return func() string {
r.used = true
return "result"
}
}
})
result := operation(context.Background())()
assert.True(t, resource.acquired)
assert.True(t, resource.used)
assert.True(t, resource.released)
assert.Equal(t, "result", result)
}
// TestWithResource_Reusability tests that WithResource can be reused with different operations
func TestWithResource_Reusability(t *testing.T) {
callCount := 0
withResource := WithResource[*mockResource, int, any](
func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource {
callCount++
return &mockResource{id: callCount, acquired: true}
}
},
func(r *mockResource) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
r.released = true
return nil
}
}
},
)
// First operation
op1 := withResource(func(r *mockResource) ReaderIO[int] {
return func(ctx context.Context) io.IO[int] {
return func() int {
r.used = true
return r.id * 2
}
}
})
result1 := op1(context.Background())()
assert.Equal(t, 2, result1)
assert.Equal(t, 1, callCount)
// Second operation (should create new resource)
op2 := withResource(func(r *mockResource) ReaderIO[int] {
return func(ctx context.Context) io.IO[int] {
return func() int {
r.used = true
return r.id * 3
}
}
})
result2 := op2(context.Background())()
assert.Equal(t, 6, result2)
assert.Equal(t, 2, callCount)
}
// TestWithResource_DifferentResultTypes tests WithResource with different result types
func TestWithResource_DifferentResultTypes(t *testing.T) {
resource := &mockResource{id: 42}
withResourceInt := WithResource[*mockResource, int, any](
func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource {
resource.acquired = true
return resource
}
},
func(r *mockResource) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
r.released = true
return nil
}
}
},
)
// Operation returning int
opInt := withResourceInt(func(r *mockResource) ReaderIO[int] {
return func(ctx context.Context) io.IO[int] {
return func() int {
return r.id
}
}
})
resultInt := opInt(context.Background())()
assert.Equal(t, 42, resultInt)
// Reset resource state
resource.acquired = false
resource.released = false
// Create new WithResource for string type
withResourceString := WithResource[*mockResource, string, any](
func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource {
resource.acquired = true
return resource
}
},
func(r *mockResource) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
r.released = true
return nil
}
}
},
)
// Operation returning string
opString := withResourceString(func(r *mockResource) ReaderIO[string] {
return func(ctx context.Context) io.IO[string] {
return func() string {
return "value"
}
}
})
resultString := opString(context.Background())()
assert.Equal(t, "value", resultString)
assert.True(t, resource.released)
}
// TestWithResource_ContextPropagation tests that context is properly propagated
func TestWithResource_ContextPropagation(t *testing.T) {
type contextKey string
const key contextKey = "test-key"
withResource := WithResource[string, string, any](
func(ctx context.Context) io.IO[string] {
return func() string {
value := ctx.Value(key)
if value != nil {
return value.(string)
}
return "no-value"
}
},
func(r string) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
return nil
}
}
},
)
operation := withResource(func(r string) ReaderIO[string] {
return func(ctx context.Context) io.IO[string] {
return func() string {
return r + "-processed"
}
}
})
ctx := context.WithValue(context.Background(), key, "test-value")
result := operation(ctx)()
assert.Equal(t, "test-value-processed", result)
}
// TestWithResource_ErrorInRelease tests behavior when release function encounters an error
func TestWithResource_ErrorInRelease(t *testing.T) {
resource := &mockResource{id: 1}
releaseError := errors.New("release failed")
withResource := WithResource[*mockResource, string, error](
func(ctx context.Context) io.IO[*mockResource] {
return func() *mockResource {
resource.acquired = true
return resource
}
},
func(r *mockResource) ReaderIO[error] {
return func(ctx context.Context) io.IO[error] {
return func() error {
r.released = true
return releaseError
}
}
},
)
operation := withResource(func(r *mockResource) ReaderIO[string] {
return func(ctx context.Context) io.IO[string] {
return func() string {
r.used = true
return "success"
}
}
})
result := operation(context.Background())()
// Operation should succeed even if release returns error
assert.Equal(t, "success", result)
assert.True(t, resource.acquired)
assert.True(t, resource.used)
assert.True(t, resource.released)
}
// BenchmarkBracket benchmarks the Bracket function
func BenchmarkBracket(b *testing.B) {
acquire := func(ctx context.Context) io.IO[int] {
return func() int {
return 42
}
}
use := func(n int) ReaderIO[int] {
return func(ctx context.Context) io.IO[int] {
return func() int {
return n * 2
}
}
}
release := func(n int, result int) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
return nil
}
}
}
operation := Bracket(acquire, use, release)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
operation(ctx)()
}
}
// BenchmarkWithResource benchmarks the WithResource function
func BenchmarkWithResource(b *testing.B) {
withResource := WithResource[int, int, any](
func(ctx context.Context) io.IO[int] {
return func() int {
return 42
}
},
func(n int) ReaderIO[any] {
return func(ctx context.Context) io.IO[any] {
return func() any {
return nil
}
}
},
)
operation := withResource(func(n int) ReaderIO[int] {
return func(ctx context.Context) io.IO[int] {
return func() int {
return n * 2
}
}
})
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
operation(ctx)()
}
}
// Made with Bob

View File

@@ -19,6 +19,9 @@ import (
"context"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/pair"
RIO "github.com/IBM/fp-go/v2/readerio"
)
// Promap is the profunctor map operation that transforms both the input and output of a context-based ReaderIO.
@@ -33,21 +36,24 @@ import (
// The function f returns both a new context and a CancelFunc that should be called to release resources.
//
// Type Parameters:
// - R: The input environment type that f transforms into context.Context
// - A: The original result type produced by the ReaderIO
// - B: The new output result type
//
// Parameters:
// - f: Function to transform the input context (contravariant)
// - f: Function to transform the input environment R into context.Context (contravariant)
// - g: Function to transform the output value from A to B (covariant)
//
// Returns:
// - An Operator that takes a ReaderIO[A] and returns a ReaderIO[B]
// - A Kleisli arrow that takes a ReaderIO[A] and returns a function from R to B
//
// Note: When R is context.Context, this simplifies to an Operator[A, B]
//
//go:inline
func Promap[A, B any](f func(context.Context) (context.Context, context.CancelFunc), g func(A) B) Operator[A, B] {
func Promap[R, A, B any](f pair.Kleisli[context.CancelFunc, R, context.Context], g func(A) B) RIO.Kleisli[R, ReaderIO[A], B] {
return function.Flow2(
Local[A](f),
Map(g),
RIO.Map[R](g),
)
}
@@ -61,14 +67,87 @@ func Promap[A, B any](f func(context.Context) (context.Context, context.CancelFu
//
// Type Parameters:
// - A: The result type (unchanged)
// - R: The input environment type that f transforms into context.Context
//
// Parameters:
// - f: Function to transform the context, returning a new context and CancelFunc
// - f: Function to transform the input environment R into context.Context, returning a new context and CancelFunc
//
// Returns:
// - An Operator that takes a ReaderIO[A] and returns a ReaderIO[A]
// - A Kleisli arrow that takes a ReaderIO[A] and returns a function from R to A
//
// Note: When R is context.Context, this simplifies to an Operator[A, A]
//
//go:inline
func Contramap[A any](f func(context.Context) (context.Context, context.CancelFunc)) Operator[A, A] {
func Contramap[A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) RIO.Kleisli[R, ReaderIO[A], A] {
return Local[A](f)
}
// LocalIOK transforms the context using an IO effect before passing it to a ReaderIO computation.
//
// This is similar to Local, but the context transformation itself is wrapped in an IO effect,
// allowing for side-effectful context transformations. The transformation function receives
// the current context and returns an IO effect that produces a new context along with a
// cancel function. The cancel function is automatically called when the computation completes
// (via defer), ensuring proper cleanup of resources.
//
// This is useful for:
// - Context transformations that require side effects (e.g., loading configuration)
// - Lazy initialization of context values
// - Context transformations that may fail or need to perform I/O
// - Composing effectful context setup with computations
//
// Type Parameters:
// - A: The value type of the ReaderIO
//
// Parameters:
// - f: An IO Kleisli arrow that transforms the context with side effects
//
// Returns:
// - An Operator that runs the computation with the effectfully transformed context
//
// Example:
//
// import (
// "context"
// G "github.com/IBM/fp-go/v2/io"
// F "github.com/IBM/fp-go/v2/function"
// )
//
// // Context transformation with side effects (e.g., loading config)
// loadConfig := func(ctx context.Context) G.IO[ContextCancel] {
// return func() ContextCancel {
// // Simulate loading configuration
// config := loadConfigFromFile()
// newCtx := context.WithValue(ctx, "config", config)
// return pair.MakePair[context.CancelFunc](func() {}, newCtx)
// }
// }
//
// getValue := readerio.FromReader(func(ctx context.Context) string {
// if cfg := ctx.Value("config"); cfg != nil {
// return cfg.(string)
// }
// return "default"
// })
//
// result := F.Pipe1(
// getValue,
// readerio.LocalIOK[string](loadConfig),
// )
// value := result(t.Context())() // Loads config and uses it
//
// Comparison with Local:
// - Local: Takes a pure function that transforms the context
// - LocalIOK: Takes an IO effect that transforms the context, allowing side effects
func LocalIOK[A any](f io.Kleisli[context.Context, ContextCancel]) Operator[A, A] {
return func(r ReaderIO[A]) ReaderIO[A] {
return func(ctx context.Context) IO[A] {
p := f(ctx)
return func() A {
otherCancel, otherCtx := pair.Unpack(p())
defer otherCancel()
return r(otherCtx)()
}
}
}
}

View File

@@ -21,6 +21,7 @@ import (
"testing"
"time"
"github.com/IBM/fp-go/v2/pair"
"github.com/stretchr/testify/assert"
)
@@ -38,9 +39,9 @@ func TestPromapBasic(t *testing.T) {
}
// Transform context and result
addKey := func(ctx context.Context) (context.Context, context.CancelFunc) {
addKey := func(ctx context.Context) ContextCancel {
newCtx := context.WithValue(ctx, "key", 42)
return newCtx, func() {}
return pair.MakePair[context.CancelFunc](func() {}, newCtx)
}
toString := strconv.Itoa
@@ -63,9 +64,9 @@ func TestContramapBasic(t *testing.T) {
}
}
addKey := func(ctx context.Context) (context.Context, context.CancelFunc) {
addKey := func(ctx context.Context) ContextCancel {
newCtx := context.WithValue(ctx, "key", 100)
return newCtx, func() {}
return pair.MakePair[context.CancelFunc](func() {}, newCtx)
}
adapted := Contramap[int](addKey)(getValue)
@@ -85,8 +86,9 @@ func TestLocalBasic(t *testing.T) {
}
}
addTimeout := func(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, time.Second)
addTimeout := func(ctx context.Context) ContextCancel {
newCtx, cancelFct := context.WithTimeout(ctx, time.Second)
return pair.MakePair(cancelFct, newCtx)
}
adapted := Local[bool](addTimeout)(getValue)
@@ -95,3 +97,81 @@ func TestLocalBasic(t *testing.T) {
assert.True(t, result)
})
}
// TestLocalIOKBasic tests basic LocalIOK functionality
func TestLocalIOKBasic(t *testing.T) {
t.Run("context transformation with IO effect", func(t *testing.T) {
getValue := func(ctx context.Context) IO[string] {
return func() string {
if v := ctx.Value("key"); v != nil {
return v.(string)
}
return "default"
}
}
// Context transformation wrapped in IO effect
addKeyIO := func(ctx context.Context) IO[ContextCancel] {
return func() ContextCancel {
// Simulate side effect (e.g., loading config)
newCtx := context.WithValue(ctx, "key", "loaded-value")
return pair.MakePair[context.CancelFunc](func() {}, newCtx)
}
}
adapted := LocalIOK[string](addKeyIO)(getValue)
result := adapted(t.Context())()
assert.Equal(t, "loaded-value", result)
})
t.Run("cleanup function is called", func(t *testing.T) {
cleanupCalled := false
getValue := func(ctx context.Context) IO[int] {
return func() int {
if v := ctx.Value("value"); v != nil {
return v.(int)
}
return 0
}
}
addValueIO := func(ctx context.Context) IO[ContextCancel] {
return func() ContextCancel {
newCtx := context.WithValue(ctx, "value", 42)
cleanup := context.CancelFunc(func() {
cleanupCalled = true
})
return pair.MakePair(cleanup, newCtx)
}
}
adapted := LocalIOK[int](addValueIO)(getValue)
result := adapted(t.Context())()
assert.Equal(t, 42, result)
assert.True(t, cleanupCalled, "cleanup function should be called")
})
t.Run("works with timeout context", func(t *testing.T) {
getValue := func(ctx context.Context) IO[bool] {
return func() bool {
_, hasDeadline := ctx.Deadline()
return hasDeadline
}
}
addTimeoutIO := func(ctx context.Context) IO[ContextCancel] {
return func() ContextCancel {
newCtx, cancelFct := context.WithTimeout(ctx, time.Second)
return pair.MakePair(cancelFct, newCtx)
}
}
adapted := LocalIOK[bool](addTimeoutIO)(getValue)
result := adapted(t.Context())()
assert.True(t, result, "context should have deadline")
})
}

View File

@@ -20,6 +20,7 @@ import (
"time"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/reader"
RIO "github.com/IBM/fp-go/v2/readerio"
)
@@ -633,12 +634,15 @@ func ReadIO[A any](r IO[context.Context]) func(ReaderIO[A]) IO[A] {
//
// Type Parameters:
// - A: The value type of the ReaderIO
// - R: The input environment type that f transforms into context.Context
//
// Parameters:
// - f: A function that transforms the context and returns a cancel function
// - f: A function that transforms the input environment R into context.Context and returns a cancel function
//
// Returns:
// - An Operator that runs the computation with the transformed context
// - A Kleisli arrow that runs the computation with the transformed context
//
// Note: When R is context.Context, this simplifies to an Operator[A, A]
//
// Example:
//
@@ -677,11 +681,11 @@ func ReadIO[A any](r IO[context.Context]) func(ReaderIO[A]) IO[A] {
// fetchData,
// withTimeout,
// )
func Local[A any](f func(context.Context) (context.Context, context.CancelFunc)) Operator[A, A] {
return func(rr ReaderIO[A]) ReaderIO[A] {
return func(ctx context.Context) IO[A] {
func Local[A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) RIO.Kleisli[R, ReaderIO[A], A] {
return func(rr ReaderIO[A]) RIO.ReaderIO[R, A] {
return func(r R) IO[A] {
return func() A {
otherCtx, otherCancel := f(ctx)
otherCancel, otherCtx := pair.Unpack(f(r))
defer otherCancel()
return rr(otherCtx)()
}
@@ -742,8 +746,9 @@ func Local[A any](f func(context.Context) (context.Context, context.CancelFunc))
// )
// data := result(t.Context())() // Returns Data{Value: "quick"}
func WithTimeout[A any](timeout time.Duration) Operator[A, A] {
return Local[A](func(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, timeout)
return Local[A](func(ctx context.Context) ContextCancel {
newCtx, cancelFct := context.WithTimeout(ctx, timeout)
return pair.MakePair(cancelFct, newCtx)
})
}
@@ -806,8 +811,9 @@ func WithTimeout[A any](timeout time.Duration) Operator[A, A] {
// )
// data := result(parentCtx)() // Will use parent's 1-hour deadline
func WithDeadline[A any](deadline time.Time) Operator[A, A] {
return Local[A](func(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithDeadline(ctx, deadline)
return Local[A](func(ctx context.Context) ContextCancel {
newCtx, cancelFct := context.WithDeadline(ctx, deadline)
return pair.MakePair(cancelFct, newCtx)
})
}

View File

@@ -23,6 +23,7 @@ import (
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/lazy"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/predicate"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/readerio"
@@ -81,4 +82,15 @@ type (
Predicate[A any] = predicate.Predicate[A]
Void = function.Void
// Pair represents a tuple of two values of types A and B.
// It is used to group two related values together.
Pair[A, B any] = pair.Pair[A, B]
// ContextCancel represents a pair of a cancel function and a context.
// It is used in operations that create new contexts with cancellation capabilities.
//
// The first element is the CancelFunc that should be called to release resources.
// The second element is the new Context that was created.
ContextCancel = Pair[context.CancelFunc, context.Context]
)

View File

@@ -28,6 +28,7 @@ import (
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/logging"
"github.com/IBM/fp-go/v2/option"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/reader"
"github.com/IBM/fp-go/v2/result"
)
@@ -90,132 +91,7 @@ func withLoggingContext(lctx loggingContext) Endomorphism[context.Context] {
return F.Bind2nd(withLoggingContextValue, any(lctx))
}
// LogEntryExitF creates a customizable operator that wraps a ReaderIOResult computation with entry/exit callbacks.
//
// This is a more flexible version of LogEntryExit that allows you to provide custom callbacks for
// entry and exit events. The onEntry callback receives the current context and can return a modified
// context (e.g., with additional logging information). The onExit callback receives the computation
// result and can perform custom logging, metrics collection, or cleanup.
//
// The function uses the bracket pattern to ensure that:
// - The onEntry callback is executed before the computation starts
// - The computation runs with the context returned by onEntry
// - The onExit callback is executed after the computation completes (success or failure)
// - The original result is preserved and returned unchanged
// - Cleanup happens even if the computation fails
//
// Type Parameters:
// - A: The success type of the ReaderIOResult
// - ANY: The return type of the onExit callback (typically any)
//
// Parameters:
// - onEntry: A ReaderIO that receives the current context and returns a (possibly modified) context.
// This is executed before the computation starts. Use this for logging entry, adding context values,
// starting timers, or initialization logic.
// - onExit: A Kleisli function that receives the Result[A] and returns a ReaderIO[ANY].
// This is executed after the computation completes, regardless of success or failure.
// Use this for logging exit, recording metrics, cleanup, or finalization logic.
//
// Returns:
// - An Operator that wraps the ReaderIOResult computation with the custom entry/exit callbacks
//
// Example with custom context modification:
//
// type RequestID string
//
// logOp := LogEntryExitF[User, any](
// func(ctx context.Context) IO[context.Context] {
// return func() context.Context {
// reqID := RequestID(uuid.New().String())
// log.Printf("[%s] Starting operation", reqID)
// return context.WithValue(ctx, "requestID", reqID)
// }
// },
// func(res Result[User]) ReaderIO[any] {
// return func(ctx context.Context) IO[any] {
// return func() any {
// reqID := ctx.Value("requestID").(RequestID)
// return F.Pipe1(
// res,
// result.Fold(
// func(err error) any {
// log.Printf("[%s] Operation failed: %v", reqID, err)
// return nil
// },
// func(_ User) any {
// log.Printf("[%s] Operation succeeded", reqID)
// return nil
// },
// ),
// )
// }
// }
// },
// )
//
// wrapped := logOp(fetchUser(123))
//
// Example with metrics collection:
//
// import "github.com/prometheus/client_golang/prometheus"
//
// metricsOp := LogEntryExitF[Response, any](
// func(ctx context.Context) IO[context.Context] {
// return func() context.Context {
// requestCount.WithLabelValues("api_call", "started").Inc()
// return context.WithValue(ctx, "startTime", time.Now())
// }
// },
// func(res Result[Response]) ReaderIO[any] {
// return func(ctx context.Context) IO[any] {
// return func() any {
// startTime := ctx.Value("startTime").(time.Time)
// duration := time.Since(startTime).Seconds()
//
// return F.Pipe1(
// res,
// result.Fold(
// func(err error) any {
// requestCount.WithLabelValues("api_call", "error").Inc()
// requestDuration.WithLabelValues("api_call", "error").Observe(duration)
// return nil
// },
// func(_ Response) any {
// requestCount.WithLabelValues("api_call", "success").Inc()
// requestDuration.WithLabelValues("api_call", "success").Observe(duration)
// return nil
// },
// ),
// )
// }
// }
// },
// )
//
// Use Cases:
// - Custom context modification: Adding request IDs, trace IDs, or other context values
// - Structured logging: Integration with zap, logrus, or other structured loggers
// - Metrics collection: Recording operation durations, success/failure rates
// - Distributed tracing: OpenTelemetry, Jaeger integration
// - Custom monitoring: Application-specific monitoring and alerting
//
// Note: LogEntryExit is implemented using LogEntryExitF with standard logging and context management.
// Use LogEntryExitF when you need more control over the entry/exit behavior or context modification.
func LogEntryExitF[A, ANY any](
onEntry ReaderIO[context.Context],
onExit readerio.Kleisli[Result[A], ANY],
) Operator[A, A] {
bracket := F.Bind13of3(readerio.Bracket[context.Context, Result[A], ANY])(onEntry, func(newCtx context.Context, res Result[A]) ReaderIO[ANY] {
return readerio.FromIO(onExit(res)(newCtx)) // Get the exit callback for this result
})
return func(src ReaderIOResult[A]) ReaderIOResult[A] {
return bracket(F.Flow2(
src,
FromIOResult,
))
}
}
func noop() {}
// onEntry creates a ReaderIO that handles the entry logging for an operation.
// It generates a unique logging ID, captures the start time, and logs the entry message.
@@ -230,15 +106,15 @@ func LogEntryExitF[A, ANY any](
// - A ReaderIO that prepares the context with logging information and logs the entry
func onEntry(
logLevel slog.Level,
cb func(context.Context) *slog.Logger,
cb Reader[context.Context, *slog.Logger],
nameAttr slog.Attr,
) ReaderIO[context.Context] {
) ReaderIO[ContextCancel] {
return func(ctx context.Context) IO[context.Context] {
return func(ctx context.Context) IO[ContextCancel] {
// logger
logger := cb(ctx)
return func() context.Context {
return func() ContextCancel {
// check if the logger is enabled
if logger.Enabled(ctx, logLevel) {
// Generate unique logging ID and capture start time
@@ -258,19 +134,23 @@ func onEntry(
})
withLogger := logging.WithLogger(newLogger)
return withCtx(withLogger(ctx))
return F.Pipe2(
ctx,
withLogger,
pair.Map[context.CancelFunc](withCtx),
)
}
// logging disabled
withCtx := withLoggingContext(loggingContext{
logger: logger,
isEnabled: false,
})
return withCtx(ctx)
return pair.MakePair[context.CancelFunc](noop, withCtx(ctx))
}
}
}
// onExitAny creates a Kleisli function that handles exit logging for an operation.
// onExitVoid creates a Kleisli function that handles exit logging for an operation.
// It logs either success or error based on the Result, including the operation duration.
// Only logs if logging was enabled during entry (checked via loggingContext.isEnabled).
//
@@ -280,33 +160,33 @@ func onEntry(
//
// Returns:
// - A Kleisli function that logs the exit/error and returns nil
func onExitAny(
func onExitVoid(
logLevel slog.Level,
nameAttr slog.Attr,
) readerio.Kleisli[Result[any], any] {
return func(res Result[any]) ReaderIO[any] {
return func(ctx context.Context) IO[any] {
) readerio.Kleisli[Result[Void], Void] {
return func(res Result[Void]) ReaderIO[Void] {
return func(ctx context.Context) IO[Void] {
value := getLoggingContext(ctx)
if value.isEnabled {
return func() any {
return func() Void {
// Retrieve logging information from context
durationAttr := slog.Duration("duration", time.Since(value.startTime))
// Log error with ID and duration
onError := func(err error) any {
onError := func(err error) Void {
value.logger.LogAttrs(ctx, logLevel, "[throwing]",
nameAttr,
durationAttr,
slog.Any("error", err))
return nil
return F.VOID
}
// Log success with ID and duration
onSuccess := func(_ any) any {
onSuccess := func(v Void) Void {
value.logger.LogAttrs(ctx, logLevel, "[exiting ]", nameAttr, durationAttr)
return nil
return v
}
return F.Pipe1(
@@ -316,7 +196,7 @@ func onExitAny(
}
}
// nothing to do
return io.Of[any](nil)
return io.Of(F.VOID)
}
}
}
@@ -374,13 +254,21 @@ func LogEntryExitWithCallback[A any](
nameAttr := slog.String("name", name)
return LogEntryExitF(
entry := F.Pipe1(
onEntry(logLevel, cb, nameAttr),
F.Flow2(
result.MapTo[A, any](nil),
onExitAny(logLevel, nameAttr),
),
readerio.LocalIOK[Result[A]],
)
exit := readerio.Tap(F.Flow2(
result.MapTo[A](F.VOID),
onExitVoid(logLevel, nameAttr),
))
return F.Flow2(
exit,
entry,
)
}
// LogEntryExit creates an operator that logs the entry and exit of a ReaderIOResult computation with timing and correlation IDs.

View File

@@ -0,0 +1,417 @@
// Copyright (c) 2023 - 2025 IBM Corp.
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package readerioresult
import (
"bytes"
"errors"
"log/slog"
"strings"
"testing"
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/logging"
N "github.com/IBM/fp-go/v2/number"
"github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
// TestTapSLogComprehensive_Success verifies TapSLog logs successful values
func TestTapSLogComprehensive_Success(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
oldLogger := logging.SetLogger(logger)
defer logging.SetLogger(oldLogger)
t.Run("logs integer success value", func(t *testing.T) {
buf.Reset()
pipeline := F.Pipe2(
Of(42),
TapSLog[int]("Integer value"),
Map(N.Mul(2)),
)
res := pipeline(t.Context())()
// Verify result is correct
assert.Equal(t, 84, F.Pipe1(res, getOrZero))
// Verify logging occurred
logOutput := buf.String()
assert.Contains(t, logOutput, "Integer value", "Should log the message")
assert.Contains(t, logOutput, "value=42", "Should log the success value")
assert.NotContains(t, logOutput, "error", "Should not contain error keyword for success")
})
t.Run("logs string success value", func(t *testing.T) {
buf.Reset()
pipeline := F.Pipe1(
Of("hello world"),
TapSLog[string]("String value"),
)
res := pipeline(t.Context())()
// Verify result is correct
assert.True(t, F.Pipe1(res, isRight[string]))
// Verify logging occurred
logOutput := buf.String()
assert.Contains(t, logOutput, "String value")
assert.Contains(t, logOutput, `value="hello world"`)
})
t.Run("logs struct success value", func(t *testing.T) {
buf.Reset()
type User struct {
ID int
Name string
}
user := User{ID: 123, Name: "Alice"}
pipeline := F.Pipe1(
Of(user),
TapSLog[User]("User struct"),
)
res := pipeline(t.Context())()
// Verify result is correct
assert.True(t, F.Pipe1(res, isRight[User]))
// Verify logging occurred with struct fields
logOutput := buf.String()
assert.Contains(t, logOutput, "User struct")
assert.Contains(t, logOutput, "ID:123")
assert.Contains(t, logOutput, "Name:Alice")
})
t.Run("logs multiple success values in pipeline", func(t *testing.T) {
buf.Reset()
step1 := F.Pipe2(
Of(10),
TapSLog[int]("Initial value"),
Map(N.Mul(2)),
)
pipeline := F.Pipe2(
step1,
TapSLog[int]("After doubling"),
Map(N.Add(5)),
)
res := pipeline(t.Context())()
// Verify result is correct
assert.Equal(t, 25, getOrZero(res))
// Verify both log entries
logOutput := buf.String()
assert.Contains(t, logOutput, "Initial value")
assert.Contains(t, logOutput, "value=10")
assert.Contains(t, logOutput, "After doubling")
assert.Contains(t, logOutput, "value=20")
})
}
// TestTapSLogComprehensive_Error verifies TapSLog behavior with errors
func TestTapSLogComprehensive_Error(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
oldLogger := logging.SetLogger(logger)
defer logging.SetLogger(oldLogger)
t.Run("logs error values", func(t *testing.T) {
buf.Reset()
testErr := errors.New("test error")
pipeline := F.Pipe2(
Left[int](testErr),
TapSLog[int]("Error case"),
Map(N.Mul(2)),
)
res := pipeline(t.Context())()
// Verify error is preserved
assert.True(t, F.Pipe1(res, isLeft[int]))
// Verify logging occurred for error
logOutput := buf.String()
assert.Contains(t, logOutput, "Error case", "Should log the message")
assert.Contains(t, logOutput, "error", "Should contain error keyword")
assert.Contains(t, logOutput, "test error", "Should log the error message")
assert.NotContains(t, logOutput, "value=", "Should not log 'value=' for errors")
})
t.Run("preserves error through pipeline", func(t *testing.T) {
buf.Reset()
originalErr := errors.New("original error")
step1 := F.Pipe2(
Left[int](originalErr),
TapSLog[int]("First tap"),
Map(N.Mul(2)),
)
pipeline := F.Pipe2(
step1,
TapSLog[int]("Second tap"),
Map(N.Add(5)),
)
res := pipeline(t.Context())()
// Verify error is preserved
assert.True(t, isLeft(res))
// Verify both taps logged the error
logOutput := buf.String()
errorCount := strings.Count(logOutput, "original error")
assert.Equal(t, 2, errorCount, "Both TapSLog calls should log the error")
assert.Contains(t, logOutput, "First tap")
assert.Contains(t, logOutput, "Second tap")
})
t.Run("logs error after successful operation", func(t *testing.T) {
buf.Reset()
pipeline := F.Pipe3(
Of(10),
TapSLog[int]("Before error"),
Chain(func(n int) ReaderIOResult[int] {
return Left[int](errors.New("chain error"))
}),
TapSLog[int]("After error"),
)
res := pipeline(t.Context())()
// Verify error is present
assert.True(t, F.Pipe1(res, isLeft[int]))
// Verify both logs
logOutput := buf.String()
assert.Contains(t, logOutput, "Before error")
assert.Contains(t, logOutput, "value=10")
assert.Contains(t, logOutput, "After error")
assert.Contains(t, logOutput, "chain error")
})
}
// TestTapSLogComprehensive_EdgeCases verifies TapSLog with edge cases
func TestTapSLogComprehensive_EdgeCases(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
oldLogger := logging.SetLogger(logger)
defer logging.SetLogger(oldLogger)
t.Run("logs zero value", func(t *testing.T) {
buf.Reset()
pipeline := F.Pipe1(
Of(0),
TapSLog[int]("Zero value"),
)
res := pipeline(t.Context())()
assert.Equal(t, 0, F.Pipe1(res, getOrZero))
logOutput := buf.String()
assert.Contains(t, logOutput, "Zero value")
assert.Contains(t, logOutput, "value=0")
})
t.Run("logs empty string", func(t *testing.T) {
buf.Reset()
pipeline := F.Pipe1(
Of(""),
TapSLog[string]("Empty string"),
)
res := pipeline(t.Context())()
assert.True(t, F.Pipe1(res, isRight[string]))
logOutput := buf.String()
assert.Contains(t, logOutput, "Empty string")
assert.Contains(t, logOutput, `value=""`)
})
t.Run("logs nil pointer", func(t *testing.T) {
buf.Reset()
type Data struct {
Value string
}
var nilData *Data
pipeline := F.Pipe1(
Of(nilData),
TapSLog[*Data]("Nil pointer"),
)
res := pipeline(t.Context())()
assert.True(t, F.Pipe1(res, isRight[*Data]))
logOutput := buf.String()
assert.Contains(t, logOutput, "Nil pointer")
// Nil representation may vary, but should be logged
assert.NotEmpty(t, logOutput)
})
t.Run("respects logger level - disabled", func(t *testing.T) {
buf.Reset()
// Create logger that only logs errors
errorLogger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelError,
}))
oldLogger := logging.SetLogger(errorLogger)
defer logging.SetLogger(oldLogger)
pipeline := F.Pipe1(
Of(42),
TapSLog[int]("Should not log"),
)
res := pipeline(t.Context())()
assert.Equal(t, 42, F.Pipe1(res, getOrZero))
// Should have no logs since level is ERROR
logOutput := buf.String()
assert.Empty(t, logOutput, "Should not log when level is disabled")
})
}
// TestTapSLogComprehensive_Integration verifies TapSLog in realistic scenarios
func TestTapSLogComprehensive_Integration(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
oldLogger := logging.SetLogger(logger)
defer logging.SetLogger(oldLogger)
t.Run("complex pipeline with mixed success and error", func(t *testing.T) {
buf.Reset()
// Simulate a data processing pipeline
validatePositive := func(n int) ReaderIOResult[int] {
if n > 0 {
return Of(n)
}
return Left[int](errors.New("number must be positive"))
}
step1 := F.Pipe3(
Of(5),
TapSLog[int]("Input received"),
Map(N.Mul(2)),
TapSLog[int]("After multiplication"),
)
pipeline := F.Pipe2(
step1,
Chain(validatePositive),
TapSLog[int]("After validation"),
)
res := pipeline(t.Context())()
assert.Equal(t, 10, getOrZero(res))
logOutput := buf.String()
assert.Contains(t, logOutput, "Input received")
assert.Contains(t, logOutput, "value=5")
assert.Contains(t, logOutput, "After multiplication")
assert.Contains(t, logOutput, "value=10")
assert.Contains(t, logOutput, "After validation")
assert.Contains(t, logOutput, "value=10")
})
t.Run("error propagation with logging", func(t *testing.T) {
buf.Reset()
validatePositive := func(n int) ReaderIOResult[int] {
if n > 0 {
return Of(n)
}
return Left[int](errors.New("number must be positive"))
}
step1 := F.Pipe3(
Of(-5),
TapSLog[int]("Input received"),
Map(N.Mul(2)),
TapSLog[int]("After multiplication"),
)
pipeline := F.Pipe2(
step1,
Chain(validatePositive),
TapSLog[int]("After validation"),
)
res := pipeline(t.Context())()
assert.True(t, isLeft(res))
logOutput := buf.String()
// First two taps should log success
assert.Contains(t, logOutput, "Input received")
assert.Contains(t, logOutput, "value=-5")
assert.Contains(t, logOutput, "After multiplication")
assert.Contains(t, logOutput, "value=-10")
// Last tap should log error
assert.Contains(t, logOutput, "After validation")
assert.Contains(t, logOutput, "number must be positive")
})
}
// Helper functions for tests
func getOrZero(res Result[int]) int {
val, err := result.Unwrap(res)
if err == nil {
return val
}
return 0
}
func isRight[A any](res Result[A]) bool {
return result.IsRight(res)
}
func isLeft[A any](res Result[A]) bool {
return result.IsLeft(res)
}
// Made with Bob

View File

@@ -13,6 +13,7 @@ import (
F "github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/logging"
N "github.com/IBM/fp-go/v2/number"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/result"
S "github.com/IBM/fp-go/v2/string"
"github.com/stretchr/testify/assert"
@@ -53,6 +54,11 @@ func TestLogEntryExitSuccess(t *testing.T) {
assert.Contains(t, logOutput, "TestOperation")
assert.Contains(t, logOutput, "ID=")
assert.Contains(t, logOutput, "duration=")
// Verify entry log appears before exit log
enteringIdx := strings.Index(logOutput, "[entering]")
exitingIdx := strings.Index(logOutput, "[exiting ]")
assert.Greater(t, exitingIdx, enteringIdx, "Exit log should appear after entry log")
}
// TestLogEntryExitError tests error operation logging
@@ -81,6 +87,11 @@ func TestLogEntryExitError(t *testing.T) {
assert.Contains(t, logOutput, "test error")
assert.Contains(t, logOutput, "ID=")
assert.Contains(t, logOutput, "duration=")
// Verify entry log appears before error log
enteringIdx := strings.Index(logOutput, "[entering]")
throwingIdx := strings.Index(logOutput, "[throwing]")
assert.Greater(t, throwingIdx, enteringIdx, "Error log should appear after entry log")
}
// TestLogEntryExitNested tests nested operations with different IDs
@@ -119,6 +130,48 @@ func TestLogEntryExitNested(t *testing.T) {
exitCount := strings.Count(logOutput, "[exiting ]")
assert.Equal(t, 2, enterCount, "Should have 2 entering logs")
assert.Equal(t, 2, exitCount, "Should have 2 exiting logs")
// Verify log ordering: Each operation logs entry before exit
// Note: Due to Chain semantics, OuterOp completes before InnerOp starts
lines := strings.Split(logOutput, "\n")
var logSequence []string
for _, line := range lines {
if strings.Contains(line, "OuterOp") && strings.Contains(line, "[entering]") {
logSequence = append(logSequence, "OuterOp-entering")
} else if strings.Contains(line, "OuterOp") && strings.Contains(line, "[exiting ]") {
logSequence = append(logSequence, "OuterOp-exiting")
} else if strings.Contains(line, "InnerOp") && strings.Contains(line, "[entering]") {
logSequence = append(logSequence, "InnerOp-entering")
} else if strings.Contains(line, "InnerOp") && strings.Contains(line, "[exiting ]") {
logSequence = append(logSequence, "InnerOp-exiting")
}
}
// Verify each operation's entry comes before its exit
assert.Equal(t, 4, len(logSequence), "Should have 4 log entries")
// Find indices
outerEnterIdx := -1
outerExitIdx := -1
innerEnterIdx := -1
innerExitIdx := -1
for i, log := range logSequence {
switch log {
case "OuterOp-entering":
outerEnterIdx = i
case "OuterOp-exiting":
outerExitIdx = i
case "InnerOp-entering":
innerEnterIdx = i
case "InnerOp-exiting":
innerExitIdx = i
}
}
// Verify entry before exit for each operation
assert.Greater(t, outerExitIdx, outerEnterIdx, "OuterOp exit should come after OuterOp entry")
assert.Greater(t, innerExitIdx, innerEnterIdx, "InnerOp exit should come after InnerOp entry")
}
// TestLogEntryExitWithCallback tests custom log level and callback
@@ -172,76 +225,6 @@ func TestLogEntryExitDisabled(t *testing.T) {
assert.Empty(t, logOutput, "Should have no logs when logging is disabled")
}
// TestLogEntryExitF tests custom entry/exit callbacks
func TestLogEntryExitF(t *testing.T) {
var entryCount, exitCount int
onEntry := func(ctx context.Context) IO[context.Context] {
return func() context.Context {
entryCount++
return ctx
}
}
onExit := func(res Result[string]) ReaderIO[any] {
return func(ctx context.Context) IO[any] {
return func() any {
exitCount++
return nil
}
}
}
operation := F.Pipe1(
Of("test"),
LogEntryExitF(onEntry, onExit),
)
res := operation(t.Context())()
assert.True(t, result.IsRight(res))
assert.Equal(t, 1, entryCount, "Entry callback should be called once")
assert.Equal(t, 1, exitCount, "Exit callback should be called once")
}
// TestLogEntryExitFWithError tests custom callbacks with error
func TestLogEntryExitFWithError(t *testing.T) {
var entryCount, exitCount int
var capturedError error
onEntry := func(ctx context.Context) IO[context.Context] {
return func() context.Context {
entryCount++
return ctx
}
}
onExit := func(res Result[string]) ReaderIO[any] {
return func(ctx context.Context) IO[any] {
return func() any {
exitCount++
if result.IsLeft(res) {
_, capturedError = result.Unwrap(res)
}
return nil
}
}
}
testErr := errors.New("custom error")
operation := F.Pipe1(
Left[string](testErr),
LogEntryExitF(onEntry, onExit),
)
res := operation(t.Context())()
assert.True(t, result.IsLeft(res))
assert.Equal(t, 1, entryCount, "Entry callback should be called once")
assert.Equal(t, 1, exitCount, "Exit callback should be called once")
assert.Equal(t, testErr, capturedError, "Should capture the error")
}
// TestLoggingIDUniqueness tests that logging IDs are unique
func TestLoggingIDUniqueness(t *testing.T) {
var buf bytes.Buffer
@@ -287,7 +270,8 @@ func TestLogEntryExitWithContextLogger(t *testing.T) {
Level: slog.LevelInfo,
}))
ctx := logging.WithLogger(contextLogger)(t.Context())
cancelFct, ctx := pair.Unpack(logging.WithLogger(contextLogger)(t.Context()))
defer cancelFct()
operation := F.Pipe1(
Of("context value"),
@@ -546,7 +530,8 @@ func TestTapSLogWithContextLogger(t *testing.T) {
Level: slog.LevelInfo,
}))
ctx := logging.WithLogger(contextLogger)(t.Context())
cancelFct, ctx := pair.Unpack(logging.WithLogger(contextLogger)(t.Context()))
defer cancelFct()
operation := F.Pipe2(
Of("test value"),
@@ -660,3 +645,138 @@ func TestSLogWithCallbackLogsError(t *testing.T) {
assert.Contains(t, logOutput, "warning error")
assert.Contains(t, logOutput, "level=WARN")
}
// TestTapSLogPreservesResult tests that TapSLog doesn't modify the result
func TestTapSLogPreservesResult(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
oldLogger := logging.SetLogger(logger)
defer logging.SetLogger(oldLogger)
// Test with success value
successOp := F.Pipe2(
Of(42),
TapSLog[int]("Success value"),
Map(N.Mul(2)),
)
successRes := successOp(t.Context())()
assert.Equal(t, result.Of(84), successRes)
// Test with error value
testErr := errors.New("test error")
errorOp := F.Pipe2(
Left[int](testErr),
TapSLog[int]("Error value"),
Map(N.Mul(2)),
)
errorRes := errorOp(t.Context())()
assert.True(t, result.IsLeft(errorRes))
// Verify the error is preserved
_, err := result.Unwrap(errorRes)
assert.Equal(t, testErr, err)
}
// TestTapSLogChainBehavior tests that TapSLog properly chains with other operations
func TestTapSLogChainBehavior(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
oldLogger := logging.SetLogger(logger)
defer logging.SetLogger(oldLogger)
// Create a pipeline with multiple TapSLog calls
step1 := F.Pipe2(
Of(1),
TapSLog[int]("Step 1"),
Map(N.Mul(2)),
)
step2 := F.Pipe2(
step1,
TapSLog[int]("Step 2"),
Map(N.Mul(3)),
)
pipeline := F.Pipe1(
step2,
TapSLog[int]("Step 3"),
)
res := pipeline(t.Context())()
assert.Equal(t, result.Of(6), res)
logOutput := buf.String()
// Verify all steps were logged
assert.Contains(t, logOutput, "Step 1")
assert.Contains(t, logOutput, "value=1")
assert.Contains(t, logOutput, "Step 2")
assert.Contains(t, logOutput, "value=2")
assert.Contains(t, logOutput, "Step 3")
assert.Contains(t, logOutput, "value=6")
}
// TestTapSLogWithNilValue tests TapSLog with nil pointer values
func TestTapSLogWithNilValue(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
oldLogger := logging.SetLogger(logger)
defer logging.SetLogger(oldLogger)
type Data struct {
Value string
}
// Test with nil pointer
var nilData *Data
operation := F.Pipe1(
Of(nilData),
TapSLog[*Data]("Nil pointer value"),
)
res := operation(t.Context())()
assert.True(t, result.IsRight(res))
logOutput := buf.String()
assert.Contains(t, logOutput, "Nil pointer value")
// The exact representation of nil may vary, but it should be logged
assert.NotEmpty(t, logOutput)
}
// TestTapSLogLogsErrors verifies that TapSLog DOES log errors
// TapSLog uses SLog internally, which logs both success values and errors
func TestTapSLogLogsErrors(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
oldLogger := logging.SetLogger(logger)
defer logging.SetLogger(oldLogger)
testErr := errors.New("test error message")
pipeline := F.Pipe2(
Left[int](testErr),
TapSLog[int]("Error logging test"),
Map(N.Mul(2)),
)
res := pipeline(t.Context())()
// Verify the error is preserved
assert.True(t, result.IsLeft(res))
// Verify logging occurred for the error
logOutput := buf.String()
assert.NotEmpty(t, logOutput, "TapSLog should log when the Result is an error")
assert.Contains(t, logOutput, "Error logging test")
assert.Contains(t, logOutput, "error")
assert.Contains(t, logOutput, "test error message")
}

View File

@@ -22,6 +22,7 @@ import (
"github.com/IBM/fp-go/v2/io"
"github.com/IBM/fp-go/v2/ioresult"
"github.com/IBM/fp-go/v2/pair"
RIOR "github.com/IBM/fp-go/v2/readerioresult"
"github.com/IBM/fp-go/v2/result"
)
@@ -38,21 +39,24 @@ import (
// The error type is fixed as error and remains unchanged through the transformation.
//
// Type Parameters:
// - R: The input environment type that f transforms into context.Context
// - A: The original success type produced by the ReaderIOResult
// - B: The new output success type
//
// Parameters:
// - f: Function to transform the input context (contravariant)
// - f: Function to transform the input environment R into context.Context (contravariant)
// - g: Function to transform the output success value from A to B (covariant)
//
// Returns:
// - An Operator that takes a ReaderIOResult[A] and returns a ReaderIOResult[B]
// - A Kleisli arrow that takes a ReaderIOResult[A] and returns a function from R to B
//
// Note: When R is context.Context, this simplifies to an Operator[A, B]
//
//go:inline
func Promap[A, B any](f pair.Kleisli[context.CancelFunc, context.Context, context.Context], g func(A) B) Operator[A, B] {
func Promap[R, A, B any](f pair.Kleisli[context.CancelFunc, R, context.Context], g func(A) B) RIOR.Kleisli[R, ReaderIOResult[A], B] {
return function.Flow2(
Local[A](f),
Map(g),
RIOR.Map[R](g),
)
}
@@ -66,18 +70,41 @@ func Promap[A, B any](f pair.Kleisli[context.CancelFunc, context.Context, contex
//
// Type Parameters:
// - A: The success type (unchanged)
// - R: The input environment type that f transforms into context.Context
//
// Parameters:
// - f: Function to transform the context, returning a new context and CancelFunc
// - f: Function to transform the input environment R into context.Context, returning a new context and CancelFunc
//
// Returns:
// - A Kleisli arrow that takes a ReaderIOResult[A] and returns a function from R to A
//
// Note: When R is context.Context, this simplifies to an Operator[A, A]
//
//go:inline
func Contramap[A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) RIOR.Kleisli[R, ReaderIOResult[A], A] {
return Local[A](f)
}
// ContramapIOK changes the context during the execution of a ReaderIOResult using an IO effect.
// This is the contravariant functor operation with IO effects.
//
// ContramapIOK is an alias for LocalIOK and is useful for adapting a ReaderIOResult to work with
// a modified context when the transformation itself requires side effects.
//
// Type Parameters:
// - A: The success type (unchanged)
//
// Parameters:
// - f: An IO Kleisli arrow that transforms the context with side effects
//
// Returns:
// - An Operator that takes a ReaderIOResult[A] and returns a ReaderIOResult[A]
//
// See Also:
// - Contramap: For pure context transformations
// - LocalIOK: The underlying implementation
//
//go:inline
func Contramap[A any](f pair.Kleisli[context.CancelFunc, context.Context, context.Context]) Operator[A, A] {
return Local[A](f)
}
func ContramapIOK[A any](f io.Kleisli[context.Context, ContextCancel]) Operator[A, A] {
return LocalIOK[A](f)
}
@@ -189,8 +216,6 @@ func LocalIOK[A any](f io.Kleisli[context.Context, ContextCancel]) Operator[A, A
//
// - Local: For pure context transformations
// - LocalIOK: For context transformations with side effects that cannot fail
//
//go:inline
func LocalIOResultK[A any](f ioresult.Kleisli[context.Context, ContextCancel]) Operator[A, A] {
return func(rr ReaderIOResult[A]) ReaderIOResult[A] {
return func(ctx context.Context) IOResult[A] {

View File

@@ -77,6 +77,105 @@ func TestContramapBasic(t *testing.T) {
})
}
// TestContramapIOK tests ContramapIOK functionality
func TestContramapIOK(t *testing.T) {
t.Run("transforms context with IO effect", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[string] {
return func() R.Result[string] {
if v := ctx.Value("requestID"); v != nil {
return R.Of(v.(string))
}
return R.Of("no-id")
}
}
addRequestID := func(ctx context.Context) io.IO[ContextCancel] {
return func() ContextCancel {
// Simulate generating a request ID via side effect
requestID := "req-12345"
newCtx := context.WithValue(ctx, "requestID", requestID)
return pair.MakePair(context.CancelFunc(func() {}), newCtx)
}
}
adapted := ContramapIOK[string](addRequestID)(getValue)
result := adapted(t.Context())()
assert.Equal(t, R.Of("req-12345"), result)
})
t.Run("preserves value type", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[int] {
return func() R.Result[int] {
if v := ctx.Value("counter"); v != nil {
return R.Of(v.(int))
}
return R.Of(0)
}
}
addCounter := func(ctx context.Context) io.IO[ContextCancel] {
return func() ContextCancel {
newCtx := context.WithValue(ctx, "counter", 999)
return pair.MakePair(context.CancelFunc(func() {}), newCtx)
}
}
adapted := ContramapIOK[int](addCounter)(getValue)
result := adapted(t.Context())()
assert.Equal(t, R.Of(999), result)
})
t.Run("calls cancel function", func(t *testing.T) {
cancelCalled := false
getValue := func(ctx context.Context) IOResult[string] {
return func() R.Result[string] {
return R.Of("test")
}
}
addData := func(ctx context.Context) io.IO[ContextCancel] {
return func() ContextCancel {
newCtx := context.WithValue(ctx, "data", "value")
cancelFunc := context.CancelFunc(func() {
cancelCalled = true
})
return pair.MakePair(cancelFunc, newCtx)
}
}
adapted := ContramapIOK[string](addData)(getValue)
_ = adapted(t.Context())()
assert.True(t, cancelCalled, "cancel function should be called")
})
t.Run("handles cancelled context", func(t *testing.T) {
getValue := func(ctx context.Context) IOResult[string] {
return func() R.Result[string] {
return R.Of("should not reach here")
}
}
addData := func(ctx context.Context) io.IO[ContextCancel] {
return func() ContextCancel {
newCtx := context.WithValue(ctx, "data", "value")
return pair.MakePair(context.CancelFunc(func() {}), newCtx)
}
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
adapted := ContramapIOK[string](addData)(getValue)
result := adapted(ctx)()
assert.True(t, R.IsLeft(result))
})
}
// TestLocalBasic tests basic Local functionality
func TestLocalBasic(t *testing.T) {
t.Run("adds value to context", func(t *testing.T) {

View File

@@ -32,7 +32,6 @@ import (
"github.com/IBM/fp-go/v2/reader"
RIOR "github.com/IBM/fp-go/v2/readerioresult"
"github.com/IBM/fp-go/v2/readeroption"
"github.com/IBM/fp-go/v2/result"
)
const (
@@ -1011,12 +1010,15 @@ func TapLeftIOK[A, B any](f io.Kleisli[error, B]) Operator[A, A] {
//
// Type Parameters:
// - A: The value type of the ReaderIOResult
// - R: The input environment type that f transforms into context.Context
//
// Parameters:
// - f: A function that transforms the context and returns a cancel function
// - f: A function that transforms the input environment R into context.Context and returns a cancel function
//
// Returns:
// - An Operator that runs the computation with the transformed context
// - A Kleisli arrow that runs the computation with the transformed context
//
// Note: When R is context.Context, this simplifies to an Operator[A, A]
//
// Example:
//
@@ -1055,19 +1057,10 @@ func TapLeftIOK[A, B any](f io.Kleisli[error, B]) Operator[A, A] {
// fetchData,
// withTimeout,
// )
func Local[A any](f pair.Kleisli[context.CancelFunc, context.Context, context.Context]) Operator[A, A] {
return func(rr ReaderIOResult[A]) ReaderIOResult[A] {
return func(ctx context.Context) IOResult[A] {
return func() Result[A] {
if ctx.Err() != nil {
return result.Left[A](context.Cause(ctx))
}
otherCancel, otherCtx := pair.Unpack(f(ctx))
defer otherCancel()
return rr(otherCtx)()
}
}
}
//
//go:inline
func Local[A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) RIOR.Kleisli[R, ReaderIOResult[A], A] {
return readerio.Local[Result[A]](f)
}
// WithTimeout adds a timeout to the context for a ReaderIOResult computation.

View File

@@ -13,6 +13,17 @@ import (
// Local modifies the outer environment before passing it to a computation.
// Useful for providing different configurations to sub-computations.
//
// Type Parameters:
// - A: The success type produced by the ReaderReaderIOResult
// - R1: The original outer environment type expected by the ReaderReaderIOResult
// - R2: The new input outer environment type
//
// Parameters:
// - f: A function that transforms R2 to R1
//
// Returns:
// - A function that takes a ReaderReaderIOResult[R1, A] and returns a ReaderReaderIOResult[R2, A]
//
//go:inline
func Local[A, R1, R2 any](f func(R2) R1) func(ReaderReaderIOResult[R1, A]) ReaderReaderIOResult[R2, A] {
return RRIOE.Local[context.Context, error, A](f)
@@ -102,6 +113,29 @@ func LocalIOResultK[A, R1, R2 any](f ioresult.Kleisli[R2, R1]) func(ReaderReader
return RRIOE.LocalIOEitherK[context.Context, A](f)
}
// LocalResultK transforms the outer environment of a ReaderReaderIOResult using a Result-based Kleisli arrow.
// It allows you to modify the outer environment through a pure computation that can fail before
// passing it to the ReaderReaderIOResult.
//
// This is useful when the outer environment transformation is a pure computation that can fail,
// such as parsing, validation, or data transformation that doesn't require IO effects.
//
// The transformation happens in two stages:
// 1. The Result function f is executed with the R2 environment to produce Result[R1]
// 2. If successful (Ok), the R1 value is passed as the outer environment to the ReaderReaderIOResult[R1, A]
// 3. If failed (Err), the error is propagated without executing the ReaderReaderIOResult
//
// Type Parameters:
// - A: The success type produced by the ReaderReaderIOResult
// - R1: The original outer environment type expected by the ReaderReaderIOResult
// - R2: The new input outer environment type
//
// Parameters:
// - f: A Result Kleisli arrow that transforms R2 to R1 with pure computation that can fail
//
// Returns:
// - A function that takes a ReaderReaderIOResult[R1, A] and returns a ReaderReaderIOResult[R2, A]
//
//go:inline
func LocalResultK[A, R1, R2 any](f result.Kleisli[R2, R1]) func(ReaderReaderIOResult[R1, A]) ReaderReaderIOResult[R2, A] {
return RRIOE.LocalEitherK[context.Context, A](f)
@@ -162,6 +196,31 @@ func LocalReaderIOResultK[A, R1, R2 any](f readerioresult.Kleisli[R2, R1]) func(
return RRIOE.LocalReaderIOEitherK[A](f)
}
// LocalReaderReaderIOEitherK transforms the outer environment of a ReaderReaderIOResult using a ReaderReaderIOResult-based Kleisli arrow.
// It allows you to modify the outer environment through a computation that depends on both the outer environment
// and the inner context, and can perform IO effects that may fail.
//
// This is the most powerful Local variant, useful when the outer environment transformation requires:
// - Access to both the outer environment (R2) and inner context (context.Context)
// - IO operations that can fail
// - Complex transformations that need the full computational context
//
// The transformation happens in three stages:
// 1. The ReaderReaderIOResult effect f is executed with the R2 outer environment and inner context
// 2. If successful (Ok), the R1 value is passed as the outer environment to the ReaderReaderIOResult[R1, A]
// 3. If failed (Err), the error is propagated without executing the ReaderReaderIOResult
//
// Type Parameters:
// - A: The success type produced by the ReaderReaderIOResult
// - R1: The original outer environment type expected by the ReaderReaderIOResult
// - R2: The new input outer environment type
//
// Parameters:
// - f: A ReaderReaderIOResult Kleisli arrow that transforms R2 to R1 with full context-aware IO effects that can fail
//
// Returns:
// - A function that takes a ReaderReaderIOResult[R1, A] and returns a ReaderReaderIOResult[R2, A]
//
//go:inline
func LocalReaderReaderIOEitherK[A, R1, R2 any](f Kleisli[R2, R2, R1]) func(ReaderReaderIOResult[R1, A]) ReaderReaderIOResult[R2, A] {
return RRIOE.LocalReaderReaderIOEitherK[A](f)

View File

@@ -24,6 +24,7 @@ import (
"github.com/IBM/fp-go/v2/logging"
N "github.com/IBM/fp-go/v2/number"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
)
@@ -104,7 +105,8 @@ func TestSLogWithContextLogger(t *testing.T) {
Level: slog.LevelInfo,
}))
ctx := logging.WithLogger(contextLogger)(t.Context())
cancelFct, ctx := pair.Unpack(logging.WithLogger(contextLogger)(t.Context()))
defer cancelFct()
res1 := result.Of("test value")
logged := SLog[string]("Context logger test")(res1)(ctx)

View File

@@ -21,8 +21,9 @@ import (
RIORES "github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/function"
"github.com/IBM/fp-go/v2/internal/statet"
RIOR "github.com/IBM/fp-go/v2/readerioresult"
"github.com/IBM/fp-go/v2/pair"
"github.com/IBM/fp-go/v2/result"
SRIOE "github.com/IBM/fp-go/v2/statereaderioeither"
)
// Left creates a StateReaderIOResult that represents a failed computation with the given error.
@@ -202,21 +203,41 @@ func FromResult[S, A any](ma Result[A]) StateReaderIOResult[S, A] {
// Combinators
// Local runs a computation with a modified context.
// The function f transforms the context before passing it to the computation.
// The function f transforms the context before passing it to the computation,
// returning both a new context and a CancelFunc that should be called to release resources.
//
// This is useful for:
// - Adding values to the context
// - Setting timeouts or deadlines
// - Modifying context metadata
//
// The CancelFunc is automatically called after the computation completes to ensure proper cleanup.
//
// Type Parameters:
// - S: The state type
// - A: The result type
// - R: The input environment type that f transforms into context.Context
//
// Parameters:
// - f: Function to transform the input environment R into context.Context, returning a new context and CancelFunc
//
// Returns:
// - A Kleisli arrow that takes a StateReaderIOResult[S, A] and returns a StateReaderIOEither[S, R, error, A]
//
// Note: When R is context.Context, the return type simplifies to func(StateReaderIOResult[S, A]) StateReaderIOResult[S, A]
//
// Example:
//
// // Modify context before running computation
// withTimeout := statereaderioresult.Local[AppState](
// func(ctx context.Context) context.Context {
// ctx, _ = context.WithTimeout(ctx, 60*time.Second)
// return ctx
// }
// // Add a timeout to a specific operation
// withTimeout := statereaderioresult.Local[AppState, Data](
// func(ctx context.Context) (context.Context, context.CancelFunc) {
// return context.WithTimeout(ctx, 60*time.Second)
// },
// )
// result := withTimeout(computation)
func Local[S, A any](f func(context.Context) context.Context) func(StateReaderIOResult[S, A]) StateReaderIOResult[S, A] {
return func(ma StateReaderIOResult[S, A]) StateReaderIOResult[S, A] {
return function.Flow2(ma, RIOR.Local[Pair[S, A]](f))
func Local[S, A, R any](f pair.Kleisli[context.CancelFunc, R, context.Context]) SRIOE.Kleisli[S, R, error, StateReaderIOResult[S, A], A] {
return func(ma StateReaderIOResult[S, A]) SRIOE.StateReaderIOEither[S, R, error, A] {
return function.Flow2(ma, RIORES.Local[Pair[S, A]](f))
}
}

View File

@@ -25,6 +25,7 @@ import (
"github.com/IBM/fp-go/v2/io"
IOR "github.com/IBM/fp-go/v2/ioresult"
N "github.com/IBM/fp-go/v2/number"
"github.com/IBM/fp-go/v2/pair"
P "github.com/IBM/fp-go/v2/pair"
RES "github.com/IBM/fp-go/v2/result"
"github.com/stretchr/testify/assert"
@@ -264,8 +265,8 @@ func TestLocal(t *testing.T) {
// Modify context before running computation
result := Local[testState, string](
func(c context.Context) context.Context {
return context.WithValue(c, "key", "value2")
func(c context.Context) ContextCancel {
return pair.MakePair[context.CancelFunc](func() {}, context.WithValue(c, "key", "value2"))
},
)(comp)

View File

@@ -16,6 +16,8 @@
package statereaderioresult
import (
"context"
RIORES "github.com/IBM/fp-go/v2/context/readerioresult"
"github.com/IBM/fp-go/v2/endomorphism"
"github.com/IBM/fp-go/v2/io"
@@ -84,4 +86,11 @@ type (
Operator[S, A, B any] = Reader[StateReaderIOResult[S, A], StateReaderIOResult[S, B]]
Predicate[A any] = predicate.Predicate[A]
// ContextCancel represents a pair of a cancel function and a context.
// It is used in operations that create new contexts with cancellation capabilities.
//
// The first element is the CancelFunc that should be called to release resources.
// The second element is the new Context that was created.
ContextCancel = Pair[context.CancelFunc, context.Context]
)

View File

@@ -23,6 +23,8 @@ import (
"log"
"log/slog"
"sync/atomic"
"github.com/IBM/fp-go/v2/pair"
)
// LoggingCallbacks creates a pair of logging callback functions from the provided loggers.
@@ -136,9 +138,11 @@ func GetLoggerFromContext(ctx context.Context) *slog.Logger {
return value
}
// WithLogger returns an endomorphism that adds a logger to a context.
// An endomorphism is a function that takes a value and returns a value of the same type.
// This function creates a context transformation that embeds the provided logger.
func noop() {}
// WithLogger returns a Kleisli arrow that adds a logger to a context.
// A Kleisli arrow transforms a context into a ContextCancel pair containing
// a no-op cancel function and the new context with the embedded logger.
//
// This is particularly useful in functional programming patterns where you want to
// compose context transformations, or when working with middleware that needs to
@@ -148,7 +152,7 @@ func GetLoggerFromContext(ctx context.Context) *slog.Logger {
// - l: The *slog.Logger to embed in the context
//
// Returns:
// - An Endomorphism[context.Context] function that adds the logger to a context
// - A Kleisli arrow (function from context.Context to ContextCancel) that adds the logger to a context
//
// Example:
//
@@ -157,13 +161,14 @@ func GetLoggerFromContext(ctx context.Context) *slog.Logger {
//
// // Apply it to a context
// ctx := context.Background()
// ctxWithLogger := addLogger(ctx)
// result := addLogger(ctx)
// ctxWithLogger := pair.Second(result)
//
// // Retrieve the logger later
// logger := GetLoggerFromContext(ctxWithLogger)
// logger.Info("Using context logger")
func WithLogger(l *slog.Logger) Endomorphism[context.Context] {
return func(ctx context.Context) context.Context {
return context.WithValue(ctx, loggerInContextKey, l)
func WithLogger(l *slog.Logger) pair.Kleisli[context.CancelFunc, context.Context, context.Context] {
return func(ctx context.Context) ContextCancel {
return pair.MakePair[context.CancelFunc](noop, context.WithValue(ctx, loggerInContextKey, l))
}
}

View File

@@ -17,10 +17,13 @@ package logging
import (
"bytes"
"context"
"log"
"log/slog"
"strings"
"testing"
"github.com/IBM/fp-go/v2/pair"
S "github.com/IBM/fp-go/v2/string"
)
@@ -288,3 +291,355 @@ func BenchmarkLoggingCallbacks_Logging(b *testing.B) {
infoLog("benchmark message %d", i)
}
}
// TestSetLogger_Success tests setting a new global logger and verifying it returns the old one.
func TestSetLogger_Success(t *testing.T) {
// Save original logger to restore later
originalLogger := GetLogger()
defer SetLogger(originalLogger)
// Create a new logger
var buf bytes.Buffer
handler := slog.NewTextHandler(&buf, nil)
newLogger := slog.New(handler)
// Set the new logger
oldLogger := SetLogger(newLogger)
// Verify old logger was returned
if oldLogger == nil {
t.Error("Expected SetLogger to return the previous logger")
}
// Verify new logger is now active
currentLogger := GetLogger()
if currentLogger != newLogger {
t.Error("Expected GetLogger to return the newly set logger")
}
}
// TestSetLogger_Multiple tests setting logger multiple times.
func TestSetLogger_Multiple(t *testing.T) {
// Save original logger to restore later
originalLogger := GetLogger()
defer SetLogger(originalLogger)
// Create three loggers
logger1 := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
logger2 := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
logger3 := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
// Set first logger
old1 := SetLogger(logger1)
if GetLogger() != logger1 {
t.Error("Expected logger1 to be active")
}
// Set second logger
old2 := SetLogger(logger2)
if old2 != logger1 {
t.Error("Expected SetLogger to return logger1")
}
if GetLogger() != logger2 {
t.Error("Expected logger2 to be active")
}
// Set third logger
old3 := SetLogger(logger3)
if old3 != logger2 {
t.Error("Expected SetLogger to return logger2")
}
if GetLogger() != logger3 {
t.Error("Expected logger3 to be active")
}
// Restore to original
restored := SetLogger(old1)
if restored != logger3 {
t.Error("Expected SetLogger to return logger3")
}
}
// TestGetLogger_Default tests that GetLogger returns a valid logger by default.
func TestGetLogger_Default(t *testing.T) {
logger := GetLogger()
if logger == nil {
t.Error("Expected GetLogger to return a non-nil logger")
}
// Verify it's usable
var buf bytes.Buffer
handler := slog.NewTextHandler(&buf, nil)
testLogger := slog.New(handler)
oldLogger := SetLogger(testLogger)
defer SetLogger(oldLogger)
GetLogger().Info("test message")
if !strings.Contains(buf.String(), "test message") {
t.Errorf("Expected logger to log message, got: %s", buf.String())
}
}
// TestGetLogger_AfterSet tests that GetLogger returns the logger set by SetLogger.
func TestGetLogger_AfterSet(t *testing.T) {
originalLogger := GetLogger()
defer SetLogger(originalLogger)
var buf bytes.Buffer
handler := slog.NewTextHandler(&buf, nil)
customLogger := slog.New(handler)
SetLogger(customLogger)
retrievedLogger := GetLogger()
if retrievedLogger != customLogger {
t.Error("Expected GetLogger to return the custom logger")
}
// Verify it's the same instance by logging
retrievedLogger.Info("test")
if !strings.Contains(buf.String(), "test") {
t.Error("Expected retrieved logger to be the same instance")
}
}
// TestGetLoggerFromContext_WithLogger tests retrieving a logger from context.
func TestGetLoggerFromContext_WithLogger(t *testing.T) {
var buf bytes.Buffer
handler := slog.NewTextHandler(&buf, nil)
contextLogger := slog.New(handler)
// Create context with logger using WithLogger
ctx := context.Background()
kleisli := WithLogger(contextLogger)
result := kleisli(ctx)
ctxWithLogger := pair.Second(result)
// Retrieve logger from context
retrievedLogger := GetLoggerFromContext(ctxWithLogger)
if retrievedLogger != contextLogger {
t.Error("Expected to retrieve the context logger")
}
// Verify it's the same instance by logging
retrievedLogger.Info("context test")
if !strings.Contains(buf.String(), "context test") {
t.Error("Expected retrieved logger to be the same instance")
}
}
// TestGetLoggerFromContext_WithoutLogger tests that it returns global logger when context has no logger.
func TestGetLoggerFromContext_WithoutLogger(t *testing.T) {
originalLogger := GetLogger()
defer SetLogger(originalLogger)
var buf bytes.Buffer
handler := slog.NewTextHandler(&buf, nil)
globalLogger := slog.New(handler)
SetLogger(globalLogger)
// Create context without logger
ctx := context.Background()
// Should return global logger
retrievedLogger := GetLoggerFromContext(ctx)
if retrievedLogger != globalLogger {
t.Error("Expected to retrieve the global logger when context has no logger")
}
// Verify it's the same instance
retrievedLogger.Info("global test")
if !strings.Contains(buf.String(), "global test") {
t.Error("Expected retrieved logger to be the global logger")
}
}
// TestGetLoggerFromContext_NilContext tests behavior with nil context value.
func TestGetLoggerFromContext_NilContext(t *testing.T) {
originalLogger := GetLogger()
defer SetLogger(originalLogger)
var buf bytes.Buffer
handler := slog.NewTextHandler(&buf, nil)
globalLogger := slog.New(handler)
SetLogger(globalLogger)
// Create context with wrong type value
ctx := context.WithValue(context.Background(), loggerInContextKey, "not a logger")
// Should return global logger when type assertion fails
retrievedLogger := GetLoggerFromContext(ctx)
if retrievedLogger != globalLogger {
t.Error("Expected to retrieve the global logger when context value is wrong type")
}
}
// TestWithLogger_CreatesContextWithLogger tests that WithLogger adds logger to context.
func TestWithLogger_CreatesContextWithLogger(t *testing.T) {
var buf bytes.Buffer
handler := slog.NewTextHandler(&buf, nil)
testLogger := slog.New(handler)
// Create Kleisli arrow
kleisli := WithLogger(testLogger)
// Apply to context
ctx := context.Background()
result := kleisli(ctx)
// Verify result is a ContextCancel pair
cancelFunc := pair.First(result)
newCtx := pair.Second(result)
if cancelFunc == nil {
t.Error("Expected cancel function to be non-nil")
}
if newCtx == nil {
t.Error("Expected new context to be non-nil")
}
// Verify logger is in context
retrievedLogger := GetLoggerFromContext(newCtx)
if retrievedLogger != testLogger {
t.Error("Expected logger to be in the new context")
}
}
// TestWithLogger_CancelFuncIsNoop tests that the cancel function is a no-op.
func TestWithLogger_CancelFuncIsNoop(t *testing.T) {
testLogger := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
kleisli := WithLogger(testLogger)
ctx := context.Background()
result := kleisli(ctx)
cancelFunc := pair.First(result)
// Calling cancel should not panic
defer func() {
if r := recover(); r != nil {
t.Errorf("Cancel function panicked: %v", r)
}
}()
cancelFunc()
}
// TestWithLogger_PreservesOriginalContext tests that original context is not modified.
func TestWithLogger_PreservesOriginalContext(t *testing.T) {
originalLogger := GetLogger()
defer SetLogger(originalLogger)
var buf bytes.Buffer
handler := slog.NewTextHandler(&buf, nil)
globalLogger := slog.New(handler)
SetLogger(globalLogger)
testLogger := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
kleisli := WithLogger(testLogger)
// Original context without logger
originalCtx := context.Background()
// Apply transformation
result := kleisli(originalCtx)
newCtx := pair.Second(result)
// Original context should still return global logger
originalCtxLogger := GetLoggerFromContext(originalCtx)
if originalCtxLogger != globalLogger {
t.Error("Expected original context to still use global logger")
}
// New context should have the test logger
newCtxLogger := GetLoggerFromContext(newCtx)
if newCtxLogger != testLogger {
t.Error("Expected new context to have the test logger")
}
}
// TestWithLogger_Composition tests composing multiple WithLogger calls.
func TestWithLogger_Composition(t *testing.T) {
logger1 := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
logger2 := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
kleisli1 := WithLogger(logger1)
kleisli2 := WithLogger(logger2)
ctx := context.Background()
// Apply first transformation
result1 := kleisli1(ctx)
ctx1 := pair.Second(result1)
// Verify first logger
if GetLoggerFromContext(ctx1) != logger1 {
t.Error("Expected first logger in context after first transformation")
}
// Apply second transformation (should override)
result2 := kleisli2(ctx1)
ctx2 := pair.Second(result2)
// Verify second logger (should override first)
if GetLoggerFromContext(ctx2) != logger2 {
t.Error("Expected second logger to override first logger")
}
}
// BenchmarkSetLogger benchmarks setting the global logger.
func BenchmarkSetLogger(b *testing.B) {
logger := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
b.ResetTimer()
for i := 0; i < b.N; i++ {
SetLogger(logger)
}
}
// BenchmarkGetLogger benchmarks getting the global logger.
func BenchmarkGetLogger(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
GetLogger()
}
}
// BenchmarkGetLoggerFromContext_WithLogger benchmarks retrieving logger from context.
func BenchmarkGetLoggerFromContext_WithLogger(b *testing.B) {
logger := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
kleisli := WithLogger(logger)
ctx := pair.Second(kleisli(context.Background()))
b.ResetTimer()
for i := 0; i < b.N; i++ {
GetLoggerFromContext(ctx)
}
}
// BenchmarkGetLoggerFromContext_WithoutLogger benchmarks retrieving global logger from context.
func BenchmarkGetLoggerFromContext_WithoutLogger(b *testing.B) {
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
GetLoggerFromContext(ctx)
}
}
// BenchmarkWithLogger benchmarks creating context with logger.
func BenchmarkWithLogger(b *testing.B) {
logger := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
kleisli := WithLogger(logger)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
kleisli(ctx)
}
}

View File

@@ -16,7 +16,10 @@
package logging
import (
"context"
"github.com/IBM/fp-go/v2/endomorphism"
"github.com/IBM/fp-go/v2/pair"
)
type (
@@ -39,4 +42,15 @@ type (
// ctx := context.Background()
// newCtx := addLogger(ctx) // Both ctx and newCtx are context.Context
Endomorphism[A any] = endomorphism.Endomorphism[A]
// Pair represents a tuple of two values of types A and B.
// It is used to group two related values together.
Pair[A, B any] = pair.Pair[A, B]
// ContextCancel represents a pair of a cancel function and a context.
// It is used in operations that create new contexts with cancellation capabilities.
//
// The first element is the CancelFunc that should be called to release resources.
// The second element is the new Context that was created.
ContextCancel = Pair[context.CancelFunc, context.Context]
)

View File

@@ -1,6 +1,7 @@
package readerio
import (
"github.com/IBM/fp-go/v2/function"
G "github.com/IBM/fp-go/v2/internal/bracket"
)
@@ -30,3 +31,10 @@ func Bracket[
release,
)
}
//go:inline
func WithResource[R, A, B, ANY any](
onCreate ReaderIO[R, A], onRelease Kleisli[R, A, ANY]) Kleisli[R, Kleisli[R, A, B], B] {
return function.Bind13of3(Bracket[R, A, B, ANY])(onCreate, function.Ignore2of2[B](onRelease))
}