From 7713dec9c66cc8c08cd5cc0d438b0337e08569f3 Mon Sep 17 00:00:00 2001 From: Simon HUET Date: Mon, 26 Oct 2026 20:50:05 +0100 Subject: [PATCH] feat(stream): Add Java-like Streams API and core collections Introduce a functional Streams API inspired by Java 8, complete core collection interfaces and implementations with JSON/SQL serialization, and consolidate internal utilities. - Sequential and parallel stream execution (`Stream[T]`, `Parallel()`, `Sequential()`) using Go 1.23+ iterators (`iter.Seq[T]`). - Source constructors: `StreamOf`, `StreamOfSlice`, `StreamOfCollection`, `StreamOfSeq`, `StreamRange`, `StreamRangeClosed`, `StreamConcat`, `StreamGenerate`, `StreamIterate`, and `StreamEmpty`. - Intermediate operations: `Filter`, `Map`, `FlatMap`, `Distinct`, `DistinctBy`, `Sorted`, `SortedBy`, `Limit`, `Skip`, `TakeWhile`, `DropWhile`, and `Peek`. - Terminal operations: `ForEach`, `ForEachOrdered`, `ToSlice`, `Collect`, `Reduce`, `Count`, `Min`, `Max`, `AnyMatch`, `AllMatch`, `NoneMatch`, `FindFirst`, `FindAny`, and `OnClose` handlers with `sync.OnceFunc` guards. - Concurrent/parallel safety: Worker pool with panic propagation, shared panic flags to stop upstream producers, and channel draining to prevent goroutine leaks. - Collectors: `ToList`, `ToSet`, `ToMap`, `Joining`, `GroupingBy`, `PartitioningBy`, `Counting`, `SummarizingInt/Long/Double`, `Mapping`, `Filtering`, and `Reducing`. - Define `Collection[T]`, `List[T]`, and `Set[T]` in `pkg/core/collection.go`. - Relocate `Map[K, V]` from `pkg/util/map.go` to `pkg/core/collection.go` to unify the collection hierarchy. - Provide `ArrayList[T]` and `HashSet[T]` backed by `AbstractList[T]` and `AbstractSet[T]`. - Implement `json.Marshaler` and `json.Unmarshaler` on `ArrayList` and `HashSet` with zero-value lazy initialization and `null`/empty handling. - Implement `driver.Valuer` and `sql.Scanner` on `ArrayList` and `HashSet` for seamless SQL/database persistence (PostgreSQL JSONB, MySQL JSON, SQLite TEXT). - Introduce `internal/utils` package with centralized `IsNil` and text utilities. - Add comprehensive documentation and Go test examples (`Example...`) covering stream operators and collectors. --- .github/workflows/codecov.yml | 2 +- .../3-ioc-container/2-dependency-injection.md | 6 +- .../docs/3-ioc-container/3-lifecycle.md | 6 +- .../4-configuration-and-profiles.md | 29 +- .../docs/3-ioc-container/5-prototypes.md | 3 +- .../5-language-helpers/1-error-handling.md | 33 +- .../docs/5-language-helpers/2-enum-types.md | 9 +- .../docs/5-language-helpers/3-streams.md | 663 ++++++++++++++++++ .../content/docs/5-language-helpers/_index.md | 1 + .../content/docs/7-i18n/1-message-catalogs.md | 20 +- docs/content/docs/7-i18n/2-configuration.md | 14 +- .../docs/7-i18n/3-web-and-templates.md | 22 +- docs/content/docs/7-i18n/4-validation.md | 50 +- .../docs/7-i18n/5-catalogs-reference.md | 9 +- internal/config/config.go | 12 +- internal/config/config_suite_test.go | 18 - internal/utils/is_nil.go | 14 + internal/utils/is_nil_test.go | 38 + internal/utils/text.go | 14 + internal/utils/text_test.go | 43 ++ internal/utils/utils_suite_test.go | 14 + pkg/core/beam.go | 4 +- pkg/core/collection.go | 152 ++++ pkg/util/array_list.go | 198 ++++++ pkg/util/array_list_test.go | 224 ++++++ pkg/util/concurrenthashmap.go | 1 + pkg/util/entry.go | 53 -- pkg/util/entry_test.go | 98 --- pkg/util/hash_set.go | 151 ++++ pkg/util/hash_set_test.go | 194 +++++ pkg/util/hashmap.go | 1 + pkg/util/internal/abstract_list.go | 329 +++++++++ pkg/util/internal/abstract_list_test.go | 282 ++++++++ pkg/util/internal/abstract_set.go | 257 +++++++ pkg/util/internal/abstract_set_test.go | 208 ++++++ pkg/util/internal/internal_suite_test.go | 14 + pkg/util/map.go | 59 -- pkg/util/stream/collector.go | 662 +++++++++++++++++ pkg/util/stream/collector_example_test.go | 162 +++++ pkg/util/stream/collector_test.go | 634 +++++++++++++++++ pkg/util/stream/intermediate.go | 390 +++++++++++ pkg/util/stream/intermediate_test.go | 495 +++++++++++++ pkg/util/stream/parallel_test.go | 400 +++++++++++ pkg/util/stream/statistics.go | 148 ++++ pkg/util/stream/stream.go | 275 ++++++++ pkg/util/stream/stream_example_test.go | 356 ++++++++++ pkg/util/stream/stream_suite_test.go | 14 + pkg/util/stream/stream_test.go | 203 ++++++ pkg/util/stream/stream_test_helpers_test.go | 49 ++ pkg/util/stream/terminal.go | 594 ++++++++++++++++ pkg/util/stream/terminal_test.go | 201 ++++++ pkg/web/funcmap.go | 1 + 52 files changed, 7458 insertions(+), 371 deletions(-) create mode 100644 docs/content/docs/5-language-helpers/3-streams.md create mode 100644 internal/utils/is_nil.go create mode 100644 internal/utils/is_nil_test.go create mode 100644 internal/utils/text.go create mode 100644 internal/utils/text_test.go create mode 100644 internal/utils/utils_suite_test.go create mode 100644 pkg/core/collection.go create mode 100644 pkg/util/array_list.go create mode 100644 pkg/util/array_list_test.go delete mode 100644 pkg/util/entry.go delete mode 100644 pkg/util/entry_test.go create mode 100644 pkg/util/hash_set.go create mode 100644 pkg/util/hash_set_test.go create mode 100644 pkg/util/internal/abstract_list.go create mode 100644 pkg/util/internal/abstract_list_test.go create mode 100644 pkg/util/internal/abstract_set.go create mode 100644 pkg/util/internal/abstract_set_test.go create mode 100644 pkg/util/internal/internal_suite_test.go delete mode 100644 pkg/util/map.go create mode 100644 pkg/util/stream/collector.go create mode 100644 pkg/util/stream/collector_example_test.go create mode 100644 pkg/util/stream/collector_test.go create mode 100644 pkg/util/stream/intermediate.go create mode 100644 pkg/util/stream/intermediate_test.go create mode 100644 pkg/util/stream/parallel_test.go create mode 100644 pkg/util/stream/statistics.go create mode 100644 pkg/util/stream/stream.go create mode 100644 pkg/util/stream/stream_example_test.go create mode 100644 pkg/util/stream/stream_suite_test.go create mode 100644 pkg/util/stream/stream_test.go create mode 100644 pkg/util/stream/stream_test_helpers_test.go create mode 100644 pkg/util/stream/terminal.go create mode 100644 pkg/util/stream/terminal_test.go diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 4e246ce..f145658 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -18,7 +18,7 @@ jobs: run: go mod download - name: Run tests - run: go test -coverprofile=coverage.txt ./ ./internal/... ./pkg/... + run: go test -coverprofile=coverage.txt ./... - name: Upload results to Codecov uses: codecov/codecov-action@v5 diff --git a/docs/content/docs/3-ioc-container/2-dependency-injection.md b/docs/content/docs/3-ioc-container/2-dependency-injection.md index 5f88e03..ca5b77b 100644 --- a/docs/content/docs/3-ioc-container/2-dependency-injection.md +++ b/docs/content/docs/3-ioc-container/2-dependency-injection.md @@ -169,8 +169,7 @@ func (m *MetricsService) Report() { ## Multi-Binding (Slices & Maps) -Advanced architectural patterns (like the Strategy Pattern) rely on injecting *multiple* implementations of an interface -or struct. Rays natively supports multi-binding into slices and maps for both interface types and struct pointers. +Advanced architectural patterns (like the Strategy Pattern) rely on injecting *multiple* implementations of an interface or struct. Rays natively supports multi-binding into slices and maps for both interface types and struct pointers. ### Slice Injection (`[]T`) @@ -178,8 +177,7 @@ Injecting into a slice will populate it with all registered Beams that match the ### Map Injection (`map[string]T`) -Injecting into a map requires the key to be of type `string`. Rays will map the injected Beams by their Qualifier name. -The key will be the injected Beam's `Qualifier` value. +Injecting into a map requires the key to be of type `string`. Rays will map the injected Beams by their Qualifier name. The key will be the injected Beam's `Qualifier` value. ```go import . "github.com/BeamFoundry/rays/pkg/core" diff --git a/docs/content/docs/3-ioc-container/3-lifecycle.md b/docs/content/docs/3-ioc-container/3-lifecycle.md index c6b0eb5..727b60c 100644 --- a/docs/content/docs/3-ioc-container/3-lifecycle.md +++ b/docs/content/docs/3-ioc-container/3-lifecycle.md @@ -92,11 +92,9 @@ func (e *EmailWorker) PostConstruct() Error { ``` ## Phase 3: Runtime (`BeamRunner`) -Beams that implement the `rays.BeamRunner` interface will have their `Run(wg *sync.WaitGroup)` method executed when -`ctx.Run()` is called in `main.go`. +Beams that implement the `rays.BeamRunner` interface will have their `Run(wg *sync.WaitGroup)` method executed when `ctx.Run()` is called in `main.go`. -This hook is specifically designed for long-running processes (like HTTP servers, Kafka consumers, or periodic cron -jobs). The WaitGroup keeps the main application thread alive while your background workers execute. +This hook is specifically designed for long-running processes (like HTTP servers, Kafka consumers, or periodic cron jobs). The WaitGroup keeps the main application thread alive while your background workers execute. ```go import ( diff --git a/docs/content/docs/3-ioc-container/4-configuration-and-profiles.md b/docs/content/docs/3-ioc-container/4-configuration-and-profiles.md index 625f6c2..de92fac 100644 --- a/docs/content/docs/3-ioc-container/4-configuration-and-profiles.md +++ b/docs/content/docs/3-ioc-container/4-configuration-and-profiles.md @@ -7,41 +7,40 @@ weight: 4 In enterprise applications, configuration and environment management go hand-in-hand. -Rays provides a unified system to bind YAML configurations directly to your structs, override them based on active -profiles (e.g., `dev`, `prod`), and conditionally load specific components depending on the environment. +Rays provides a unified system to bind YAML configurations directly to your structs, +override them based on active profiles (e.g., `dev`, `prod`), and conditionally load +specific components depending on the environment. ## 1. Binding Data Embed `ConfigurationProperties` and define the YAML path via the `Name` tag. -**NOTE:** *Rays strictly enforces a 1-to-1 parity between your YAML files and your Go code. The `yaml` tag is **not -honored**. Only properties with the exact same case name as the Go struct field will be injected.* +**NOTE:** *Rays strictly enforces a 1-to-1 parity between your +YAML files and your Go code. The `yaml` tag is **not honored**. Only properties with +the exact same case name as the Go struct field will be injected.* ### Valid types All numeric types, `bool` and `string` are honored. -Rays includes native support for `time.Duration`. Text values like `"5000ms"` in the YAML file are automatically parsed -using Go's standard `time.ParseDuration` function. +Rays includes native support for `time.Duration`. +Text values like `"5000ms"` in the YAML file are automatically parsed using Go's standard `time.ParseDuration` function. Struct and struct pointers (sub-object mapping) are supported. -Struct implementing the `encoding.TextUnmarshaler` interface can also be used (string to struct mapping). The struct -type (not a pointer to that) can be used directly. +Struct implementing the `encoding.TextUnmarshaler` interface can also be used (string to struct mapping). +The struct type (not a pointer to that) can be used directly. Slices of all the above types are supported. -Finally map of `string` and of `encoding.TextUnmarshaler` to all the above types and to other map of those two types are -supported. +Finally map of `string` and of `encoding.TextUnmarshaler` to all the above types and to other map of those two types are supported. ### Validation -Validation is done using the [Validation](github.com/go-playground/validator) project, given you provides the -corresponding tags. +Validation is done using the [Validation](github.com/go-playground/validator) project, given you provides the corresponding tags. -NOTE: To validate private fields on a struct, reflection structs will be constructed internally with public fields -instead. If using Cross-Field validation, fields should be indicated in their public form. Errors will be formatted -using the original private field instead. +NOTE: To validate private fields on a struct, reflection structs will be constructed internally with public fields instead. +If using Cross-Field validation, fields should be indicated in their public form. Errors will be formatted using the original private field instead. ### Example diff --git a/docs/content/docs/3-ioc-container/5-prototypes.md b/docs/content/docs/3-ioc-container/5-prototypes.md index 68f526f..26ae8c6 100644 --- a/docs/content/docs/3-ioc-container/5-prototypes.md +++ b/docs/content/docs/3-ioc-container/5-prototypes.md @@ -5,8 +5,7 @@ weight: 5 # Prototypes & Foundries -If you need a new instance of an object every time it is requested (e.g., per HTTP request, per GRPC stream), you cannot -use a Singleton. You need a **Prototype**. +If you need a new instance of an object every time it is requested (e.g., per HTTP request, per GRPC stream), you cannot use a Singleton. You need a **Prototype**. ## `Foundry[T]` diff --git a/docs/content/docs/5-language-helpers/1-error-handling.md b/docs/content/docs/5-language-helpers/1-error-handling.md index 50d75c8..8139532 100644 --- a/docs/content/docs/5-language-helpers/1-error-handling.md +++ b/docs/content/docs/5-language-helpers/1-error-handling.md @@ -5,9 +5,7 @@ weight: 1 # Error Handling -If a container fails to boot, you need to know exactly *where* the wiring failed. Rays replaces standard Go `error` -handling during the lifecycle with a richer `Error` interface that captures stack traces and key-value attributes -automatically. +If a container fails to boot, you need to know exactly *where* the wiring failed. Rays replaces standard Go `error` handling during the lifecycle with a richer `Error` interface that captures stack traces and key-value attributes automatically. ```go import . "github.com/BeamFoundry/rays/pkg/lang" @@ -28,11 +26,9 @@ func (s *UserService) PostConstruct() Error { ## Integration with Logging -Because Rays integrates its `lang` package directly with the `logging` framework (covered in Chapter 4), an `Error` can -report itself seamlessly. +Because Rays integrates its `lang` package directly with the `logging` framework (covered in Chapter 4), an `Error` can report itself seamlessly. -Instead of manually unpacking the error to log it, simply call `err.LogError()`. This function automatically extracts -the error message, the stack trace, and all attached attributes, serializing them into your configured logging backend. +Instead of manually unpacking the error to log it, simply call `err.LogError()`. This function automatically extracts the error message, the stack trace, and all attached attributes, serializing them into your configured logging backend. ```go import . "github.com/BeamFoundry/rays/pkg/lang" @@ -54,27 +50,18 @@ func (s *PaymentService) Process(amount float64) Error { ## The Try/Catch Pattern -While standard Go relies heavily on `if err != nil` checks, complex procedural logic can sometimes benefit from a more -traditional exception-handling flow. The `lang` package provides a robust `Try/Catch` mechanism designed specifically -around the framework's `Error` interface. +While standard Go relies heavily on `if err != nil` checks, complex procedural logic can sometimes benefit from a more traditional exception-handling flow. The `lang` package provides a robust `Try/Catch` mechanism designed specifically around the framework's `Error` interface. ### Core Functions -* **`Try(try func(), catches ...ErrorHandler) Error`**: Executes the `try` function. If a panic occurs involving an - `Error`, it evaluates the provided catches and returns the resulting `Error`. If no catch matches, the original error is - returned automatically. Additionally, if standard Go code or a sub-function triggers a regular `panic()`, the `Try` - block will safely catch it and wrap it into a new framework `Error` with the name "\"PanicError\"", ensuring your - application does not crash unexpectedly. +* **`Try(try func(), catches ...ErrorHandler) Error`**: Executes the `try` function. If a panic occurs involving an `Error`, it evaluates the provided catches and returns the resulting `Error`. If no catch matches, the original error is returned automatically. Additionally, if standard Go code or a sub-function triggers a regular `panic()`, the `Try` block will safely catch it and wrap it into a new framework `Error` with the name "\"PanicError\"", ensuring your application does not crash unexpectedly. * **`Catch[E Error](action func(E) Error) ErrorHandler`**: Catches an error based on its specific Go type. -* **`CatchName(name string, action func(Error) Error) ErrorHandler`**: Catches an error based on its internal string -name. -* **`Finally(action func()) ErrorHandler`**: Executes an action after the `try` block finishes, regardless of success or -failure. +* **`CatchName(name string, action func(Error) Error) ErrorHandler`**: Catches an error based on its internal string name. +* **`Finally(action func()) ErrorHandler`**: Executes an action after the `try` block finishes, regardless of success or failure. -### Raise Helpers To easily trigger panics within a `Try` block when dealing with standard Go returns, use the `Raise` -helpers: +### Raise Helpers +To easily trigger panics within a `Try` block when dealing with standard Go returns, use the `Raise` helpers: * **`RaiseErr(err error) bool`**: Panics if `err` is not nil. -* **`RaiseVal[T any](t T, err error) T`**: Panics if `err` is not nil; otherwise, returns the object `T`. This is -perfect for wrapping standard library calls like `RaiseVal(os.ReadFile("config.yaml"))`. +* **`RaiseVal[T any](t T, err error) T`**: Panics if `err` is not nil; otherwise, returns the object `T`. This is perfect for wrapping standard library calls like `RaiseVal(os.ReadFile("config.yaml"))`. ### Example Usage diff --git a/docs/content/docs/5-language-helpers/2-enum-types.md b/docs/content/docs/5-language-helpers/2-enum-types.md index 2bf520f..aa33bb0 100644 --- a/docs/content/docs/5-language-helpers/2-enum-types.md +++ b/docs/content/docs/5-language-helpers/2-enum-types.md @@ -5,11 +5,9 @@ weight: 2 # Type-Safe Enums -Go does not have native, strict enums. This often leads to configuration bugs where an invalid string from a YAML file -is happily parsed into a `string` field, only to fail deep within the application logic. +Go does not have native, strict enums. This often leads to configuration bugs where an invalid string from a YAML file is happily parsed into a `string` field, only to fail deep within the application logic. -Rays solves this with `Enum[T]`. It strictly binds string values from YAML into custom Go types and validates them -during the framework's Instantiation phase. +Rays solves this with `Enum[T]`. It strictly binds string values from YAML into custom Go types and validates them during the framework's Instantiation phase. ### 1. Define the Enum Type You can define your enums using a struct to hold the variants as fields. @@ -48,8 +46,7 @@ type AppConfig struct { ``` ### 3. Safe Access in Business Logic -Because of the generic structuring, you must initialize the enum type by calling `.New()` to ensure its internal state -is properly configured before accessing its variants. +Because of the generic structuring, you must initialize the enum type by calling `.New()` to ensure its internal state is properly configured before accessing its variants. You can then compare it directly to your configuration fields without needing a getter function. diff --git a/docs/content/docs/5-language-helpers/3-streams.md b/docs/content/docs/5-language-helpers/3-streams.md new file mode 100644 index 0000000..a80fa8f --- /dev/null +++ b/docs/content/docs/5-language-helpers/3-streams.md @@ -0,0 +1,663 @@ +--- +title: "Streams" +weight: 3 +--- + +# Java 8-Style Streams + +Processing sequences of data in Go often requires verbose `for` loops, manual slice allocation, and custom grouping +logic. While Go introduced native range-over-function iterators (`iter.Seq`), chaining multiple transformations across +different generic types remained cumbersome. + +With **Go 1.27** introducing generic type parameters on struct methods, Rays provides `pkg/util/stream`: an idiomatic, +lazy, thread-safe, and expressive Stream processing framework inspired by Java 8 Streams. + +Similar to `pkg/lang` (e.g. `OptionalOf`, `OptionalEmpty`), `pkg/util/stream` is designed to be cleanly **dot-imported** +(`import . "github.com/BeamFoundry/rays/pkg/util/stream"`). Streams are constructed directly via static-like constructor methods on `Stream[T]` (e.g. `Stream[T]{}.From(...)`). + +```go +import . "github.com/BeamFoundry/rays/pkg/util/stream" + +// Fluent pipeline with Go 1.27 struct method type parameters +topActiveNames := Stream[User]{}.FromSlice(users). + Filter(func(u User) bool { return u.Active }). + SortedByDesc(func(u User) int { return u.Score }). + Limit(5). + Map(func(u User) string { return u.Name }). + ToSlice() +``` + +## Key Capabilities + +* **Fluent Method Type Inference**: Call `.Map(...)`, `.FlatMap(...)`, `.Collect(...)`, `.SortedBy(...)`, or + `.ReduceWithInitial(...)` directly on the stream without standalone functions. +* **Lazy Evaluation**: Backed by Go's native `iter.Seq[T]`. Stream pipelines defer computation until a terminal + operation is called. Short-circuiting operations (`Limit`, `TakeWhile`, `FindFirst`, `AnyMatch`) stop evaluating + upstream elements as soon as their termination condition is met. +* **Single-Use Semantics**: Following Java 8 Stream semantics, a stream can only be consumed once. Attempting to + traverse or reuse an already operated or closed stream raises a framework `IllegalStateError`. +* **Parallel Execution**: Switch any pipeline to concurrent mode with `.Parallel()`. Terminal operations like `ForEach`, + `Collect` (with batch accumulation and encounter-order combining), `AnyMatch`, and `FindAny` execute across goroutines. +* **Standard Collectors**: Rich collection abstractions provided via `Collectors` methods including `ToList`, `ToSet`, `ToSlice`, `ToMap`, `Joining`, `GroupingBy`, `PartitioningBy`, `Counting`, `SummingInt`, `SummarizingInt`, and downstream collectors. +* **`lang.Optional` Integration**: Operations that may produce an empty result (`FindFirst`, `FindAny`, `Min`, `Max`, + `Reduce`) return Rays' type-safe `lang.Optional[T]`. + +--- + +## 1. Creating Streams + +Streams can be instantiated from values, slices, channels, functions, ranges, or existing iterators: + +```go +import . "github.com/BeamFoundry/rays/pkg/util/stream" + +// Empty stream +s0 := Stream[string]{}.FromEmpty() + +// From explicit values or slices +s1 := Stream[int]{}.From(1, 2, 3, 4, 5) +s2 := Stream[string]{}.FromSlice([]string{"apple", "banana", "cherry"}) + +// From an existing Go iterator (iter.Seq[T]) +s3 := Stream[T]{}.FromSeq(myIterSeq) + +// From a collection (core.List, core.Set, ArrayList, HashSet) +sCollection := Stream[T]{}.FromCollection(myList) +// Or directly via collection's Stream() method: myList.Stream() + +// From a Go channel (reads until channel is closed) +s4 := Stream[Job]{}.FromChan(jobChan) + +// Numeric ranges +s5 := Stream[int]{}.FromRange(0, 10) // 0 to 9 (exclusive) +s6 := Stream[int]{}.FromRangeClosed(1, 10) // 1 to 10 (inclusive) + +// Infinite lazy generators +s7 := Stream[float64]{}.FromGenerate(rand.Float64) +s8 := Stream[int]{}.FromIterate(1, func(x int) int { return x * 2 }) +s9 := Stream[int]{}.FromIterateWithPredicate(1, func(x int) bool { return x <= 100 }, func(x int) int { return x + 1 }) + +// Concatenation of two streams +s10 := Stream[int]{}.FromConcat(s1, s5) +``` + +--- + +## 2. Intermediate Operations + +Intermediate operations return a new `Stream` and are **lazy**. Elements are pulled through the pipeline only when a +terminal operation triggers evaluation. + +| Operation | Description | +|---|---| +| `Filter(predicate func(T) bool)` | Retains only elements that satisfy the predicate. | +| `Map[R](mapper func(T) R)` | Transforms elements from type `T` to type `R`. | +| `FlatMap[R](mapper func(T) Stream[R])` | Flattens mapped sub-streams into a single continuous stream. | +| `FlatMapSlice[R](mapper func(T) []R)` | Flattens mapped slices into a single stream. | +| `FlatMapSeq[R](mapper func(T) iter.Seq[R])` | Flattens mapped `iter.Seq` iterators. | +| `Distinct()` | Deduplicates elements based on equality. | +| `DistinctBy[K comparable](keySelector func(T) K)` | Deduplicates elements using an extracted key. | +| `Sorted(comparator...)` | Sorts elements using natural ordering or a custom comparator (`func(a, b T) int`). | +| `SortedFunc(less func(a, b T) bool)` | Sorts elements using a custom `less` function. | +| `SortedBy[K Ordered](keySelector func(T) K)` | Sorts elements in ascending order by an extracted ordered key. | +| `SortedByDesc[K Ordered](keySelector func(T) K)` | Sorts elements in descending order by an extracted ordered key. | +| `Limit(maxSize int64)` | Truncates the stream to at most `maxSize` elements (short-circuits upstream). | +| `Skip(n int64)` | Discards the first `n` elements. | +| `TakeWhile(predicate func(T) bool)` | Takes elements as long as the predicate returns true, then terminates. | +| `DropWhile(predicate func(T) bool)` | Drops elements until the predicate first returns false, then yields the rest. | +| `Peek(action func(T))` | Performs an action on each element as it passes through (useful for debugging). | + +### Example: Mapping and Sorting + +```go +type Employee struct { + Name string + Department string + Salary int +} + +// Fluent transformation from Employee structs to formatted strings +salaries := Stream[Employee]{}.FromSlice(employees). + Filter(func(e Employee) bool { return e.Department == "Engineering" }). + SortedByDesc(func(e Employee) int { return e.Salary }). + Map(func(e Employee) string { + return fmt.Sprintf("%s: $%d", e.Name, e.Salary) + }). + ToSlice() +``` + +--- + +## 3. Terminal Operations + +Terminal operations trigger the stream pipeline and consume the stream. + +### Slice & Iterator Conversion + +* **`ToSlice() []T`**: Consumes the stream and returns all elements as a slice in encounter order. +* **`Seq() iter.Seq[T]`**: Yields elements as a Go 1.23+ iterator sequence, enabling direct `for ... := range stream.Seq()` loops. +* **`ForEach(action func(T))`**: Invokes the action on each element (concurrently if parallel). +* **`ForEachOrdered(action func(T))`**: Invokes the action in strict encounter order. + +### Matching & Search + +All match operations short-circuit as soon as the outcome is determined: + +```go +hasEven := Stream[int]{}.From(1, 2, 3, 4, 5).AnyMatch(func(x int) bool { return x%2 == 0 }) // true +allPositive := Stream[int]{}.From(1, 2, 3, 4, 5).AllMatch(func(x int) bool { return x > 0 }) // true +noneNegative := Stream[int]{}.From(1, 2, 3, 4, 5).NoneMatch(func(x int) bool { return x < 0 }) // true + +first := Stream[string]{}.From("apple", "banana").FindFirst() // returns lang.OptionalOf("apple") +any := Stream[string]{}.From("apple", "banana").FindAny() // returns lang.Optional[string] +``` + +### Reductions + +* **`Reduce(accumulator func(a, b T) T) lang.Optional[T]`**: Folds elements into an optional result. +* **`ReduceWithIdentity(identity T, accumulator func(a, b T) T) T`**: Folds elements starting from `identity`. +* **`ReduceWithInitial[R](identity R, accumulator func(R, T) R) R`**: Folds elements of type `T` into an accumulator of type `R`. + +```go +// Reducing ints into a formatted string +joined := Stream[int]{}.From(1, 2, 3).ReduceWithInitial("Values:", func(acc string, v int) string { + return fmt.Sprintf("%s %d", acc, v) +}) // "Values: 1 2 3" +``` + +### Min & Max + +```go +// Natural ordering +minVal := Stream[int]{}.From(5, 2, 8, 1).Min() // OptionalOf(1) +maxVal := Stream[int]{}.From(5, 2, 8, 1).Max() // OptionalOf(8) + +// By key selector +youngest := Stream[Employee]{}.FromSlice(employees).MinBy(func(e Employee) int { return e.Age }) +``` + +--- + +## 4. Collectors (`stream.Collect`) + +Rays provides a robust `Collector[T, A, R]` abstraction modeled after `java.util.stream.Collectors`. +A collector encapsulates a mutable reduction operation that folds input elements of type `T` into a mutable +intermediate accumulator of type `A`, and finally finishes the accumulator into a result of type `R`. + +```go +type Collector[T, A, R any] struct { + Supplier func() A // Allocates a fresh accumulator + Accumulator func(A, T) A // Folds a new item into the accumulator + Combiner func(A, A) A // Merges two accumulators (used in parallel streams) + Finisher func(A) R // Transforms the intermediate accumulator into the final result +} +``` + +Factory methods are instantiated through the `Collectors` struct (e.g. `Collectors{}.ToList[T]()` or `var c Collectors; c.ToList[T]()`). + +### Complete Collectors Reference + +All 23 built-in collectors provided by `Collectors` are summarized below: + +| Collector | Signature | Intermediate `A` | Result `R` | Description | +|---|---|---|---|---| +| `ToList` | `ToList[T]()` | `List[T]` | `core.List[T]` | Accumulates elements into a Rays `core.List[T]`. | +| `ToSlice` | `ToSlice[T]()` | `[]T` | `[]T` | Accumulates elements into a standard Go slice `[]T`. | +| `ToSet` | `ToSet[T comparable]()` | `Set[T]` | `core.Set[T]` | Accumulates unique elements into a Rays `core.Set[T]`. | +| `ToSetSlice` | `ToSetSlice[T comparable]()` | `map[T]struct{}` | `[]T` | Collects unique elements into a standard Go slice `[]T`. | +| `ToMap` | `ToMap[T, K, V](keyMapper, valMapper, mergeRule...)` | `map[K]V` | `map[K]V` | Accumulates elements into a map with optional collision resolution. | +| `Joining` | `Joining(delimiter, prefixAndSuffix...)` | `*joinAcc` | `string` | Joins string elements with a delimiter and optional prefix/suffix. | +| `Counting` | `Counting[T]()` | `*int64` | `int64` | Counts the total number of stream elements. | +| `SummingInt` | `SummingInt[T](mapper)` | `*int64` | `int64` | Sums mapped `int` values as an `int64`. | +| `SummingFloat64` | `SummingFloat64[T](mapper)` | `*float64` | `float64` | Sums mapped `float64` values. | +| `AveragingInt` | `AveragingInt[T](mapper)` | `*avgAcc` | `float64` | Computes the arithmetic average of mapped `int` values. | +| `AveragingFloat64` | `AveragingFloat64[T](mapper)` | `*avgAcc` | `float64` | Computes the arithmetic average of mapped `float64` values. | +| `SummarizingInt` | `SummarizingInt[T](mapper)` | `*IntSummaryStatistics` | `IntSummaryStatistics` | Computes count, sum, min, max, and average for integers. | +| `SummarizingFloat64` | `SummarizingFloat64[T](mapper)` | `*Float64SummaryStatistics` | `Float64SummaryStatistics` | Computes count, sum, min, max, and average for floats. | +| `MinBy` | `MinBy[T](comparator)` | `*Optional[T]` | `lang.Optional[T]` | Finds the minimum element using a three-way comparator (`func(a, b T) int`). | +| `MaxBy` | `MaxBy[T](comparator)` | `*Optional[T]` | `lang.Optional[T]` | Finds the maximum element using a three-way comparator (`func(a, b T) int`). | +| `Reducing` | `Reducing[T](identity, op)` | `*T` | `T` | Reduces elements under an associative operator and identity value. | +| `GroupingBySlice` | `GroupingBySlice[T, K](classifier)` | `map[K][]T` | `map[K][]T` | Groups elements by key into standard slices. | +| `GroupingBy` | `GroupingBy[T, K, A, D](classifier, downstream)` | `map[K]A` | `map[K]D` | Groups elements by key and applies a downstream reduction collector. | +| `PartitioningBySlice` | `PartitioningBySlice[T](predicate)` | `map[bool][]T` | `map[bool][]T` | Partitions elements into `true` and `false` slices. | +| `PartitioningBy` | `PartitioningBy[T, A, D](predicate, downstream)` | `map[bool]A` | `map[bool]D` | Partitions elements by predicate with a downstream collector. | +| `Mapping` | `Mapping[T, U, A, R](mapper, downstream)` | `A` | `R` | Adapts a collector of type `U` to accept elements of type `T`. | +| `Filtering` | `Filtering[T, A, R](predicate, downstream)` | `A` | `R` | Adapts a collector to only accept elements satisfying a predicate. | +| `FlatMapping` | `FlatMapping[T, U, A, R](mapper, downstream)` | `A` | `R` | Adapts a collector by flattening mapped slices `[]U` into downstream. | + +--- + +### Categorized Usage Guide + +```go +import . "github.com/BeamFoundry/rays/pkg/util/stream" + +var c Collectors +``` + +#### 4.1 Collections & Slices +Collect stream items into Rays collections (`core.List[T]`, `core.Set[T]`), native slices (`[]T`), or maps: + +```go +// core.List[T] +list := Stream[int]{}.From(1, 2, 3).Collect(c.ToList[int]()) + +// Native slice []T +slice := Stream[int]{}.From(1, 2, 3).Collect(c.ToSlice[int]()) + +// core.Set[T] (deduplicated) +set := Stream[string]{}.From("a", "b", "a", "c").Collect(c.ToSet[string]()) + +// Unique slice []T +uniqueSlice := Stream[int]{}.From(1, 2, 2, 3, 1).Collect(c.ToSetSlice[int]()) + +// Key-Value map with collision resolution +userMap := Stream[User]{}.FromSlice(users).Collect( + c.ToMap( + func(u User) string { return u.ID }, + func(u User) string { return u.Name }, + func(existing, newVal string) string { return newVal }, // keep latest on collision + ), +) +``` + +#### 4.2 String Joining +Join string streams with configurable delimiters, optional prefixes, and suffixes: + +```go +// Delimiter only: "apple, banana, cherry" +csv := Stream[string]{}.From("apple", "banana", "cherry").Collect( + c.Joining(", "), +) + +// Delimiter with prefix and suffix: "[A, B, C]" +bracketed := Stream[string]{}.From("A", "B", "C").Collect( + c.Joining(", ", "[", "]"), +) +``` + +#### 4.3 Counting, Numeric Reductions & Averages +Efficient single-pass aggregations over numeric properties: + +```go +// Counting elements +count := Stream[string]{}.From("x", "y", "z").Collect(c.Counting[string]()) // 3 + +// Summing ints and floats +totalSalary := Stream[Employee]{}.FromSlice(employees).Collect( + c.SummingInt(func(e Employee) int { return e.Salary }), +) +totalWeight := Stream[Package]{}.FromSlice(packages).Collect( + c.SummingFloat64(func(p Package) float64 { return p.Weight }), +) + +// Calculating averages +avgSalary := Stream[Employee]{}.FromSlice(employees).Collect( + c.AveragingInt(func(e Employee) int { return e.Salary }), // returns float64 +) +avgWeight := Stream[Package]{}.FromSlice(packages).Collect( + c.AveragingFloat64(func(p Package) float64 { return p.Weight }), +) +``` + +#### 4.4 Summary Statistics +Collect comprehensive statistical metrics in a single pass without re-traversing the stream: + +```go +stats := Stream[Employee]{}.FromSlice(employees).Collect( + c.SummarizingInt(func(e Employee) int { return e.Salary }), +) + +fmt.Println("Count: ", stats.Count) +fmt.Println("Sum: ", stats.Sum) +fmt.Println("Min: ", stats.Min) +fmt.Println("Max: ", stats.Max) +fmt.Println("Average:", stats.Average()) // (float64) +``` + +#### 4.5 Extrema & Folding +Find minimum or maximum items using custom comparators, or fold elements with `Reducing`: + +```go +// Find employee with lowest salary using three-way comparator (returns lang.Optional[Employee]) +lowestPaid := Stream[Employee]{}.FromSlice(employees).Collect( + c.MinBy(func(a, b Employee) int { return a.Salary - b.Salary }), +) + +// Associative reduction +product := Stream[int]{}.From(2, 3, 4).Collect( + c.Reducing(1, func(a, b int) int { return a * b }), +) // 24 +``` + +#### 4.6 Grouping & Partitioning +Group elements by an extracted key, or partition into a two-value boolean map (`map[bool]...`): + +```go +// Grouping into slices: map[string][]Employee +byDept := Stream[Employee]{}.FromSlice(employees).Collect( + c.GroupingBySlice(func(e Employee) string { return e.Department }), +) + +// Grouping with downstream counting: map[string]int64 +headcountByDept := Stream[Employee]{}.FromSlice(employees).Collect( + c.GroupingBy( + func(e Employee) string { return e.Department }, + c.Counting[Employee](), + ), +) + +// Partitioning into true/false slices: map[bool][]Student +passedFailed := Stream[Student]{}.FromSlice(students).Collect( + c.PartitioningBySlice(func(s Student) bool { return s.Score >= 60 }), +) + +// Partitioning with downstream aggregation: map[bool]int64 +countPassedFailed := Stream[Student]{}.FromSlice(students).Collect( + c.PartitioningBy( + func(s Student) bool { return s.Score >= 60 }, + c.Counting[Student](), + ), +) +``` + +#### 4.7 Downstream Transforming Adapters +Compose downstream collectors by mapping, filtering, or flattening elements before they reach the target collector: + +```go +// Mapping downstream: group departments by set of employee names +namesByDept := Stream[Employee]{}.FromSlice(employees).Collect( + c.GroupingBy( + func(e Employee) string { return e.Department }, + c.Mapping( + func(e Employee) string { return e.Name }, + c.ToSet[string](), + ), + ), +) + +// Filtering downstream: group departments, but only count senior staff +seniorsByDept := Stream[Employee]{}.FromSlice(employees).Collect( + c.GroupingBy( + func(e Employee) string { return e.Department }, + c.Filtering( + func(e Employee) bool { return e.YearsOfService >= 5 }, + c.Counting[Employee](), + ), + ), +) + +// FlatMapping downstream: extract all distinct skills per department +skillsByDept := Stream[Employee]{}.FromSlice(employees).Collect( + c.GroupingBy( + func(e Employee) string { return e.Department }, + c.FlatMapping( + func(e Employee) []string { return e.Skills }, + c.ToSet[string](), + ), + ), +) +``` + +#### 4.8 Custom Collectors +Construct specialized collectors using `NewCollector` (with an intermediate accumulator type) or `NewSimpleCollector` (where intermediate accumulator type equals final result): + +```go +// Custom collector computing a frequency map: map[string]int +freqCollector := NewCollector( + func() map[string]int { return make(map[string]int) }, // Supplier + func(acc map[string]int, item string) map[string]int { // Accumulator + acc[item]++ + return acc + }, + func(a, b map[string]int) map[string]int { // Combiner (parallel merge) + for k, v := range b { + a[k] += v + } + return a + }, + func(acc map[string]int) map[string]int { return acc }, // Finisher +) + +counts := Stream[string]{}.From("cat", "dog", "cat", "bird").Collect(freqCollector) +``` + +> [!IMPORTANT] +> To support parallel streams correctly: +> - `Supplier` must return a fresh, distinct accumulator on each call. +> - `Combiner` must be associative: `combiner(combiner(a, b), c)` must produce the same result as `combiner(a, combiner(b, c))`. + +--- + +## 5. Parallel Processing + +To execute a pipeline concurrently across multiple goroutines, simply call `.Parallel()` on the stream: + +```go +totalPrimes := Stream[int]{}.FromRangeClosed(1, 1_000_000). + Parallel(). + Filter(func(n int) bool { return isPrime(n) }). + Collect(Collectors{}.Counting[int]()) +``` + +### How Parallel Streams Work +1. **Goroutine Scaling**: Concurrency is automatically scaled to `runtime.GOMAXPROCS` (with a minimum of 2 workers). +2. **Sequential Upstream Pipeline**: Upstream stages such as `Map`, `Filter`, and `FlatMapSlice` run sequentially on the producing goroutine driving the stream sequence. Concurrency begins at the terminal operation; in `Collect`, only the accumulation phase runs on worker goroutines. +3. **Batch Accumulation**: `Stream.Collect` chunks elements from the producing goroutine into 64-element batches and dispatches them across worker goroutines. For each 64-element batch, a fresh accumulator is created via `Collector.Supplier`, accumulated across workers, and stored by batch index. `Collector.Supplier` must return a fresh accumulator on each call. +4. **Encounter-Order Combining**: Once all batches finish processing, partial accumulators are merged in encounter order (from batch index `0` to `n-1`) via `Collector.Combiner`, and the final result is produced via `Collector.Finisher`. `Collector.Combiner` must be associative to ensure deterministic reduction. +5. **Short-Circuiting**: In operations such as `AnyMatch`, `AllMatch`, and `FindAny`, an atomic trigger cancels remaining worker execution as soon as the outcome is determined. + +--- + +## 6. Well-Known Usage Patterns & Recipes + +### Recipe 1: Classic Map-Reduce Word Frequency Analysis + +Map-Reduce is a foundational data processing paradigm consisting of three stages: +1. **Map (Tokenization & Normalization)**: Split raw text documents into words, normalize casing, and discard unwanted punctuation. +2. **Shuffle / Partition (Key Grouping)**: Group the stream of tokens by the word itself as the grouping key. +3. **Reduce (Aggregation)**: Aggregate and count occurrences per word key. + +In Rays, this entire Map-Reduce workflow is expressed fluently in a single pipeline: + +```go +documents := []string{ + "Hello world", + "Hello Go streams", + "World of streams and Go", +} + +// Sequential Map-Reduce Word Count +wordCounts := Stream[string]{}.FromSlice(documents). + FlatMapSlice(func(doc string) []string { + return strings.Fields(strings.ToLower(doc)) + }). + Collect(Collectors{}.GroupingBy( + func(word string) string { return word }, + Collectors{}.Counting[string](), + )) + +// Result: map[string]int64{"hello": 2, "world": 2, "go": 2, "streams": 2, "of": 1, "and": 1} +``` + +#### Parallel Map-Reduce +To scale accumulation across multiple CPU cores for large datasets, simply insert `.Parallel()` into the pipeline: + +```go +// Parallel Map-Reduce Word Count across all available CPU cores +parallelWordCounts := Stream[string]{}.FromSlice(largeCorpus). + Parallel(). + FlatMapSlice(func(doc string) []string { + return strings.Fields(strings.ToLower(doc)) + }). + Collect(Collectors{}.GroupingBy( + func(word string) string { return word }, + Collectors{}.Counting[string](), + )) +``` + +> [!NOTE] +> Because upstream intermediate stages run sequentially on the producing goroutine, tokenization via `FlatMapSlice` is not parallel; only accumulation into the collector runs concurrently across worker goroutines. +> +> During `Stream.Collect`, the producing goroutine dispatches tokens in 64-element batches. For each batch, a fresh accumulator is created via `Collector.Supplier`, accumulated across workers, and stored by batch index. Once all batches finish processing, `Collector.Combiner` merges the partial maps in encounter order into the final result without locks or race conditions. + +--- + +### Recipe 2: Multi-Level Grouping & Business Analytics + +Real-world analytics pipelines often require multi-dimensional grouping and reduction (e.g. sales reports by product category, top transactions, and unique customer reach). + +Given an e-commerce order dataset: + +```go +type Order struct { + ID string + Customer string + Category string + Amount float64 +} + +orders := []Order{ + {ID: "O1", Customer: "Alice", Category: "Electronics", Amount: 299.99}, + {ID: "O2", Customer: "Bob", Category: "Books", Amount: 19.99}, + {ID: "O3", Customer: "Alice", Category: "Electronics", Amount: 49.99}, + {ID: "O4", Customer: "Charlie", Category: "Books", Amount: 35.50}, + {ID: "O5", Customer: "Bob", Category: "Electronics", Amount: 890.00}, +} + +var c Collectors +``` + +#### Total Revenue per Category +```go +revenueByCategory := Stream[Order]{}.FromSlice(orders).Collect( + c.GroupingBy( + func(o Order) string { return o.Category }, + c.SummingFloat64(func(o Order) float64 { return o.Amount }), + ), +) +// map[string]float64{"Books": 55.49, "Electronics": 1239.98} +``` + +#### Highest Value Order per Category (`GroupingBy` + `MaxBy`) +```go +topOrderByCategory := Stream[Order]{}.FromSlice(orders).Collect( + c.GroupingBy( + func(o Order) string { return o.Category }, + c.MaxBy(func(a, b Order) int { + if a.Amount < b.Amount { + return -1 + } else if a.Amount > b.Amount { + return 1 + } + return 0 + }), + ), +) +// topOrderByCategory["Electronics"].Get() -> Order O5 ($890.00) +``` + +#### Unique Customers per Category (`GroupingBy` + `Mapping` + `ToSet`) +```go +customersByCategory := Stream[Order]{}.FromSlice(orders).Collect( + c.GroupingBy( + func(o Order) string { return o.Category }, + c.Mapping( + func(o Order) string { return o.Customer }, + c.ToSet[string](), + ), + ), +) +// customersByCategory["Electronics"].Size() -> 2 (Alice, Bob) +``` + +--- + +### Recipe 3: Single-Pass Two-Way Classification (Data Validation Pipeline) + +When processing incoming streams (e.g., ETL batches or HTTP form submissions), separating valid records from rejected ones typically requires two filtering passes. With `PartitioningBySlice`, this is achieved in a single scan: + +```go +type Record struct { + ID int + Score int +} + +records := []Record{ + {ID: 1, Score: 85}, + {ID: 2, Score: -5}, + {ID: 3, Score: 92}, + {ID: 4, Score: 0}, +} + +// Single pass validation +partitioned := Stream[Record]{}.FromSlice(records).Collect( + Collectors{}.PartitioningBySlice(func(r Record) bool { return r.Score > 0 }), +) + +validRecords := partitioned[true] // [{ID: 1, Score: 85}, {ID: 3, Score: 92}] +invalidRecords := partitioned[false] // [{ID: 2, Score: -5}, {ID: 4, Score: 0}] +``` + +--- + +### Recipe 4: Financial Summary Metrics Dashboard + +For financial or operational dashboards, `SummarizingFloat64` computes the transaction volume, min, max, average, and transaction count in a single traversal: + +```go +stats := Stream[Order]{}.FromSlice(orders).Collect( + Collectors{}.SummarizingFloat64(func(o Order) float64 { return o.Amount }), +) + +fmt.Printf("Total Volume: $%.2f\n", stats.Sum) +fmt.Printf("Average Order: $%.2f\n", stats.Average()) +fmt.Printf("Smallest: $%.2f\n", stats.Min) +fmt.Printf("Largest: $%.2f\n", stats.Max) +fmt.Printf("Transactions: %d\n", stats.Count) +``` + +--- + +### Recipe 5: Building Safe Lookup Dictionaries (`ToMap`) + +When indexing data into maps, key collisions can cause silent data loss or panics. `ToMap` accepts an optional merge function to resolve collisions gracefully: + +```go +type UserRole struct { + User string + Role string +} + +roles := []UserRole{ + {User: "alice", Role: "viewer"}, + {User: "bob", Role: "editor"}, + {User: "alice", Role: "admin"}, +} + +// Resolve collisions by keeping the latest assigned role +roleLookup := Stream[UserRole]{}.FromSlice(roles).Collect( + Collectors{}.ToMap( + func(ur UserRole) string { return ur.User }, + func(ur UserRole) string { return ur.Role }, + func(existing, newVal string) string { return newVal }, // collision rule + ), +) +// roleLookup["alice"] -> "admin" +``` + +--- + +## 7. Resource Lifecycle & AutoClose + +Streams support registering cleanup hooks via `.OnClose(...)` and closing resources using `.Close()`: + +```go +s := Stream[Event]{}.FromChan(eventChannel). + OnClose(func() { + log.Println("Stream pipeline finished; releasing resources") + }) + +// Consuming the stream automatically closes it upon completion +s.ForEach(processEvent) +``` diff --git a/docs/content/docs/5-language-helpers/_index.md b/docs/content/docs/5-language-helpers/_index.md index 8bfab63..ae25ec5 100644 --- a/docs/content/docs/5-language-helpers/_index.md +++ b/docs/content/docs/5-language-helpers/_index.md @@ -11,3 +11,4 @@ Rays provides a suite of tools in the `lang` package to defend your business log Explore the framework helpers below: * [Error Handling](1-error-handling/) * [Enum Types](2-enum-types/) +* [Streams](3-streams/) diff --git a/docs/content/docs/7-i18n/1-message-catalogs.md b/docs/content/docs/7-i18n/1-message-catalogs.md index 1bc2354..5f25098 100644 --- a/docs/content/docs/7-i18n/1-message-catalogs.md +++ b/docs/content/docs/7-i18n/1-message-catalogs.md @@ -5,16 +5,13 @@ weight: 1 # Message Catalogs & Pluralization -Rays simplifies message localization by letting you define translation catalogs directly in your YAML configuration -files. Messages are automatically loaded into `LocaleMessageBundle` instances, with full support for positional -parameters, Unicode CLDR pluralization rules, and numeric ranges. +Rays simplifies message localization by letting you define translation catalogs directly in your YAML configuration files. Messages are automatically loaded into `LocaleMessageBundle` instances, with full support for positional parameters, Unicode CLDR pluralization rules, and numeric ranges. --- ## Defining Messages in YAML -Message catalogs are organized by locale under the `localization.messages` property in your `application.yaml` file. -Rays recognizes four distinct categories of localized messages: +Message catalogs are organized by locale under the `localization.messages` property in your `application.yaml` file. Rays recognizes four distinct categories of localized messages: | Category | Description | Placeholders | | :--- | :--- | :--- | @@ -74,9 +71,7 @@ localization: ## CLDR Plural Rules -Different languages follow different rules for pluralization. In English, cardinal numbers only distinguish between -`ONE` (1) and `OTHER` (0, 2, 3, ...). Other languages like Polish, Russian, or Arabic use additional categories such as -`FEW` or `MANY`. +Different languages follow different rules for pluralization. In English, cardinal numbers only distinguish between `ONE` (1) and `OTHER` (0, 2, 3, ...). Other languages like Polish, Russian, or Arabic use additional categories such as `FEW` or `MANY`. Rays provides a type-safe `PluralRule` enum corresponding to standard Unicode CLDR plural categories: @@ -97,15 +92,13 @@ type PluralRule = Enum[PluralRuleEnum] var PluralRules = PluralRule{}.New() ``` -When defining cardinal, ordinal, or range entries in YAML, specify rule names matching these enum values (e.g., `ONE`, -`FEW`, `OTHER`). +When defining cardinal, ordinal, or range entries in YAML, specify rule names matching these enum values (e.g., `ONE`, `FEW`, `OTHER`). --- ## Programmatic Translation API -You can look up any `LocaleMessageBundle` from a `MessageSource` to format and translate messages in your business -logic. +You can look up any `LocaleMessageBundle` from a `MessageSource` to format and translate messages in your business logic. ### 1. Simple Translations @@ -125,8 +118,7 @@ msg, err = bundle.Translate("welcome_user", "John", "Doe") ### 2. Cardinal Pluralization -Use `Cardinal()` to format quantities according to the language's plural rules. Rays automatically formats the number -according to the locale's number convention: +Use `Cardinal()` to format quantities according to the language's plural rules. Rays automatically formats the number according to the locale's number convention: ```go // English: 1 -> ONE, 5 -> OTHER diff --git a/docs/content/docs/7-i18n/2-configuration.md b/docs/content/docs/7-i18n/2-configuration.md index f30c1e8..2d93128 100644 --- a/docs/content/docs/7-i18n/2-configuration.md +++ b/docs/content/docs/7-i18n/2-configuration.md @@ -5,16 +5,13 @@ weight: 2 # Container Configuration -Rays integrates internationalization cleanly into its Inversion of Control (IoC) container. By declaring an i18n -`Configuration` Beam, you can bind YAML configuration properties, configure default and auxiliary locales, load -validator translation packs, and publish i18n services for injection throughout your application. +Rays integrates internationalization cleanly into its Inversion of Control (IoC) container. By declaring an i18n `Configuration` Beam, you can bind YAML configuration properties, configure default and auxiliary locales, load validator translation packs, and publish i18n services for injection throughout your application. --- ## 1. Defining Configuration Properties -First, define a struct embedding `ConfigurationProperties` to bind the `localization` section from your -`application.yaml` file: +First, define a struct embedding `ConfigurationProperties` to bind the `localization` section from your `application.yaml` file: ```go package config @@ -31,16 +28,13 @@ type LocalizationProperties struct { } ``` -Rays automatically maps the YAML properties to the internal `LocalizedMessages` struct containing `translation`, -`cardinal`, `ordinal`, and `ranges` maps. +Rays automatically maps the YAML properties to the internal `LocalizedMessages` struct containing `translation`, `cardinal`, `ordinal`, and `ranges` maps. --- ## 2. The Localization Configuration Beam -Next, define your `LocalizationConfig` struct embedding `Configuration`. During the container's `PostConstruct()` -lifecycle hook, it initializes the `ThreadLocaleContextHolder`, creates the central `DefaultMessageSource`, and -registers each supported locale: +Next, define your `LocalizationConfig` struct embedding `Configuration`. During the container's `PostConstruct()` lifecycle hook, it initializes the `ThreadLocaleContextHolder`, creates the central `DefaultMessageSource`, and registers each supported locale: ```go package config diff --git a/docs/content/docs/7-i18n/3-web-and-templates.md b/docs/content/docs/7-i18n/3-web-and-templates.md index 8328d97..59e6caa 100644 --- a/docs/content/docs/7-i18n/3-web-and-templates.md +++ b/docs/content/docs/7-i18n/3-web-and-templates.md @@ -5,12 +5,9 @@ weight: 3 # Web & Template Integration -Internationalized web applications must dynamically determine the user's preferred language for each incoming HTTP -request without requiring developers to manually pass a locale or context parameter through every service, repository, -and template call. +Internationalized web applications must dynamically determine the user's preferred language for each incoming HTTP request without requiring developers to manually pass a locale or context parameter through every service, repository, and template call. -Rays solves this using **`ThreadLocaleContextHolder`** for request-scoped locale management and **`LocaleFuncMap`** for -seamless integration with Go's standard `html/template` package. +Rays solves this using **`ThreadLocaleContextHolder`** for request-scoped locale management and **`LocaleFuncMap`** for seamless integration with Go's standard `html/template` package. --- @@ -18,15 +15,12 @@ seamless integration with Go's standard `html/template` package. ### How It Works -`ThreadLocaleContextHolder` is powered by thread-local storage (`routine.ThreadLocal`). When an HTTP request enters your -application: +`ThreadLocaleContextHolder` is powered by thread-local storage (`routine.ThreadLocal`). When an HTTP request enters your application: 1. A middleware clears any leftover thread state by calling `holder.Clear()`. -2. The middleware determines the desired locale from the request (URL query parameter, cookie, or `Accept-Language` - header). +2. The middleware determines the desired locale from the request (URL query parameter, cookie, or `Accept-Language` header). 3. The middleware sets the locale via `holder.Set(locale)`. -4. All subsequent function calls on that goroutine—including validators, template renderers, and business - services—automatically access that locale via `holder.Get()`. +4. All subsequent function calls on that goroutine—including validators, template renderers, and business services—automatically access that locale via `holder.Get()`. ### Writing an HTTP Middleware @@ -80,8 +74,7 @@ func LocaleMiddleware(holder i18n.LocaleContextHolder) func(http.Handler) http.H ## 2. HTML Template Integration (`pkg/web`) -Rays provides `LocaleFuncMap` in the `pkg/web` package, implementing the `FuncMapper` interface. It bridges the current -`LocaleContextHolder` and `MessageSource` to Go's standard `html/template` engine. +Rays provides `LocaleFuncMap` in the `pkg/web` package, implementing the `FuncMapper` interface. It bridges the current `LocaleContextHolder` and `MessageSource` to Go's standard `html/template` engine. ### Available Template Functions @@ -124,8 +117,7 @@ func (r *TemplateRenderer) Render(w io.Writer, tmplPath string, data any) error ### Writing Templates -Because the template functions look up the current locale from `ThreadLocaleContextHolder` dynamically at execution -time, your templates automatically render in the user's active language: +Because the template functions look up the current locale from `ThreadLocaleContextHolder` dynamically at execution time, your templates automatically render in the user's active language: ```html diff --git a/docs/content/docs/7-i18n/4-validation.md b/docs/content/docs/7-i18n/4-validation.md index 6444dd5..ef5a02b 100644 --- a/docs/content/docs/7-i18n/4-validation.md +++ b/docs/content/docs/7-i18n/4-validation.md @@ -5,15 +5,11 @@ weight: 4 # Localized Struct Validation -Web frameworks and API routers typically bind request payloads to structs and validate them using [Go-Playground -Validator](https://github.com/go-playground/validator). However, default validation outputs suffer from two major -problems: +Web frameworks and API routers typically bind request payloads to structs and validate them using [Go-Playground Validator](https://github.com/go-playground/validator). However, default validation outputs suffer from two major problems: 1. Error descriptions are in English only. -2. Field names reflect raw Go struct fields (e.g., `"ConfirmPassword"` or `"user_email"`) rather than user-friendly UI - labels. +2. Field names reflect raw Go struct fields (e.g., `"ConfirmPassword"` or `"user_email"`) rather than user-friendly UI labels. -Rays' **`MessageSourceValidator`** solves both problems by translating validation rules into the active request locale -and replacing field names with localized labels defined in your message catalog. +Rays' **`MessageSourceValidator`** solves both problems by translating validation rules into the active request locale and replacing field names with localized labels defined in your message catalog. --- @@ -21,14 +17,10 @@ and replacing field names with localized labels defined in your message catalog. `MessageSourceValidator` wraps the validation engine with localization awareness: -1. **Validation**: It invokes `Validate(&struct)` to execute standard Go-Playground validation tags (`required`, - `min=8`, `email`, `eqfield`, etc.). -2. **Field Name Resolution**: Field names are determined by the first matching tag among `field`, `form`, `json`, and - `xml`. If none are present, the struct field name is lowercased. -3. **Namespace Translation**: When calling `TranslateValidationErrors(errs, namespace)`, Rays looks up the localized - field name in the active locale's bundle under `.` (e.g., `fields.registration.username`). -4. **Cross-Field Translation**: For cross-field rules like `eqfield=Password` or `eqcsfield=Parent.Field`, Rays - translates both the source field *and* the target comparison field. +1. **Validation**: It invokes `Validate(&struct)` to execute standard Go-Playground validation tags (`required`, `min=8`, `email`, `eqfield`, etc.). +2. **Field Name Resolution**: Field names are determined by the first matching tag among `field`, `form`, `json`, and `xml`. If none are present, the struct field name is lowercased. +3. **Namespace Translation**: When calling `TranslateValidationErrors(errs, namespace)`, Rays looks up the localized field name in the active locale's bundle under `.` (e.g., `fields.registration.username`). +4. **Cross-Field Translation**: For cross-field rules like `eqfield=Password` or `eqcsfield=Parent.Field`, Rays translates both the source field *and* the target comparison field. --- @@ -50,8 +42,7 @@ type UserRegistrationForm struct { ### 2. Configure Field Labels in YAML -Define the friendly labels for each field under a dedicated namespace (e.g., `fields.registration`) in your message -catalogs: +Define the friendly labels for each field under a dedicated namespace (e.g., `fields.registration`) in your message catalogs: ```yaml localization: @@ -70,8 +61,7 @@ localization: ### 3. Validate and Translate in Controllers -Inject `MessageSourceValidator` into your controller or service. When validation fails, extract -`validator.ValidationErrors` and call `TranslateValidationErrors()`: +Inject `MessageSourceValidator` into your controller or service. When validation fails, extract `validator.ValidationErrors` and call `TranslateValidationErrors()`: ```go package controllers @@ -114,20 +104,20 @@ Assume a user submits a registration form with `Password: "short"` (less than 8 ### English (`Locales.EN`) -``` password: 'Password' must be at least 8 characters in length confirm: 'Confirm Password' must be equal to -'Password' ``` +``` +password: 'Password' must be at least 8 characters in length +confirm: 'Confirm Password' must be equal to 'Password' +``` ### French (`Locales.FR`) -``` password: 'Mot de passe' doit faire une taille minimum de 8 caractères confirm: 'Confirmation du mot de passe' doit -être égal à 'Mot de passe' ``` +``` +password: 'Mot de passe' doit faire une taille minimum de 8 caractères +confirm: 'Confirmation du mot de passe' doit être égal à 'Mot de passe' +``` ### Key Highlights -- **Quotes and Labels**: Field names are wrapped in quotes (`'Password'`, `'Mot de passe'`) using your translated terms - instead of raw struct identifiers. -- **Cross-Field Equality**: Notice that the `confirm` field's error in French correctly localized both fields: - `'Confirmation du mot de passe' doit être égal à 'Mot de passe'`. -- **Returned Data Type**: `TranslateValidationErrors` returns `validator.ValidationErrorsTranslations` - (`map[string]string`), keyed by the field name (`"password"`, `"confirm"`), making it straightforward to serialize - directly to JSON or render alongside form inputs in HTML templates. +- **Quotes and Labels**: Field names are wrapped in quotes (`'Password'`, `'Mot de passe'`) using your translated terms instead of raw struct identifiers. +- **Cross-Field Equality**: Notice that the `confirm` field's error in French correctly localized both fields: `'Confirmation du mot de passe' doit être égal à 'Mot de passe'`. +- **Returned Data Type**: `TranslateValidationErrors` returns `validator.ValidationErrorsTranslations` (`map[string]string`), keyed by the field name (`"password"`, `"confirm"`), making it straightforward to serialize directly to JSON or render alongside form inputs in HTML templates. diff --git a/docs/content/docs/7-i18n/5-catalogs-reference.md b/docs/content/docs/7-i18n/5-catalogs-reference.md index 1779c8e..c70d03e 100644 --- a/docs/content/docs/7-i18n/5-catalogs-reference.md +++ b/docs/content/docs/7-i18n/5-catalogs-reference.md @@ -5,15 +5,13 @@ weight: 5 # Predefined Catalogs Reference -The `github.com/BeamFoundry/rays/pkg/i18n/locales` package eliminates boilerplate by providing pre-instantiated -singletons for standard locales and pre-registered validation translation functions. +The `github.com/BeamFoundry/rays/pkg/i18n/locales` package eliminates boilerplate by providing pre-instantiated singletons for standard locales and pre-registered validation translation functions. --- ## Predefined Locales -The `Locales` struct contains pre-instantiated [Go-Playground Locales](https://github.com/go-playground/locales) -singletons for over 200 standard language and territory combinations. +The `Locales` struct contains pre-instantiated [Go-Playground Locales](https://github.com/go-playground/locales) singletons for over 200 standard language and territory combinations. ### Common Locales @@ -81,8 +79,7 @@ bundle := messageSource.NewMessageBundle(locales.Locales.DE) ## Predefined Validation Translations -The `ValidationTranslations` struct provides ready-to-use registration functions that load built-in translation catalogs -for Go-Playground Validator into a `LocaleMessageBundle`. +The `ValidationTranslations` struct provides ready-to-use registration functions that load built-in translation catalogs for Go-Playground Validator into a `LocaleMessageBundle`. ### Loading Validation Translations diff --git a/internal/config/config.go b/internal/config/config.go index 08383aa..c317111 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -509,17 +509,7 @@ func (this *Config) unmarshal(obj any) Error { continue } s := NewConfig() - _, hasKey := field.Type.Elem().FieldByName("key") - _, hasValue := field.Type.Elem().FieldByName("value") - mapEntry := Strings(field.Type.Elem().String()).HasPrefix("util.MapEntry[") - if len(c) == 1 && hasKey && hasValue && mapEntry { - for entryKey, entryValue := range c { - s.config.Put("key", entryKey) - s.config.Put("value", entryValue) - } - } else { - s.config.PutAll(NewHashMapFor(c)) - } + s.config.PutAll(NewHashMapFor(c)) prefix := String(key).ToLowerCase().String() + ".[" + strconv.Itoa(i) + "]." for k, v := range this.overrides.Iterator() { if p := String(k); p.StartsWith(prefix) { diff --git a/internal/config/config_suite_test.go b/internal/config/config_suite_test.go index c6c1140..1cacbe7 100644 --- a/internal/config/config_suite_test.go +++ b/internal/config/config_suite_test.go @@ -9,7 +9,6 @@ import ( "github.com/BeamFoundry/rays/internal/config" _ "github.com/BeamFoundry/rays/pkg/testing" - . "github.com/BeamFoundry/rays/pkg/util" ) func TestConfig(t *testing.T) { @@ -58,10 +57,6 @@ type BookElemsMap struct { books []map[string]string } -type BookElemsMapEntry struct { - books []MapEntry[string, string] -} - type TranslationMap struct { en map[string]string fr map[string]string @@ -304,19 +299,6 @@ var _ = Describe("config", func() { Expect(elems.books[0]["Les Miserables"]).To(Equal("Victor Hugo")) Expect(elems.books[1]["Germinal"]).To(Equal("emile zola")) }) - It("can load an object using slice of MapEntry", func() { - elems := &BookElemsMapEntry{} - c := config.NewConfigFor(favoritesListElems) - Expect(c.AddOverride("favorites.books.[1].value", "emile zola")).NotTo(HaveOccurred()) - Expect(c.InitConfig()).NotTo(HaveOccurred()) - subkey, err := c.GetSubConfig("favorites") - Expect(err).NotTo(HaveOccurred()) - - Expect(subkey.Unmarshal(elems)).NotTo(HaveOccurred()) - Expect(elems.books[0].Key()).To(Equal("Les Miserables")) - Expect(elems.books[0].Value()).To(Equal("Victor Hugo")) - Expect(elems.books[1].Value()).To(Equal("emile zola")) - }) It("can duplicate default config", func() { elems := &BookElemsMapObject{} c := config.NewConfig() diff --git a/internal/utils/is_nil.go b/internal/utils/is_nil.go new file mode 100644 index 0000000..73f3480 --- /dev/null +++ b/internal/utils/is_nil.go @@ -0,0 +1,14 @@ +package utils + +import ( + "reflect" +) + +// IsNil reports whether c is nil or holds a nil pointer, map, or slice. +func IsNil(c any) bool { + if c == nil { + return true + } + v := reflect.ValueOf(c) + return (v.Kind() == reflect.Pointer || v.Kind() == reflect.Map || v.Kind() == reflect.Slice) && v.IsNil() +} diff --git a/internal/utils/is_nil_test.go b/internal/utils/is_nil_test.go new file mode 100644 index 0000000..417108c --- /dev/null +++ b/internal/utils/is_nil_test.go @@ -0,0 +1,38 @@ +package utils_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/BeamFoundry/rays/internal/utils" +) + +var _ = Describe("IsNil", func() { + It("correctly identifies nil and typed-nil values", func() { + Expect(IsNil(nil)).To(BeTrue()) + + var ptr *int + Expect(IsNil(ptr)).To(BeTrue()) + + var m map[string]int + Expect(IsNil(m)).To(BeTrue()) + + var s []string + Expect(IsNil(s)).To(BeTrue()) + + var ch chan int + Expect(IsNil(ch)).To(BeFalse()) + + nonNilInt := 42 + Expect(IsNil(&nonNilInt)).To(BeFalse()) + + nonNilMap := make(map[string]int) + Expect(IsNil(nonNilMap)).To(BeFalse()) + + nonNilSlice := make([]string, 0) + Expect(IsNil(nonNilSlice)).To(BeFalse()) + + Expect(IsNil(123)).To(BeFalse()) + Expect(IsNil("hello")).To(BeFalse()) + }) +}) diff --git a/internal/utils/text.go b/internal/utils/text.go new file mode 100644 index 0000000..cf274cb --- /dev/null +++ b/internal/utils/text.go @@ -0,0 +1,14 @@ +package utils + +import ( + "unicode" + "unicode/utf8" +) + +func Uncapitalize(text string) string { + r, size := utf8.DecodeRuneInString(text) + if r == utf8.RuneError && size <= 1 { + return text + } + return string(unicode.ToLower(r)) + text[size:] +} diff --git a/internal/utils/text_test.go b/internal/utils/text_test.go new file mode 100644 index 0000000..274b5a2 --- /dev/null +++ b/internal/utils/text_test.go @@ -0,0 +1,43 @@ +package utils_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/BeamFoundry/rays/internal/utils" +) + +var _ = Describe("Text", func() { + Describe("Uncapitalize", func() { + It("uncapitalizes empty strings and single characters", func() { + Expect(Uncapitalize("")).To(Equal("")) + Expect(Uncapitalize("A")).To(Equal("a")) + Expect(Uncapitalize("a")).To(Equal("a")) + }) + + It("uncapitalizes words while preserving remaining characters", func() { + Expect(Uncapitalize("HelloWorld")).To(Equal("helloWorld")) + Expect(Uncapitalize("Test")).To(Equal("test")) + Expect(Uncapitalize("test")).To(Equal("test")) + Expect(Uncapitalize("ALL")).To(Equal("aLL")) + }) + + It("handles strings beginning with non-letter characters", func() { + Expect(Uncapitalize("123ABC")).To(Equal("123ABC")) + Expect(Uncapitalize("_private")).To(Equal("_private")) + Expect(Uncapitalize(" space")).To(Equal(" space")) + }) + + It("handles multi-byte unicode characters", func() { + Expect(Uncapitalize("Éléphant")).To(Equal("éléphant")) + Expect(Uncapitalize("Ångström")).To(Equal("ångström")) + Expect(Uncapitalize("Σigma")).To(Equal("σigma")) + Expect(Uncapitalize("日本語")).To(Equal("日本語")) + }) + + It("handles invalid UTF-8 sequences", func() { + invalid := string([]byte{0xff, 0xfe}) + Expect(Uncapitalize(invalid)).To(Equal(invalid)) + }) + }) +}) diff --git a/internal/utils/utils_suite_test.go b/internal/utils/utils_suite_test.go new file mode 100644 index 0000000..03adb36 --- /dev/null +++ b/internal/utils/utils_suite_test.go @@ -0,0 +1,14 @@ +package utils_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// TestUtils runs the internal utils test suite. +func TestUtils(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Internal/Utils Suite") +} diff --git a/pkg/core/beam.go b/pkg/core/beam.go index 564a3f1..c69a5fc 100644 --- a/pkg/core/beam.go +++ b/pkg/core/beam.go @@ -4,8 +4,8 @@ import ( "context" "reflect" + . "github.com/BeamFoundry/rays/internal/utils" . "github.com/BeamFoundry/rays/pkg/lang" - . "github.com/BeamFoundry/rays/pkg/util" ) // DefaultBeam is the default [Beam] implementation. @@ -101,7 +101,7 @@ func NewBeamBuilder(ptr any) (*BeamBuilder, Error) { return nil, NewError("InvalidType", "Bean must be a pointer to a struct") } - builder.beam.attributes["Qualifier"] = CaseUtils(reflectType.Elem().Name()).Uncapitalize().String() + builder.beam.attributes["Qualifier"] = Uncapitalize(reflectType.Elem().Name()) if field := FindFieldWithAnnotation[Prototype](reflectType); field != nil { builder.beam.scope = BeamScope{}.New().PROTOTYPE diff --git a/pkg/core/collection.go b/pkg/core/collection.go new file mode 100644 index 0000000..57368ba --- /dev/null +++ b/pkg/core/collection.go @@ -0,0 +1,152 @@ +package core + +import ( + "iter" + + . "github.com/BeamFoundry/rays/pkg/lang" +) + +// Collection represents the root interface in the collection hierarchy. +// A collection represents a group of objects, known as its elements. +type Collection[T any] interface { + // Size returns the number of elements in this collection. + Size() int + + // IsEmpty returns true if this collection contains no elements. + IsEmpty() bool + + // Contains returns true if this collection contains the specified element. + Contains(element T) bool + + // ContainsAll returns true if this collection contains all of the elements in the specified collection. + ContainsAll(c Collection[T]) bool + + // Add ensures that this collection contains the specified elements. + // Returns true if this collection changed as a result of the call. + Add(elements ...T) bool + + // AddAll adds all of the elements in the specified collection to this collection. + // Returns true if this collection changed as a result of the call. + AddAll(c Collection[T]) bool + + // Remove removes a single instance of the specified element from this collection, if it is present. + // Returns true if an element was removed. + Remove(element T) bool + + // RemoveAll removes all of this collection's elements that are also contained in the specified collection. + // Returns true if this collection changed as a result of the call. + RemoveAll(c Collection[T]) bool + + // RetainAll retains only the elements in this collection that are contained in the specified collection. + // Returns true if this collection changed as a result of the call. + RetainAll(c Collection[T]) bool + + // Clear removes all of the elements from this collection. + Clear() + + // ToSlice returns a slice containing all of the elements in this collection. + ToSlice() []T + + // Seq returns an iterator sequence (iter.Seq[T]) over the elements in this collection, + // enabling direct Go 1.23+ range loops: for item := range c.Seq() + Seq() iter.Seq[T] + + // Equals returns true if the specified collection is equal to this collection. + Equals(c Collection[T]) bool +} + +// List represents an ordered collection (also known as a sequence). +// The user can access elements by their integer index and search for elements in the list. +type List[T any] interface { + Collection[T] + + // Get returns the element at the specified position in this list. + // Panics with IndexOutOfBoundsError if the index is out of range (index < 0 || index >= Size()). + Get(index int) T + + // Set replaces the element at the specified position in this list with the specified element. + // Returns the element previously at the specified position. + // Panics with IndexOutOfBoundsError if the index is out of range. + Set(index int, element T) T + + // AddAt inserts the specified element at the specified position in this list. + // Shifts the element currently at that position (if any) and any subsequent elements to the right. + // Panics with IndexOutOfBoundsError if the index is out of range (index < 0 || index > Size()). + AddAt(index int, element T) + + // RemoveAt removes the element at the specified position in this list. + // Shifts any subsequent elements to the left. + // Returns the element that was removed from the list. + // Panics with IndexOutOfBoundsError if the index is out of range. + RemoveAt(index int) T + + // IndexOf returns the index of the first occurrence of the specified element in this list, + // or -1 if this list does not contain the element. + IndexOf(element T) int + + // LastIndexOf returns the index of the last occurrence of the specified element in this list, + // or -1 if this list does not contain the element. + LastIndexOf(element T) int + + // SubList returns an independent copy of the portion of this list between the specified fromIndex, inclusive, + // and toIndex, exclusive. Changes to the returned list do not affect the original list. + SubList(fromIndex, toIndex int) List[T] +} + +// Set represents a collection that contains no duplicate elements. +// As elements are compared for equality, element type T must satisfy comparable. +type Set[T comparable] interface { + Collection[T] +} + +// Map is an interface for an object oriented map. +// +// TODO: Add Clone and JSON Marshaler/Unmarshaler ??? +type Map[K comparable, V any] interface { + // Map() returns a snapshot of the underlying map as a Go native map. + // + // This should be a shallow copy of the Map. + Map() map[K]V + + // Clear() empty the map + Clear() + + // ContainsKey() returns whether the map contains that key + ContainsKey(key K) bool + + // Merge() will merge the given map + // + // To the difference of PutAll(), this will try to merge a map recusively. + Merge(m Map[K, V]) + + // Iterator() returns an iterator over the underlying map. + // + // This should be done over a snapshot (shallow copy) of the Map. + Iterator() iter.Seq2[K, V] + + // Equals() returns whether a Map equals to this one. + Equals(m Map[K, V]) bool + + // Get() returns optionally the requested key + Get(key K) Optional[V] + + // Remove() returns optionally the requested key after removing it from the map + Remove(key K) Optional[V] + + // Delete() apply a filter to remove key/values from the map + Delete(f func(key K, val V) bool) + + // IsEmpty() returns whether the Map is empty or not + IsEmpty() bool + + // Put() add (or replace) a key/value in the Map. + Put(key K, val V) Map[K, V] + + // PutAll() merge the given Map and returns it. + // + // To the difference of Merge() this will replace existing key without merging Map entry of the same type. + PutAll(m Map[K, V]) Map[K, V] + + // Size() returns the size of the Map + Size() int +} diff --git a/pkg/util/array_list.go b/pkg/util/array_list.go new file mode 100644 index 0000000..ed4765c --- /dev/null +++ b/pkg/util/array_list.go @@ -0,0 +1,198 @@ +package util + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "errors" + "iter" + + . "github.com/BeamFoundry/rays/pkg/core" + . "github.com/BeamFoundry/rays/pkg/util/internal" + . "github.com/BeamFoundry/rays/pkg/util/stream" +) + +// ArrayList is a resizable-array implementation of the List interface. +type ArrayList[T any] struct { + AbstractList[T] +} + +var ( + _ List[int] = (*ArrayList[int])(nil) + _ json.Marshaler = (*ArrayList[int])(nil) + _ json.Unmarshaler = (*ArrayList[int])(nil) + _ driver.Valuer = (*ArrayList[int])(nil) + _ sql.Scanner = (*ArrayList[int])(nil) + + errNilArrayList = errors.New("cannot operate on nil *ArrayList") +) + +// NewArrayList creates a new ArrayList containing the given elements. +func NewArrayList[T any](elements ...T) *ArrayList[T] { + return &ArrayList[T]{ + AbstractList: *NewAbstractList(elements...), + } +} + +// NewArrayListWithCapacity creates an empty ArrayList with the specified initial capacity. +func NewArrayListWithCapacity[T any](capacity int) *ArrayList[T] { + return &ArrayList[T]{ + AbstractList: *NewAbstractListWithCapacity[T](capacity), + } +} + +// Size returns the number of elements in this list. +func (this *ArrayList[T]) Size() int { + return this.AbstractList.Size() +} + +// IsEmpty returns true if this list contains no elements. +func (this *ArrayList[T]) IsEmpty() bool { + return this.AbstractList.IsEmpty() +} + +// Get returns the element at the specified position in this list. +// Panics with IndexOutOfBoundsError if the index is out of range. +func (this *ArrayList[T]) Get(index int) T { + return this.AbstractList.Get(index) +} + +// Set replaces the element at the specified position in this list with the specified element. +// Returns the element previously at the specified position. +// Panics with IndexOutOfBoundsError if the index is out of range. +func (this *ArrayList[T]) Set(index int, element T) T { + return this.AbstractList.Set(index, element) +} + +// Add appends the specified elements to the end of this list. +// Returns true if this list changed as a result of the call. +func (this *ArrayList[T]) Add(elements ...T) bool { + return this.AbstractList.Add(elements...) +} + +// AddAt inserts the specified element at the specified position in this list. +// Shifts the element currently at that position (if any) and any subsequent elements to the right. +// Panics with IndexOutOfBoundsError if the index is out of range. +func (this *ArrayList[T]) AddAt(index int, element T) { + this.AbstractList.AddAt(index, element) +} + +// IndexOf returns the index of the first occurrence of the specified element in this list, +// or -1 if this list does not contain the element. +func (this *ArrayList[T]) IndexOf(element T) int { + return this.AbstractList.IndexOf(element) +} + +// LastIndexOf returns the index of the last occurrence of the specified element in this list, +// or -1 if this list does not contain the element. +func (this *ArrayList[T]) LastIndexOf(element T) int { + return this.AbstractList.LastIndexOf(element) +} + +// Contains returns true if this list contains the specified element. +func (this *ArrayList[T]) Contains(element T) bool { + return this.AbstractList.Contains(element) +} + +// ContainsAll returns true if this list contains all of the elements in the specified collection. +func (this *ArrayList[T]) ContainsAll(c Collection[T]) bool { + return this.AbstractList.ContainsAll(c) +} + +// AddAll appends all of the elements in the specified collection to the end of this list. +// Returns true if this list changed as a result of the call. +func (this *ArrayList[T]) AddAll(c Collection[T]) bool { + return this.AbstractList.AddAll(c) +} + +// Remove removes the first occurrence of the specified element from this list, if it is present. +// Returns true if an element was removed. +func (this *ArrayList[T]) Remove(element T) bool { + return this.AbstractList.Remove(element) +} + +// RemoveAt removes the element at the specified position in this list. +// Shifts any subsequent elements to the left. +// Returns the element that was removed from the list. +// Panics with IndexOutOfBoundsError if the index is out of range. +func (this *ArrayList[T]) RemoveAt(index int) T { + return this.AbstractList.RemoveAt(index) +} + +// RemoveAll removes all of this list's elements that are also contained in the specified collection. +// Returns true if this list changed as a result of the call. +func (this *ArrayList[T]) RemoveAll(c Collection[T]) bool { + return this.AbstractList.RemoveAll(c) +} + +// RetainAll retains only the elements in this list that are contained in the specified collection. +// Returns true if this list changed as a result of the call. +func (this *ArrayList[T]) RetainAll(c Collection[T]) bool { + return this.AbstractList.RetainAll(c) +} + +// Clear removes all elements from this list. +func (this *ArrayList[T]) Clear() { + this.AbstractList.Clear() +} + +// ToSlice returns a copy of the underlying slice. +func (this *ArrayList[T]) ToSlice() []T { + return this.AbstractList.ToSlice() +} + +// Seq returns an iterator sequence over the elements in this list. +func (this *ArrayList[T]) Seq() iter.Seq[T] { + return this.AbstractList.Seq() +} + +// SubList returns an independent copy of the portion of this list between fromIndex, inclusive, +// and toIndex, exclusive. Changes to the returned list do not affect the original list. +// Panics with IndexOutOfBoundsError if the indices are out of range. +func (this *ArrayList[T]) SubList(fromIndex, toIndex int) List[T] { + return this.AbstractList.SubList(fromIndex, toIndex) +} + +// Equals returns true if the specified collection is equal to this list. +func (this *ArrayList[T]) Equals(c Collection[T]) bool { + return this.AbstractList.Equals(c) +} + +// Stream returns a sequential Stream with this list as its source. +func (this *ArrayList[T]) Stream() Stream[T] { + return Stream[T]{}.FromSeq(this.Seq()) +} + +// MarshalJSON implements json.Marshaler. +func (this *ArrayList[T]) MarshalJSON() ([]byte, error) { + if this == nil { + return []byte("[]"), nil + } + return this.AbstractList.MarshalJSON() +} + +// UnmarshalJSON implements json.Unmarshaler. +func (this *ArrayList[T]) UnmarshalJSON(data []byte) error { + if this == nil { + return errNilArrayList + } + return this.AbstractList.UnmarshalJSON(data) +} + +// Value implements driver.Valuer. +// +//nolint:nilnil // driver.Valuer returns nil, nil to represent SQL NULL. +func (this *ArrayList[T]) Value() (driver.Value, error) { + if this == nil { + return nil, nil + } + return this.AbstractList.Value() +} + +// Scan implements sql.Scanner. +func (this *ArrayList[T]) Scan(src any) error { + if this == nil { + return errNilArrayList + } + return this.AbstractList.Scan(src) +} diff --git a/pkg/util/array_list_test.go b/pkg/util/array_list_test.go new file mode 100644 index 0000000..e9208b3 --- /dev/null +++ b/pkg/util/array_list_test.go @@ -0,0 +1,224 @@ +package util_test + +import ( + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/BeamFoundry/rays/pkg/core" + . "github.com/BeamFoundry/rays/pkg/util" +) + +var _ = Describe("ArrayList", func() { + It("creates an empty or populated ArrayList", func() { + l1 := NewArrayList[string]() + Expect(l1.Size()).To(Equal(0)) + Expect(l1.IsEmpty()).To(BeTrue()) + + l2 := NewArrayList("a", "b", "c") + Expect(l2.Size()).To(Equal(3)) + Expect(l2.IsEmpty()).To(BeFalse()) + + var iface List[string] = l2 + Expect(iface.Size()).To(Equal(3)) + + lCap := NewArrayListWithCapacity[int](10) + Expect(lCap.Size()).To(Equal(0)) + Expect(lCap.IsEmpty()).To(BeTrue()) + }) + + It("supports element access and modification by index", func() { + l := NewArrayList("alpha", "beta", "gamma") + + Expect(l.Get(0)).To(Equal("alpha")) + Expect(l.Get(1)).To(Equal("beta")) + + old := l.Set(1, "BETA") + Expect(old).To(Equal("beta")) + Expect(l.Get(1)).To(Equal("BETA")) + + Expect(func() { l.Get(-1) }).To(Panic()) + Expect(func() { l.Get(10) }).To(Panic()) + Expect(func() { l.Set(-1, "x") }).To(Panic()) + }) + + It("supports Add and AddAt", func() { + l := NewArrayList(1, 2) + Expect(l.Add(3, 4)).To(BeTrue()) + Expect(l.ToSlice()).To(Equal([]int{1, 2, 3, 4})) + + l.AddAt(0, 0) + Expect(l.ToSlice()).To(Equal([]int{0, 1, 2, 3, 4})) + + l.AddAt(5, 5) + Expect(l.ToSlice()).To(Equal([]int{0, 1, 2, 3, 4, 5})) + + Expect(func() { l.AddAt(-1, 99) }).To(Panic()) + Expect(func() { l.AddAt(100, 99) }).To(Panic()) + }) + + It("supports RemoveAt and Remove", func() { + l := NewArrayList("x", "y", "z") + + removed := l.RemoveAt(1) + Expect(removed).To(Equal("y")) + Expect(l.ToSlice()).To(Equal([]string{"x", "z"})) + + Expect(l.Remove("z")).To(BeTrue()) + Expect(l.Remove("not-found")).To(BeFalse()) + Expect(l.ToSlice()).To(Equal([]string{"x"})) + + Expect(func() { l.RemoveAt(5) }).To(Panic()) + }) + + It("supports IndexOf and LastIndexOf", func() { + l := NewArrayList("a", "b", "c", "b", "a") + Expect(l.IndexOf("b")).To(Equal(1)) + Expect(l.LastIndexOf("b")).To(Equal(3)) + Expect(l.IndexOf("missing")).To(Equal(-1)) + Expect(l.LastIndexOf("missing")).To(Equal(-1)) + }) + + It("supports Contains and ContainsAll", func() { + l := NewArrayList("one", "two", "three") + Expect(l.Contains("two")).To(BeTrue()) + Expect(l.Contains("four")).To(BeFalse()) + + other := NewArrayList("one", "three") + Expect(l.ContainsAll(other)).To(BeTrue()) + + notAll := NewArrayList("one", "four") + Expect(l.ContainsAll(notAll)).To(BeFalse()) + }) + + It("supports AddAll, RemoveAll, RetainAll, and Clear", func() { + l := NewArrayList(1, 2, 3) + extra := NewArrayList(4, 5) + Expect(l.AddAll(extra)).To(BeTrue()) + Expect(l.ToSlice()).To(Equal([]int{1, 2, 3, 4, 5})) + + toRemove := NewArrayList(2, 4) + Expect(l.RemoveAll(toRemove)).To(BeTrue()) + Expect(l.ToSlice()).To(Equal([]int{1, 3, 5})) + + toRetain := NewArrayList(1, 5, 9) + Expect(l.RetainAll(toRetain)).To(BeTrue()) + Expect(l.ToSlice()).To(Equal([]int{1, 5})) + + l.Clear() + Expect(l.Size()).To(Equal(0)) + Expect(l.IsEmpty()).To(BeTrue()) + }) + + It("supports SubList", func() { + l := NewArrayList("a", "b", "c", "d", "e") + sub := l.SubList(1, 4) + Expect(sub.ToSlice()).To(Equal([]string{"b", "c", "d"})) + + Expect(func() { l.SubList(-1, 2) }).To(Panic()) + Expect(func() { l.SubList(3, 2) }).To(Panic()) + Expect(func() { l.SubList(0, 10) }).To(Panic()) + }) + + It("supports Seq iterator and Equals", func() { + l1 := NewArrayList(10, 20, 30) + l2 := NewArrayList(10, 20, 30) + l3 := NewArrayList(10, 20, 40) + + Expect(l1.Equals(l2)).To(BeTrue()) + Expect(l1.Equals(l3)).To(BeFalse()) + + var collected []int + for item := range l1.Seq() { + collected = append(collected, item) + } + Expect(collected).To(Equal([]int{10, 20, 30})) + }) + + It("supports Stream() method", func() { + l := NewArrayList(1, 2, 3, 4, 5, 6) + evensSquared := l.Stream(). + Filter(func(n int) bool { return n%2 == 0 }). + Map(func(n int) int { return n * n }). + ToSlice() + + Expect(evensSquared).To(Equal([]int{4, 16, 36})) + }) + + It("implements json.Marshaler and json.Unmarshaler", func() { + l := NewArrayList("alpha", "beta") + data, err := json.Marshal(l) + Expect(err).NotTo(HaveOccurred()) + Expect(string(data)).To(Equal(`["alpha","beta"]`)) + + empty := NewArrayList[int]() + dataEmpty, err := json.Marshal(empty) + Expect(err).NotTo(HaveOccurred()) + Expect(string(dataEmpty)).To(Equal(`[]`)) + + var nilList *ArrayList[int] + dataNil, err := nilList.MarshalJSON() + Expect(err).NotTo(HaveOccurred()) + Expect(string(dataNil)).To(Equal(`[]`)) + + var unmarshaled ArrayList[string] + Expect(json.Unmarshal([]byte(`["first","second"]`), &unmarshaled)).To(Succeed()) + Expect(unmarshaled.ToSlice()).To(Equal([]string{"first", "second"})) + + Expect(json.Unmarshal([]byte(`null`), &unmarshaled)).To(Succeed()) + Expect(unmarshaled.Size()).To(Equal(0)) + + Expect(nilList.UnmarshalJSON([]byte(`[]`))).NotTo(Succeed()) + Expect(unmarshaled.UnmarshalJSON([]byte(`invalid`))).NotTo(Succeed()) + }) + + It("implements driver.Valuer and sql.Scanner", func() { + l := NewArrayList(100, 200) + val, err := l.Value() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal(`[100,200]`)) + + var nilList *ArrayList[int] + nilVal, err := nilList.Value() + Expect(err).NotTo(HaveOccurred()) + Expect(nilVal).To(BeNil()) + + scanned := NewArrayList[int]() + Expect(scanned.Scan(`[1,2,3]`)).To(Succeed()) + Expect(scanned.ToSlice()).To(Equal([]int{1, 2, 3})) + + Expect(scanned.Scan([]byte(`[4,5]`))).To(Succeed()) + Expect(scanned.ToSlice()).To(Equal([]int{4, 5})) + + Expect(scanned.Scan(nil)).To(Succeed()) + Expect(scanned.Size()).To(Equal(0)) + + Expect(scanned.Scan("")).To(Succeed()) + Expect(scanned.Size()).To(Equal(0)) + + Expect(scanned.Scan(12345)).NotTo(Succeed()) + Expect(nilList.Scan(`[]`)).NotTo(Succeed()) + + var zeroList ArrayList[string] + Expect(zeroList.Scan(`["hello"]`)).To(Succeed()) + Expect(zeroList.ToSlice()).To(Equal([]string{"hello"})) + }) + + It("works as an empty list from its zero value", func() { + var l ArrayList[int] + Expect(l.Size()).To(Equal(0)) + Expect(l.IsEmpty()).To(BeTrue()) + Expect(l.ToSlice()).To(Equal([]int{})) + + var collected []int + for item := range l.Seq() { + collected = append(collected, item) + } + Expect(collected).To(BeEmpty()) + + Expect(l.Add(1, 2, 3)).To(BeTrue()) + Expect(l.Size()).To(Equal(3)) + Expect(l.ToSlice()).To(Equal([]int{1, 2, 3})) + }) +}) diff --git a/pkg/util/concurrenthashmap.go b/pkg/util/concurrenthashmap.go index 3ac11d9..f155a5c 100644 --- a/pkg/util/concurrenthashmap.go +++ b/pkg/util/concurrenthashmap.go @@ -4,6 +4,7 @@ import ( "iter" "sync" + . "github.com/BeamFoundry/rays/pkg/core" . "github.com/BeamFoundry/rays/pkg/lang" ) diff --git a/pkg/util/entry.go b/pkg/util/entry.go deleted file mode 100644 index d0625c5..0000000 --- a/pkg/util/entry.go +++ /dev/null @@ -1,53 +0,0 @@ -package util - -import ( - "reflect" -) - -// MapEntry is default [MapEntry] implementation. -type MapEntry[K comparable, V any] struct { - key K - value V -} - -// KeyType() implements [MapEntry.KeyType]. -// -// It returns the [reflect.Type] of K. -func (this *MapEntry[K, V]) KeyType() reflect.Type { - return reflect.TypeFor[K]() -} - -// ValueType() implements [MapEntry.ValueType]. -// -// It returns the [reflect.Type] of V. -func (this *MapEntry[K, V]) ValueType() reflect.Type { - return reflect.TypeFor[V]() -} - -// Key() implements [MapEntry.Key]. -// -// It returns the key corresponding to this entry. -func (this *MapEntry[K, V]) Key() K { - return this.key -} - -// Value() implements [MapEntry.Value]. -// -// It returns the value corresponding to this entry. -func (this *MapEntry[K, V]) Value() V { - return this.value -} - -// SetValue() implements [MapEntry.SetValue]. -// -// It replaces the value corresponding to this entry with the specified value. -func (this *MapEntry[K, V]) SetValue(value V) { - this.value = value -} - -// Equals() implements [MapEntry.Equals]. -// -// It compares the specified object with this entry for equality using [reflect.DeepEqual]. -func (this *MapEntry[K, V]) Equals(value V) bool { - return reflect.DeepEqual(this.value, value) -} diff --git a/pkg/util/entry_test.go b/pkg/util/entry_test.go deleted file mode 100644 index 57c4df3..0000000 --- a/pkg/util/entry_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package util_test - -import ( - "reflect" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - . "github.com/BeamFoundry/rays/pkg/util" -) - -var _ = Describe("MapEntry", func() { - It("returns correct KeyType and ValueType", func() { - entry := &MapEntry[string, int]{} - Expect(entry.KeyType()).To(Equal(reflect.TypeFor[string]())) - Expect(entry.ValueType()).To(Equal(reflect.TypeFor[int]())) - }) - - It("can get Key and get/set Value", func() { - entry := &MapEntry[string, string]{} - Expect(entry.Key()).To(Equal("")) - Expect(entry.Value()).To(Equal("")) - - entry.SetValue("hello") - Expect(entry.Value()).To(Equal("hello")) - }) - - Context("Equals", func() { - It("compares primitive values correctly", func() { - entry := &MapEntry[string, int]{} - entry.SetValue(42) - - Expect(entry.Equals(42)).To(BeTrue()) - Expect(entry.Equals(0)).To(BeFalse()) - Expect(entry.Equals(100)).To(BeFalse()) - }) - - It("compares string values correctly", func() { - entry := &MapEntry[int, string]{} - entry.SetValue("test") - - Expect(entry.Equals("test")).To(BeTrue()) - Expect(entry.Equals("other")).To(BeFalse()) - }) - - It("compares complex and non-comparable values like slices and maps", func() { - entrySlice := &MapEntry[string, []int]{} - entrySlice.SetValue([]int{1, 2, 3}) - - Expect(entrySlice.Equals([]int{1, 2, 3})).To(BeTrue()) - Expect(entrySlice.Equals([]int{1, 2})).To(BeFalse()) - Expect(entrySlice.Equals(nil)).To(BeFalse()) - - entryMap := &MapEntry[string, map[string]int]{} - entryMap.SetValue(map[string]int{"a": 1, "b": 2}) - - Expect(entryMap.Equals(map[string]int{"a": 1, "b": 2})).To(BeTrue()) - Expect(entryMap.Equals(map[string]int{"a": 1})).To(BeFalse()) - Expect(entryMap.Equals(nil)).To(BeFalse()) - }) - - It("compares any/interface values correctly", func() { - entryAny := &MapEntry[string, any]{} - entryAny.SetValue("dynamic") - - Expect(entryAny.Equals("dynamic")).To(BeTrue()) - Expect(entryAny.Equals("other")).To(BeFalse()) - Expect(entryAny.Equals(123)).To(BeFalse()) - Expect(entryAny.Equals(nil)).To(BeFalse()) - - entryAny.SetValue(nil) - Expect(entryAny.Equals(nil)).To(BeTrue()) - Expect(entryAny.Equals("not nil")).To(BeFalse()) - }) - - It("compares struct and pointer values correctly", func() { - type sample struct { - Name string - ID int - } - - s1 := sample{ID: 1, Name: "alpha"} - s2 := sample{ID: 1, Name: "alpha"} - s3 := sample{ID: 2, Name: "beta"} - - entryStruct := &MapEntry[string, sample]{} - entryStruct.SetValue(s1) - Expect(entryStruct.Equals(s2)).To(BeTrue()) - Expect(entryStruct.Equals(s3)).To(BeFalse()) - - entryPtr := &MapEntry[string, *sample]{} - entryPtr.SetValue(&s1) - Expect(entryPtr.Equals(&s2)).To(BeTrue()) - Expect(entryPtr.Equals(&s3)).To(BeFalse()) - Expect(entryPtr.Equals(nil)).To(BeFalse()) - }) - }) -}) diff --git a/pkg/util/hash_set.go b/pkg/util/hash_set.go new file mode 100644 index 0000000..6edb4ca --- /dev/null +++ b/pkg/util/hash_set.go @@ -0,0 +1,151 @@ +package util + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "errors" + "iter" + + . "github.com/BeamFoundry/rays/pkg/core" + . "github.com/BeamFoundry/rays/pkg/util/internal" + . "github.com/BeamFoundry/rays/pkg/util/stream" +) + +// HashSet is a hash table based implementation of the Set interface. +type HashSet[T comparable] struct { + AbstractSet[T] +} + +var ( + _ Set[int] = (*HashSet[int])(nil) + _ json.Marshaler = (*HashSet[int])(nil) + _ json.Unmarshaler = (*HashSet[int])(nil) + _ driver.Valuer = (*HashSet[int])(nil) + _ sql.Scanner = (*HashSet[int])(nil) + + errNilHashSet = errors.New("cannot operate on nil *HashSet") +) + +// NewHashSet creates a new HashSet containing the given elements. +func NewHashSet[T comparable](elements ...T) *HashSet[T] { + return &HashSet[T]{ + AbstractSet: *NewAbstractSet(elements...), + } +} + +// NewHashSetWithCapacity creates an empty HashSet with the specified initial capacity. +func NewHashSetWithCapacity[T comparable](capacity int) *HashSet[T] { + return &HashSet[T]{ + AbstractSet: *NewAbstractSetWithCapacity[T](capacity), + } +} + +// Size returns the number of elements in this set. +func (this *HashSet[T]) Size() int { + return this.AbstractSet.Size() +} + +// IsEmpty returns true if this set contains no elements. +func (this *HashSet[T]) IsEmpty() bool { + return this.AbstractSet.IsEmpty() +} + +// Contains returns true if this set contains the specified element. +func (this *HashSet[T]) Contains(element T) bool { + return this.AbstractSet.Contains(element) +} + +// ContainsAll returns true if this set contains all of the elements in the specified collection. +func (this *HashSet[T]) ContainsAll(c Collection[T]) bool { + return this.AbstractSet.ContainsAll(c) +} + +// Add adds the specified elements to this set. +// Returns true if this set changed as a result of the call. +func (this *HashSet[T]) Add(elements ...T) bool { + return this.AbstractSet.Add(elements...) +} + +// AddAll adds all of the elements in the specified collection to this set. +// Returns true if this set changed as a result of the call. +func (this *HashSet[T]) AddAll(c Collection[T]) bool { + return this.AbstractSet.AddAll(c) +} + +// Remove removes the specified element from this set, if it is present. +// Returns true if an element was removed. +func (this *HashSet[T]) Remove(element T) bool { + return this.AbstractSet.Remove(element) +} + +// RemoveAll removes all of this set's elements that are also contained in the specified collection. +// Returns true if this set changed as a result of the call. +func (this *HashSet[T]) RemoveAll(c Collection[T]) bool { + return this.AbstractSet.RemoveAll(c) +} + +// RetainAll retains only the elements in this set that are contained in the specified collection. +// Returns true if this set changed as a result of the call. +func (this *HashSet[T]) RetainAll(c Collection[T]) bool { + return this.AbstractSet.RetainAll(c) +} + +// Clear removes all elements from this set. +func (this *HashSet[T]) Clear() { + this.AbstractSet.Clear() +} + +// ToSlice returns a slice containing all elements in this set. +func (this *HashSet[T]) ToSlice() []T { + return this.AbstractSet.ToSlice() +} + +// Seq returns an iterator sequence over the elements in this set. +func (this *HashSet[T]) Seq() iter.Seq[T] { + return this.AbstractSet.Seq() +} + +// Equals returns true if the specified collection is equal to this set. +func (this *HashSet[T]) Equals(c Collection[T]) bool { + return this.AbstractSet.Equals(c) +} + +// Stream returns a sequential Stream with this set as its source. +func (this *HashSet[T]) Stream() Stream[T] { + return Stream[T]{}.FromSeq(this.Seq()) +} + +// MarshalJSON implements json.Marshaler. +func (this *HashSet[T]) MarshalJSON() ([]byte, error) { + if this == nil { + return []byte("[]"), nil + } + return this.AbstractSet.MarshalJSON() +} + +// UnmarshalJSON implements json.Unmarshaler. +func (this *HashSet[T]) UnmarshalJSON(data []byte) error { + if this == nil { + return errNilHashSet + } + return this.AbstractSet.UnmarshalJSON(data) +} + +// Value implements driver.Valuer. +// +//nolint:nilnil // driver.Valuer returns nil, nil to represent SQL NULL. +func (this *HashSet[T]) Value() (driver.Value, error) { + if this == nil { + return nil, nil + } + return this.AbstractSet.Value() +} + +// Scan implements sql.Scanner. +func (this *HashSet[T]) Scan(src any) error { + if this == nil { + return errNilHashSet + } + return this.AbstractSet.Scan(src) +} diff --git a/pkg/util/hash_set_test.go b/pkg/util/hash_set_test.go new file mode 100644 index 0000000..d7b09c6 --- /dev/null +++ b/pkg/util/hash_set_test.go @@ -0,0 +1,194 @@ +package util_test + +import ( + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/BeamFoundry/rays/pkg/core" + . "github.com/BeamFoundry/rays/pkg/util" +) + +var _ = Describe("HashSet", func() { + It("creates an empty or populated HashSet", func() { + s1 := NewHashSet[string]() + Expect(s1.Size()).To(Equal(0)) + Expect(s1.IsEmpty()).To(BeTrue()) + + s2 := NewHashSet("a", "b", "c", "a") + Expect(s2.Size()).To(Equal(3)) + Expect(s2.IsEmpty()).To(BeFalse()) + + var iface Set[string] = s2 + Expect(iface.Size()).To(Equal(3)) + + sCap := NewHashSetWithCapacity[int](10) + Expect(sCap.Size()).To(Equal(0)) + Expect(sCap.IsEmpty()).To(BeTrue()) + }) + + It("supports Add, Contains, and Remove", func() { + s := NewHashSet[int]() + Expect(s.Add(1)).To(BeTrue()) + Expect(s.Add(1)).To(BeFalse()) + Expect(s.Add(2, 3)).To(BeTrue()) + + Expect(s.Contains(1)).To(BeTrue()) + Expect(s.Contains(2)).To(BeTrue()) + Expect(s.Contains(99)).To(BeFalse()) + + Expect(s.Remove(2)).To(BeTrue()) + Expect(s.Remove(2)).To(BeFalse()) + Expect(s.Contains(2)).To(BeFalse()) + Expect(s.Size()).To(Equal(2)) + }) + + It("supports ContainsAll, AddAll, RemoveAll, RetainAll, and Clear", func() { + s := NewHashSet(1, 2, 3) + other := NewHashSet(2, 3) + Expect(s.ContainsAll(other)).To(BeTrue()) + + notContained := NewHashSet(3, 4) + Expect(s.ContainsAll(notContained)).To(BeFalse()) + + Expect(s.AddAll(notContained)).To(BeTrue()) + Expect(s.Size()).To(Equal(4)) + + Expect(s.RemoveAll(NewHashSet(1, 4))).To(BeTrue()) + Expect(s.Size()).To(Equal(2)) + Expect(s.Contains(1)).To(BeFalse()) + Expect(s.Contains(4)).To(BeFalse()) + + Expect(s.RetainAll(NewHashSet(2))).To(BeTrue()) + Expect(s.Size()).To(Equal(1)) + Expect(s.Contains(2)).To(BeTrue()) + + s.Clear() + Expect(s.Size()).To(Equal(0)) + Expect(s.IsEmpty()).To(BeTrue()) + }) + + It("supports ToSlice, Seq, and Equals", func() { + s1 := NewHashSet("x", "y") + s2 := NewHashSet("y", "x") + s3 := NewHashSet("x", "z") + + Expect(s1.Equals(s2)).To(BeTrue()) + Expect(s1.Equals(s3)).To(BeFalse()) + + slice := s1.ToSlice() + Expect(slice).To(ConsistOf("x", "y")) + + var seqItems []string + for item := range s1.Seq() { + seqItems = append(seqItems, item) + } + Expect(seqItems).To(ConsistOf("x", "y")) + }) + + It("supports Stream() method", func() { + s := NewHashSet(10, 20, 30, 40) + count := s.Stream(). + Filter(func(n int) bool { return n > 15 }). + Count() + + Expect(count).To(Equal(int64(3))) + }) + + It("implements json.Marshaler and json.Unmarshaler", func() { + s := NewHashSet("apple", "banana") + data, err := json.Marshal(s) + Expect(err).NotTo(HaveOccurred()) + var unmarshaledSlice []string + Expect(json.Unmarshal(data, &unmarshaledSlice)).To(Succeed()) + Expect(unmarshaledSlice).To(ConsistOf("apple", "banana")) + + empty := NewHashSet[int]() + dataEmpty, err := json.Marshal(empty) + Expect(err).NotTo(HaveOccurred()) + Expect(string(dataEmpty)).To(Equal(`[]`)) + + var nilSet *HashSet[int] + dataNil, err := nilSet.MarshalJSON() + Expect(err).NotTo(HaveOccurred()) + Expect(string(dataNil)).To(Equal(`[]`)) + + var unmarshaled HashSet[int] + Expect(json.Unmarshal([]byte(`[1, 2, 2, 3]`), &unmarshaled)).To(Succeed()) + Expect(unmarshaled.Size()).To(Equal(3)) + Expect(unmarshaled.Contains(1)).To(BeTrue()) + Expect(unmarshaled.Contains(2)).To(BeTrue()) + Expect(unmarshaled.Contains(3)).To(BeTrue()) + + Expect(json.Unmarshal([]byte(`null`), &unmarshaled)).To(Succeed()) + Expect(unmarshaled.Size()).To(Equal(0)) + + Expect(nilSet.UnmarshalJSON([]byte(`[]`))).NotTo(Succeed()) + Expect(unmarshaled.UnmarshalJSON([]byte(`invalid`))).NotTo(Succeed()) + }) + + It("implements driver.Valuer and sql.Scanner", func() { + s := NewHashSet(10, 20) + val, err := s.Value() + Expect(err).NotTo(HaveOccurred()) + var valSlice []int + Expect(json.Unmarshal([]byte(val.(string)), &valSlice)).To(Succeed()) + Expect(valSlice).To(ConsistOf(10, 20)) + + var nilSet *HashSet[int] + nilVal, err := nilSet.Value() + Expect(err).NotTo(HaveOccurred()) + Expect(nilVal).To(BeNil()) + + scanned := NewHashSet[int]() + Expect(scanned.Scan(`[1,2,2,3]`)).To(Succeed()) + Expect(scanned.Size()).To(Equal(3)) + Expect(scanned.Contains(1)).To(BeTrue()) + Expect(scanned.Contains(2)).To(BeTrue()) + Expect(scanned.Contains(3)).To(BeTrue()) + + Expect(scanned.Scan([]byte(`[4,5]`))).To(Succeed()) + Expect(scanned.Size()).To(Equal(2)) + Expect(scanned.Contains(4)).To(BeTrue()) + Expect(scanned.Contains(5)).To(BeTrue()) + + Expect(scanned.Scan(nil)).To(Succeed()) + Expect(scanned.Size()).To(Equal(0)) + + Expect(scanned.Scan("")).To(Succeed()) + Expect(scanned.Size()).To(Equal(0)) + + Expect(scanned.Scan(12345)).NotTo(Succeed()) + Expect(nilSet.Scan(`[]`)).NotTo(Succeed()) + + var zeroSet HashSet[string] + Expect(zeroSet.Scan(`["hello", "world"]`)).To(Succeed()) + Expect(zeroSet.Size()).To(Equal(2)) + Expect(zeroSet.Contains("hello")).To(BeTrue()) + Expect(zeroSet.Contains("world")).To(BeTrue()) + }) + + It("works as an empty set from its zero value", func() { + var s HashSet[int] + Expect(s.Size()).To(Equal(0)) + Expect(s.IsEmpty()).To(BeTrue()) + Expect(s.Contains(1)).To(BeFalse()) + Expect(s.ToSlice()).To(BeEmpty()) + + var collected []int + for item := range s.Seq() { + collected = append(collected, item) + } + Expect(collected).To(BeEmpty()) + + Expect(s.Add(1, 2)).To(BeTrue()) + Expect(s.Size()).To(Equal(2)) + Expect(s.Contains(1)).To(BeTrue()) + + var s2 HashSet[int] + Expect(s2.AddAll(NewHashSet(10, 20))).To(BeTrue()) + Expect(s2.Size()).To(Equal(2)) + Expect(s2.Contains(10)).To(BeTrue()) + }) +}) diff --git a/pkg/util/hashmap.go b/pkg/util/hashmap.go index 70e6ce1..532f372 100644 --- a/pkg/util/hashmap.go +++ b/pkg/util/hashmap.go @@ -6,6 +6,7 @@ import ( "maps" "reflect" + . "github.com/BeamFoundry/rays/pkg/core" . "github.com/BeamFoundry/rays/pkg/lang" ) diff --git a/pkg/util/internal/abstract_list.go b/pkg/util/internal/abstract_list.go new file mode 100644 index 0000000..80676de --- /dev/null +++ b/pkg/util/internal/abstract_list.go @@ -0,0 +1,329 @@ +package internal + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "iter" + "reflect" + "slices" + + . "github.com/BeamFoundry/rays/internal/utils" + . "github.com/BeamFoundry/rays/pkg/core" + . "github.com/BeamFoundry/rays/pkg/lang" +) + +var ( + _ List[int] = (*AbstractList[int])(nil) + _ json.Marshaler = (*AbstractList[int])(nil) + _ json.Unmarshaler = (*AbstractList[int])(nil) + _ driver.Valuer = (*AbstractList[int])(nil) + _ sql.Scanner = (*AbstractList[int])(nil) + + // ErrUnsupportedScanType indicates an incompatible type was passed to Scan. + ErrUnsupportedScanType = errors.New("unsupported scan type") +) + +// AbstractList provides a skeletal implementation of the List interface, +// backed by a slice. +type AbstractList[T any] struct { + items []T +} + +// NewAbstractList creates a new AbstractList with the given elements. +func NewAbstractList[T any](elements ...T) *AbstractList[T] { + items := make([]T, len(elements)) + copy(items, elements) + return &AbstractList[T]{items: items} +} + +// NewAbstractListWithCapacity creates a new empty AbstractList with the specified capacity. +func NewAbstractListWithCapacity[T any](capacity int) *AbstractList[T] { + if capacity < 0 { + capacity = 0 + } + return &AbstractList[T]{items: make([]T, 0, capacity)} +} + +// Size returns the number of elements in this list. +func (this *AbstractList[T]) Size() int { + return len(this.items) +} + +// IsEmpty returns true if this list contains no elements. +func (this *AbstractList[T]) IsEmpty() bool { + return len(this.items) == 0 +} + +// Get returns the element at the specified position in this list. +func (this *AbstractList[T]) Get(index int) T { + if index < 0 || index >= len(this.items) { + panic(NewIndexOutOfBoundsError("list index out of range")) + } + return this.items[index] +} + +// Set replaces the element at the specified position in this list with the specified element. +func (this *AbstractList[T]) Set(index int, element T) T { + if index < 0 || index >= len(this.items) { + panic(NewIndexOutOfBoundsError("list index out of range")) + } + old := this.items[index] + this.items[index] = element + return old +} + +// Add appends the specified elements to the end of this list. +func (this *AbstractList[T]) Add(elements ...T) bool { + if len(elements) == 0 { + return false + } + this.items = append(this.items, elements...) + return true +} + +// AddAt inserts the specified element at the specified position in this list. +func (this *AbstractList[T]) AddAt(index int, element T) { + if index < 0 || index > len(this.items) { + panic(NewIndexOutOfBoundsError("list index out of range")) + } + if index == len(this.items) { + this.items = append(this.items, element) + return + } + this.items = append(this.items[:index+1], this.items[index:]...) + this.items[index] = element +} + +// RemoveAt removes the element at the specified position in this list. +func (this *AbstractList[T]) RemoveAt(index int) T { + if index < 0 || index >= len(this.items) { + panic(NewIndexOutOfBoundsError("list index out of range")) + } + removed := this.items[index] + copy(this.items[index:], this.items[index+1:]) + var zero T + this.items[len(this.items)-1] = zero + this.items = this.items[:len(this.items)-1] + return removed +} + +// IndexOf returns the index of the first occurrence of the specified element in this list, +// or -1 if this list does not contain the element. +func (this *AbstractList[T]) IndexOf(element T) int { + for i, item := range this.items { + if reflect.DeepEqual(item, element) { + return i + } + } + return -1 +} + +// LastIndexOf returns the index of the last occurrence of the specified element in this list, +// or -1 if this list does not contain the element. +func (this *AbstractList[T]) LastIndexOf(element T) int { + for i, v := range slices.Backward(this.items) { + if reflect.DeepEqual(v, element) { + return i + } + } + return -1 +} + +// Contains returns true if this list contains the specified element. +func (this *AbstractList[T]) Contains(element T) bool { + return this.IndexOf(element) != -1 +} + +// ContainsAll returns true if this list contains all of the elements in the specified collection. +func (this *AbstractList[T]) ContainsAll(c Collection[T]) bool { + if IsNil(c) { + return false + } + for item := range c.Seq() { + if !this.Contains(item) { + return false + } + } + return true +} + +// AddAll adds all of the elements in the specified collection to this list. +func (this *AbstractList[T]) AddAll(c Collection[T]) bool { + if IsNil(c) { + return false + } + modified := false + for item := range c.Seq() { + this.items = append(this.items, item) + modified = true + } + return modified +} + +// Remove removes the first occurrence of the specified element from this list, if it is present. +func (this *AbstractList[T]) Remove(element T) bool { + idx := this.IndexOf(element) + if idx == -1 { + return false + } + this.RemoveAt(idx) + return true +} + +// RemoveAll removes all of this list's elements that are also contained in the specified collection. +func (this *AbstractList[T]) RemoveAll(c Collection[T]) bool { + if IsNil(c) { + return false + } + newItems := make([]T, 0, len(this.items)) + modified := false + for _, item := range this.items { + if c.Contains(item) { + modified = true + } else { + newItems = append(newItems, item) + } + } + if modified { + this.items = newItems + } + return modified +} + +// RetainAll retains only the elements in this list that are contained in the specified collection. +func (this *AbstractList[T]) RetainAll(c Collection[T]) bool { + if IsNil(c) { + return false + } + newItems := make([]T, 0, len(this.items)) + modified := false + for _, item := range this.items { + if c.Contains(item) { + newItems = append(newItems, item) + } else { + modified = true + } + } + if modified { + this.items = newItems + } + return modified +} + +// Clear removes all elements from this list. +func (this *AbstractList[T]) Clear() { + clear(this.items) + this.items = this.items[:0] +} + +// ToSlice returns a copy of the underlying slice. +func (this *AbstractList[T]) ToSlice() []T { + result := make([]T, len(this.items)) + copy(result, this.items) + return result +} + +// Seq returns an iterator sequence over the elements in this list. +func (this *AbstractList[T]) Seq() iter.Seq[T] { + return func(yield func(T) bool) { + for _, item := range this.items { + if !yield(item) { + return + } + } + } +} + +// SubList returns an independent copy of the portion of this list between fromIndex, inclusive, +// and toIndex, exclusive. Changes to the returned list do not affect the original list. +func (this *AbstractList[T]) SubList(fromIndex, toIndex int) List[T] { + if fromIndex < 0 || toIndex > len(this.items) || fromIndex > toIndex { + panic(NewIndexOutOfBoundsError("sublist indices out of range")) + } + return NewAbstractList(this.items[fromIndex:toIndex]...) +} + +// Equals returns true if the specified collection is equal to this list. +func (this *AbstractList[T]) Equals(c Collection[T]) bool { + if IsNil(c) { + return false + } + other, ok := c.(List[T]) + if !ok || this.Size() != other.Size() { + return false + } + i := 0 + for item := range other.Seq() { + if !reflect.DeepEqual(this.items[i], item) { + return false + } + i++ + } + return true +} + +// MarshalJSON implements json.Marshaler. +func (this *AbstractList[T]) MarshalJSON() ([]byte, error) { + if this == nil || len(this.items) == 0 { + return []byte("[]"), nil + } + //nolint:wrapcheck // Will return the error as is. + return json.Marshal(this.items) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (this *AbstractList[T]) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + this.items = make([]T, 0) + return nil + } + var items []T + //nolint:wrapcheck // Will return the error as is. + if err := json.Unmarshal(data, &items); err != nil { + return err + } + if items == nil { + items = make([]T, 0) + } + this.items = items + return nil +} + +// Value implements driver.Valuer. +// +//nolint:nilnil // driver.Valuer returns nil, nil to represent SQL NULL. +func (this *AbstractList[T]) Value() (driver.Value, error) { + if this == nil { + return nil, nil + } + b, err := this.MarshalJSON() + if err != nil { + return nil, err + } + return string(b), nil +} + +// Scan implements sql.Scanner. +func (this *AbstractList[T]) Scan(src any) error { + if src == nil { + this.items = make([]T, 0) + return nil + } + var data []byte + switch v := src.(type) { + case []byte: + data = v + case string: + data = []byte(v) + default: + return fmt.Errorf("%w: cannot scan %T into AbstractList: expected []byte or string", ErrUnsupportedScanType, src) + } + if len(data) == 0 { + this.items = make([]T, 0) + return nil + } + return this.UnmarshalJSON(data) +} diff --git a/pkg/util/internal/abstract_list_test.go b/pkg/util/internal/abstract_list_test.go new file mode 100644 index 0000000..7135ef7 --- /dev/null +++ b/pkg/util/internal/abstract_list_test.go @@ -0,0 +1,282 @@ +package internal + +import ( + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AbstractList", func() { + It("creates empty or populated lists and supports capacity constructor", func() { + l1 := NewAbstractList[int]() + Expect(l1.Size()).To(Equal(0)) + Expect(l1.IsEmpty()).To(BeTrue()) + + l2 := NewAbstractList(1, 2, 3) + Expect(l2.Size()).To(Equal(3)) + Expect(l2.IsEmpty()).To(BeFalse()) + + lCap := NewAbstractListWithCapacity[string](10) + Expect(lCap.Size()).To(Equal(0)) + Expect(lCap.IsEmpty()).To(BeTrue()) + + lNegCap := NewAbstractListWithCapacity[string](-5) + Expect(lNegCap.Size()).To(Equal(0)) + }) + + It("supports element access and modification with bounds checking", func() { + l := NewAbstractList("alpha", "beta", "gamma") + + Expect(l.Get(0)).To(Equal("alpha")) + Expect(l.Get(1)).To(Equal("beta")) + Expect(l.Get(2)).To(Equal("gamma")) + + old := l.Set(1, "BETA") + Expect(old).To(Equal("beta")) + Expect(l.Get(1)).To(Equal("BETA")) + + Expect(func() { l.Get(-1) }).To(Panic()) + Expect(func() { l.Get(3) }).To(Panic()) + Expect(func() { l.Set(-1, "x") }).To(Panic()) + Expect(func() { l.Set(3, "x") }).To(Panic()) + }) + + It("supports Add and AddAt with bounds checking", func() { + l := NewAbstractList[int]() + Expect(l.Add()).To(BeFalse()) + Expect(l.Add(1, 2)).To(BeTrue()) + Expect(l.ToSlice()).To(Equal([]int{1, 2})) + + // AddAt at end + l.AddAt(2, 3) + Expect(l.ToSlice()).To(Equal([]int{1, 2, 3})) + + // AddAt at beginning + l.AddAt(0, 0) + Expect(l.ToSlice()).To(Equal([]int{0, 1, 2, 3})) + + // AddAt in middle + l.AddAt(2, 99) + Expect(l.ToSlice()).To(Equal([]int{0, 1, 99, 2, 3})) + + Expect(func() { l.AddAt(-1, 5) }).To(Panic()) + Expect(func() { l.AddAt(10, 5) }).To(Panic()) + }) + + It("supports RemoveAt and Remove with tail slot zeroing", func() { + type item struct { + name string + } + + i1 := &item{"first"} + i2 := &item{"second"} + i3 := &item{"third"} + + list := NewAbstractList(i1, i2, i3) + removed := list.RemoveAt(1) + + Expect(removed).To(Equal(i2)) + Expect(list.Size()).To(Equal(2)) + Expect(list.Get(0)).To(Equal(i1)) + Expect(list.Get(1)).To(Equal(i3)) + + // The vacated tail slot within capacity should be zeroed (nil pointer) + backing := list.items[:3] + Expect(backing[2]).To(BeNil()) + + // Remove by element + Expect(list.Remove(i1)).To(BeTrue()) + Expect(list.Size()).To(Equal(1)) + Expect(list.Remove(&item{"not-present"})).To(BeFalse()) + + Expect(func() { list.RemoveAt(-1) }).To(Panic()) + Expect(func() { list.RemoveAt(5) }).To(Panic()) + }) + + It("supports IndexOf, LastIndexOf, and Contains", func() { + l := NewAbstractList("a", "b", "c", "b", "a") + + Expect(l.IndexOf("b")).To(Equal(1)) + Expect(l.IndexOf("missing")).To(Equal(-1)) + + Expect(l.LastIndexOf("b")).To(Equal(3)) + Expect(l.LastIndexOf("missing")).To(Equal(-1)) + + Expect(l.Contains("a")).To(BeTrue()) + Expect(l.Contains("missing")).To(BeFalse()) + }) + + It("supports ContainsAll and AddAll", func() { + l := NewAbstractList(1, 2, 3) + + Expect(l.ContainsAll(nil)).To(BeFalse()) + Expect(l.ContainsAll(NewAbstractList(1, 3))).To(BeTrue()) + Expect(l.ContainsAll(NewAbstractList(1, 99))).To(BeFalse()) + + Expect(l.AddAll(nil)).To(BeFalse()) + Expect(l.AddAll(NewAbstractList(4, 5))).To(BeTrue()) + Expect(l.ToSlice()).To(Equal([]int{1, 2, 3, 4, 5})) + }) + + It("supports RemoveAll and RetainAll", func() { + l := NewAbstractList(1, 2, 3, 4, 5) + + Expect(l.RemoveAll(nil)).To(BeFalse()) + Expect(l.RemoveAll(NewAbstractList(99, 100))).To(BeFalse()) + Expect(l.RemoveAll(NewAbstractList(2, 4))).To(BeTrue()) + Expect(l.ToSlice()).To(Equal([]int{1, 3, 5})) + + Expect(l.RetainAll(nil)).To(BeFalse()) + Expect(l.RetainAll(NewAbstractList(1, 3, 5))).To(BeFalse()) + Expect(l.RetainAll(NewAbstractList(1, 5, 99))).To(BeTrue()) + Expect(l.ToSlice()).To(Equal([]int{1, 5})) + }) + + It("supports Clear with element zeroing", func() { + type item struct { + name string + } + + i1 := &item{"first"} + i2 := &item{"second"} + + list := NewAbstractList(i1, i2) + list.Clear() + + Expect(list.Size()).To(Equal(0)) + Expect(list.IsEmpty()).To(BeTrue()) + + // All elements up to previous length within capacity should be zeroed + backing := list.items[:2] + Expect(backing[0]).To(BeNil()) + Expect(backing[1]).To(BeNil()) + }) + + It("supports ToSlice and Seq iteration", func() { + l := NewAbstractList("x", "y", "z") + + slice := l.ToSlice() + Expect(slice).To(Equal([]string{"x", "y", "z"})) + // Mutating returned slice should not mutate internal list + slice[0] = "mutated" + Expect(l.Get(0)).To(Equal("x")) + + var full []string + for item := range l.Seq() { + full = append(full, item) + } + Expect(full).To(Equal([]string{"x", "y", "z"})) + + var partial []string + for item := range l.Seq() { + partial = append(partial, item) + if item == "y" { + break + } + } + Expect(partial).To(Equal([]string{"x", "y"})) + }) + + It("supports SubList with bounds checking and independent copy semantics", func() { + l := NewAbstractList("a", "b", "c", "d", "e") + + sub := l.SubList(1, 4) + Expect(sub.ToSlice()).To(Equal([]string{"b", "c", "d"})) + + // Modifications to sublist do not affect original + sub.Set(0, "MODIFIED") + Expect(l.Get(1)).To(Equal("b")) + + Expect(func() { l.SubList(-1, 2) }).To(Panic()) + Expect(func() { l.SubList(0, 10) }).To(Panic()) + Expect(func() { l.SubList(3, 2) }).To(Panic()) + }) + + It("supports Equals", func() { + l1 := NewAbstractList(10, 20, 30) + l2 := NewAbstractList(10, 20, 30) + l3 := NewAbstractList(10, 20, 40) + lDiffSize := NewAbstractList(10, 20) + + Expect(l1.Equals(nil)).To(BeFalse()) + Expect(l1.Equals(lDiffSize)).To(BeFalse()) + Expect(l1.Equals(l3)).To(BeFalse()) + Expect(l1.Equals(l2)).To(BeTrue()) + + set := NewAbstractSet(10, 20, 30) + Expect(l1.Equals(set)).To(BeFalse()) + }) + + It("handles typed-nil collection operands across Equals, ContainsAll, AddAll, RemoveAll, and RetainAll", func() { + l := NewAbstractList(1, 2, 3) + var typedNil *AbstractList[int] + + Expect(l.ContainsAll(typedNil)).To(BeFalse()) + Expect(l.AddAll(typedNil)).To(BeFalse()) + Expect(l.RemoveAll(typedNil)).To(BeFalse()) + Expect(l.RetainAll(typedNil)).To(BeFalse()) + Expect(l.Equals(typedNil)).To(BeFalse()) + }) + + It("implements json.Marshaler and json.Unmarshaler", func() { + l := NewAbstractList("alpha", "beta") + data, err := json.Marshal(l) + Expect(err).NotTo(HaveOccurred()) + Expect(string(data)).To(Equal(`["alpha","beta"]`)) + + empty := NewAbstractList[int]() + dataEmpty, err := json.Marshal(empty) + Expect(err).NotTo(HaveOccurred()) + Expect(string(dataEmpty)).To(Equal(`[]`)) + + var nilList *AbstractList[int] + dataNil, err := nilList.MarshalJSON() + Expect(err).NotTo(HaveOccurred()) + Expect(string(dataNil)).To(Equal(`[]`)) + + var unmarshaled AbstractList[string] + Expect(json.Unmarshal([]byte(`["one","two"]`), &unmarshaled)).To(Succeed()) + Expect(unmarshaled.ToSlice()).To(Equal([]string{"one", "two"})) + + Expect(json.Unmarshal([]byte(`null`), &unmarshaled)).To(Succeed()) + Expect(unmarshaled.Size()).To(Equal(0)) + + Expect(json.Unmarshal([]byte(`invalid`), &unmarshaled)).NotTo(Succeed()) + Expect(unmarshaled.UnmarshalJSON([]byte(`invalid`))).NotTo(Succeed()) + Expect(unmarshaled.UnmarshalJSON([]byte(` null `))).To(Succeed()) + Expect(unmarshaled.Size()).To(Equal(0)) + }) + + It("implements driver.Valuer and sql.Scanner", func() { + l := NewAbstractList(10, 20) + val, err := l.Value() + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal(`[10,20]`)) + + var nilList *AbstractList[int] + nilVal, err := nilList.Value() + Expect(err).NotTo(HaveOccurred()) + Expect(nilVal).To(BeNil()) + + unmarshalableList := NewAbstractList(func() {}) + valErr, err := unmarshalableList.Value() + Expect(err).To(HaveOccurred()) + Expect(valErr).To(BeNil()) + + scanned := NewAbstractList[int]() + Expect(scanned.Scan(`[1,2,3]`)).To(Succeed()) + Expect(scanned.ToSlice()).To(Equal([]int{1, 2, 3})) + + Expect(scanned.Scan([]byte(`[4,5]`))).To(Succeed()) + Expect(scanned.ToSlice()).To(Equal([]int{4, 5})) + + Expect(scanned.Scan(nil)).To(Succeed()) + Expect(scanned.Size()).To(Equal(0)) + + Expect(scanned.Scan("")).To(Succeed()) + Expect(scanned.Size()).To(Equal(0)) + + Expect(scanned.Scan(12345)).NotTo(Succeed()) + }) +}) diff --git a/pkg/util/internal/abstract_set.go b/pkg/util/internal/abstract_set.go new file mode 100644 index 0000000..4296dde --- /dev/null +++ b/pkg/util/internal/abstract_set.go @@ -0,0 +1,257 @@ +package internal + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "iter" + + . "github.com/BeamFoundry/rays/internal/utils" + . "github.com/BeamFoundry/rays/pkg/core" +) + +// AbstractSet provides a skeletal implementation of the Set interface, +// backed by a Go map. +type AbstractSet[T comparable] struct { + elements map[T]struct{} +} + +var ( + _ Set[int] = (*AbstractSet[int])(nil) + _ json.Marshaler = (*AbstractSet[int])(nil) + _ json.Unmarshaler = (*AbstractSet[int])(nil) + _ driver.Valuer = (*AbstractSet[int])(nil) + _ sql.Scanner = (*AbstractSet[int])(nil) +) + +// NewAbstractSet creates a new AbstractSet with the given elements. +func NewAbstractSet[T comparable](elements ...T) *AbstractSet[T] { + m := make(map[T]struct{}, len(elements)) + for _, el := range elements { + m[el] = struct{}{} + } + return &AbstractSet[T]{elements: m} +} + +// NewAbstractSetWithCapacity creates a new empty AbstractSet with the specified capacity. +func NewAbstractSetWithCapacity[T comparable](capacity int) *AbstractSet[T] { + if capacity < 0 { + capacity = 0 + } + return &AbstractSet[T]{elements: make(map[T]struct{}, capacity)} +} + +// Size returns the number of elements in this set. +func (this *AbstractSet[T]) Size() int { + return len(this.elements) +} + +// IsEmpty returns true if this set contains no elements. +func (this *AbstractSet[T]) IsEmpty() bool { + return len(this.elements) == 0 +} + +// Contains returns true if this set contains the specified element. +func (this *AbstractSet[T]) Contains(element T) bool { + _, ok := this.elements[element] + return ok +} + +// ContainsAll returns true if this set contains all of the elements in the specified collection. +func (this *AbstractSet[T]) ContainsAll(c Collection[T]) bool { + if IsNil(c) { + return false + } + for item := range c.Seq() { + if !this.Contains(item) { + return false + } + } + return true +} + +// Add adds the specified elements to this set. +func (this *AbstractSet[T]) Add(elements ...T) bool { + if this.elements == nil { + this.elements = make(map[T]struct{}) + } + modified := false + for _, el := range elements { + if _, exists := this.elements[el]; !exists { + this.elements[el] = struct{}{} + modified = true + } + } + return modified +} + +// AddAll adds all of the elements in the specified collection to this set. +func (this *AbstractSet[T]) AddAll(c Collection[T]) bool { + if IsNil(c) { + return false + } + if this.elements == nil { + this.elements = make(map[T]struct{}) + } + modified := false + for item := range c.Seq() { + if _, exists := this.elements[item]; !exists { + this.elements[item] = struct{}{} + modified = true + } + } + return modified +} + +// Remove removes the specified element from this set, if it is present. +func (this *AbstractSet[T]) Remove(element T) bool { + if _, exists := this.elements[element]; exists { + delete(this.elements, element) + return true + } + return false +} + +// RemoveAll removes all of this set's elements that are also contained in the specified collection. +func (this *AbstractSet[T]) RemoveAll(c Collection[T]) bool { + if IsNil(c) { + return false + } + modified := false + for item := range c.Seq() { + if this.Remove(item) { + modified = true + } + } + return modified +} + +// RetainAll retains only the elements in this set that are contained in the specified collection. +func (this *AbstractSet[T]) RetainAll(c Collection[T]) bool { + if IsNil(c) { + return false + } + modified := false + for item := range this.elements { + if !c.Contains(item) { + delete(this.elements, item) + modified = true + } + } + return modified +} + +// Clear removes all elements from this set. +func (this *AbstractSet[T]) Clear() { + clear(this.elements) +} + +// ToSlice returns a slice containing all elements in this set. +func (this *AbstractSet[T]) ToSlice() []T { + result := make([]T, 0, len(this.elements)) + for item := range this.elements { + result = append(result, item) + } + return result +} + +// Seq returns an iterator sequence over the elements in this set. +func (this *AbstractSet[T]) Seq() iter.Seq[T] { + return func(yield func(T) bool) { + for item := range this.elements { + if !yield(item) { + return + } + } + } +} + +// Equals returns true if the specified collection is equal to this set. +func (this *AbstractSet[T]) Equals(c Collection[T]) bool { + if IsNil(c) { + return false + } + other, ok := c.(Set[T]) + if !ok || this.Size() != other.Size() { + return false + } + if _, isList := c.(List[T]); isList { + return false + } + for item := range this.elements { + if !other.Contains(item) { + return false + } + } + return true +} + +// MarshalJSON implements json.Marshaler. +func (this *AbstractSet[T]) MarshalJSON() ([]byte, error) { + if this == nil || len(this.elements) == 0 { + return []byte("[]"), nil + } + //nolint:wrapcheck // Will return the error as is. + return json.Marshal(this.ToSlice()) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (this *AbstractSet[T]) UnmarshalJSON(data []byte) error { + if this.elements == nil { + this.elements = make(map[T]struct{}) + } + if string(data) == "null" { + clear(this.elements) + return nil + } + var items []T + //nolint:wrapcheck // Will return the error as is. + if err := json.Unmarshal(data, &items); err != nil { + return err + } + this.elements = make(map[T]struct{}, len(items)) + for _, item := range items { + this.elements[item] = struct{}{} + } + return nil +} + +// Value implements driver.Valuer. +// +//nolint:nilnil // driver.Valuer returns nil, nil to represent SQL NULL. +func (this *AbstractSet[T]) Value() (driver.Value, error) { + if this == nil { + return nil, nil + } + b, err := this.MarshalJSON() + if err != nil { + return nil, err + } + return string(b), nil +} + +// Scan implements sql.Scanner. +func (this *AbstractSet[T]) Scan(src any) error { + if this.elements == nil { + this.elements = make(map[T]struct{}) + } + if src == nil { + clear(this.elements) + return nil + } + var data []byte + switch v := src.(type) { + case []byte: + data = v + case string: + data = []byte(v) + default: + return fmt.Errorf("%w: cannot scan %T into AbstractSet: expected []byte or string", ErrUnsupportedScanType, src) + } + if len(data) == 0 { + clear(this.elements) + return nil + } + return this.UnmarshalJSON(data) +} diff --git a/pkg/util/internal/abstract_set_test.go b/pkg/util/internal/abstract_set_test.go new file mode 100644 index 0000000..49a459e --- /dev/null +++ b/pkg/util/internal/abstract_set_test.go @@ -0,0 +1,208 @@ +package internal + +import ( + "encoding/json" + "errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type failJSONKey struct { + A int +} + +func (f failJSONKey) MarshalJSON() ([]byte, error) { + return nil, errors.New("cannot marshal failJSONKey") +} + +var _ = Describe("AbstractSet", func() { + It("creates an empty or populated AbstractSet", func() { + s1 := NewAbstractSet[string]() + Expect(s1.Size()).To(Equal(0)) + Expect(s1.IsEmpty()).To(BeTrue()) + + s2 := NewAbstractSet("a", "b", "c", "a") + Expect(s2.Size()).To(Equal(3)) + Expect(s2.IsEmpty()).To(BeFalse()) + Expect(s2.Contains("a")).To(BeTrue()) + Expect(s2.Contains("d")).To(BeFalse()) + }) + + It("supports NewAbstractSetWithCapacity and negative capacity clamping", func() { + s1 := NewAbstractSetWithCapacity[int](10) + Expect(s1.Size()).To(Equal(0)) + + s2 := NewAbstractSetWithCapacity[int](-5) + Expect(s2.Size()).To(Equal(0)) + }) + + It("supports Add, AddAll, Remove, RemoveAll, RetainAll, and Clear", func() { + s := NewAbstractSet(1, 2) + Expect(s.Add(3)).To(BeTrue()) + Expect(s.Add(3)).To(BeFalse()) + + Expect(s.ContainsAll(nil)).To(BeFalse()) + Expect(s.AddAll(nil)).To(BeFalse()) + Expect(s.RemoveAll(nil)).To(BeFalse()) + Expect(s.RetainAll(nil)).To(BeFalse()) + + other := NewAbstractSet(3, 4, 5) + Expect(s.AddAll(other)).To(BeTrue()) + Expect(s.AddAll(other)).To(BeFalse()) + Expect(s.ContainsAll(other)).To(BeTrue()) + Expect(s.ContainsAll(NewAbstractSet(3, 99))).To(BeFalse()) + + Expect(s.Remove(5)).To(BeTrue()) + Expect(s.Remove(99)).To(BeFalse()) + + toRemove := NewAbstractSet(1, 4) + Expect(s.RemoveAll(toRemove)).To(BeTrue()) + Expect(s.Contains(1)).To(BeFalse()) + Expect(s.Contains(4)).To(BeFalse()) + + toRetain := NewAbstractSet(2, 99) + Expect(s.RetainAll(toRetain)).To(BeTrue()) + Expect(s.Size()).To(Equal(1)) + Expect(s.Contains(2)).To(BeTrue()) + + s.Clear() + Expect(s.Size()).To(Equal(0)) + Expect(s.IsEmpty()).To(BeTrue()) + }) + + It("supports ToSlice and Seq with early exit", func() { + s := NewAbstractSet(10, 20) + slice := s.ToSlice() + Expect(slice).To(ConsistOf(10, 20)) + + var collected []int + for item := range s.Seq() { + collected = append(collected, item) + } + Expect(collected).To(ConsistOf(10, 20)) + + var partial []int + for item := range s.Seq() { + partial = append(partial, item) + break + } + Expect(partial).To(HaveLen(1)) + }) + + It("supports Equals", func() { + s1 := NewAbstractSet(1, 2, 3) + s2 := NewAbstractSet(1, 2, 3) + s3 := NewAbstractSet(1, 2, 4) + sDiffSize := NewAbstractSet(1, 2) + + Expect(s1.Equals(nil)).To(BeFalse()) + Expect(s1.Equals(sDiffSize)).To(BeFalse()) + Expect(s1.Equals(s3)).To(BeFalse()) + Expect(s1.Equals(s2)).To(BeTrue()) + + list := NewAbstractList(1, 2, 3) + Expect(s1.Equals(list)).To(BeFalse()) + }) + + It("handles typed-nil collection operands across Equals, ContainsAll, AddAll, RemoveAll, and RetainAll", func() { + s := NewAbstractSet(1, 2, 3) + var typedNil *AbstractSet[int] + + Expect(s.ContainsAll(typedNil)).To(BeFalse()) + Expect(s.AddAll(typedNil)).To(BeFalse()) + Expect(s.RemoveAll(typedNil)).To(BeFalse()) + Expect(s.RetainAll(typedNil)).To(BeFalse()) + Expect(s.Equals(typedNil)).To(BeFalse()) + }) + + It("implements json.Marshaler and json.Unmarshaler", func() { + s := NewAbstractSet("apple", "banana") + data, err := json.Marshal(s) + Expect(err).NotTo(HaveOccurred()) + var unmarshaledSlice []string + Expect(json.Unmarshal(data, &unmarshaledSlice)).To(Succeed()) + Expect(unmarshaledSlice).To(ConsistOf("apple", "banana")) + + empty := NewAbstractSet[int]() + dataEmpty, err := json.Marshal(empty) + Expect(err).NotTo(HaveOccurred()) + Expect(string(dataEmpty)).To(Equal(`[]`)) + + var nilSet *AbstractSet[int] + dataNil, err := nilSet.MarshalJSON() + Expect(err).NotTo(HaveOccurred()) + Expect(string(dataNil)).To(Equal(`[]`)) + + var unmarshaled AbstractSet[int] + Expect(json.Unmarshal([]byte(`[1, 2, 2, 3]`), &unmarshaled)).To(Succeed()) + Expect(unmarshaled.Size()).To(Equal(3)) + Expect(unmarshaled.Contains(1)).To(BeTrue()) + Expect(unmarshaled.Contains(2)).To(BeTrue()) + Expect(unmarshaled.Contains(3)).To(BeTrue()) + + Expect(json.Unmarshal([]byte(`null`), &unmarshaled)).To(Succeed()) + Expect(unmarshaled.Size()).To(Equal(0)) + + Expect(json.Unmarshal([]byte(`invalid`), &unmarshaled)).NotTo(Succeed()) + Expect(unmarshaled.UnmarshalJSON([]byte(`invalid`))).NotTo(Succeed()) + }) + + It("implements driver.Valuer and sql.Scanner", func() { + s := NewAbstractSet(10, 20) + val, err := s.Value() + Expect(err).NotTo(HaveOccurred()) + var valSlice []int + Expect(json.Unmarshal([]byte(val.(string)), &valSlice)).To(Succeed()) + Expect(valSlice).To(ConsistOf(10, 20)) + + var nilSet *AbstractSet[int] + nilVal, err := nilSet.Value() + Expect(err).NotTo(HaveOccurred()) + Expect(nilVal).To(BeNil()) + + unmarshalableSet := NewAbstractSet(failJSONKey{A: 1}) + valErr, err := unmarshalableSet.Value() + Expect(err).To(HaveOccurred()) + Expect(valErr).To(BeNil()) + + scanned := NewAbstractSet[int]() + + Expect(scanned.Scan(`[1,2,2,3]`)).To(Succeed()) + Expect(scanned.Size()).To(Equal(3)) + Expect(scanned.Contains(1)).To(BeTrue()) + Expect(scanned.Contains(2)).To(BeTrue()) + Expect(scanned.Contains(3)).To(BeTrue()) + + Expect(scanned.Scan([]byte(`[4,5]`))).To(Succeed()) + Expect(scanned.Size()).To(Equal(2)) + Expect(scanned.Contains(4)).To(BeTrue()) + Expect(scanned.Contains(5)).To(BeTrue()) + + Expect(scanned.Scan(nil)).To(Succeed()) + Expect(scanned.Size()).To(Equal(0)) + + Expect(scanned.Scan("")).To(Succeed()) + Expect(scanned.Size()).To(Equal(0)) + + Expect(scanned.Scan(12345)).NotTo(Succeed()) + }) + + It("supports lazily initializing elements on zero-value AbstractSet", func() { + var s AbstractSet[int] + Expect(s.Add(1, 2)).To(BeTrue()) + Expect(s.Contains(1)).To(BeTrue()) + + var s2 AbstractSet[int] + Expect(s2.AddAll(NewAbstractSet(10, 20))).To(BeTrue()) + Expect(s2.Contains(10)).To(BeTrue()) + + var s3 AbstractSet[int] + Expect(s3.Scan(`[1, 2]`)).To(Succeed()) + Expect(s3.Contains(1)).To(BeTrue()) + + var s4 AbstractSet[int] + Expect(s4.UnmarshalJSON([]byte(`[5, 6]`))).To(Succeed()) + Expect(s4.Contains(5)).To(BeTrue()) + }) +}) diff --git a/pkg/util/internal/internal_suite_test.go b/pkg/util/internal/internal_suite_test.go new file mode 100644 index 0000000..d617543 --- /dev/null +++ b/pkg/util/internal/internal_suite_test.go @@ -0,0 +1,14 @@ +package internal + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// TestInternal runs the internal collection test suite. +func TestInternal(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Pkg/Util/Internal Suite") +} diff --git a/pkg/util/map.go b/pkg/util/map.go deleted file mode 100644 index 16b4386..0000000 --- a/pkg/util/map.go +++ /dev/null @@ -1,59 +0,0 @@ -package util - -import ( - "iter" - - . "github.com/BeamFoundry/rays/pkg/lang" -) - -// Map is an interface for an object oriented map. -// -// TODO: Add Clone and JSON Marshaler/Unmarshaler ??? -type Map[K comparable, V any] interface { - // Map() returns a snapshot of the underlying map as a Go native map. - // - // This should be a shallow copy of the Map. - Map() map[K]V - - // Clear() empty the map - Clear() - - // ContainsKey() returns whether the map contains that key - ContainsKey(key K) bool - - // Merge() will merge the given map - // - // To the difference of PutAll(), this will try to merge a map recusively. - Merge(m Map[K, V]) - - // Iterator() returns an iterator over the underlying map. - // - // This should be done over a snapshot (shallow copy) of the Map. - Iterator() iter.Seq2[K, V] - - // Equals() returns whether a Map equals to this one. - Equals(m Map[K, V]) bool - - // Get() returns optionally the requested key - Get(key K) Optional[V] - - // Remove() returns optionally the requested key after removing it from the map - Remove(key K) Optional[V] - - // Delete() apply a filter to remove key/values from the map - Delete(f func(key K, val V) bool) - - // IsEmpty() returns whether the Map is empty or not - IsEmpty() bool - - // Put() add (or replace) a key/value in the Map. - Put(key K, val V) Map[K, V] - - // PutAll() merge the given Map and returns it. - // - // To the difference of Merge() this will replace existing key without merging Map entry of the same type. - PutAll(m Map[K, V]) Map[K, V] - - // Size() returns the size of the Map - Size() int -} diff --git a/pkg/util/stream/collector.go b/pkg/util/stream/collector.go new file mode 100644 index 0000000..d94a0ca --- /dev/null +++ b/pkg/util/stream/collector.go @@ -0,0 +1,662 @@ +package stream + +import ( + "fmt" + "strings" + + . "github.com/BeamFoundry/rays/pkg/core" + . "github.com/BeamFoundry/rays/pkg/lang" + . "github.com/BeamFoundry/rays/pkg/util/internal" +) + +// streamList is a private implementation of List for stream collection. +type streamList[T any] struct { + *AbstractList[T] +} + +// newStreamList creates a list containing the given elements for stream collection. +func newStreamList[T any](elements ...T) *streamList[T] { + return &streamList[T]{ + AbstractList: NewAbstractList(elements...), + } +} + +// Stream returns a sequential Stream with this list as its source. +func (this *streamList[T]) Stream() Stream[T] { + return Stream[T]{}.FromSeq(this.Seq()) +} + +var _ List[int] = (*streamList[int])(nil) + +// streamSet is a private implementation of Set for stream collection. +type streamSet[T comparable] struct { + *AbstractSet[T] +} + +// newStreamSet creates a set containing the given unique elements for stream collection. +func newStreamSet[T comparable](elements ...T) *streamSet[T] { + return &streamSet[T]{ + AbstractSet: NewAbstractSet(elements...), + } +} + +// Stream returns a sequential Stream with this set as its source. +func (this *streamSet[T]) Stream() Stream[T] { + return Stream[T]{}.FromSeq(this.Seq()) +} + +var _ Set[int] = (*streamSet[int])(nil) + +// Collector is a mutable reduction operation that accumulates input elements into a mutable +// result container, optionally transforming the accumulated result into a final representation +// after all input elements have been processed. +// +// T is the type of input elements to the reduction. +// A is the mutable accumulation type of the reduction. +// R is the result type of the reduction. +type Collector[T, A, R any] struct { + Supplier func() A + Accumulator func(A, T) A + Combiner func(A, A) A + Finisher func(A) R +} + +// NewCollector constructs a new Collector with the specified supplier, accumulator, combiner, and finisher. +func NewCollector[T, A, R any](supplier func() A, accumulator func(A, T) A, combiner func(A, A) A, finisher func(A) R) Collector[T, A, R] { + return Collector[T, A, R]{ + Supplier: supplier, + Accumulator: accumulator, + Combiner: combiner, + Finisher: finisher, + } +} + +// NewSimpleCollector constructs a Collector where the intermediate accumulation type and final result type are identical, +// using an identity finisher. +func NewSimpleCollector[T, R any](supplier func() R, accumulator func(R, T) R, combiner ...func(R, R) R) Collector[T, R, R] { + var comb func(R, R) R + if len(combiner) > 0 { + comb = combiner[0] + } + return Collector[T, R, R]{ + Supplier: supplier, + Accumulator: accumulator, + Combiner: comb, + Finisher: func(r R) R { return r }, + } +} + +// Collectors provides static factory methods for standard Collector implementations, +// mirroring Java 8's java.util.stream.Collectors. +type Collectors struct{} + +// ToList returns a Collector that accumulates input elements into a List[T]. +func (Collectors) ToList[T any]() Collector[T, List[T], List[T]] { + return Collector[T, List[T], List[T]]{ + Supplier: func() List[T] { + return newStreamList[T]() + }, + Accumulator: func(l List[T], item T) List[T] { + l.Add(item) + return l + }, + Combiner: func(a, b List[T]) List[T] { + a.AddAll(b) + return a + }, + Finisher: func(l List[T]) List[T] { + return l + }, + } +} + +// ToSlice returns a Collector that accumulates input elements into a new slice. +func (Collectors) ToSlice[T any]() Collector[T, []T, []T] { + return Collector[T, []T, []T]{ + Supplier: func() []T { + return make([]T, 0) + }, + Accumulator: func(acc []T, item T) []T { + return append(acc, item) + }, + Combiner: func(a, b []T) []T { + return append(a, b...) + }, + Finisher: func(acc []T) []T { + return acc + }, + } +} + +// ToSet returns a Collector that accumulates unique input elements into a Set[T]. +func (Collectors) ToSet[T comparable]() Collector[T, Set[T], Set[T]] { + return Collector[T, Set[T], Set[T]]{ + Supplier: func() Set[T] { + return newStreamSet[T]() + }, + Accumulator: func(set Set[T], item T) Set[T] { + set.Add(item) + return set + }, + Combiner: func(a, b Set[T]) Set[T] { + a.AddAll(b) + return a + }, + Finisher: func(set Set[T]) Set[T] { + return set + }, + } +} + +// ToSetSlice returns a Collector that accumulates unique elements into a slice. +func (Collectors) ToSetSlice[T comparable]() Collector[T, map[T]struct{}, []T] { + return Collector[T, map[T]struct{}, []T]{ + Supplier: func() map[T]struct{} { + return make(map[T]struct{}) + }, + Accumulator: func(set map[T]struct{}, item T) map[T]struct{} { + set[item] = struct{}{} + return set + }, + Combiner: func(a, b map[T]struct{}) map[T]struct{} { + for k := range b { + a[k] = struct{}{} + } + return a + }, + Finisher: func(set map[T]struct{}) []T { + res := make([]T, 0, len(set)) + for k := range set { + res = append(res, k) + } + return res + }, + } +} + +type joinAcc struct { + builder strings.Builder + first bool +} + +// Joining returns a Collector that concatenates input strings, separated by the specified delimiter, +// with an optional prefix and suffix. +func (Collectors) Joining(delimiter string, prefixAndSuffix ...string) Collector[string, *joinAcc, string] { + prefix := "" + suffix := "" + if len(prefixAndSuffix) > 0 { + prefix = prefixAndSuffix[0] + } + if len(prefixAndSuffix) > 1 { + suffix = prefixAndSuffix[1] + } + + return Collector[string, *joinAcc, string]{ + Supplier: func() *joinAcc { + return &joinAcc{first: true} + }, + Accumulator: func(acc *joinAcc, s string) *joinAcc { + if !acc.first { + //nolint:errcheck // strings.Builder WriteString never returns error + acc.builder.WriteString(delimiter) + } else { + acc.first = false + } + //nolint:errcheck // strings.Builder WriteString never returns error + acc.builder.WriteString(s) + return acc + }, + Combiner: func(a, b *joinAcc) *joinAcc { + if b.first { + return a + } + if a.first { + return b + } + //nolint:errcheck // strings.Builder WriteString never returns error + a.builder.WriteString(delimiter) + //nolint:errcheck // strings.Builder WriteString never returns error + a.builder.WriteString(b.builder.String()) + return a + }, + Finisher: func(acc *joinAcc) string { + if acc.first { + return prefix + suffix + } + return prefix + acc.builder.String() + suffix + }, + } +} + +// Counting returns a Collector that counts the number of input elements. +func (Collectors) Counting[T any]() Collector[T, *int64, int64] { + return Collector[T, *int64, int64]{ + Supplier: func() *int64 { + var n int64 + return &n + }, + Accumulator: func(acc *int64, _ T) *int64 { + *acc++ + return acc + }, + Combiner: func(a, b *int64) *int64 { + *a += *b + return a + }, + Finisher: func(acc *int64) int64 { + return *acc + }, + } +} + +// SummingInt returns a Collector that produces the sum of an integer-valued function applied to input elements. +func (Collectors) SummingInt[T any](mapper func(T) int) Collector[T, *int64, int64] { + return Collector[T, *int64, int64]{ + Supplier: func() *int64 { + var s int64 + return &s + }, + Accumulator: func(acc *int64, item T) *int64 { + *acc += int64(mapper(item)) + return acc + }, + Combiner: func(a, b *int64) *int64 { + *a += *b + return a + }, + Finisher: func(acc *int64) int64 { + return *acc + }, + } +} + +// SummingFloat64 returns a Collector that produces the sum of a float64-valued function applied to input elements. +func (Collectors) SummingFloat64[T any](mapper func(T) float64) Collector[T, *float64, float64] { + return Collector[T, *float64, float64]{ + Supplier: func() *float64 { + var s float64 + return &s + }, + Accumulator: func(acc *float64, item T) *float64 { + *acc += mapper(item) + return acc + }, + Combiner: func(a, b *float64) *float64 { + *a += *b + return a + }, + Finisher: func(acc *float64) float64 { + return *acc + }, + } +} + +type avgAcc struct { + count int64 + sum float64 +} + +// AveragingInt returns a Collector that produces the arithmetic mean of an integer-valued function applied to input elements. +func (Collectors) AveragingInt[T any](mapper func(T) int) Collector[T, *avgAcc, float64] { + return Collector[T, *avgAcc, float64]{ + Supplier: func() *avgAcc { + return &avgAcc{} + }, + Accumulator: func(acc *avgAcc, item T) *avgAcc { + acc.count++ + acc.sum += float64(mapper(item)) + return acc + }, + Combiner: func(a, b *avgAcc) *avgAcc { + a.count += b.count + a.sum += b.sum + return a + }, + Finisher: func(acc *avgAcc) float64 { + if acc.count == 0 { + return 0 + } + return acc.sum / float64(acc.count) + }, + } +} + +// AveragingFloat64 returns a Collector that produces the arithmetic mean of a float64-valued function applied to input elements. +func (Collectors) AveragingFloat64[T any](mapper func(T) float64) Collector[T, *avgAcc, float64] { + return Collector[T, *avgAcc, float64]{ + Supplier: func() *avgAcc { + return &avgAcc{} + }, + Accumulator: func(acc *avgAcc, item T) *avgAcc { + acc.count++ + acc.sum += mapper(item) + return acc + }, + Combiner: func(a, b *avgAcc) *avgAcc { + a.count += b.count + a.sum += b.sum + return a + }, + Finisher: func(acc *avgAcc) float64 { + if acc.count == 0 { + return 0 + } + return acc.sum / float64(acc.count) + }, + } +} + +// SummarizingInt returns a Collector that computes summary statistics on an integer-valued function applied to input elements. +func (Collectors) SummarizingInt[T any](mapper func(T) int) Collector[T, *IntSummaryStatistics, IntSummaryStatistics] { + return Collector[T, *IntSummaryStatistics, IntSummaryStatistics]{ + Supplier: NewIntSummaryStatistics, + Accumulator: func(stats *IntSummaryStatistics, item T) *IntSummaryStatistics { + stats.Accept(mapper(item)) + return stats + }, + Combiner: func(a, b *IntSummaryStatistics) *IntSummaryStatistics { + a.Combine(b) + return a + }, + Finisher: func(stats *IntSummaryStatistics) IntSummaryStatistics { + return *stats + }, + } +} + +// SummarizingFloat64 returns a Collector that computes summary statistics on a float64-valued function applied to input elements. +func (Collectors) SummarizingFloat64[T any](mapper func(T) float64) Collector[T, *Float64SummaryStatistics, Float64SummaryStatistics] { + return Collector[T, *Float64SummaryStatistics, Float64SummaryStatistics]{ + Supplier: NewFloat64SummaryStatistics, + Accumulator: func(stats *Float64SummaryStatistics, item T) *Float64SummaryStatistics { + stats.Accept(mapper(item)) + return stats + }, + Combiner: func(a, b *Float64SummaryStatistics) *Float64SummaryStatistics { + a.Combine(b) + return a + }, + Finisher: func(stats *Float64SummaryStatistics) Float64SummaryStatistics { + return *stats + }, + } +} + +// Mapping adapts a Collector accepting elements of type U to one accepting elements of type T +// by applying a mapping function to each input element before accumulation. +func (Collectors) Mapping[T, U, A, R any](mapper func(T) U, downstream Collector[U, A, R]) Collector[T, A, R] { + return Collector[T, A, R]{ + Supplier: downstream.Supplier, + Accumulator: func(acc A, item T) A { + return downstream.Accumulator(acc, mapper(item)) + }, + Combiner: downstream.Combiner, + Finisher: downstream.Finisher, + } +} + +// Filtering adapts a Collector to one accepting elements of the same type T by applying the predicate +// and accumulating only elements that match. +func (Collectors) Filtering[T, A, R any](predicate func(T) bool, downstream Collector[T, A, R]) Collector[T, A, R] { + return Collector[T, A, R]{ + Supplier: downstream.Supplier, + Accumulator: func(acc A, item T) A { + if predicate(item) { + return downstream.Accumulator(acc, item) + } + return acc + }, + Combiner: downstream.Combiner, + Finisher: downstream.Finisher, + } +} + +// FlatMapping adapts a Collector accepting elements of type U to one accepting elements of type T +// by applying a flat-mapping function that maps each input element to a slice of U before accumulation. +func (Collectors) FlatMapping[T, U, A, R any](mapper func(T) []U, downstream Collector[U, A, R]) Collector[T, A, R] { + return Collector[T, A, R]{ + Supplier: downstream.Supplier, + Accumulator: func(acc A, item T) A { + for _, sub := range mapper(item) { + acc = downstream.Accumulator(acc, sub) + } + return acc + }, + Combiner: downstream.Combiner, + Finisher: downstream.Finisher, + } +} + +// GroupingBy returns a Collector implementing a "group by" operation on input elements of type T, +// grouping elements according to a classification function, and performing a downstream reduction +// on the elements associated with a given key. +func (Collectors) GroupingBy[T any, K comparable, A, D any](classifier func(T) K, downstream Collector[T, A, D]) Collector[T, map[K]A, map[K]D] { + var combiner func(m1, m2 map[K]A) map[K]A + if downstream.Combiner != nil { + combiner = func(m1, m2 map[K]A) map[K]A { + for k, v2 := range m2 { + if v1, exists := m1[k]; exists { + m1[k] = downstream.Combiner(v1, v2) + } else { + m1[k] = v2 + } + } + return m1 + } + } + + return Collector[T, map[K]A, map[K]D]{ + Supplier: func() map[K]A { + return make(map[K]A) + }, + Accumulator: func(m map[K]A, item T) map[K]A { + key := classifier(item) + acc, exists := m[key] + if !exists { + acc = downstream.Supplier() + } + m[key] = downstream.Accumulator(acc, item) + return m + }, + Combiner: combiner, + Finisher: func(m map[K]A) map[K]D { + res := make(map[K]D, len(m)) + for k, v := range m { + res[k] = downstream.Finisher(v) + } + return res + }, + } +} + +// GroupingBySlice returns a Collector that groups elements by key into slices of elements. +func (this Collectors) GroupingBySlice[T any, K comparable](classifier func(T) K) Collector[T, map[K][]T, map[K][]T] { + return this.GroupingBy(classifier, this.ToSlice[T]()) +} + +// PartitioningBy returns a Collector which partitions the input elements according to a Predicate, +// and organizes them into a map[bool]D via a downstream collector. +func (Collectors) PartitioningBy[T, A, D any](predicate func(T) bool, downstream Collector[T, A, D]) Collector[T, map[bool]A, map[bool]D] { + var combiner func(m1, m2 map[bool]A) map[bool]A + if downstream.Combiner != nil { + combiner = func(m1, m2 map[bool]A) map[bool]A { + m1[false] = downstream.Combiner(m1[false], m2[false]) + m1[true] = downstream.Combiner(m1[true], m2[true]) + return m1 + } + } + + return Collector[T, map[bool]A, map[bool]D]{ + Supplier: func() map[bool]A { + m := make(map[bool]A, 2) + m[false] = downstream.Supplier() + m[true] = downstream.Supplier() + return m + }, + Accumulator: func(m map[bool]A, item T) map[bool]A { + part := predicate(item) + m[part] = downstream.Accumulator(m[part], item) + return m + }, + Combiner: combiner, + Finisher: func(m map[bool]A) map[bool]D { + return map[bool]D{ + false: downstream.Finisher(m[false]), + true: downstream.Finisher(m[true]), + } + }, + } +} + +// PartitioningBySlice returns a Collector which partitions the input elements according to a Predicate +// into two slices: map[true][]T and map[false][]T. +func (this Collectors) PartitioningBySlice[T any](predicate func(T) bool) Collector[T, map[bool][]T, map[bool][]T] { + return this.PartitioningBy(predicate, this.ToSlice[T]()) +} + +// ToMap returns a Collector that accumulates elements into a map whose keys and values are the result +// of applying the provided mapping functions to the input elements. +// +// If duplicate keys are encountered, mergeRule (if provided) is invoked to resolve the collision. +// If no mergeRule is provided, duplicate keys trigger an IllegalStateError. +func (Collectors) ToMap[T any, K comparable, V any](keyMapper func(T) K, valueMapper func(T) V, mergeRule ...func(existing, newVal V) V) Collector[T, map[K]V, map[K]V] { + var merger func(existing, newVal V) V + if len(mergeRule) > 0 { + merger = mergeRule[0] + } + + return Collector[T, map[K]V, map[K]V]{ + Supplier: func() map[K]V { + return make(map[K]V) + }, + Accumulator: func(m map[K]V, item T) map[K]V { + k := keyMapper(item) + v := valueMapper(item) + if existing, exists := m[k]; exists { + if merger == nil { + panic(NewError("IllegalStateError", fmt.Sprintf("duplicate key %v", k))) + } + m[k] = merger(existing, v) + } else { + m[k] = v + } + return m + }, + Combiner: func(m1, m2 map[K]V) map[K]V { + for k, v2 := range m2 { + if v1, exists := m1[k]; exists { + if merger == nil { + panic(NewError("IllegalStateError", fmt.Sprintf("duplicate key %v", k))) + } + m1[k] = merger(v1, v2) + } else { + m1[k] = v2 + } + } + return m1 + }, + Finisher: func(m map[K]V) map[K]V { + return m + }, + } +} + +// Reducing returns a Collector which performs a reduction of its input elements under a specified identity +// and associative BinaryOperator. +func (Collectors) Reducing[T any](identity T, op func(T, T) T) Collector[T, *T, T] { + return Collector[T, *T, T]{ + Supplier: func() *T { + id := identity + return &id + }, + Accumulator: func(acc *T, item T) *T { + *acc = op(*acc, item) + return acc + }, + Combiner: func(a, b *T) *T { + *a = op(*a, *b) + return a + }, + Finisher: func(acc *T) T { + return *acc + }, + } +} + +// MaxBy returns a Collector that produces the maximal element according to a given comparator, +// described as an Optional[T]. +func (Collectors) MaxBy[T any](comparator func(a, b T) int) Collector[T, *Optional[T], Optional[T]] { + return Collector[T, *Optional[T], Optional[T]]{ + Supplier: func() *Optional[T] { + opt := OptionalEmpty[T]() + return &opt + }, + Accumulator: func(acc *Optional[T], item T) *Optional[T] { + if acc.IsEmpty() { + *acc = OptionalOf(item) + } else { + curr, _ := acc.Get() + if comparator(item, curr) > 0 { + *acc = OptionalOf(item) + } + } + return acc + }, + Combiner: func(a, b *Optional[T]) *Optional[T] { + if b.IsEmpty() { + return a + } + if a.IsEmpty() { + return b + } + vA, _ := a.Get() + vB, _ := b.Get() + if comparator(vB, vA) > 0 { + return b + } + return a + }, + Finisher: func(acc *Optional[T]) Optional[T] { + return *acc + }, + } +} + +// MinBy returns a Collector that produces the minimal element according to a given comparator, +// described as an Optional[T]. +func (Collectors) MinBy[T any](comparator func(a, b T) int) Collector[T, *Optional[T], Optional[T]] { + return Collector[T, *Optional[T], Optional[T]]{ + Supplier: func() *Optional[T] { + opt := OptionalEmpty[T]() + return &opt + }, + Accumulator: func(acc *Optional[T], item T) *Optional[T] { + if acc.IsEmpty() { + *acc = OptionalOf(item) + } else { + curr, _ := acc.Get() + if comparator(item, curr) < 0 { + *acc = OptionalOf(item) + } + } + return acc + }, + Combiner: func(a, b *Optional[T]) *Optional[T] { + if b.IsEmpty() { + return a + } + if a.IsEmpty() { + return b + } + vA, _ := a.Get() + vB, _ := b.Get() + if comparator(vB, vA) < 0 { + return b + } + return a + }, + Finisher: func(acc *Optional[T]) Optional[T] { + return *acc + }, + } +} diff --git a/pkg/util/stream/collector_example_test.go b/pkg/util/stream/collector_example_test.go new file mode 100644 index 0000000..26f4250 --- /dev/null +++ b/pkg/util/stream/collector_example_test.go @@ -0,0 +1,162 @@ +//nolint:errcheck // Not printing error handling +package stream_test + +import ( + "fmt" + + . "github.com/BeamFoundry/rays/pkg/util/stream" +) + +// ExampleStream_Collect demonstrates collecting stream elements into a list. +func ExampleStream_Collect() { + list := streamOf(1, 2, 3). + Collect(Collectors{}.ToList[int]()) + fmt.Println(list.ToSlice()) + // Output: + // [1 2 3] +} + +// ExampleCollectors_ToList demonstrates collecting filtered even numbers into a list. +func ExampleCollectors_ToList() { + evens := streamOf(1, 2, 3, 4). + Filter(func(n int) bool { return n%2 == 0 }). + Collect(Collectors{}.ToList[int]()) + fmt.Println(evens.ToSlice()) + // Output: + // [2 4] +} + +// ExampleCollectors_ToSet demonstrates collecting unique strings into a set. +func ExampleCollectors_ToSet() { + set := streamOf("apple", "banana", "apple", "cherry"). + Collect(Collectors{}.ToSet[string]()) + + fmt.Println(set.Contains("apple")) + fmt.Println(set.Contains("banana")) + fmt.Println(set.Size()) + // Output: + // true + // true + // 3 +} + +// ExampleCollectors_Joining demonstrates joining names with a delimiter, prefix, and suffix. +func ExampleCollectors_Joining() { + joined := streamOf("Alice", "Bob", "Charlie"). + Collect(Collectors{}.Joining(", ", "[", "]")) + fmt.Println(joined) + // Output: + // [Alice, Bob, Charlie] +} + +// ExampleCollectors_GroupingBySlice demonstrates grouping employees into slices by department. +func ExampleCollectors_GroupingBySlice() { + type Employee struct { + Name string + Dept string + } + employees := []Employee{ + {Name: "Alice", Dept: "Engineering"}, + {Name: "Bob", Dept: "Sales"}, + {Name: "Charlie", Dept: "Engineering"}, + } + byDept := streamOfSlice(employees).Collect( + Collectors{}.GroupingBySlice(func(e Employee) string { return e.Dept }), + ) + fmt.Println(len(byDept["Engineering"])) + fmt.Println(len(byDept["Sales"])) + // Output: + // 2 + // 1 +} + +// ExampleCollectors_GroupingBy demonstrates counting words grouped by their first letter. +func ExampleCollectors_GroupingBy() { + words := []string{"apple", "apricot", "banana", "cherry", "cranberry"} + counts := streamOfSlice(words).Collect( + Collectors{}.GroupingBy( + func(w string) string { return string(w[0]) }, + Collectors{}.Counting[string](), + ), + ) + fmt.Println(counts["a"]) + fmt.Println(counts["b"]) + fmt.Println(counts["c"]) + // Output: + // 2 + // 1 + // 2 +} + +// ExampleCollectors_PartitioningBySlice demonstrates partitioning numbers into even and odd slices. +func ExampleCollectors_PartitioningBySlice() { + partitioned := streamOf(1, 2, 3, 4, 5, 6).Collect( + Collectors{}.PartitioningBySlice(func(n int) bool { return n%2 == 0 }), + ) + fmt.Println(partitioned[true]) + fmt.Println(partitioned[false]) + // Output: + // [2 4 6] + // [1 3 5] +} + +// ExampleCollectors_PartitioningBy demonstrates counting passing and failing grades. +func ExampleCollectors_PartitioningBy() { + grades := []int{45, 78, 92, 55, 88} + counts := streamOfSlice(grades).Collect( + Collectors{}.PartitioningBy( + func(grade int) bool { return grade >= 60 }, + Collectors{}.Counting[int](), + ), + ) + fmt.Println("Passed:", counts[true]) + fmt.Println("Failed:", counts[false]) + // Output: + // Passed: 3 + // Failed: 2 +} + +// ExampleCollectors_SummarizingInt demonstrates collecting count, sum, extrema, and average. +func ExampleCollectors_SummarizingInt() { + stats := streamOf(10, 20, 30, 40).Collect( + Collectors{}.SummarizingInt(func(n int) int { return n }), + ) + fmt.Printf("Count=%d, Sum=%d, Min=%d, Max=%d, Avg=%.1f\n", + stats.Count, stats.Sum, stats.Min, stats.Max, stats.Average()) + // Output: + // Count=4, Sum=100, Min=10, Max=40, Avg=25.0 +} + +// ExampleCollectors_ToMap demonstrates collecting city names into a map keyed by city code. +func ExampleCollectors_ToMap() { + type City struct { + Code string + Name string + } + cities := []City{ + {Code: "NYC", Name: "New York City"}, + {Code: "PAR", Name: "Paris"}, + {Code: "LON", Name: "London"}, + } + cityMap := streamOfSlice(cities).Collect( + Collectors{}.ToMap( + func(c City) string { return c.Code }, + func(c City) string { return c.Name }, + ), + ) + fmt.Println(cityMap["PAR"]) + fmt.Println(cityMap["NYC"]) + // Output: + // Paris + // New York City +} + +// ExampleCollectors_Counting demonstrates counting elements with a collector. +func ExampleCollectors_Counting() { + count := streamOf("one", "two", "three").Collect( + Collectors{}.Counting[string](), + ) + fmt.Println(count) + // Output: + // 3 +} diff --git a/pkg/util/stream/collector_test.go b/pkg/util/stream/collector_test.go new file mode 100644 index 0000000..35fb1cf --- /dev/null +++ b/pkg/util/stream/collector_test.go @@ -0,0 +1,634 @@ +package stream_test + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/BeamFoundry/rays/pkg/lang" + . "github.com/BeamFoundry/rays/pkg/util/stream" +) + +var _ = Describe("Collectors", func() { + var c Collectors + + It("collects to list with ToList", func() { + res := streamOf(1, 2, 3).Collect(c.ToList[int]()) + Expect(res.Size()).To(Equal(3)) + Expect(res.ToSlice()).To(Equal([]int{1, 2, 3})) + }) + + It("collects to slice with ToSlice", func() { + res := streamOf("a", "b").Collect(c.ToSlice[string]()) + Expect(res).To(Equal([]string{"a", "b"})) + }) + + It("collects to set with ToSet", func() { + set := streamOf(1, 2, 2, 3, 1).Collect(c.ToSet[int]()) + Expect(set.Size()).To(Equal(3)) + Expect(set.Contains(1)).To(BeTrue()) + Expect(set.Contains(2)).To(BeTrue()) + Expect(set.Contains(3)).To(BeTrue()) + }) + + It("collects to deduplicated slice with ToSetSlice", func() { + slice := streamOf(1, 2, 2, 3, 1).Collect(c.ToSetSlice[int]()) + Expect(slice).To(ConsistOf(1, 2, 3)) + }) + + It("joins strings with Joining", func() { + res := streamOf("apple", "banana", "cherry").Collect(c.Joining(", ")) + Expect(res).To(Equal("apple, banana, cherry")) + + resWithPrefixSuffix := streamOf("apple", "banana").Collect(c.Joining(", ", "[", "]")) + Expect(resWithPrefixSuffix).To(Equal("[apple, banana]")) + + emptyJoin := streamEmpty[string]().Collect(c.Joining(", ", "[", "]")) + Expect(emptyJoin).To(Equal("[]")) + }) + + It("counts elements with Counting", func() { + count := streamOf(10, 20, 30).Collect(c.Counting[int]()) + Expect(count).To(Equal(int64(3))) + }) + + It("sums integers and floats with SummingInt and SummingFloat64", func() { + words := streamOf("apple", "banana", "pie") + sum := words.Collect(c.SummingInt(func(s string) int { return len(s) })) + Expect(sum).To(Equal(int64(5 + 6 + 3))) + + floats := streamOf(1.5, 2.5, 3.0) + floatSum := floats.Collect(c.SummingFloat64(func(x float64) float64 { return x })) + Expect(floatSum).To(Equal(7.0)) + }) + + It("averages values with AveragingInt and AveragingFloat64", func() { + words := streamOf("a", "bb", "ccc") + avg := words.Collect(c.AveragingInt(func(s string) int { return len(s) })) + Expect(avg).To(Equal(2.0)) + + floats := streamOf(10.0, 20.0, 30.0) + floatAvg := floats.Collect(c.AveragingFloat64(func(x float64) float64 { return x })) + Expect(floatAvg).To(Equal(20.0)) + + emptyAvg := streamEmpty[int]().Collect(c.AveragingInt(func(x int) int { return x })) + Expect(emptyAvg).To(Equal(0.0)) + }) + + It("summarizes values with SummarizingInt and SummarizingFloat64", func() { + stats := streamOf(1, 2, 3, 4, 5).Collect(c.SummarizingInt(func(x int) int { return x })) + Expect(stats.Count).To(Equal(int64(5))) + Expect(stats.Sum).To(Equal(int64(15))) + Expect(stats.Min).To(Equal(1)) + Expect(stats.Max).To(Equal(5)) + Expect(stats.Average()).To(Equal(3.0)) + Expect(stats.String()).To(ContainSubstring("IntSummaryStatistics")) + + fStats := streamOf(1.0, 2.0, 3.0).Collect(c.SummarizingFloat64(func(x float64) float64 { return x })) + Expect(fStats.Count).To(Equal(int64(3))) + Expect(fStats.Sum).To(Equal(6.0)) + Expect(fStats.Min).To(Equal(1.0)) + Expect(fStats.Max).To(Equal(3.0)) + Expect(fStats.Average()).To(Equal(2.0)) + Expect(fStats.String()).To(ContainSubstring("Float64SummaryStatistics")) + }) + + It("transforms with Mapping, Filtering, and FlatMapping downstream collectors", func() { + // Mapping: extract lengths and sum them + sumLens := streamOf("hi", "hello", "world"). + Collect(c.Mapping( + func(s string) int { return len(s) }, + c.SummingInt(func(l int) int { return l }), + )) + Expect(sumLens).To(Equal(int64(2 + 5 + 5))) + + // Filtering: only keep words of length > 2 and join them + joined := streamOf("a", "big", "apple"). + Collect(c.Filtering( + func(s string) bool { return len(s) > 2 }, + c.Joining(", "), + )) + Expect(joined).To(Equal("big, apple")) + + // FlatMapping: split words and collect all characters into set + chars := streamOf("hi", "bye"). + Collect(c.FlatMapping( + func(s string) []string { return strings.Split(s, "") }, + c.ToSet[string](), + )) + Expect(chars.Size()).To(Equal(5)) // h, i, b, y, e + }) + + It("groups elements with GroupingBy and GroupingBySlice", func() { + words := streamOf("apple", "banana", "apricot", "blueberry", "cherry") + + grouped := words.Collect(c.GroupingBy( + func(s string) string { return string(s[0]) }, + c.ToList[string](), + )) + + Expect(grouped["a"].ToSlice()).To(Equal([]string{"apple", "apricot"})) + Expect(grouped["b"].ToSlice()).To(Equal([]string{"banana", "blueberry"})) + Expect(grouped["c"].ToSlice()).To(Equal([]string{"cherry"})) + + // GroupingBySlice + groupedSlice := streamOf("one", "two", "three", "four"). + Collect(c.GroupingBySlice(func(s string) int { return len(s) })) + + Expect(groupedSlice[3]).To(Equal([]string{"one", "two"})) + Expect(groupedSlice[4]).To(Equal([]string{"four"})) + Expect(groupedSlice[5]).To(Equal([]string{"three"})) + }) + + It("partitions elements with PartitioningBy and PartitioningBySlice", func() { + partitioned := streamOf(1, 2, 3, 4, 5, 6). + Collect(c.PartitioningBySlice(func(x int) bool { return x%2 == 0 })) + Expect(partitioned[true]).To(Equal([]int{2, 4, 6})) + Expect(partitioned[false]).To(Equal([]int{1, 3, 5})) + + // With downstream collector (Counting) + partitionCounts := streamOf(1, 2, 3, 4, 5, 6). + Collect(c.PartitioningBy( + func(x int) bool { return x%2 == 0 }, + c.Counting[int](), + )) + + Expect(partitionCounts[true]).To(Equal(int64(3))) + Expect(partitionCounts[false]).To(Equal(int64(3))) + }) + + It("collects into map with ToMap", func() { + m := streamOf("a", "bb", "ccc").Collect( + c.ToMap( + func(s string) string { return s }, + func(s string) int { return len(s) }, + ), + ) + Expect(m).To(Equal(map[string]int{"a": 1, "bb": 2, "ccc": 3})) + + // With merge rule for duplicate keys using ToMap + merged := streamOf("apple", "apricot", "banana").Collect( + c.ToMap( + func(s string) string { return string(s[0]) }, + func(s string) string { return s }, + func(existing, newVal string) string { return existing + "/" + newVal }, + ), + ) + Expect(merged["a"]).To(Equal("apple/apricot")) + Expect(merged["b"]).To(Equal("banana")) + + // Duplicate keys without merge rule panics with IllegalStateError + Expect(func() { + streamOf("apple", "apricot").Collect( + c.ToMap( + func(s string) string { return string(s[0]) }, + func(s string) string { return s }, + ), + ) + }).To(Panic()) + }) + + It("reduces using Reducing collector", func() { + res := streamOf("a", "b", "c").Collect( + c.Reducing("", func(a, b string) string { return a + b }), + ) + Expect(res).To(Equal("abc")) + }) + + It("finds min and max using MinBy and MaxBy collectors", func() { + minOpt := streamOf(4, 2, 9, 1, 7).Collect( + c.MinBy(func(a, b int) int { return a - b }), + ) + Expect(minOpt.IsPresent()).To(BeTrue()) + valMin, _ := minOpt.Get() + Expect(valMin).To(Equal(1)) + + maxOpt := streamOf(4, 2, 9, 1, 7).Collect( + c.MaxBy(func(a, b int) int { return a - b }), + ) + Expect(maxOpt.IsPresent()).To(BeTrue()) + valMax, _ := maxOpt.Get() + Expect(valMax).To(Equal(9)) + }) + + It("collects using CollectWith", func() { + res := streamOf(1, 2, 3, 4).CollectWith( + func() []int { return []int{} }, + func(acc []int, item int) []int { return append(acc, item*2) }, + ) + Expect(res).To(Equal([]int{2, 4, 6, 8})) + }) + + It("supports NewCollector directly", func() { + c := NewCollector( + func() []string { return []string{} }, + func(acc []string, item int) []string { return append(acc, strings.Repeat("x", item)) }, + func(a, b []string) []string { return append(a, b...) }, + func(acc []string) int { return len(acc) }, + ) + res := streamOf(1, 2, 3).Collect(c) + Expect(res).To(Equal(3)) + }) + + It("combines IntSummaryStatistics and Float64SummaryStatistics directly", func() { + s1 := NewIntSummaryStatistics() + s1.Accept(10) + s1.Accept(20) + s1.Accept(2) + + s2 := NewIntSummaryStatistics() + s2.Accept(5) + s2.Accept(40) + + s1.Combine(s2) + Expect(s1.Count).To(Equal(int64(5))) + Expect(s1.Sum).To(Equal(int64(77))) + Expect(s1.Min).To(Equal(2)) + Expect(s1.Max).To(Equal(40)) + + // Combine with empty + s1.Combine(nil) + s1.Combine(NewIntSummaryStatistics()) + Expect(s1.Count).To(Equal(int64(5))) + + // Combine into empty + emptyStats := NewIntSummaryStatistics() + emptyStats.Combine(s1) + Expect(emptyStats.Count).To(Equal(int64(5))) + + // Float64 + f1 := NewFloat64SummaryStatistics() + f1.Accept(1.5) + f1.Accept(3.5) + f1.Accept(0.2) + + f2 := NewFloat64SummaryStatistics() + f2.Accept(0.5) + f2.Accept(4.5) + + f1.Combine(f2) + Expect(f1.Count).To(Equal(int64(5))) + Expect(f1.Sum).To(Equal(10.2)) + Expect(f1.Min).To(Equal(0.2)) + Expect(f1.Max).To(Equal(4.5)) + + // Combine with empty / nil + f1.Combine(nil) + f1.Combine(NewFloat64SummaryStatistics()) + Expect(f1.Count).To(Equal(int64(5))) + + emptyF := NewFloat64SummaryStatistics() + emptyF.Combine(f1) + Expect(emptyF.Count).To(Equal(int64(5))) + + // Combine where other has smaller Min and larger Max + combIntTarget := NewIntSummaryStatistics() + combIntTarget.Accept(20) + combIntOther := NewIntSummaryStatistics() + combIntOther.Accept(5) + combIntOther.Accept(50) + combIntTarget.Combine(combIntOther) + Expect(combIntTarget.Min).To(Equal(5)) + Expect(combIntTarget.Max).To(Equal(50)) + + combFloatTarget := NewFloat64SummaryStatistics() + combFloatTarget.Accept(20.0) + combFloatOther := NewFloat64SummaryStatistics() + combFloatOther.Accept(5.0) + combFloatOther.Accept(50.0) + combFloatTarget.Combine(combFloatOther) + Expect(combFloatTarget.Min).To(Equal(5.0)) + Expect(combFloatTarget.Max).To(Equal(50.0)) + + // Test zero-initialized IntSummaryStatistics + var zeroIntStats IntSummaryStatistics + zeroIntStats.Accept(42) + Expect(zeroIntStats.Count).To(Equal(int64(1))) + Expect(zeroIntStats.Sum).To(Equal(int64(42))) + Expect(zeroIntStats.Min).To(Equal(42)) + Expect(zeroIntStats.Max).To(Equal(42)) + + // Test zero-initialized Float64SummaryStatistics + var zeroFloatStats Float64SummaryStatistics + zeroFloatStats.Accept(42.5) + Expect(zeroFloatStats.Count).To(Equal(int64(1))) + Expect(zeroFloatStats.Sum).To(Equal(42.5)) + Expect(zeroFloatStats.Min).To(Equal(42.5)) + Expect(zeroFloatStats.Max).To(Equal(42.5)) + }) + + It("propagates nil Combiner in GroupingBy and PartitioningBy when downstream has no Combiner", func() { + noCombinerDownstream := NewSimpleCollector( + func() []int { return nil }, + func(acc []int, item int) []int { return append(acc, item) }, + ) + Expect(noCombinerDownstream.Combiner).To(BeNil()) + + grouped := c.GroupingBy(func(x int) string { + if x%2 == 0 { + return "even" + } + return "odd" + }, noCombinerDownstream) + Expect(grouped.Combiner).To(BeNil()) + + // Parallel stream falls back to sequential collect when Combiner is nil + resGrouped := streamOf(1, 2, 3, 4).Parallel().Collect(grouped) + Expect(resGrouped["even"]).To(Equal([]int{2, 4})) + Expect(resGrouped["odd"]).To(Equal([]int{1, 3})) + + partitioned := c.PartitioningBy(func(x int) bool { return x%2 == 0 }, noCombinerDownstream) + Expect(partitioned.Combiner).To(BeNil()) + + resPartitioned := streamOf(1, 2, 3, 4).Parallel().Collect(partitioned) + Expect(resPartitioned[true]).To(Equal([]int{2, 4})) + Expect(resPartitioned[false]).To(Equal([]int{1, 3})) + }) + + It("supports Stream() method on streamList and streamSet", func() { + listRes := streamOf(10, 20, 30).Collect(c.ToList[int]()) + streamer, ok := listRes.(interface{ Stream() Stream[int] }) + Expect(ok).To(BeTrue()) + Expect(streamer.Stream().ToSlice()).To(Equal([]int{10, 20, 30})) + + setRes := streamOf("x", "y").Collect(c.ToSet[string]()) + setStreamer, ok := setRes.(interface{ Stream() Stream[string] }) + Expect(ok).To(BeTrue()) + Expect(setStreamer.Stream().Count()).To(Equal(int64(2))) + }) + + It("supports parallel execution of all collectors (exercising combiners)", func() { + // ToList + list := streamRangeClosed(1, 100).Parallel().Collect(c.ToList[int]()) + Expect(list.Size()).To(Equal(100)) + + // ToSlice + slice := streamRangeClosed(1, 100).Parallel().Collect(c.ToSlice[int]()) + Expect(slice).To(HaveLen(100)) + + // ToSet + set := streamRangeClosed(1, 100).Parallel().Collect(c.ToSet[int]()) + Expect(set.Size()).To(Equal(100)) + + // ToSetSlice + setSlice := streamRangeClosed(1, 100).Parallel().Collect(c.ToSetSlice[int]()) + Expect(setSlice).To(HaveLen(100)) + + // Joining + joined := streamOf("a", "b", "c", "d").Parallel().Collect(c.Joining("-", "[", "]")) + Expect(joined).To(ContainSubstring("a")) + Expect(joined).To(HavePrefix("[")) + Expect(joined).To(HaveSuffix("]")) + + // Joining combiner edge cases + jCollector := c.Joining("-", "<", ">") + acc1 := jCollector.Supplier() + acc2 := jCollector.Supplier() + // both empty + Expect(jCollector.Finisher(jCollector.Combiner(acc1, acc2))).To(Equal("<>")) + // a empty, b not empty + jCollector.Accumulator(acc2, "B") + Expect(jCollector.Finisher(jCollector.Combiner(acc1, acc2))).To(Equal("")) + // b empty, a not empty + acc3 := jCollector.Supplier() + jCollector.Accumulator(acc3, "A") + acc4 := jCollector.Supplier() + Expect(jCollector.Finisher(jCollector.Combiner(acc3, acc4))).To(Equal("")) + + // Counting + cnt := streamRangeClosed(1, 100).Parallel().Collect(c.Counting[int]()) + Expect(cnt).To(Equal(int64(100))) + + // SummingInt & SummingFloat64 + sumI := streamRangeClosed(1, 100).Parallel().Collect(c.SummingInt(func(x int) int { return x })) + Expect(sumI).To(Equal(int64(5050))) + sumF := streamOf(1.5, 2.5, 3.0).Parallel().Collect(c.SummingFloat64(func(x float64) float64 { return x })) + Expect(sumF).To(Equal(7.0)) + + // AveragingInt & AveragingFloat64 + avgI := streamRangeClosed(1, 100).Parallel().Collect(c.AveragingInt(func(x int) int { return x })) + Expect(avgI).To(Equal(50.5)) + avgF := streamOf(2.0, 4.0, 6.0).Parallel().Collect(c.AveragingFloat64(func(x float64) float64 { return x })) + Expect(avgF).To(Equal(4.0)) + + // Averaging on empty streams + Expect(streamEmpty[int]().Collect(c.AveragingInt(func(x int) int { return x }))).To(Equal(0.0)) + Expect(streamEmpty[float64]().Collect(c.AveragingFloat64(func(x float64) float64 { return x }))).To(Equal(0.0)) + + // SummarizingInt & SummarizingFloat64 + statsI := streamRangeClosed(1, 100).Parallel().Collect(c.SummarizingInt(func(x int) int { return x })) + Expect(statsI.Count).To(Equal(int64(100))) + Expect(statsI.Sum).To(Equal(int64(5050))) + + statsF := streamOf(1.0, 2.0, 3.0).Parallel().Collect(c.SummarizingFloat64(func(x float64) float64 { return x })) + Expect(statsF.Count).To(Equal(int64(3))) + Expect(statsF.Sum).To(Equal(6.0)) + + // SummaryStatistics on empty + emptyIntStats := NewIntSummaryStatistics() + Expect(emptyIntStats.Average()).To(Equal(0.0)) + emptyFloatStats := NewFloat64SummaryStatistics() + Expect(emptyFloatStats.Average()).To(Equal(0.0)) + + // GroupingBy & GroupingBySlice + grouped := streamRangeClosed(1, 100).Parallel().Collect(c.GroupingBy(func(x int) int { return x % 2 }, c.ToList[int]())) + Expect(grouped[0].Size()).To(Equal(50)) + Expect(grouped[1].Size()).To(Equal(50)) + + groupedSlice := streamRangeClosed(1, 100).Parallel().Collect(c.GroupingBySlice(func(x int) int { return x % 2 })) + Expect(groupedSlice[0]).To(HaveLen(50)) + + // PartitioningBy & PartitioningBySlice + part := streamRangeClosed(1, 100).Parallel().Collect(c.PartitioningBy(func(x int) bool { return x%2 == 0 }, c.ToList[int]())) + Expect(part[true].Size()).To(Equal(50)) + + partSlice := streamRangeClosed(1, 100).Parallel().Collect(c.PartitioningBySlice(func(x int) bool { return x%2 == 0 })) + Expect(partSlice[true]).To(HaveLen(50)) + + // ToMap in parallel with merger + toMap := streamOf("apple", "apricot", "banana", "blueberry").Parallel().Collect( + c.ToMap( + func(s string) string { return string(s[0]) }, + func(s string) string { return s }, + func(a, b string) string { return a + "," + b }, + ), + ) + Expect(toMap["a"]).To(ContainSubstring("apple")) + Expect(toMap["b"]).To(ContainSubstring("banana")) + + // ToMap combiner duplicate key panic without merger + toMapColl := c.ToMap(func(s string) string { return s }, func(s string) string { return s }) + Expect(func() { + toMapColl.Combiner(map[string]string{"k": "v1"}, map[string]string{"k": "v2"}) + }).To(Panic()) + + // Reducing in parallel + red := streamRangeClosed(1, 10).Parallel().Collect(c.Reducing(0, func(a, b int) int { return a + b })) + Expect(red).To(Equal(55)) + + // MaxBy & MinBy in parallel + maxVal := streamRangeClosed(1, 100).Parallel().Collect(c.MaxBy(func(a, b int) int { return a - b })) + Expect(maxVal.IsPresent()).To(BeTrue()) + v, _ := maxVal.Get() + Expect(v).To(Equal(100)) + + minVal := streamRangeClosed(1, 100).Parallel().Collect(c.MinBy(func(a, b int) int { return a - b })) + Expect(minVal.IsPresent()).To(BeTrue()) + vMin, _ := minVal.Get() + Expect(vMin).To(Equal(1)) + + // MaxBy & MinBy on empty stream + Expect(streamEmpty[int]().Collect(c.MaxBy(func(a, b int) int { return a - b })).IsEmpty()).To(BeTrue()) + Expect(streamEmpty[int]().Collect(c.MinBy(func(a, b int) int { return a - b })).IsEmpty()).To(BeTrue()) + + // MaxBy & MinBy combiner branches + maxColl := c.MaxBy(func(a, b int) int { return a - b }) + optEmp1 := maxColl.Supplier() + optEmp2 := maxColl.Supplier() + optVal1 := maxColl.Supplier() + *optVal1 = OptionalOf(10) + optVal2 := maxColl.Supplier() + *optVal2 = OptionalOf(20) + + Expect(maxColl.Combiner(optEmp1, optEmp2).IsEmpty()).To(BeTrue()) + Expect(*maxColl.Combiner(optEmp1, optVal2)).To(Equal(OptionalOf(20))) + Expect(*maxColl.Combiner(optVal1, optEmp2)).To(Equal(OptionalOf(10))) + Expect(*maxColl.Combiner(optVal1, optVal2)).To(Equal(OptionalOf(20))) + Expect(*maxColl.Combiner(optVal2, optVal1)).To(Equal(OptionalOf(20))) + + minColl := c.MinBy(func(a, b int) int { return a - b }) + mOptEmp1 := minColl.Supplier() + mOptEmp2 := minColl.Supplier() + mOptVal1 := minColl.Supplier() + *mOptVal1 = OptionalOf(10) + mOptVal2 := minColl.Supplier() + *mOptVal2 = OptionalOf(20) + + Expect(minColl.Combiner(mOptEmp1, mOptEmp2).IsEmpty()).To(BeTrue()) + Expect(*minColl.Combiner(mOptEmp1, mOptVal2)).To(Equal(OptionalOf(20))) + Expect(*minColl.Combiner(mOptVal1, mOptEmp2)).To(Equal(OptionalOf(10))) + Expect(*minColl.Combiner(mOptVal1, mOptVal2)).To(Equal(OptionalOf(10))) + Expect(*minColl.Combiner(mOptVal2, mOptVal1)).To(Equal(OptionalOf(10))) + + // NewSimpleCollector with combiner + simpleWithComb := NewSimpleCollector( + func() []int { return nil }, + func(acc []int, item int) []int { return append(acc, item) }, + func(a, b []int) []int { return append(a, b...) }, + ) + Expect(simpleWithComb.Combiner([]int{1}, []int{2})).To(Equal([]int{1, 2})) + + // Joining with prefix only + Expect(streamOf("a", "b").Collect(c.Joining(",", "["))).To(Equal("[a,b")) + + // Direct combiner invocations for 100% coverage + sumIntColl := c.SummingInt(func(x int) int { return x }) + aI, bI := sumIntColl.Supplier(), sumIntColl.Supplier() + *aI = 10 + *bI = 20 + Expect(*sumIntColl.Combiner(aI, bI)).To(Equal(int64(30))) + + sumFloatColl := c.SummingFloat64(func(x float64) float64 { return x }) + aF, bF := sumFloatColl.Supplier(), sumFloatColl.Supplier() + *aF = 1.5 + *bF = 2.5 + Expect(*sumFloatColl.Combiner(aF, bF)).To(Equal(4.0)) + + avgIntColl := c.AveragingInt(func(x int) int { return x }) + aAvg, bAvg := avgIntColl.Supplier(), avgIntColl.Supplier() + avgIntColl.Accumulator(aAvg, 10) + avgIntColl.Accumulator(bAvg, 20) + Expect(avgIntColl.Finisher(avgIntColl.Combiner(aAvg, bAvg))).To(Equal(15.0)) + + avgFColl := c.AveragingFloat64(func(x float64) float64 { return x }) + aAvgF, bAvgF := avgFColl.Supplier(), avgFColl.Supplier() + avgFColl.Accumulator(aAvgF, 5.0) + avgFColl.Accumulator(bAvgF, 15.0) + Expect(avgFColl.Finisher(avgFColl.Combiner(aAvgF, bAvgF))).To(Equal(10.0)) + + statsIColl := c.SummarizingInt(func(x int) int { return x }) + aStatsI, bStatsI := statsIColl.Supplier(), statsIColl.Supplier() + aStatsI.Accept(10) + bStatsI.Accept(20) + combStatsI := statsIColl.Combiner(aStatsI, bStatsI) + Expect(combStatsI.Count).To(Equal(int64(2))) + Expect(combStatsI.Sum).To(Equal(int64(30))) + + statsFColl := c.SummarizingFloat64(func(x float64) float64 { return x }) + aStatsF, bStatsF := statsFColl.Supplier(), statsFColl.Supplier() + aStatsF.Accept(10.5) + bStatsF.Accept(20.5) + combStatsF := statsFColl.Combiner(aStatsF, bStatsF) + Expect(combStatsF.Count).To(Equal(int64(2))) + Expect(combStatsF.Sum).To(Equal(31.0)) + + redColl := c.Reducing(0, func(a, b int) int { return a + b }) + Expect(*redColl.Combiner(redColl.Accumulator(redColl.Supplier(), 10), redColl.Accumulator(redColl.Supplier(), 20))).To(Equal(30)) + + partColl := c.PartitioningBy(func(x int) bool { return x > 0 }, c.ToSlice[int]()) + pm1, pm2 := partColl.Supplier(), partColl.Supplier() + pm1[true] = []int{1} + pm2[true] = []int{2} + pComb := partColl.Combiner(pm1, pm2) + Expect(pComb[true]).To(Equal([]int{1, 2})) + + groupByColl := c.GroupingBy(func(_ int) string { return "k" }, c.ToSlice[int]()) + gm1 := map[string][]int{"k1": {1}} + gm2 := map[string][]int{"k1": {2}, "k2": {3}} + gcomb := groupByColl.Combiner(gm1, gm2) + Expect(gcomb["k1"]).To(Equal([]int{1, 2})) + Expect(gcomb["k2"]).To(Equal([]int{3})) + + toMapWithMerger := c.ToMap(func(s string) string { return s }, func(s string) string { return s }, func(a, b string) string { return a + b }) + tm1 := map[string]string{"k1": "a"} + tm2 := map[string]string{"k1": "b", "k2": "c"} + tmComb := toMapWithMerger.Combiner(tm1, tm2) + Expect(tmComb["k1"]).To(Equal("ab")) + Expect(tmComb["k2"]).To(Equal("c")) + + setSliceColl := c.ToSetSlice[int]() + Expect(setSliceColl.Combiner(map[int]struct{}{1: {}}, map[int]struct{}{2: {}})).To(HaveKey(2)) + + setColl := c.ToSet[int]() + sA, sB := setColl.Supplier(), setColl.Supplier() + sA.Add(1) + sB.Add(2) + Expect(setColl.Combiner(sA, sB).Contains(2)).To(BeTrue()) + + cntColl := c.Counting[int]() + cntA, cntB := cntColl.Supplier(), cntColl.Supplier() + *cntA = 5 + *cntB = 7 + Expect(*cntColl.Combiner(cntA, cntB)).To(Equal(int64(12))) + + jColl := c.Joining(",") + ja1 := jColl.Supplier() + jColl.Accumulator(ja1, "x") + ja2 := jColl.Supplier() + jColl.Accumulator(ja2, "y") + Expect(jColl.Finisher(jColl.Combiner(ja1, ja2))).To(Equal("x,y")) + }) + + It("provides all collector factory methods on Collectors", func() { + var coll Collectors + Expect(coll.Joining(",").Finisher(coll.Joining(",").Accumulator(coll.Joining(",").Supplier(), "a"))).To(Equal("a")) + Expect(*coll.Counting[int]().Supplier()).To(Equal(int64(0))) + Expect(*coll.SummingInt(func(x int) int { return x }).Supplier()).To(Equal(int64(0))) + Expect(*coll.SummingFloat64(func(x float64) float64 { return x }).Supplier()).To(Equal(0.0)) + Expect(coll.AveragingInt(func(x int) int { return x }).Finisher(coll.AveragingInt(func(x int) int { return x }).Supplier())).To(Equal(0.0)) + Expect(coll.AveragingFloat64(func(x float64) float64 { return x }).Finisher(coll.AveragingFloat64(func(x float64) float64 { return x }).Supplier())).To(Equal(0.0)) + Expect(coll.SummarizingInt(func(x int) int { return x }).Finisher(coll.SummarizingInt(func(x int) int { return x }).Supplier()).Count).To(Equal(int64(0))) + Expect(coll.SummarizingFloat64(func(x float64) float64 { return x }).Finisher(coll.SummarizingFloat64(func(x float64) float64 { return x }).Supplier()).Count).To(Equal(int64(0))) + Expect(coll.Mapping(func(x int) int { return x * 2 }, coll.ToSlice[int]()).Supplier()).To(Equal([]int{})) + Expect(coll.Filtering(func(x int) bool { return x > 0 }, coll.ToSlice[int]()).Supplier()).To(Equal([]int{})) + Expect(coll.FlatMapping(func(x int) []int { return []int{x} }, coll.ToSlice[int]()).Supplier()).To(Equal([]int{})) + Expect(coll.GroupingBy(func(x int) int { return x }, coll.ToSlice[int]()).Supplier()).NotTo(BeNil()) + Expect(coll.GroupingBySlice(func(x int) int { return x }).Supplier()).NotTo(BeNil()) + Expect(coll.PartitioningBy(func(x int) bool { return x > 0 }, coll.ToSlice[int]()).Supplier()).NotTo(BeNil()) + Expect(coll.PartitioningBySlice(func(x int) bool { return x > 0 }).Supplier()).NotTo(BeNil()) + Expect(coll.Reducing(0, func(a, b int) int { return a + b }).Finisher(coll.Reducing(0, func(a, b int) int { return a + b }).Supplier())).To(Equal(0)) + Expect(coll.MaxBy(func(a, b int) int { return a - b }).Supplier().IsEmpty()).To(BeTrue()) + Expect(coll.MinBy(func(a, b int) int { return a - b }).Supplier().IsEmpty()).To(BeTrue()) + }) +}) diff --git a/pkg/util/stream/intermediate.go b/pkg/util/stream/intermediate.go new file mode 100644 index 0000000..c94c9d6 --- /dev/null +++ b/pkg/util/stream/intermediate.go @@ -0,0 +1,390 @@ +package stream + +import ( + "fmt" + "iter" + "reflect" + "slices" + + . "github.com/BeamFoundry/rays/pkg/lang" +) + +// Filter returns a stream consisting of the elements of this stream that match the given predicate. +func (this Stream[T]) Filter(predicate func(T) bool) Stream[T] { + return this.newIntermediate(func(yield func(T) bool) { + this.seq(func(item T) bool { + if predicate(item) { + return yield(item) + } + return true + }) + }) +} + +// Map returns a stream consisting of the results of applying the given function to the elements of this stream. +// Note: This method leverages Go 1.27's type parameters on struct methods. +func (this Stream[T]) Map[R any](mapper func(T) R) Stream[R] { + return this.newIntermediate(func(yield func(R) bool) { + this.seq(func(item T) bool { + return yield(mapper(item)) + }) + }) +} + +// FlatMap returns a stream consisting of the results of replacing each element of this stream with the contents of a mapped stream. +// Each mapped stream is automatically closed after its contents have been placed into this stream. +// Note: This method leverages Go 1.27's type parameters on struct methods. +func (this Stream[T]) FlatMap[R any](mapper func(T) Stream[R]) Stream[R] { + return this.newIntermediate(func(yield func(R) bool) { + shouldContinue := true + this.seq(func(item T) bool { + subStream := mapper(item) + defer subStream.Close() + + subStream.seq(func(subItem R) bool { + if !yield(subItem) { + shouldContinue = false + return false + } + return true + }) + return shouldContinue + }) + }) +} + +// FlatMapSlice returns a stream consisting of the results of replacing each element of this stream with the contents of a mapped slice. +// Note: This method leverages Go 1.27's type parameters on struct methods. +func (this Stream[T]) FlatMapSlice[R any](mapper func(T) []R) Stream[R] { + return this.newIntermediate(func(yield func(R) bool) { + shouldContinue := true + this.seq(func(item T) bool { + for _, subItem := range mapper(item) { + if !yield(subItem) { + shouldContinue = false + return false + } + } + return shouldContinue + }) + }) +} + +// FlatMapSeq returns a stream consisting of the results of replacing each element of this stream with the contents of a mapped Go iterator sequence. +// Note: This method leverages Go 1.27's type parameters on struct methods. +func (this Stream[T]) FlatMapSeq[R any](mapper func(T) iter.Seq[R]) Stream[R] { + return this.newIntermediate(func(yield func(R) bool) { + shouldContinue := true + this.seq(func(item T) bool { + subSeq := mapper(item) + subSeq(func(subItem R) bool { + if !yield(subItem) { + shouldContinue = false + return false + } + return true + }) + return shouldContinue + }) + }) +} + +// Distinct returns a stream consisting of the distinct elements of this stream. +// For comparable types, deduplication uses hash set lookup. For non-comparable types (e.g. slices), +// it safely falls back to deep equality checks. +func (this Stream[T]) Distinct() Stream[T] { + return this.newIntermediate(func(yield func(T) bool) { + seen := make(map[any]struct{}) + var nonComparableSeen []T + + this.seq(func(item T) bool { + v := any(item) + if v == nil { + if _, ok := seen[nil]; !ok { + seen[nil] = struct{}{} + return yield(item) + } + return true + } + + if reflect.ValueOf(v).Comparable() { + if _, ok := seen[v]; !ok { + seen[v] = struct{}{} + return yield(item) + } + return true + } + + // Non-comparable fallback + for _, prev := range nonComparableSeen { + if reflect.DeepEqual(prev, item) { + return true + } + } + nonComparableSeen = append(nonComparableSeen, item) + return yield(item) + }) + }) +} + +// DistinctBy returns a stream consisting of distinct elements according to the key returned by keySelector. +// Note: This method leverages Go 1.27's type parameters on struct methods. +func (this Stream[T]) DistinctBy[K comparable](keySelector func(T) K) Stream[T] { + return this.newIntermediate(func(yield func(T) bool) { + seen := make(map[K]struct{}) + this.seq(func(item T) bool { + key := keySelector(item) + if _, ok := seen[key]; !ok { + seen[key] = struct{}{} + return yield(item) + } + return true + }) + }) +} + +// Peek returns a stream consisting of the elements of this stream, additionally performing the provided action +// on each element as elements are consumed from the resulting stream. +func (this Stream[T]) Peek(action func(T)) Stream[T] { + return this.newIntermediate(func(yield func(T) bool) { + this.seq(func(item T) bool { + action(item) + return yield(item) + }) + }) +} + +// Limit returns a stream consisting of the elements of this stream, truncated to be no longer than maxSize in length. +func (this Stream[T]) Limit(maxSize int64) Stream[T] { + if maxSize <= 0 { + return this.newIntermediate(func(_ func(T) bool) {}) + } + return this.newIntermediate(func(yield func(T) bool) { + var count int64 + this.seq(func(item T) bool { + if count < maxSize { + count++ + return yield(item) && count < maxSize + } + return false + }) + }) +} + +// Skip returns a stream consisting of the remaining elements of this stream after discarding the first n elements. +func (this Stream[T]) Skip(n int64) Stream[T] { + if n <= 0 { + return this + } + return this.newIntermediate(func(yield func(T) bool) { + var count int64 + this.seq(func(item T) bool { + if count < n { + count++ + return true + } + return yield(item) + }) + }) +} + +// TakeWhile returns a stream consisting of the longest prefix of elements of this stream that match the given predicate. +func (this Stream[T]) TakeWhile(predicate func(T) bool) Stream[T] { + return this.newIntermediate(func(yield func(T) bool) { + taking := true + this.seq(func(item T) bool { + if taking && predicate(item) { + return yield(item) + } + taking = false + return false + }) + }) +} + +// DropWhile returns a stream consisting of the remaining elements of this stream after dropping the longest prefix +// of elements that match the given predicate. +func (this Stream[T]) DropWhile(predicate func(T) bool) Stream[T] { + return this.newIntermediate(func(yield func(T) bool) { + dropping := true + this.seq(func(item T) bool { + if dropping { + if predicate(item) { + return true + } + dropping = false + } + return yield(item) + }) + }) +} + +// Unordered returns an equivalent stream that is unordered. +func (this Stream[T]) Unordered() Stream[T] { + return this +} + +// Sorted returns a stream consisting of the elements of this stream, sorted according to natural order +// or the provided comparator function (which returns negative if a < b, 0 if a == b, and positive if a > b). +// +// If no comparator is supplied and the elements are not naturally ordered, an IllegalArgumentError is raised. +func (this Stream[T]) Sorted(comparator ...func(a, b T) int) Stream[T] { + if len(comparator) > 0 { + comp := comparator[0] + return this.SortedFunc(func(a, b T) bool { + return comp(a, b) < 0 + }) + } + + return this.newIntermediate(func(yield func(T) bool) { + var items []T + this.seq(func(item T) bool { + items = append(items, item) + return true + }) + + if len(items) <= 1 { + for _, item := range items { + if !yield(item) { + return + } + } + return + } + + // Try natural ordering + less, err := getNaturalLess(items[0]) + if err != nil { + panic(err) + } + + slices.SortStableFunc(items, func(a, b T) int { + if less(a, b) { + return -1 + } + if less(b, a) { + return 1 + } + return 0 + }) + + for _, item := range items { + if !yield(item) { + return + } + } + }) +} + +// SortedFunc returns a stream consisting of the elements of this stream, sorted according to the provided less function. +func (this Stream[T]) SortedFunc(less func(a, b T) bool) Stream[T] { + return this.newIntermediate(func(yield func(T) bool) { + var items []T + this.seq(func(item T) bool { + items = append(items, item) + return true + }) + + if len(items) > 1 { + slices.SortStableFunc(items, func(a, b T) int { + if less(a, b) { + return -1 + } + if less(b, a) { + return 1 + } + return 0 + }) + } + + for _, item := range items { + if !yield(item) { + return + } + } + }) +} + +// SortedBy returns a stream consisting of the elements of this stream, sorted in ascending order +// by the key returned from the keySelector function. +// Note: This method leverages Go 1.27's type parameters on struct methods. +func (this Stream[T]) SortedBy[K OrderedConstraint](keySelector func(T) K) Stream[T] { + return this.SortedFunc(func(a, b T) bool { + return keySelector(a) < keySelector(b) + }) +} + +// SortedByDesc returns a stream consisting of the elements of this stream, sorted in descending order +// by the key returned from the keySelector function. +// Note: This method leverages Go 1.27's type parameters on struct methods. +func (this Stream[T]) SortedByDesc[K OrderedConstraint](keySelector func(T) K) Stream[T] { + return this.SortedFunc(func(a, b T) bool { + return keySelector(a) > keySelector(b) + }) +} + +// getNaturalLess determines an ordering function for type T based on standard ordered types +// or Comparable interfaces. +func getNaturalLess[T any](sample T) (func(a, b T) bool, error) { + switch any(sample).(type) { + case int: + return func(a, b T) bool { return any(a).(int) < any(b).(int) }, nil + case int8: + return func(a, b T) bool { return any(a).(int8) < any(b).(int8) }, nil + case int16: + return func(a, b T) bool { return any(a).(int16) < any(b).(int16) }, nil + case int32: + return func(a, b T) bool { return any(a).(int32) < any(b).(int32) }, nil + case int64: + return func(a, b T) bool { return any(a).(int64) < any(b).(int64) }, nil + case uint: + return func(a, b T) bool { return any(a).(uint) < any(b).(uint) }, nil + case uint8: + return func(a, b T) bool { return any(a).(uint8) < any(b).(uint8) }, nil + case uint16: + return func(a, b T) bool { return any(a).(uint16) < any(b).(uint16) }, nil + case uint32: + return func(a, b T) bool { return any(a).(uint32) < any(b).(uint32) }, nil + case uint64: + return func(a, b T) bool { return any(a).(uint64) < any(b).(uint64) }, nil + case uintptr: + return func(a, b T) bool { return any(a).(uintptr) < any(b).(uintptr) }, nil + case float32: + return func(a, b T) bool { return any(a).(float32) < any(b).(float32) }, nil + case float64: + return func(a, b T) bool { return any(a).(float64) < any(b).(float64) }, nil + case string: + return func(a, b T) bool { return any(a).(string) < any(b).(string) }, nil + } + + // Check for CompareTo + if comp, ok := any(sample).(interface{ CompareTo(T) int }); ok { + _ = comp + return func(a, b T) bool { + return any(a).(interface{ CompareTo(T) int }).CompareTo(b) < 0 + }, nil + } + + // Check for Less + if l, ok := any(sample).(interface{ Less(T) bool }); ok { + _ = l + return func(a, b T) bool { + return any(a).(interface{ Less(T) bool }).Less(b) + }, nil + } + + // Fall back to the underlying kind for named ordered types. + //nolint:exhaustive // other kinds are not naturally ordered and return the error below + switch reflect.ValueOf(sample).Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return func(a, b T) bool { return reflect.ValueOf(a).Int() < reflect.ValueOf(b).Int() }, nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return func(a, b T) bool { return reflect.ValueOf(a).Uint() < reflect.ValueOf(b).Uint() }, nil + case reflect.Float32, reflect.Float64: + return func(a, b T) bool { return reflect.ValueOf(a).Float() < reflect.ValueOf(b).Float() }, nil + case reflect.String: + return func(a, b T) bool { return reflect.ValueOf(a).String() < reflect.ValueOf(b).String() }, nil + } + + return nil, NewError("IllegalArgumentError", fmt.Sprintf("type %T is not naturally ordered; provide an explicit comparator or less function", sample)) +} diff --git a/pkg/util/stream/intermediate_test.go b/pkg/util/stream/intermediate_test.go new file mode 100644 index 0000000..d278dee --- /dev/null +++ b/pkg/util/stream/intermediate_test.go @@ -0,0 +1,495 @@ +package stream_test + +import ( + "fmt" + "iter" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/BeamFoundry/rays/pkg/util/stream" +) + +type person struct { + name string + age int +} + +// CompareTo orders people by ascending age, breaking ties by name. +func (p person) CompareTo(other person) int { + if p.age < other.age { + return -1 + } + if p.age > other.age { + return 1 + } + return strings.Compare(p.name, other.name) +} + +type lessItem int + +// Less orders lessItem values in ascending numeric order. +func (l lessItem) Less(other lessItem) bool { + return l < other +} + +type reverseComparable int + +// CompareTo orders reverseComparable values in descending numeric order. +func (v reverseComparable) CompareTo(other reverseComparable) int { + if v > other { + return -1 + } + if v < other { + return 1 + } + return 0 +} + +type reverseCompare int + +// CompareTo supplies a descending comparison for the small integers used in tests. +func (v reverseCompare) CompareTo(other reverseCompare) int { + return int(other - v) +} + +type reverseLess string + +// Less orders reverseLess values in descending lexicographic order. +func (v reverseLess) Less(other reverseLess) bool { + return v > other +} + +var _ = Describe("Intermediate Operations", func() { + It("filters elements", func() { + res := streamRangeClosed(1, 10). + Filter(func(x int) bool { return x%2 == 0 }). + ToSlice() + + Expect(res).To(Equal([]int{2, 4, 6, 8, 10})) + }) + + It("maps elements using method type parameters", func() { + // Map int to string + res := streamOf(1, 2, 3). + Map(func(x int) string { + return fmt.Sprintf("val:%d", x) + }). + ToSlice() + + Expect(res).To(Equal([]string{"val:1", "val:2", "val:3"})) + + // Map string to struct + people := streamOf("Alice", "Bob"). + Map(func(name string) person { + return person{name: name, age: len(name) * 10} + }). + ToSlice() + + Expect(people).To(Equal([]person{ + {name: "Alice", age: 50}, + {name: "Bob", age: 30}, + })) + }) + + It("flat-maps streams using method type parameters", func() { + res := streamOf(1, 2, 3). + FlatMap(func(x int) Stream[int] { + return streamRangeClosed(1, x) + }). + ToSlice() + + Expect(res).To(Equal([]int{1, 1, 2, 1, 2, 3})) + }) + + It("flat-maps slices using method type parameters", func() { + res := streamOf("foo bar", "hello world"). + FlatMapSlice(func(s string) []string { + return strings.Split(s, " ") + }). + ToSlice() + + Expect(res).To(Equal([]string{"foo", "bar", "hello", "world"})) + }) + + It("flat-maps iterator sequences using method type parameters", func() { + res := streamOf(1, 2). + FlatMapSeq(func(x int) iter.Seq[string] { + return func(yield func(string) bool) { + yield(fmt.Sprintf("%d-A", x)) + yield(fmt.Sprintf("%d-B", x)) + } + }). + ToSlice() + + Expect(res).To(Equal([]string{"1-A", "1-B", "2-A", "2-B"})) + }) + + It("deduplicates elements with Distinct", func() { + res := streamOf(1, 2, 2, 3, 1, 4, 3, 5). + Distinct(). + ToSlice() + + Expect(res).To(Equal([]int{1, 2, 3, 4, 5})) + + // Non-comparable types (slices) + slicesList := streamOf([]int{1, 2}, []int{3}, []int{1, 2}, []int{4}). + Distinct(). + ToSlice() + + Expect(slicesList).To(Equal([][]int{{1, 2}, {3}, {4}})) + + // Struct with dynamically uncomparable field (interface holding a slice) + type dynamicContainer struct { + data any + } + containers := streamOf( + dynamicContainer{data: []int{1, 2}}, + dynamicContainer{data: []int{3}}, + dynamicContainer{data: []int{1, 2}}, + dynamicContainer{data: "comparable"}, + dynamicContainer{data: "comparable"}, + ). + Distinct(). + ToSlice() + + Expect(containers).To(Equal([]dynamicContainer{ + {data: []int{1, 2}}, + {data: []int{3}}, + {data: "comparable"}, + })) + + // Distinct with nil elements + val := 1 + ptrs := streamOf(nil, &val, nil).Distinct().ToSlice() + Expect(ptrs).To(Equal([]*int{nil, &val})) + }) + + It("deduplicates elements with DistinctBy using method type parameters", func() { + people := streamOf( + person{name: "Alice", age: 25}, + person{name: "Bob", age: 30}, + person{name: "Alice", age: 35}, + ). + DistinctBy(func(p person) string { return p.name }). + ToSlice() + + Expect(people).To(Equal([]person{ + {name: "Alice", age: 25}, + {name: "Bob", age: 30}, + })) + }) + + It("peeks elements during consumption", func() { + var peeked []int + res := streamOf(1, 2, 3). + Peek(func(x int) { peeked = append(peeked, x) }). + Map(func(x int) int { return x * 10 }). + ToSlice() + + Expect(peeked).To(Equal([]int{1, 2, 3})) + Expect(res).To(Equal([]int{10, 20, 30})) + }) + + It("limits elements lazily", func() { + // Verify Limit short-circuits an infinite stream + res := streamIterate(1, func(x int) int { return x + 1 }). + Filter(func(x int) bool { return x%2 == 0 }). + Limit(3). + ToSlice() + + Expect(res).To(Equal([]int{2, 4, 6})) + + Expect(streamOf(1, 2, 3).Limit(0).ToSlice()).To(BeEmpty()) + Expect(streamOf(1, 2, 3).Limit(-5).ToSlice()).To(BeEmpty()) + }) + + It("skips elements", func() { + res := streamRangeClosed(1, 5). + Skip(2). + ToSlice() + + Expect(res).To(Equal([]int{3, 4, 5})) + Expect(streamOf(1, 2).Skip(10).ToSlice()).To(BeEmpty()) + Expect(streamOf(1, 2).Skip(0).ToSlice()).To(Equal([]int{1, 2})) + }) + + It("takes elements while predicate is satisfied", func() { + res := streamOf(2, 4, 6, 7, 8, 10). + TakeWhile(func(x int) bool { return x%2 == 0 }). + ToSlice() + + Expect(res).To(Equal([]int{2, 4, 6})) + }) + + It("drops elements while predicate is satisfied", func() { + res := streamOf(2, 4, 6, 7, 8, 10). + DropWhile(func(x int) bool { return x%2 == 0 }). + ToSlice() + + Expect(res).To(Equal([]int{7, 8, 10})) + }) + + It("sorts elements with natural order", func() { + Expect(streamOf(5, 3, 1, 4, 2).Sorted().ToSlice()).To(Equal([]int{1, 2, 3, 4, 5})) + Expect(streamOf("banana", "apple", "cherry").Sorted().ToSlice()).To(Equal([]string{"apple", "banana", "cherry"})) + + // Struct implementing Comparable + people := streamOf( + person{name: "Bob", age: 30}, + person{name: "Alice", age: 25}, + person{name: "Charlie", age: 25}, + ).Sorted().ToSlice() + + Expect(people).To(Equal([]person{ + {name: "Alice", age: 25}, + {name: "Charlie", age: 25}, + {name: "Bob", age: 30}, + })) + }) + + It("naturally orders named numeric and string types", func() { + type namedInt int + type namedInt8 int8 + type namedInt16 int16 + type namedInt32 int32 + type namedInt64 int64 + type namedUint uint + type namedUint8 uint8 + type namedUint16 uint16 + type namedUint32 uint32 + type namedUint64 uint64 + type namedUintptr uintptr + type namedFloat32 float32 + type namedFloat64 float64 + type namedString string + + for _, pair := range [][]any{ + {namedInt(3), namedInt(-1)}, + {namedInt8(3), namedInt8(-1)}, + {namedInt16(3), namedInt16(-1)}, + {namedInt32(3), namedInt32(-1)}, + {namedInt64(1 << 62), namedInt64((1 << 62) - 1)}, + {namedUint(3), namedUint(1)}, + {namedUint8(3), namedUint8(1)}, + {namedUint16(3), namedUint16(1)}, + {namedUint32(3), namedUint32(1)}, + {namedUint64(1 << 63), namedUint64((1 << 63) - 1)}, + {namedUintptr(3), namedUintptr(1)}, + {namedFloat32(3.5), namedFloat32(1.2)}, + {namedFloat64(3.5), namedFloat64(1.2)}, + {namedString("banana"), namedString("apple")}, + } { + Expect(streamOfSlice(pair).Sorted().ToSlice()).To(Equal([]any{pair[1], pair[0]})) + } + Expect(streamOf(namedInt(3), namedInt(-1)).Min().OrElse(0)).To(Equal(namedInt(-1))) + Expect(streamOf(namedString("banana"), namedString("apple")).Max().OrElse("")).To(Equal(namedString("banana"))) + }) + + It("prefers CompareTo and Less over the underlying kind", func() { + Expect(streamOf(reverseCompare(1), reverseCompare(3)).Sorted().ToSlice()).To(Equal([]reverseCompare{3, 1})) + Expect(streamOf(reverseLess("apple"), reverseLess("banana")).Sorted().ToSlice()).To(Equal([]reverseLess{"banana", "apple"})) + }) + + It("sorts elements with custom comparator or less function", func() { + res := streamOf(1, 5, 2, 4, 3). + Sorted(func(a, b int) int { return b - a }). + ToSlice() + + Expect(res).To(Equal([]int{5, 4, 3, 2, 1})) + + resFunc := streamOf(1, 5, 2, 4, 3). + SortedFunc(func(a, b int) bool { return a > b }). + ToSlice() + + Expect(resFunc).To(Equal([]int{5, 4, 3, 2, 1})) + }) + + It("sorts elements using SortedBy and SortedByDesc with method type parameters", func() { + people := []person{ + {name: "Charlie", age: 35}, + {name: "Alice", age: 25}, + {name: "Bob", age: 30}, + } + + byAge := streamOfSlice(people). + SortedBy(func(p person) int { return p.age }). + ToSlice() + + Expect(byAge).To(Equal([]person{ + {name: "Alice", age: 25}, + {name: "Bob", age: 30}, + {name: "Charlie", age: 35}, + })) + + byAgeDesc := streamOfSlice(people). + SortedByDesc(func(p person) int { return p.age }). + ToSlice() + + Expect(byAgeDesc).To(Equal([]person{ + {name: "Charlie", age: 35}, + {name: "Bob", age: 30}, + {name: "Alice", age: 25}, + })) + }) + + It("supports Unordered", func() { + s := streamOf(1, 2, 3).Unordered() + Expect(s.ToSlice()).To(Equal([]int{1, 2, 3})) + }) + + It("sorts various primitive numeric types naturally", func() { + Expect(streamOf(int8(3), int8(1)).Sorted().ToSlice()).To(Equal([]int8{1, 3})) + Expect(streamOf(int16(3), int16(1)).Sorted().ToSlice()).To(Equal([]int16{1, 3})) + Expect(streamOf(int32(3), int32(1)).Sorted().ToSlice()).To(Equal([]int32{1, 3})) + Expect(streamOf(int64(3), int64(1)).Sorted().ToSlice()).To(Equal([]int64{1, 3})) + Expect(streamOf(uint(3), uint(1)).Sorted().ToSlice()).To(Equal([]uint{1, 3})) + Expect(streamOf(uint8(3), uint8(1)).Sorted().ToSlice()).To(Equal([]uint8{1, 3})) + Expect(streamOf(uint16(3), uint16(1)).Sorted().ToSlice()).To(Equal([]uint16{1, 3})) + Expect(streamOf(uint32(3), uint32(1)).Sorted().ToSlice()).To(Equal([]uint32{1, 3})) + Expect(streamOf(uint64(3), uint64(1)).Sorted().ToSlice()).To(Equal([]uint64{1, 3})) + Expect(streamOf(uintptr(3), uintptr(1)).Sorted().ToSlice()).To(Equal([]uintptr{1, 3})) + Expect(streamOf(float32(3.5), float32(1.2)).Sorted().ToSlice()).To(Equal([]float32{1.2, 3.5})) + Expect(streamOf(float64(3.5), float64(1.2)).Sorted().ToSlice()).To(Equal([]float64{1.2, 3.5})) + }) + + It("naturally orders named numeric and string types in Sorted, Min, and Max", func() { + type namedInt int + type namedInt8 int8 + type namedInt16 int16 + type namedInt32 int32 + type namedInt64 int64 + type namedUint uint + type namedUint8 uint8 + type namedUint16 uint16 + type namedUint32 uint32 + type namedUint64 uint64 + type namedUintptr uintptr + type namedFloat32 float32 + type namedFloat64 float64 + type namedString string + + cases := []struct{ low, high any }{ + {namedInt(-3), namedInt(2)}, + {namedInt8(-3), namedInt8(2)}, + {namedInt16(-3), namedInt16(2)}, + {namedInt32(-3), namedInt32(2)}, + {namedInt64(-1 << 63), namedInt64(1<<63 - 1)}, + {namedUint(1), namedUint(3)}, + {namedUint8(1), namedUint8(3)}, + {namedUint16(1), namedUint16(3)}, + {namedUint32(1), namedUint32(3)}, + {namedUint64(1), namedUint64(1<<64 - 1)}, + {namedUintptr(1), namedUintptr(3)}, + {namedFloat32(-1.5), namedFloat32(2.5)}, + {namedFloat64(-1.5), namedFloat64(2.5)}, + {namedString("apple"), namedString("banana")}, + } + for _, tc := range cases { + By(fmt.Sprintf("ordering %T", tc.low)) + Expect(streamOf(tc.high, tc.low).Sorted().ToSlice()).To(Equal([]any{tc.low, tc.high})) + minVal, _ := streamOf(tc.high, tc.low).Min().Get() + maxVal, _ := streamOf(tc.low, tc.high).Max().Get() + Expect(minVal).To(Equal(tc.low)) + Expect(maxVal).To(Equal(tc.high)) + } + Expect(streamOf(namedInt(2), namedInt(-3)).Sorted().ToSlice()).To(Equal([]namedInt{-3, 2})) + }) + + It("uses CompareTo and Less before the underlying kind of named types", func() { + Expect(streamOf(reverseComparable(1), reverseComparable(3), reverseComparable(2)).Sorted().ToSlice()). + To(Equal([]reverseComparable{3, 2, 1})) + Expect(streamOf(reverseLess("a"), reverseLess("c"), reverseLess("b")).Sorted().ToSlice()). + To(Equal([]reverseLess{"c", "b", "a"})) + }) + + It("panics when sorting non-comparable type without comparator", func() { + type custom struct { + _ map[string]int + } + Expect(func() { + streamOf(custom{}, custom{}).Sorted().ToSlice() + }).To(Panic()) + }) + + It("handles edge cases in Limit, Skip, and Sorted", func() { + Expect(streamOf(1, 2, 3).Limit(0).ToSlice()).To(BeEmpty()) + Expect(streamOf(1, 2, 3).Limit(-1).ToSlice()).To(BeEmpty()) + + Expect(streamOf(1, 2).Skip(0).ToSlice()).To(Equal([]int{1, 2})) + Expect(streamOf(1, 2).Skip(-1).ToSlice()).To(Equal([]int{1, 2})) + + Expect(streamEmpty[int]().Sorted().ToSlice()).To(BeEmpty()) + Expect(streamOf(42).Sorted().ToSlice()).To(Equal([]int{42})) + + sortedPeople := streamOf(person{"Bob", 30}, person{"Alice", 25}).Sorted().ToSlice() + Expect(sortedPeople).To(Equal([]person{{"Alice", 25}, {"Bob", 30}})) + + lessSlice := streamOf(lessItem(5), lessItem(2), lessItem(8)).Sorted().ToSlice() + Expect(lessSlice).To(Equal([]lessItem{2, 5, 8})) + }) + + It("supports early termination in FlatMap, FlatMapSlice, and FlatMapSeq", func() { + fm := streamOf(1, 2, 3).FlatMap(func(x int) Stream[int] { + return streamOf(x, x*10) + }).Limit(1).ToSlice() + Expect(fm).To(Equal([]int{1})) + + fms := streamOf(1, 2, 3).FlatMapSlice(func(x int) []int { + return []int{x, x * 10} + }).Limit(1).ToSlice() + Expect(fms).To(Equal([]int{1})) + + fmseq := streamOf(1, 2, 3).FlatMapSeq(func(x int) iter.Seq[int] { + return func(yield func(int) bool) { + if !yield(x) { + return + } + yield(x * 10) + } + }).Limit(1).ToSlice() + Expect(fmseq).To(Equal([]int{1})) + }) + + It("supports early termination in Distinct, Sorted, and SortedFunc", func() { + d := streamOf(1, 2, 3).Distinct().Limit(1).ToSlice() + Expect(d).To(Equal([]int{1})) + + dNil := streamOf[any](nil, nil).Distinct().Limit(1).ToSlice() + Expect(dNil).To(Equal([]any{nil})) + + dNonComp := streamOf([]int{1}, []int{2}).Distinct().Limit(1).ToSlice() + Expect(dNonComp).To(Equal([][]int{{1}})) + + sMulti := streamOf(3, 1, 2).Sorted().Limit(1).ToSlice() + Expect(sMulti).To(Equal([]int{1})) + + sSingle := streamOf(1).Sorted().FindFirst() + val, present := sSingle.Get() + Expect(present).To(BeTrue()) + Expect(val).To(Equal(1)) + + sFunc := streamOf(3, 1, 2).SortedFunc(func(a, b int) bool { return a < b }).Limit(1).ToSlice() + Expect(sFunc).To(Equal([]int{1})) + + // Distinct with multiple nil elements + dNilAll := streamOf[any](nil, nil).Distinct().ToSlice() + Expect(dNilAll).To(Equal([]any{nil})) + + // Limit with sequence that continues after yield returns false + sloppySeq := func(yield func(int) bool) { + for i := range 5 { + yield(i) + } + } + Expect(streamOfSeq(sloppySeq).Limit(2).ToSlice()).To(Equal([]int{0, 1})) + + // Sorted with duplicate elements exercising equal comparison branch + sDup := streamOf(2, 2, 1).Sorted().ToSlice() + Expect(sDup).To(Equal([]int{1, 2, 2})) + + sFuncDup := streamOf(2, 2, 1).SortedFunc(func(a, b int) bool { return a < b }).ToSlice() + Expect(sFuncDup).To(Equal([]int{1, 2, 2})) + }) +}) diff --git a/pkg/util/stream/parallel_test.go b/pkg/util/stream/parallel_test.go new file mode 100644 index 0000000..bfce3b3 --- /dev/null +++ b/pkg/util/stream/parallel_test.go @@ -0,0 +1,400 @@ +package stream_test + +import ( + "runtime" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/BeamFoundry/rays/pkg/core" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/BeamFoundry/rays/pkg/util/stream" +) + +var _ = Describe("Parallel Streams", func() { + It("executes ForEach in parallel across goroutines", func() { + goroutines := sync.Map{} + var count atomic.Int64 + + streamRangeClosed(1, 1000). + Parallel(). + ForEach(func(x int) { + count.Add(1) + goroutines.Store(x%16, struct{}{}) + }) + + Expect(count.Load()).To(Equal(int64(1000))) + }) + + It("collects to list in parallel preserving all elements", func() { + res := streamRangeClosed(1, 500). + Parallel(). + Collect(Collectors{}.ToList[int]()) + + Expect(res.Size()).To(Equal(500)) + Expect(res.Contains(1)).To(BeTrue()) + Expect(res.Contains(250)).To(BeTrue()) + Expect(res.Contains(500)).To(BeTrue()) + }) + + It("preserves encounter order across independently accumulated batches", func() { + previous := runtime.GOMAXPROCS(2) + DeferCleanup(func() { runtime.GOMAXPROCS(previous) }) + collector := Collectors{}.ToList[int]() + accumulate := collector.Accumulator + secondBatchStarted := make(chan struct{}) + thirdBatchStarted := make(chan struct{}) + collector.Accumulator = func(acc core.List[int], item int) core.List[int] { + switch item { + case 1: + <-secondBatchStarted + case 65: + close(secondBatchStarted) + <-thirdBatchStarted + case 129: + close(thirdBatchStarted) + default: + } + return accumulate(acc, item) + } + expected := streamRangeClosed(1, 1025).ToSlice() + result := streamOfSlice(expected).Parallel().Collect(collector) + Expect(streamOfCollection(result).ToSlice()).To(Equal(expected)) + }) + + It("preserves Joining order across full and partial batches", func() { + previous := runtime.GOMAXPROCS(2) + DeferCleanup(func() { runtime.GOMAXPROCS(previous) }) + items := streamRangeClosed(1, 1025).Map(strconv.Itoa).ToSlice() + Expect(streamOfSlice(items).Parallel().Collect(Collectors{}.Joining(",", "[", "]"))).To(Equal("[" + strings.Join(items, ",") + "]")) + Expect(streamEmpty[string]().Parallel().Collect(Collectors{}.Joining(",", "[", "]"))).To(Equal("[]")) + }) + + DescribeTable("recovers worker panics, drains input, and closes the stream", + func(operation func(Stream[int])) { + previous := runtime.GOMAXPROCS(2) + DeferCleanup(func() { runtime.GOMAXPROCS(previous) }) + closed := false + stream := streamRangeClosed(1, 4096).Parallel().OnClose(func() { closed = true }) + Expect(func() { operation(stream) }).To(PanicWith("worker-panic")) + Expect(closed).To(BeTrue()) + }, + Entry("ForEach", func(s Stream[int]) { s.ForEach(func(int) { panic("worker-panic") }) }), + Entry("AnyMatch", func(s Stream[int]) { s.AnyMatch(func(int) bool { panic("worker-panic") }) }), + Entry("AllMatch", func(s Stream[int]) { s.AllMatch(func(int) bool { panic("worker-panic") }) }), + Entry("Collect accumulator", func(s Stream[int]) { + s.Collect(Collectors{}.SummingInt(func(int) int { panic("worker-panic") })) + }), + Entry("Collect supplier", func(s Stream[int]) { + collector := Collectors{}.ToSlice[int]() + collector.Supplier = func() []int { panic("worker-panic") } + s.Collect(collector) + }), + ) + + DescribeTable("closes channels and waits when a match producer panics", + func(operation func(Stream[int])) { + stream := streamOfSeq(func(yield func(int) bool) { + yield(1) + panic("seq-panic") + }).Parallel() + Expect(func() { operation(stream) }).To(PanicWith("seq-panic")) + }, + Entry("AnyMatch", func(s Stream[int]) { s.AnyMatch(func(int) bool { return false }) }), + Entry("AllMatch", func(s Stream[int]) { s.AllMatch(func(int) bool { return true }) }), + ) + + It("preserves encounter order across batches for ordered collectors", func() { + previous := runtime.GOMAXPROCS(2) + DeferCleanup(runtime.GOMAXPROCS, previous) + + for _, size := range []int{0, 1, 63, 64, 65, 1025} { + expected := streamRange(0, size).ToSlice() + Expect(streamRange(0, size).Parallel().Collect(Collectors{}.ToSlice[int]())).To(Equal(expected)) + Expect(streamRange(0, size).Parallel().Collect(Collectors{}.ToList[int]()).ToSlice()).To(Equal(expected)) + words := streamOfSlice(expected).Map(strconv.Itoa).ToSlice() + Expect(streamOfSlice(words).Parallel().Collect(Collectors{}.Joining(",", "[", "]"))). + To(Equal("[" + strings.Join(words, ",") + "]")) + } + }) + + It("preserves encounter order when a worker processes later batches first", func() { + previous := runtime.GOMAXPROCS(2) + DeferCleanup(runtime.GOMAXPROCS, previous) + + laterBatch := make(chan struct{}) + collector := Collectors{}.Mapping(func(x int) int { + if x == 0 { + <-laterBatch + } + if x == 128 { + close(laterBatch) + } + return x + }, Collectors{}.ToSlice[int]()) + Expect(streamRange(0, 1025).Parallel().Collect(collector)). + To(Equal(streamRange(0, 1025).ToSlice())) + }) + + DescribeTable("recovers worker panics and drains input before closing the stream", + func(operation func(Stream[int])) { + previous := runtime.GOMAXPROCS(2) + DeferCleanup(runtime.GOMAXPROCS, previous) + + closes := 0 + seq := func(yield func(int) bool) { + for i := range 10000 { + if !yield(i) { + return + } + } + } + s := streamOfSeq(seq).Parallel().OnClose(func() { closes++ }) + Expect(func() { operation(s) }).To(PanicWith("worker-panic")) + Expect(closes).To(Equal(1)) + }, + Entry("ForEach", func(s Stream[int]) { + s.ForEach(func(int) { panic("worker-panic") }) + }), + Entry("AnyMatch", func(s Stream[int]) { + s.AnyMatch(func(int) bool { panic("worker-panic") }) + }), + Entry("AllMatch", func(s Stream[int]) { + s.AllMatch(func(int) bool { panic("worker-panic") }) + }), + Entry("Collect accumulator", func(s Stream[int]) { + collector := Collectors{}.ToSlice[int]() + collector.Accumulator = func([]int, int) []int { panic("worker-panic") } + s.Collect(collector) + }), + Entry("Collect supplier", func(s Stream[int]) { + collector := Collectors{}.ToSlice[int]() + collector.Supplier = func() []int { panic("worker-panic") } + s.Collect(collector) + }), + ) + + DescribeTable("closes input and waits for workers when the producer panics", + func(operation func(Stream[int], *atomic.Int64)) { + var processed atomic.Int64 + closes := 0 + seq := func(yield func(int) bool) { + for i := range 128 { + yield(i) + } + panic("producer-panic") + } + s := streamOfSeq(seq).Parallel().OnClose(func() { closes++ }) + Expect(func() { operation(s, &processed) }).To(PanicWith("producer-panic")) + Expect(processed.Load()).To(Equal(int64(128))) + Expect(closes).To(Equal(1)) + }, + Entry("AnyMatch", func(s Stream[int], processed *atomic.Int64) { + s.AnyMatch(func(int) bool { processed.Add(1); return false }) + }), + Entry("AllMatch", func(s Stream[int], processed *atomic.Int64) { + s.AllMatch(func(int) bool { processed.Add(1); return true }) + }), + Entry("Collect", func(s Stream[int], processed *atomic.Int64) { + s.Collect(Collectors{}.Mapping(func(x int) int { processed.Add(1); return x }, Collectors{}.ToSlice[int]())) + }), + ) + + It("sums in parallel using SummingInt", func() { + sum := streamRangeClosed(1, 100). + Parallel(). + Collect(Collectors{}.SummingInt(func(x int) int { return x })) + + // 100 * 101 / 2 = 5050 + Expect(sum).To(Equal(int64(5050))) + }) + + It("summarizes in parallel using SummarizingInt", func() { + stats := streamRangeClosed(1, 100). + Parallel(). + Collect(Collectors{}.SummarizingInt(func(x int) int { return x })) + + Expect(stats.Count).To(Equal(int64(100))) + Expect(stats.Sum).To(Equal(int64(5050))) + Expect(stats.Min).To(Equal(1)) + Expect(stats.Max).To(Equal(100)) + Expect(stats.Average()).To(Equal(50.5)) + }) + + It("groups in parallel using GroupingBy", func() { + grouped := streamRangeClosed(1, 100). + Parallel(). + Collect(Collectors{}.GroupingBy( + func(x int) string { + if x%2 == 0 { + return "even" + } + return "odd" + }, + Collectors{}.Counting[int](), + )) + + Expect(grouped["even"]).To(Equal(int64(50))) + Expect(grouped["odd"]).To(Equal(int64(50))) + }) + + It("evaluates AnyMatch, AllMatch, and NoneMatch in parallel", func() { + anyEven := streamRangeClosed(1, 1000). + Parallel(). + AnyMatch(func(x int) bool { return x == 777 }) + Expect(anyEven).To(BeTrue()) + + anyNotFound := streamRangeClosed(1, 1000). + Parallel(). + AnyMatch(func(x int) bool { return x == 9999 }) + Expect(anyNotFound).To(BeFalse()) + + allPositive := streamRangeClosed(1, 1000). + Parallel(). + AllMatch(func(x int) bool { return x > 0 }) + Expect(allPositive).To(BeTrue()) + + allEven := streamRangeClosed(1, 1000). + Parallel(). + AllMatch(func(x int) bool { return x%2 == 0 }) + Expect(allEven).To(BeFalse()) + + noneNegative := streamRangeClosed(1, 1000). + Parallel(). + NoneMatch(func(x int) bool { return x < 0 }) + Expect(noneNegative).To(BeTrue()) + }) + + It("finds any element in parallel", func() { + found := streamRangeClosed(1, 100). + Parallel(). + FindAny() + + Expect(found.IsPresent()).To(BeTrue()) + val, _ := found.Get() + Expect(val).To(BeNumerically(">=", 1)) + Expect(val).To(BeNumerically("<=", 100)) + }) + + It("captures callback panics in parallel ForEach and rethrows in the calling goroutine", func() { + Expect(func() { + streamRangeClosed(1, 100). + Parallel(). + ForEach(func(x int) { + if x == 50 { + panic("worker-panic") + } + }) + }).To(PanicWith("worker-panic")) + }) + + It("closes producer channel when sequence panics in parallel ForEach", func() { + panickingSeq := func(yield func(int) bool) { + yield(1) + yield(2) + panic("seq-panic") + } + Expect(func() { + streamOfSeq(panickingSeq). + Parallel(). + ForEach(func(_ int) {}) + }).To(PanicWith("seq-panic")) + }) + + It("closes batch channel when sequence panics in parallel Collect", func() { + panickingSeq := func(yield func(int) bool) { + yield(1) + yield(2) + panic("seq-panic-collect") + } + Expect(func() { + streamOfSeq(panickingSeq). + Parallel(). + Collect(Collectors{}.ToList[int]()) + }).To(PanicWith("seq-panic-collect")) + }) + + It("stops producing items when upstream sequence ignores early stop", func() { + sloppySeq := func(yield func(int) bool) { + for i := range 1000 { + yield(i) + if i%20 == 0 { + time.Sleep(20 * time.Microsecond) + } + } + } + + // ForEach with worker panic + var panicOnce atomic.Bool + Expect(func() { + streamOfSeq(sloppySeq).Parallel().ForEach(func(_ int) { + if panicOnce.CompareAndSwap(false, true) { + panic("foreach-worker-panic") + } + }) + }).To(Panic()) + + // AnyMatch with early match + matched := streamOfSeq(sloppySeq).Parallel().AnyMatch(func(n int) bool { + return n == 0 + }) + Expect(matched).To(BeTrue()) + + // AllMatch with early mismatch + all := streamOfSeq(sloppySeq).Parallel().AllMatch(func(n int) bool { + return n > 0 + }) + Expect(all).To(BeFalse()) + + // FindAny with found item + anyFound := streamOfSeq(sloppySeq).Parallel().FindAny() + Expect(anyFound.IsPresent()).To(BeTrue()) + + // Collect with worker panic + var collectPanicOnce atomic.Bool + collectCollector := Collectors{}.ToList[int]() + collectCollector.Accumulator = func(acc core.List[int], _ int) core.List[int] { + if collectPanicOnce.CompareAndSwap(false, true) { + panic("collect-producer-stop-panic") + } + return acc + } + Expect(func() { + streamOfSeq(sloppySeq).Parallel().Collect(collectCollector) + }).To(Panic()) + }) + + It("skips processing queued batches after a worker panic in parallel Collect", func() { + previous := runtime.GOMAXPROCS(128) + DeferCleanup(func() { runtime.GOMAXPROCS(previous) }) + var panicOnce atomic.Bool + collector := Collectors{}.ToList[int]() + collector.Accumulator = func(acc core.List[int], item int) core.List[int] { + if item > 1000 && panicOnce.CompareAndSwap(false, true) { + panic("collect-worker-panic") + } + return acc + } + Expect(func() { + streamRangeClosed(0, 50000).Parallel().Collect(collector) + }).To(PanicWith("collect-worker-panic")) + }) + + It("skips processing queued items after a worker panic in parallel ForEach", func() { + previous := runtime.GOMAXPROCS(128) + DeferCleanup(func() { runtime.GOMAXPROCS(previous) }) + var panicOnce atomic.Bool + Expect(func() { + streamRangeClosed(0, 50000).Parallel().ForEach(func(n int) { + if n > 1000 && panicOnce.CompareAndSwap(false, true) { + panic("foreach-worker-panic") + } + }) + }).To(PanicWith("foreach-worker-panic")) + }) +}) diff --git a/pkg/util/stream/statistics.go b/pkg/util/stream/statistics.go new file mode 100644 index 0000000..a894c7b --- /dev/null +++ b/pkg/util/stream/statistics.go @@ -0,0 +1,148 @@ +package stream + +import ( + "fmt" + "math" +) + +// IntSummaryStatistics is a state object for collecting statistics such as count, min, max, sum, and average. +type IntSummaryStatistics struct { + Count int64 + Sum int64 + Min int + Max int +} + +// NewIntSummaryStatistics constructs an empty instance with zero count, zero sum, +// math.MaxInt min, and math.MinInt max. +func NewIntSummaryStatistics() *IntSummaryStatistics { + return &IntSummaryStatistics{ + Count: 0, + Sum: 0, + Min: math.MaxInt, + Max: math.MinInt, + } +} + +// Accept records a new int value into the summary information. +func (this *IntSummaryStatistics) Accept(value int) { + if this.Count == 0 { + this.Min = value + this.Max = value + } + this.Count++ + this.Sum += int64(value) + if value < this.Min { + this.Min = value + } + if value > this.Max { + this.Max = value + } +} + +// Combine combines the state of another IntSummaryStatistics into this one. +func (this *IntSummaryStatistics) Combine(other *IntSummaryStatistics) { + if other == nil || other.Count == 0 { + return + } + if this.Count == 0 { + this.Count = other.Count + this.Sum = other.Sum + this.Min = other.Min + this.Max = other.Max + return + } + this.Count += other.Count + this.Sum += other.Sum + if other.Min < this.Min { + this.Min = other.Min + } + if other.Max > this.Max { + this.Max = other.Max + } +} + +// Average returns the arithmetic mean of values recorded, or zero if no values have been recorded. +func (this *IntSummaryStatistics) Average() float64 { + if this.Count == 0 { + return 0 + } + return float64(this.Sum) / float64(this.Count) +} + +// String returns a string representation of the statistics. +func (this *IntSummaryStatistics) String() string { + return fmt.Sprintf("IntSummaryStatistics{count=%d, sum=%d, min=%d, average=%f, max=%d}", + this.Count, this.Sum, this.Min, this.Average(), this.Max) +} + +// Float64SummaryStatistics is a state object for collecting statistics such as count, min, max, sum, and average. +type Float64SummaryStatistics struct { + Count int64 + Sum float64 + Min float64 + Max float64 +} + +// NewFloat64SummaryStatistics constructs an empty instance with zero count, zero sum, +// math.MaxFloat64 min, and -math.MaxFloat64 max. +func NewFloat64SummaryStatistics() *Float64SummaryStatistics { + return &Float64SummaryStatistics{ + Count: 0, + Sum: 0, + Min: math.MaxFloat64, + Max: -math.MaxFloat64, + } +} + +// Accept records a new float64 value into the summary information. +func (this *Float64SummaryStatistics) Accept(value float64) { + if this.Count == 0 { + this.Min = value + this.Max = value + } + this.Count++ + this.Sum += value + if value < this.Min { + this.Min = value + } + if value > this.Max { + this.Max = value + } +} + +// Combine combines the state of another Float64SummaryStatistics into this one. +func (this *Float64SummaryStatistics) Combine(other *Float64SummaryStatistics) { + if other == nil || other.Count == 0 { + return + } + if this.Count == 0 { + this.Count = other.Count + this.Sum = other.Sum + this.Min = other.Min + this.Max = other.Max + return + } + this.Count += other.Count + this.Sum += other.Sum + if other.Min < this.Min { + this.Min = other.Min + } + if other.Max > this.Max { + this.Max = other.Max + } +} + +// Average returns the arithmetic mean of values recorded, or zero if no values have been recorded. +func (this *Float64SummaryStatistics) Average() float64 { + if this.Count == 0 { + return 0 + } + return this.Sum / float64(this.Count) +} + +// String returns a string representation of the statistics. +func (this *Float64SummaryStatistics) String() string { + return fmt.Sprintf("Float64SummaryStatistics{count=%d, sum=%f, min=%f, average=%f, max=%f}", + this.Count, this.Sum, this.Min, this.Average(), this.Max) +} diff --git a/pkg/util/stream/stream.go b/pkg/util/stream/stream.go new file mode 100644 index 0000000..82d2052 --- /dev/null +++ b/pkg/util/stream/stream.go @@ -0,0 +1,275 @@ +// Package stream provides a lazy, thread-safe, expressive stream processing framework +// inspired by Java 8 streams and backed by Go iterators (iter.Seq). +// +// The package is designed to be cleanly dot-imported: +// +// import . "github.com/BeamFoundry/rays/pkg/util/stream" +package stream + +import ( + "iter" + "sync" + "sync/atomic" + + . "github.com/BeamFoundry/rays/internal/utils" + . "github.com/BeamFoundry/rays/pkg/core" + . "github.com/BeamFoundry/rays/pkg/lang" +) + +// Stream represents a lazy, potentially parallel sequence of elements supporting aggregate operations. +// Stream operations are divided into intermediate operations (which return a new Stream) and terminal +// operations (which consume the stream and produce a result or a side-effect). +// +// Streams are single-use: once a terminal operation has been executed, or an intermediate operation +// has linked this stream into a new pipeline stage, the stream is considered consumed and cannot be reused. +type Stream[T any] struct { + seq iter.Seq[T] + consumed *atomic.Bool + closed *atomic.Bool + mu *sync.Mutex + closeHandlers []func() + parallel bool +} + +// FromSeq returns a sequential Stream whose source is the given Go iterator sequence (iter.Seq[T]). +func (Stream[T]) FromSeq(seq iter.Seq[T]) Stream[T] { + return Stream[T]{ + seq: seq, + consumed: &atomic.Bool{}, + closed: &atomic.Bool{}, + mu: &sync.Mutex{}, + parallel: false, + } +} + +// FromEmpty returns an empty sequential Stream. +func (this Stream[T]) FromEmpty() Stream[T] { + return this.FromSeq(func(_ func(T) bool) {}) +} + +// From returns a sequential Stream containing the specified elements. +func (this Stream[T]) From(elements ...T) Stream[T] { + return this.FromSlice(elements) +} + +// FromSlice returns a sequential Stream whose source is the specified slice. +func (this Stream[T]) FromSlice(slice []T) Stream[T] { + return this.FromSeq(func(yield func(T) bool) { + for _, item := range slice { + if !yield(item) { + return + } + } + }) +} + +// FromCollection returns a sequential Stream whose source is the specified collection. +func (this Stream[T]) FromCollection(c Collection[T]) Stream[T] { + if IsNil(c) { + return this.FromEmpty() + } + return this.FromSeq(c.Seq()) +} + +// FromChan returns a sequential Stream whose elements are read from the specified channel +// until the channel is closed. +func (this Stream[T]) FromChan(ch <-chan T) Stream[T] { + return this.FromSeq(func(yield func(T) bool) { + for item := range ch { + if !yield(item) { + return + } + } + }) +} + +// FromGenerate returns an infinite sequential unordered Stream where each element is generated +// by the provided supplier function. +func (this Stream[T]) FromGenerate(supplier func() T) Stream[T] { + return this.FromSeq(func(yield func(T) bool) { + for { + if !yield(supplier()) { + return + } + } + }) +} + +// FromIterate returns an infinite sequential ordered Stream produced by iterative application of a function +// to an initial element seed: seed, f(seed), f(f(seed)), ... +func (this Stream[T]) FromIterate(seed T, f func(T) T) Stream[T] { + return this.FromSeq(func(yield func(T) bool) { + current := seed + for { + if !yield(current) { + return + } + current = f(current) + } + }) +} + +// FromIterateWithPredicate returns a sequential ordered Stream produced by iterative application of a function +// to an initial element seed, conditioned on satisfying the specified hasNext predicate. +func (this Stream[T]) FromIterateWithPredicate(seed T, hasNext func(T) bool, f func(T) T) Stream[T] { + return this.FromSeq(func(yield func(T) bool) { + for current := seed; hasNext(current); current = f(current) { + if !yield(current) { + return + } + } + }) +} + +// FromRange returns a sequential ordered Stream from startInclusive (inclusive) to endExclusive (exclusive) by an incremental step of 1. +func (Stream[T]) FromRange[I IntegerConstraint](startInclusive, endExclusive I) Stream[I] { + if startInclusive >= endExclusive { + return Stream[I]{}.FromEmpty() + } + return Stream[I]{}.FromSeq(func(yield func(I) bool) { + for i := startInclusive; i < endExclusive; i++ { + if !yield(i) { + return + } + } + }) +} + +// FromRangeClosed returns a sequential ordered Stream from startInclusive (inclusive) to endInclusive (inclusive) by an incremental step of 1. +func (Stream[T]) FromRangeClosed[I IntegerConstraint](startInclusive, endInclusive I) Stream[I] { + if startInclusive > endInclusive { + return Stream[I]{}.FromEmpty() + } + return Stream[I]{}.FromSeq(func(yield func(I) bool) { + for i := startInclusive; i <= endInclusive; i++ { + if !yield(i) { + return + } + if i == endInclusive { + break + } + } + }) +} + +// FromConcat creates a lazily concatenated Stream whose elements are all the elements of the first stream +// followed by all the elements of the second stream. +func (this Stream[T]) FromConcat(a, b Stream[T]) Stream[T] { + a.markConsumed() + b.markConsumed() + + isParallel := a.parallel || b.parallel + + var closeHandlers []func() + if a.mu != nil { + a.mu.Lock() + closeHandlers = append(closeHandlers, a.closeHandlers...) + a.mu.Unlock() + } + if b.mu != nil { + b.mu.Lock() + closeHandlers = append(closeHandlers, b.closeHandlers...) + b.mu.Unlock() + } + + res := this.FromSeq(func(yield func(T) bool) { + shouldContinue := true + a.seq(func(item T) bool { + if !yield(item) { + shouldContinue = false + return false + } + return true + }) + if !shouldContinue { + return + } + b.seq(func(item T) bool { + return yield(item) + }) + }) + res.parallel = isParallel + res.closeHandlers = closeHandlers + return res +} + +// markConsumed validates that the stream has not already been operated upon or closed, +// and marks it as consumed. Panics with an IllegalStateError if already consumed. +func (this Stream[T]) markConsumed() { + if this.consumed != nil && !this.consumed.CompareAndSwap(false, true) { + panic(NewError("IllegalStateError", "stream has already been operated upon or closed")) + } +} + +// newIntermediate creates a downstream Stream stage inheriting the parallel mode and close handlers +// from this stream, while marking this stream as consumed. +func (this Stream[T]) newIntermediate[R any](seq iter.Seq[R]) Stream[R] { + this.markConsumed() + + var handlers []func() + if this.mu != nil { + this.mu.Lock() + if len(this.closeHandlers) > 0 { + handlers = make([]func(), len(this.closeHandlers)) + copy(handlers, this.closeHandlers) + } + this.mu.Unlock() + } + + return Stream[R]{ + seq: seq, + closeHandlers: handlers, + parallel: this.parallel, + consumed: &atomic.Bool{}, + closed: &atomic.Bool{}, + mu: &sync.Mutex{}, + } +} + +// Parallel returns an equivalent stream that is parallel. +func (this Stream[T]) Parallel() Stream[T] { + this.parallel = true + return this +} + +// Sequential returns an equivalent stream that is sequential. +func (this Stream[T]) Sequential() Stream[T] { + this.parallel = false + return this +} + +// IsParallel returns whether this stream would execute terminal operations in parallel. +func (this Stream[T]) IsParallel() bool { + return this.parallel +} + +// OnClose returns an equivalent stream with an additional close handler. +// Close handlers are run when the Close() method is called on the stream. +func (this Stream[T]) OnClose(handler func()) Stream[T] { + if handler != nil && this.mu != nil { + this.mu.Lock() + this.closeHandlers = append(this.closeHandlers, sync.OnceFunc(handler)) + this.mu.Unlock() + } + return this +} + +// Close closes this stream, executing all registered close handlers. +// Calling Close is idempotent: handlers are executed at most once. +func (this Stream[T]) Close() { + if this.consumed != nil { + this.consumed.Store(true) + } + if this.closed != nil && this.closed.CompareAndSwap(false, true) { + if this.mu != nil { + this.mu.Lock() + handlers := this.closeHandlers + this.mu.Unlock() + for _, h := range handlers { + if h != nil { + h() + } + } + } + } +} diff --git a/pkg/util/stream/stream_example_test.go b/pkg/util/stream/stream_example_test.go new file mode 100644 index 0000000..8698201 --- /dev/null +++ b/pkg/util/stream/stream_example_test.go @@ -0,0 +1,356 @@ +//nolint:errcheck // Not printing error handling +package stream_test + +import ( + "fmt" + + . "github.com/BeamFoundry/rays/pkg/util/stream" +) + +// Example demonstrates filtering, mapping, and collecting a stream of integers. +func Example() { + // A pipeline filtering even numbers, doubling them, and collecting to a slice. + result := Stream[int]{}.From(1, 2, 3, 4, 5, 6). + Filter(func(n int) bool { return n%2 == 0 }). + Map(func(n int) int { return n * 2 }). + ToSlice() + + fmt.Println(result) + // Output: + // [4 8 12] +} + +// ExampleStream demonstrates filtering, sorting, and joining language names. +func ExampleStream() { + // Process a sequence of names: filter, sort by length, and join. + result := Stream[string]{}.From("Go", "Python", "Rust", "TypeScript"). + Filter(func(s string) bool { return len(s) > 2 }). + SortedBy(func(s string) int { return len(s) }). + Collect(Collectors{}.Joining(", ")) + + fmt.Println(result) + // Output: + // Rust, Python, TypeScript +} + +// ExampleStream_FromEmpty demonstrates creating an empty stream with zero elements. +func ExampleStream_FromEmpty() { + empty := Stream[int]{}.FromEmpty() + fmt.Println(empty.Count()) + // Output: + // 0 +} + +// ExampleStream_From demonstrates creating and consuming a stream of explicit values. +func ExampleStream_From() { + Stream[string]{}.From("Go", "Java", "Rust"). + ForEach(func(lang string) { + fmt.Println(lang) + }) + // Output: + // Go + // Java + // Rust +} + +// ExampleStream_FromSlice demonstrates counting elements from a slice. +func ExampleStream_FromSlice() { + words := []string{"hello", "world"} + count := Stream[string]{}.FromSlice(words).Count() + fmt.Println(count) + // Output: + // 2 +} + +// ExampleStream_FromCollection demonstrates filtering elements from a collection. +func ExampleStream_FromCollection() { + list := Stream[string]{}.From("apple", "banana", "cherry"). + Collect(Collectors{}.ToList[string]()) + count := Stream[string]{}.FromCollection(list). + Filter(func(s string) bool { return len(s) > 5 }). + Count() + fmt.Println(count) + // Output: + // 2 +} + +// ExampleStream_FromRange demonstrates filtering a range with an exclusive upper bound. +func ExampleStream_FromRange() { + evens := Stream[int]{}.FromRange(0, 6). + Filter(func(n int) bool { return n%2 == 0 }). + ToSlice() + fmt.Println(evens) + // Output: + // [0 2 4] +} + +// ExampleStream_FromRangeClosed demonstrates summing a range with an inclusive upper bound. +func ExampleStream_FromRangeClosed() { + sum := Stream[int]{}.FromRangeClosed(1, 5). + ReduceWithIdentity(0, func(a, b int) int { return a + b }) + fmt.Println(sum) + // Output: + // 15 +} + +// ExampleStream_FromConcat demonstrates concatenating two streams in encounter order. +func ExampleStream_FromConcat() { + s1 := Stream[int]{}.From(1, 2) + s2 := Stream[int]{}.From(3, 4) + combined := Stream[int]{}.FromConcat(s1, s2).ToSlice() + fmt.Println(combined) + // Output: + // [1 2 3 4] +} + +// ExampleStream_Filter demonstrates retaining words that match a length predicate. +func ExampleStream_Filter() { + words := Stream[string]{}.From("cat", "elephant", "dog", "bear"). + Filter(func(w string) bool { return len(w) > 3 }). + ToSlice() + fmt.Println(words) + // Output: + // [elephant bear] +} + +// ExampleStream_Map demonstrates mapping strings to their lengths. +func ExampleStream_Map() { + lengths := Stream[string]{}.From("one", "three", "six"). + Map(func(s string) int { return len(s) }). + ToSlice() + fmt.Println(lengths) + // Output: + // [3 5 3] +} + +// ExampleStream_FlatMap demonstrates flattening character streams from strings. +func ExampleStream_FlatMap() { + pairs := Stream[string]{}.From("ab", "cd"). + FlatMap(func(s string) Stream[rune] { + return Stream[rune]{}.FromSlice([]rune(s)) + }). + Map(func(r rune) string { return string(r) }). + ToSlice() + fmt.Println(pairs) + // Output: + // [a b c d] +} + +// ExampleStream_Distinct demonstrates removing duplicate integers. +func ExampleStream_Distinct() { + items := Stream[int]{}.From(1, 2, 2, 3, 1, 4). + Distinct(). + ToSlice() + fmt.Println(items) + // Output: + // [1 2 3 4] +} + +// ExampleStream_DistinctBy demonstrates retaining the first person for each unique ID. +func ExampleStream_DistinctBy() { + type Person struct { + Name string + ID int + } + people := Stream[Person]{}.From( + Person{Name: "Alice", ID: 1}, + Person{Name: "Bob", ID: 2}, + Person{Name: "Alice Duplicate", ID: 1}, + ).DistinctBy(func(p Person) int { return p.ID }). + Map(func(p Person) string { return p.Name }). + ToSlice() + fmt.Println(people) + // Output: + // [Alice Bob] +} + +// ExampleStream_Sorted demonstrates sorting integers in natural order. +func ExampleStream_Sorted() { + sorted := Stream[int]{}.From(4, 1, 3, 2). + Sorted(). + ToSlice() + fmt.Println(sorted) + // Output: + // [1 2 3 4] +} + +// ExampleStream_SortedBy demonstrates sorting products by ascending price. +func ExampleStream_SortedBy() { + type Product struct { + Name string + Price int + } + sorted := Stream[Product]{}.From( + Product{Name: "Book", Price: 15}, + Product{Name: "Pen", Price: 3}, + Product{Name: "Bag", Price: 40}, + ).SortedBy(func(p Product) int { return p.Price }). + Map(func(p Product) string { return p.Name }). + ToSlice() + fmt.Println(sorted) + // Output: + // [Pen Book Bag] +} + +// ExampleStream_Limit demonstrates retaining only the first three elements. +func ExampleStream_Limit() { + firstThree := Stream[int]{}.FromRange(1, 100). + Limit(3). + ToSlice() + fmt.Println(firstThree) + // Output: + // [1 2 3] +} + +// ExampleStream_Skip demonstrates discarding the first two elements. +func ExampleStream_Skip() { + afterTwo := Stream[int]{}.From(10, 20, 30, 40). + Skip(2). + ToSlice() + fmt.Println(afterTwo) + // Output: + // [30 40] +} + +// ExampleStream_TakeWhile demonstrates taking elements until a predicate first fails. +func ExampleStream_TakeWhile() { + taken := Stream[int]{}.From(1, 2, 3, 10, 4, 5). + TakeWhile(func(n int) bool { return n < 5 }). + ToSlice() + fmt.Println(taken) + // Output: + // [1 2 3] +} + +// ExampleStream_DropWhile demonstrates skipping elements until a predicate first fails. +func ExampleStream_DropWhile() { + dropped := Stream[int]{}.From(1, 2, 3, 10, 4, 5). + DropWhile(func(n int) bool { return n < 5 }). + ToSlice() + fmt.Println(dropped) + // Output: + // [10 4 5] +} + +// ExampleStream_Peek demonstrates observing elements as a pipeline consumes them. +func ExampleStream_Peek() { + var logged []int + res := Stream[int]{}.From(1, 2, 3). + Peek(func(n int) { logged = append(logged, n) }). + Map(func(n int) int { return n * 10 }). + ToSlice() + + fmt.Println("Result:", res) + fmt.Println("Logged:", logged) + // Output: + // Result: [10 20 30] + // Logged: [1 2 3] +} + +// ExampleStream_ToSlice demonstrates collecting stream elements into a slice. +func ExampleStream_ToSlice() { + slice := Stream[string]{}.From("a", "b", "c").ToSlice() + fmt.Println(slice) + // Output: + // [a b c] +} + +// ExampleStream_FindFirst demonstrates finding the first word that matches a filter. +func ExampleStream_FindFirst() { + match := Stream[string]{}.From("cat", "elephant", "dog"). + Filter(func(s string) bool { return len(s) > 3 }). + FindFirst() + if val, ok := match.Get(); ok { + fmt.Println(val) + } + // Output: + // elephant +} + +// ExampleStream_AnyMatch demonstrates testing whether a stream contains an even number. +func ExampleStream_AnyMatch() { + hasEven := Stream[int]{}.From(1, 3, 5, 6, 7). + AnyMatch(func(n int) bool { return n%2 == 0 }) + fmt.Println(hasEven) + // Output: + // true +} + +// ExampleStream_AllMatch demonstrates testing whether every number is positive. +func ExampleStream_AllMatch() { + allPositive := Stream[int]{}.From(1, 2, 3). + AllMatch(func(n int) bool { return n > 0 }) + fmt.Println(allPositive) + // Output: + // true +} + +// ExampleStream_NoneMatch demonstrates testing whether no numbers are negative. +func ExampleStream_NoneMatch() { + noneNegative := Stream[int]{}.From(1, 2, 3). + NoneMatch(func(n int) bool { return n < 0 }) + fmt.Println(noneNegative) + // Output: + // true +} + +// ExampleStream_ReduceWithIdentity demonstrates multiplying numbers with an identity of one. +func ExampleStream_ReduceWithIdentity() { + product := Stream[int]{}.From(2, 3, 4). + ReduceWithIdentity(1, func(a, b int) int { return a * b }) + fmt.Println(product) + // Output: + // 24 +} + +// ExampleStream_ReduceWithInitial demonstrates reducing integers into a string result. +func ExampleStream_ReduceWithInitial() { + res := Stream[int]{}.From(1, 2, 3). + ReduceWithInitial("Numbers:", func(acc string, n int) string { + return fmt.Sprintf("%s %d", acc, n) + }) + fmt.Println(res) + // Output: + // Numbers: 1 2 3 +} + +// ExampleStream_Min demonstrates finding the smallest integer in a stream. +func ExampleStream_Min() { + minVal := Stream[int]{}.From(5, 2, 8, 1, 9).Min() + if val, ok := minVal.Get(); ok { + fmt.Println(val) + } + // Output: + // 1 +} + +// ExampleStream_Max demonstrates finding the largest integer in a stream. +func ExampleStream_Max() { + maxVal := Stream[int]{}.From(5, 2, 8, 1, 9).Max() + if val, ok := maxVal.Get(); ok { + fmt.Println(val) + } + // Output: + // 9 +} + +// ExampleStream_Parallel demonstrates filtering and counting a stream marked for parallel execution. +func ExampleStream_Parallel() { + count := Stream[int]{}.FromRangeClosed(1, 100). + Parallel(). + Filter(func(n int) bool { return n%2 == 0 }). + Count() + fmt.Println(count) + // Output: + // 50 +} + +// ExampleStream_OnClose demonstrates running a close handler after stream consumption. +func ExampleStream_OnClose() { + stream := Stream[int]{}.From(1, 2, 3).OnClose(func() { + fmt.Println("stream closed") + }) + _ = stream.ToSlice() + // Output: + // stream closed +} diff --git a/pkg/util/stream/stream_suite_test.go b/pkg/util/stream/stream_suite_test.go new file mode 100644 index 0000000..72722e2 --- /dev/null +++ b/pkg/util/stream/stream_suite_test.go @@ -0,0 +1,14 @@ +package stream_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// TestStream runs the stream test suite. +func TestStream(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Pkg/Util/Stream Suite") +} diff --git a/pkg/util/stream/stream_test.go b/pkg/util/stream/stream_test.go new file mode 100644 index 0000000..8fca6aa --- /dev/null +++ b/pkg/util/stream/stream_test.go @@ -0,0 +1,203 @@ +package stream_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/BeamFoundry/rays/pkg/util/internal" + . "github.com/BeamFoundry/rays/pkg/util/stream" +) + +var _ = Describe("Stream Lifecycle & Constructors", func() { + var s Stream[int] + var sStr Stream[string] + + It("creates an empty stream with Stream.FromEmpty", func() { + Expect(s.FromEmpty().ToSlice()).To(BeEmpty()) + Expect(s.FromEmpty().Count()).To(Equal(int64(0))) + Expect(sStr.FromEmpty().ToSlice()).To(BeEmpty()) + Expect(sStr.FromEmpty().Count()).To(Equal(int64(0))) + }) + + It("creates a stream from Stream.From elements", func() { + st := s.From(1, 2, 3) + Expect(st.ToSlice()).To(Equal([]int{1, 2, 3})) + }) + + It("creates a stream from Stream.FromSlice", func() { + input := []string{"hello", "world"} + st := sStr.FromSlice(input) + Expect(st.ToSlice()).To(Equal(input)) + }) + + It("creates a stream from Stream.FromChan", func() { + ch := make(chan int, 3) + ch <- 10 + ch <- 20 + ch <- 30 + close(ch) + + st := s.FromChan(ch) + Expect(st.ToSlice()).To(Equal([]int{10, 20, 30})) + + ch2 := make(chan int, 3) + ch2 <- 1 + ch2 <- 2 + ch2 <- 3 + close(ch2) + Expect(s.FromChan(ch2).Limit(1).ToSlice()).To(Equal([]int{1})) + }) + + It("creates a stream from Stream.FromSeq", func() { + seq := func(yield func(int) bool) { + yield(100) + yield(200) + } + st := s.FromSeq(seq) + Expect(st.ToSlice()).To(Equal([]int{100, 200})) + }) + + It("creates a stream from Stream.FromCollection", func() { + list := s.From(1, 2, 3).Collect(Collectors{}.ToList[int]()) + st := s.FromCollection(list) + Expect(st.ToSlice()).To(Equal([]int{1, 2, 3})) + + nilStream := s.FromCollection(nil) + Expect(nilStream.Count()).To(Equal(int64(0))) + + var typedNilList *internal.AbstractList[int] + Expect(s.FromCollection(typedNilList).Count()).To(Equal(int64(0))) + + var typedNilSet *internal.AbstractSet[int] + Expect(s.FromCollection(typedNilSet).Count()).To(Equal(int64(0))) + }) + + It("creates a stream from Stream.FromGenerate", func() { + i := 0 + st := s.FromGenerate(func() int { + i++ + return i + }).Limit(5) + + Expect(st.ToSlice()).To(Equal([]int{1, 2, 3, 4, 5})) + }) + + It("creates a stream from Stream.FromIterate", func() { + st := s.FromIterate(1, func(x int) int { + return x * 2 + }).Limit(5) + + Expect(st.ToSlice()).To(Equal([]int{1, 2, 4, 8, 16})) + }) + + It("creates a stream from Stream.FromIterateWithPredicate", func() { + st := s.FromIterateWithPredicate(1, func(x int) bool { + return x <= 16 + }, func(x int) int { + return x * 2 + }) + + Expect(st.ToSlice()).To(Equal([]int{1, 2, 4, 8, 16})) + + // When seed fails predicate + Expect(s.FromIterateWithPredicate(100, func(x int) bool { return x < 50 }, func(x int) int { return x + 1 }).ToSlice()).To(BeEmpty()) + // Early break + Expect(s.FromIterateWithPredicate(1, func(x int) bool { return x < 100 }, func(x int) int { return x + 1 }).Limit(2).ToSlice()).To(Equal([]int{1, 2})) + }) + + It("creates a stream from Stream.FromRange and FromRangeClosed", func() { + Expect(s.FromRange(1, 5).ToSlice()).To(Equal([]int{1, 2, 3, 4})) + Expect(s.FromRange(5, 5).ToSlice()).To(BeEmpty()) + Expect(s.FromRange(6, 5).ToSlice()).To(BeEmpty()) + + Expect(s.FromRangeClosed(1, 5).ToSlice()).To(Equal([]int{1, 2, 3, 4, 5})) + Expect(s.FromRangeClosed(5, 5).ToSlice()).To(Equal([]int{5})) + Expect(s.FromRangeClosed(6, 5).ToSlice()).To(BeEmpty()) + }) + + It("concatenates two streams with Stream.FromConcat", func() { + s1 := s.From(1, 2) + s2 := s.From(3, 4, 5) + st := s.FromConcat(s1, s2) + Expect(st.ToSlice()).To(Equal([]int{1, 2, 3, 4, 5})) + + // Early exit during first stream + cEarly1 := s.FromConcat(s.From(1, 2), s.From(3, 4)).Limit(1).ToSlice() + Expect(cEarly1).To(Equal([]int{1})) + + // Early exit during second stream + cEarly2 := s.FromConcat(s.From(1, 2), s.From(3, 4)).Limit(3).ToSlice() + Expect(cEarly2).To(Equal([]int{1, 2, 3})) + + // Both stream close handlers execute + var closedA, closedB bool + sA := s.From(1).OnClose(func() { closedA = true }) + sB := s.From(2).OnClose(func() { closedB = true }) + concat := s.FromConcat(sA, sB) + concat.Close() + Expect(closedA).To(BeTrue()) + Expect(closedB).To(BeTrue()) + }) + + It("handles parallel and sequential flags", func() { + st := Stream[int]{}.From(1, 2, 3) + Expect(st.IsParallel()).To(BeFalse()) + + st = st.Parallel() + Expect(st.IsParallel()).To(BeTrue()) + + st = st.Sequential() + Expect(st.IsParallel()).To(BeFalse()) + }) + + It("executes OnClose handlers when closed or consumed", func() { + closed1 := false + closed2 := false + + st := Stream[int]{}.From(1, 2, 3). + OnClose(func() { closed1 = true }). + OnClose(func() { closed2 = true }) + + Expect(st.ToSlice()).To(Equal([]int{1, 2, 3})) + Expect(closed1).To(BeTrue()) + Expect(closed2).To(BeTrue()) + }) + + It("calling Close() multiple times is idempotent", func() { + count := 0 + st := Stream[int]{}.From(1, 2).OnClose(func() { count++ }) + st.Close() + st.Close() + Expect(count).To(Equal(1)) + }) + + It("executes shared OnClose handlers once when closing both source and derived streams", func() { + count := 0 + source := Stream[int]{}.From(1, 2, 3).OnClose(func() { count++ }) + derived := source.Filter(func(x int) bool { return x > 1 }) + source.Close() + derived.Close() + Expect(count).To(Equal(1)) + }) + + It("panics when operated upon more than once (single-use semantics)", func() { + st := Stream[int]{}.From(1, 2, 3) + Expect(st.ToSlice()).To(Equal([]int{1, 2, 3})) + + Expect(func() { + st.ToSlice() + }).To(Panic()) + + st2 := Stream[int]{}.From(1, 2, 3) + _ = st2.Filter(func(x int) bool { return x > 1 }) + Expect(func() { + st2.Filter(func(x int) bool { return x > 2 }) + }).To(Panic()) + + st3 := Stream[int]{}.From(1, 2, 3) + st3.Close() + Expect(func() { + st3.ToSlice() + }).To(Panic()) + }) +}) diff --git a/pkg/util/stream/stream_test_helpers_test.go b/pkg/util/stream/stream_test_helpers_test.go new file mode 100644 index 0000000..04f00a9 --- /dev/null +++ b/pkg/util/stream/stream_test_helpers_test.go @@ -0,0 +1,49 @@ +package stream_test + +import ( + "iter" + + . "github.com/BeamFoundry/rays/pkg/core" + . "github.com/BeamFoundry/rays/pkg/lang" + . "github.com/BeamFoundry/rays/pkg/util/stream" +) + +// streamOf is a test helper constructing a Stream using Stream[T]. +func streamOf[T any](elements ...T) Stream[T] { + return Stream[T]{}.From(elements...) +} + +// streamOfSlice is a test helper constructing a Stream using Stream[T]. +func streamOfSlice[T any](slice []T) Stream[T] { + return Stream[T]{}.FromSlice(slice) +} + +// streamOfSeq is a test helper constructing a Stream using Stream[T]. +func streamOfSeq[T any](seq iter.Seq[T]) Stream[T] { + return Stream[T]{}.FromSeq(seq) +} + +// streamOfCollection is a test helper constructing a Stream using Stream[T]. +func streamOfCollection[T any](c Collection[T]) Stream[T] { + return Stream[T]{}.FromCollection(c) +} + +// streamEmpty is a test helper constructing an empty Stream using Stream[T]. +func streamEmpty[T any]() Stream[T] { + return Stream[T]{}.FromEmpty() +} + +// streamRange is a test helper constructing a range Stream using Stream[T]. +func streamRange[T IntegerConstraint](startInclusive, endExclusive T) Stream[T] { + return Stream[T]{}.FromRange(startInclusive, endExclusive) +} + +// streamRangeClosed is a test helper constructing a closed range Stream using Stream[T]. +func streamRangeClosed[T IntegerConstraint](startInclusive, endInclusive T) Stream[T] { + return Stream[T]{}.FromRangeClosed(startInclusive, endInclusive) +} + +// streamIterate is a test helper constructing an iterate Stream using Stream[T]. +func streamIterate[T any](seed T, f func(T) T) Stream[T] { + return Stream[T]{}.FromIterate(seed, f) +} diff --git a/pkg/util/stream/terminal.go b/pkg/util/stream/terminal.go new file mode 100644 index 0000000..225d02a --- /dev/null +++ b/pkg/util/stream/terminal.go @@ -0,0 +1,594 @@ +package stream + +import ( + "iter" + "runtime" + "sync" + "sync/atomic" + + . "github.com/BeamFoundry/rays/pkg/lang" +) + +// ForEach performs an action for each element of this stream. +// If the stream is parallel, the action is executed concurrently across goroutines; +// the order of execution is not guaranteed. +func (this Stream[T]) ForEach(action func(T)) { + this.markConsumed() + defer this.Close() + + if this.parallel { + numWorkers := max(runtime.GOMAXPROCS(0), 2) + + var panicked atomic.Bool + ch := make(chan T, numWorkers*16) + var wg sync.WaitGroup + var panicOnce sync.Once + var firstPanic any + + for range numWorkers { + wg.Go(func() { + defer func() { + if r := recover(); r != nil { + panicked.Store(true) + panicOnce.Do(func() { + firstPanic = r + }) + for range ch { + } + } + }() + for item := range ch { + if panicked.Load() { + continue + } + action(item) + } + }) + } + + func() { + defer wg.Wait() + defer close(ch) + this.seq(func(item T) bool { + if panicked.Load() { + return false + } + ch <- item + return !panicked.Load() + }) + }() + + if firstPanic != nil { + panic(firstPanic) + } + return + } + + this.seq(func(item T) bool { + action(item) + return true + }) +} + +// ForEachOrdered performs an action for each element of this stream, in the encounter order of the stream. +func (this Stream[T]) ForEachOrdered(action func(T)) { + this.markConsumed() + defer this.Close() + + this.seq(func(item T) bool { + action(item) + return true + }) +} + +// ToSlice returns a slice containing the elements of this stream in encounter order. +func (this Stream[T]) ToSlice() []T { + this.markConsumed() + defer this.Close() + + var result []T + this.seq(func(item T) bool { + result = append(result, item) + return true + }) + if result == nil { + return make([]T, 0) + } + return result +} + +// Seq returns an iter.Seq[T] iterator over the elements of this stream. +// The stream is marked as consumed, and closing is automatically handled when iteration finishes. +func (this Stream[T]) Seq() iter.Seq[T] { + this.markConsumed() + return func(yield func(T) bool) { + defer this.Close() + this.seq(yield) + } +} + +// Count returns the count of elements in this stream. +func (this Stream[T]) Count() int64 { + this.markConsumed() + defer this.Close() + + var count int64 + this.seq(func(_ T) bool { + count++ + return true + }) + return count +} + +// AnyMatch returns whether any elements of this stream match the provided predicate. +// May not evaluate the predicate on all elements if not necessary for determining the result. +func (this Stream[T]) AnyMatch(predicate func(T) bool) bool { + this.markConsumed() + defer this.Close() + + if this.parallel { + numWorkers := max(runtime.GOMAXPROCS(0), 2) + + var matched atomic.Bool + var panicked atomic.Bool + ch := make(chan T, numWorkers*16) + var wg sync.WaitGroup + var panicOnce sync.Once + var firstPanic any + for range numWorkers { + wg.Go(func() { + defer func() { + if r := recover(); r != nil { + panicked.Store(true) + panicOnce.Do(func() { + firstPanic = r + }) + for range ch { + } + } + }() + for item := range ch { + if matched.Load() || panicked.Load() { + continue + } + if predicate(item) { + matched.Store(true) + } + } + }) + } + + func() { + defer wg.Wait() + defer close(ch) + this.seq(func(item T) bool { + if matched.Load() || panicked.Load() { + return false + } + ch <- item + return !matched.Load() && !panicked.Load() + }) + }() + if firstPanic != nil { + panic(firstPanic) + } + return matched.Load() + } + + found := false + this.seq(func(item T) bool { + if predicate(item) { + found = true + return false + } + return true + }) + return found +} + +// AllMatch returns whether all elements of this stream match the provided predicate. +// May not evaluate the predicate on all elements if not necessary for determining the result. +// If the stream is empty, true is returned and the predicate is not evaluated. +func (this Stream[T]) AllMatch(predicate func(T) bool) bool { + this.markConsumed() + defer this.Close() + + if this.parallel { + numWorkers := max(runtime.GOMAXPROCS(0), 2) + + var failed atomic.Bool + var panicked atomic.Bool + ch := make(chan T, numWorkers*16) + var wg sync.WaitGroup + var panicOnce sync.Once + var firstPanic any + for range numWorkers { + wg.Go(func() { + defer func() { + if r := recover(); r != nil { + panicked.Store(true) + panicOnce.Do(func() { + firstPanic = r + }) + for range ch { + } + } + }() + for item := range ch { + if failed.Load() || panicked.Load() { + continue + } + if !predicate(item) { + failed.Store(true) + } + } + }) + } + + func() { + defer wg.Wait() + defer close(ch) + this.seq(func(item T) bool { + if failed.Load() || panicked.Load() { + return false + } + ch <- item + return !failed.Load() && !panicked.Load() + }) + }() + if firstPanic != nil { + panic(firstPanic) + } + return !failed.Load() + } + + allMatch := true + this.seq(func(item T) bool { + if !predicate(item) { + allMatch = false + return false + } + return true + }) + return allMatch +} + +// NoneMatch returns whether no elements of this stream match the provided predicate. +// May not evaluate the predicate on all elements if not necessary for determining the result. +// If the stream is empty, true is returned and the predicate is not evaluated. +func (this Stream[T]) NoneMatch(predicate func(T) bool) bool { + return !this.AnyMatch(predicate) +} + +// FindFirst returns an Optional describing the first element of this stream in encounter order, +// or an empty Optional if the stream is empty. +func (this Stream[T]) FindFirst() Optional[T] { + this.markConsumed() + defer this.Close() + + var first T + found := false + + this.seq(func(item T) bool { + first = item + found = true + return false + }) + + if found { + return OptionalOf(first) + } + return OptionalEmpty[T]() +} + +// FindAny returns an Optional describing some element of the stream, or an empty Optional if the stream is empty. +// In parallel streams, returns whichever element is evaluated first by any goroutine. +func (this Stream[T]) FindAny() Optional[T] { + if !this.parallel { + return this.FindFirst() + } + + this.markConsumed() + defer this.Close() + + numWorkers := max(runtime.GOMAXPROCS(0), 2) + + var found atomic.Bool + var valLock sync.Mutex + var foundVal T + + ch := make(chan T, numWorkers*16) + var wg sync.WaitGroup + for range numWorkers { + wg.Go(func() { + for item := range ch { + if found.CompareAndSwap(false, true) { + valLock.Lock() + foundVal = item + valLock.Unlock() + } + } + }) + } + + func() { + defer wg.Wait() + defer close(ch) + this.seq(func(item T) bool { + if found.Load() { + return false + } + ch <- item + return !found.Load() + }) + }() + + if found.Load() { + valLock.Lock() + res := foundVal + valLock.Unlock() + return OptionalOf(res) + } + return OptionalEmpty[T]() +} + +// Reduce performs a reduction on the elements of this stream, using an associative accumulation function, +// and returns an Optional describing the reduced value, if any. +func (this Stream[T]) Reduce(accumulator func(a, b T) T) Optional[T] { + this.markConsumed() + defer this.Close() + + var result T + found := false + + this.seq(func(item T) bool { + if !found { + result = item + found = true + } else { + result = accumulator(result, item) + } + return true + }) + + if found { + return OptionalOf(result) + } + return OptionalEmpty[T]() +} + +// ReduceWithIdentity performs a reduction on the elements of this stream, using the provided identity value +// and an associative accumulation function, and returns the reduced value. +func (this Stream[T]) ReduceWithIdentity(identity T, accumulator func(a, b T) T) T { + this.markConsumed() + defer this.Close() + + result := identity + this.seq(func(item T) bool { + result = accumulator(result, item) + return true + }) + return result +} + +// ReduceWithInitial performs a reduction on the elements of this stream, starting from an initial value +// of type R and accumulating elements of type T into R. +// Note: This method leverages Go 1.27's type parameters on struct methods. +func (this Stream[T]) ReduceWithInitial[R any](identity R, accumulator func(R, T) R) R { + this.markConsumed() + defer this.Close() + + result := identity + this.seq(func(item T) bool { + result = accumulator(result, item) + return true + }) + return result +} + +// Collect performs a mutable reduction operation on the elements of this stream using a Collector. +// If the stream is parallel and the collector has a Combiner, collection will be performed concurrently across goroutines. +// Note: This method leverages Go 1.27's type parameters on struct methods. +func (this Stream[T]) Collect[R, A any](collector Collector[T, A, R]) R { + this.markConsumed() + defer this.Close() + + if this.parallel && collector.Combiner != nil { + numWorkers := max(runtime.GOMAXPROCS(0), 2) + + type batch struct { + items []T + index int + } + batchChan := make(chan batch, numWorkers*2) + + results := make(map[int]A) + var resultsMu sync.Mutex + var panicked atomic.Bool + var wg sync.WaitGroup + var panicOnce sync.Once + var firstPanic any + for range numWorkers { + wg.Go(func() { + defer func() { + if r := recover(); r != nil { + panicked.Store(true) + panicOnce.Do(func() { + firstPanic = r + }) + for range batchChan { + } + } + }() + for b := range batchChan { + if panicked.Load() { + continue + } + acc := collector.Supplier() + for _, item := range b.items { + acc = collector.Accumulator(acc, item) + } + resultsMu.Lock() + results[b.index] = acc + resultsMu.Unlock() + } + }) + } + + batchCount := 0 + func() { + defer wg.Wait() + defer close(batchChan) + currBatch := make([]T, 0, 64) + this.seq(func(item T) bool { + if panicked.Load() { + return false + } + currBatch = append(currBatch, item) + if len(currBatch) == cap(currBatch) { + batchChan <- batch{index: batchCount, items: currBatch} + batchCount++ + currBatch = make([]T, 0, 64) + } + return !panicked.Load() + }) + if !panicked.Load() && len(currBatch) > 0 { + batchChan <- batch{index: batchCount, items: currBatch} + batchCount++ + } + }() + if firstPanic != nil { + panic(firstPanic) + } + + finalAcc := collector.Supplier() + for i := range batchCount { + if i == 0 { + finalAcc = results[i] + } else { + finalAcc = collector.Combiner(finalAcc, results[i]) + } + } + return collector.Finisher(finalAcc) + } + + acc := collector.Supplier() + this.seq(func(item T) bool { + acc = collector.Accumulator(acc, item) + return true + }) + return collector.Finisher(acc) +} + +// CollectWith is a convenience method that accumulates elements into a result container using the provided supplier, +// accumulator, and optional combiner functions. +// Note: This method leverages Go 1.27's type parameters on struct methods. +func (this Stream[T]) CollectWith[R any](supplier func() R, accumulator func(R, T) R, combiner ...func(R, R) R) R { + return this.Collect(NewSimpleCollector(supplier, accumulator, combiner...)) +} + +// Min returns the minimum element of this stream according to the provided comparator +// (or natural ordering if no comparator is passed). +func (this Stream[T]) Min(comparator ...func(a, b T) int) Optional[T] { + if len(comparator) > 0 { + comp := comparator[0] + return this.Reduce(func(a, b T) T { + if comp(a, b) <= 0 { + return a + } + return b + }) + } + + // Natural ordering + var minVal T + found := false + var less func(a, b T) bool + + this.markConsumed() + defer this.Close() + + this.seq(func(item T) bool { + if !found { + var err error + less, err = getNaturalLess(item) + if err != nil { + panic(err) + } + minVal = item + found = true + } else if less(item, minVal) { + minVal = item + } + return true + }) + + if found { + return OptionalOf(minVal) + } + return OptionalEmpty[T]() +} + +// Max returns the maximum element of this stream according to the provided comparator +// (or natural ordering if no comparator is passed). +func (this Stream[T]) Max(comparator ...func(a, b T) int) Optional[T] { + if len(comparator) > 0 { + comp := comparator[0] + return this.Reduce(func(a, b T) T { + if comp(a, b) >= 0 { + return a + } + return b + }) + } + + // Natural ordering + var maxVal T + found := false + var less func(a, b T) bool + + this.markConsumed() + defer this.Close() + + this.seq(func(item T) bool { + if !found { + var err error + less, err = getNaturalLess(item) + if err != nil { + panic(err) + } + maxVal = item + found = true + } else if less(maxVal, item) { + maxVal = item + } + return true + }) + + if found { + return OptionalOf(maxVal) + } + return OptionalEmpty[T]() +} + +// MinBy returns the minimum element of this stream according to the key returned by keySelector. +// Note: This method leverages Go 1.27's type parameters on struct methods. +func (this Stream[T]) MinBy[K OrderedConstraint](keySelector func(T) K) Optional[T] { + return this.Reduce(func(a, b T) T { + if keySelector(a) <= keySelector(b) { + return a + } + return b + }) +} + +// MaxBy returns the maximum element of this stream according to the key returned by keySelector. +// Note: This method leverages Go 1.27's type parameters on struct methods. +func (this Stream[T]) MaxBy[K OrderedConstraint](keySelector func(T) K) Optional[T] { + return this.Reduce(func(a, b T) T { + if keySelector(a) >= keySelector(b) { + return a + } + return b + }) +} diff --git a/pkg/util/stream/terminal_test.go b/pkg/util/stream/terminal_test.go new file mode 100644 index 0000000..91a7794 --- /dev/null +++ b/pkg/util/stream/terminal_test.go @@ -0,0 +1,201 @@ +package stream_test + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/BeamFoundry/rays/pkg/util/stream" +) + +var _ = Describe("Terminal Operations", func() { + It("executes ForEach and ForEachOrdered", func() { + var collected []int + streamOf(1, 2, 3).ForEach(func(x int) { + collected = append(collected, x) + }) + Expect(collected).To(Equal([]int{1, 2, 3})) + + var ordered []int + streamOf(1, 2, 3).ForEachOrdered(func(x int) { + ordered = append(ordered, x) + }) + Expect(ordered).To(Equal([]int{1, 2, 3})) + }) + + It("converts stream to Go iter.Seq for range loops", func() { + s := streamOf(10, 20, 30) + var items []int + for v := range s.Seq() { + items = append(items, v) + } + Expect(items).To(Equal([]int{10, 20, 30})) + }) + + It("counts elements in a stream", func() { + Expect(Stream[int]{}.FromEmpty().Count()).To(Equal(int64(0))) + Expect(streamOf(1, 2, 3, 4, 5).Count()).To(Equal(int64(5))) + }) + + It("evaluates AnyMatch, AllMatch, and NoneMatch with short-circuiting", func() { + s := streamOf(1, 2, 3, 4, 5) + Expect(s.AnyMatch(func(x int) bool { return x == 3 })).To(BeTrue()) + + s2 := streamOf(1, 2, 3, 4, 5) + Expect(s2.AnyMatch(func(x int) bool { return x == 10 })).To(BeFalse()) + + s3 := streamOf(2, 4, 6, 8) + Expect(s3.AllMatch(func(x int) bool { return x%2 == 0 })).To(BeTrue()) + + s4 := streamOf(2, 4, 5, 8) + Expect(s4.AllMatch(func(x int) bool { return x%2 == 0 })).To(BeFalse()) + + s5 := streamOf(1, 3, 5) + Expect(s5.NoneMatch(func(x int) bool { return x%2 == 0 })).To(BeTrue()) + + s6 := streamOf(1, 2, 5) + Expect(s6.NoneMatch(func(x int) bool { return x%2 == 0 })).To(BeFalse()) + + // Empty streams + Expect(streamEmpty[int]().AnyMatch(func(_ int) bool { return true })).To(BeFalse()) + Expect(streamEmpty[int]().AllMatch(func(_ int) bool { return false })).To(BeTrue()) + Expect(streamEmpty[int]().NoneMatch(func(_ int) bool { return true })).To(BeTrue()) + + // Parallel match operations + Expect(streamRangeClosed(1, 1000).Parallel().AnyMatch(func(x int) bool { return x == 1 })).To(BeTrue()) + Expect(streamRangeClosed(1, 1000).Parallel().AllMatch(func(x int) bool { return x < 5 })).To(BeFalse()) + Expect(streamRangeClosed(1, 1000).Parallel().FindAny().IsPresent()).To(BeTrue()) + }) + + It("finds first and any elements as Optionals", func() { + first := streamOf("apple", "banana", "cherry").FindFirst() + Expect(first.IsPresent()).To(BeTrue()) + val, _ := first.Get() + Expect(val).To(Equal("apple")) + + emptyFirst := streamEmpty[string]().FindFirst() + Expect(emptyFirst.IsEmpty()).To(BeTrue()) + + anyElem := streamOf(42, 43, 44).FindAny() + Expect(anyElem.IsPresent()).To(BeTrue()) + valInt, _ := anyElem.Get() + Expect(valInt).To(Equal(42)) + + emptyAny := streamEmpty[int]().FindAny() + Expect(emptyAny.IsEmpty()).To(BeTrue()) + }) + + It("reduces elements with accumulator only", func() { + sumOpt := streamOf(1, 2, 3, 4).Reduce(func(a, b int) int { + return a + b + }) + Expect(sumOpt.IsPresent()).To(BeTrue()) + val, _ := sumOpt.Get() + Expect(val).To(Equal(10)) + + emptyOpt := streamEmpty[int]().Reduce(func(a, b int) int { + return a + b + }) + Expect(emptyOpt.IsEmpty()).To(BeTrue()) + }) + + It("reduces elements with identity", func() { + sum := streamOf(1, 2, 3, 4).ReduceWithIdentity(100, func(a, b int) int { + return a + b + }) + Expect(sum).To(Equal(110)) + + emptySum := streamEmpty[int]().ReduceWithIdentity(100, func(a, b int) int { + return a + b + }) + Expect(emptySum).To(Equal(100)) + }) + + It("reduces elements into a different type with ReduceWithInitial using method type parameters", func() { + // Reduce ints into a formatted string + str := streamOf(1, 2, 3).ReduceWithInitial("values:", func(acc string, v int) string { + return fmt.Sprintf("%s %d", acc, v) + }) + Expect(str).To(Equal("values: 1 2 3")) + + // Reduce strings into a map of lengths + counts := streamOf("a", "bb", "ccc").ReduceWithInitial(make(map[int]int), func(m map[int]int, s string) map[int]int { + m[len(s)]++ + return m + }) + Expect(counts).To(Equal(map[int]int{1: 1, 2: 1, 3: 1})) + }) + + It("finds min and max elements with natural ordering and custom comparators", func() { + minOpt := streamOf(5, 2, 8, 1, 9).Min() + Expect(minOpt.IsPresent()).To(BeTrue()) + minVal, _ := minOpt.Get() + Expect(minVal).To(Equal(1)) + + maxOpt := streamOf(5, 2, 8, 1, 9).Max() + Expect(maxOpt.IsPresent()).To(BeTrue()) + maxVal, _ := maxOpt.Get() + Expect(maxVal).To(Equal(9)) + + // Custom comparator + minStr := streamOf("banana", "apple", "pie").Min(func(a, b string) int { + return len(a) - len(b) + }) + Expect(minStr.IsPresent()).To(BeTrue()) + valStr, _ := minStr.Get() + Expect(valStr).To(Equal("pie")) + + maxStr := streamOf("banana", "apple", "pie").Max(func(a, b string) int { + return len(a) - len(b) + }) + Expect(maxStr.IsPresent()).To(BeTrue()) + valMaxStr, _ := maxStr.Get() + Expect(valMaxStr).To(Equal("banana")) + + minStr2 := streamOf("pie", "apple", "banana").Min(func(a, b string) int { + return len(a) - len(b) + }) + Expect(minStr2.IsPresent()).To(BeTrue()) + v2, _ := minStr2.Get() + Expect(v2).To(Equal("pie")) + + maxStr2 := streamOf("apple", "banana").Max(func(a, b string) int { + return len(a) - len(b) + }) + Expect(maxStr2.IsPresent()).To(BeTrue()) + vMax2, _ := maxStr2.Get() + Expect(vMax2).To(Equal("banana")) + + // Empty streams + Expect(streamEmpty[int]().Min().IsEmpty()).To(BeTrue()) + Expect(streamEmpty[int]().Max().IsEmpty()).To(BeTrue()) + Expect(streamEmpty[int]().Min(func(a, b int) int { return a - b }).IsEmpty()).To(BeTrue()) + Expect(streamEmpty[int]().Max(func(a, b int) int { return a - b }).IsEmpty()).To(BeTrue()) + + // Unorderable types panic on natural Min/Max + Expect(func() { streamOf([]int{1}, []int{2}).Min() }).To(Panic()) + Expect(func() { streamOf([]int{1}, []int{2}).Max() }).To(Panic()) + + // Parallel FindAny on empty stream + Expect(streamEmpty[int]().Parallel().FindAny().IsEmpty()).To(BeTrue()) + }) + + It("finds min and max using MinBy and MaxBy with method type parameters", func() { + people := []person{ + {name: "Alice", age: 25}, + {name: "Bob", age: 40}, + {name: "Charlie", age: 18}, + } + + youngest := streamOfSlice(people).MinBy(func(p person) int { return p.age }) + Expect(youngest.IsPresent()).To(BeTrue()) + y, _ := youngest.Get() + Expect(y.name).To(Equal("Charlie")) + + oldest := streamOfSlice(people).MaxBy(func(p person) int { return p.age }) + Expect(oldest.IsPresent()).To(BeTrue()) + o, _ := oldest.Get() + Expect(o.name).To(Equal("Bob")) + }) +}) diff --git a/pkg/web/funcmap.go b/pkg/web/funcmap.go index 0b99bae..069222d 100644 --- a/pkg/web/funcmap.go +++ b/pkg/web/funcmap.go @@ -4,6 +4,7 @@ import ( "html/template" "reflect" + . "github.com/BeamFoundry/rays/pkg/core" . "github.com/BeamFoundry/rays/pkg/i18n" . "github.com/BeamFoundry/rays/pkg/lang" . "github.com/BeamFoundry/rays/pkg/util"