diff --git a/README.md b/README.md index b8349a0..54dc9cc 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ It is mostly a port from the `underscore.js` library based on generics brought b Install the library using -```shell -go get github.com/rjNemo/underscore@0.4.0 +```sh +go get github.com/rjNemo/underscore@0.7.0 ``` Please check out the [examples](examples) to see how to use the library. @@ -64,13 +64,13 @@ download page](https://go.dev/dl/) and install version `1.24` or beyond. First clone the repository -```shell +```sh git clone https://github.com/rjNemo/underscore.git ``` Install dependencies -```shell +```sh go mod download ``` @@ -80,7 +80,7 @@ And that's it. To run the unit tests, you can simply run: -```shell +```sh make test ``` @@ -92,7 +92,8 @@ make test - `All` - `Any` -- `Contains` (only numerics values at the moment) +- `Contains` +- `ContainsBy` - `Each` - `Filter` - `Flatmap` @@ -103,6 +104,9 @@ make test - `Min` - `Partition` - `Reduce` +- `Unique` +- `UniqueBy` +- `Chunk` ### Pipe @@ -112,6 +116,31 @@ you've finished the computation, call `Value` to retrieve the final value. Methods not returning a slice such as `Reduce`, `All`, `Any`, will break the `Chain` and return `Value` instantly. +### Concurrency + +- `ParallelMap(ctx, values, workers, fn)`: apply a function concurrently while preserving order and supporting context cancellation. + +```go +package main + +import ( + "context" + "fmt" + u "github.com/rjNemo/underscore" +) + +func main() { + out, err := u.ParallelMap(context.Background(), []int{1, 2, 3, 4}, 4, + func(ctx context.Context, n int) (int, error) { return n * n, nil }, + ) + fmt.Println(out, err) // [1 4 9 16] +} +``` + +### Subpackages + +- `maps.Keys(m)` / `maps.Values(m)`: utilities to extract keys or values from maps. + ## Built With - [Go](https://go.dev/) - Build fast, reliable, and efficient software at scale diff --git a/chunk.go b/chunk.go new file mode 100644 index 0000000..017edef --- /dev/null +++ b/chunk.go @@ -0,0 +1,19 @@ +package underscore + +// Chunk splits the input slice into groups of size n. +// If n <= 0, it returns nil. The final chunk may be smaller than n. +func Chunk[T any](values []T, n int) [][]T { + if n <= 0 { + return nil + } + l := len(values) + if l == 0 { + return [][]T{} + } + chunks := make([][]T, 0, (l+n-1)/n) + for i := 0; i < l; i += n { + j := min(i+n, l) + chunks = append(chunks, values[i:j]) + } + return chunks +} diff --git a/chunk_test.go b/chunk_test.go new file mode 100644 index 0000000..55376ef --- /dev/null +++ b/chunk_test.go @@ -0,0 +1,34 @@ +package underscore_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + u "github.com/rjNemo/underscore" +) + +func TestChunk(t *testing.T) { + in := []int{1, 2, 3, 4, 5} + got := u.Chunk(in, 2) + want := [][]int{{1, 2}, {3, 4}, {5}} + assert.Equal(t, want, got) +} + +func TestChunkLargeSize(t *testing.T) { + in := []int{1, 2, 3} + got := u.Chunk(in, 10) + want := [][]int{{1, 2, 3}} + assert.Equal(t, want, got) +} + +func TestChunkInvalidSize(t *testing.T) { + var in []int + assert.Nil(t, u.Chunk(in, 0)) + assert.Nil(t, u.Chunk(in, -1)) +} + +func TestChunkEmpty(t *testing.T) { + got := u.Chunk([]int{}, 1) + assert.Equal(t, 0, len(got)) +} diff --git a/contains.go b/contains.go index 7f81ff1..2ba7c35 100644 --- a/contains.go +++ b/contains.go @@ -1,11 +1,13 @@ package underscore +import "slices" + // Contains returns true if the value is present in the slice func Contains[T comparable](values []T, value T) bool { - for _, v := range values { - if v == value { - return true - } - } - return false + return slices.Contains(values, value) +} + +// ContainsBy returns true if any element in the slice satisfies the predicate. +func ContainsBy[T any](values []T, predicate func(T) bool) bool { + return slices.ContainsFunc(values, predicate) } diff --git a/contains_by_test.go b/contains_by_test.go new file mode 100644 index 0000000..aab05e5 --- /dev/null +++ b/contains_by_test.go @@ -0,0 +1,25 @@ +package underscore_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + u "github.com/rjNemo/underscore" +) + +func TestContainsBy(t *testing.T) { + nums := []int{1, 3, 5, 8} + assert.True(t, u.ContainsBy(nums, func(n int) bool { return n%2 == 0 })) + assert.False(t, u.ContainsBy(nums, func(n int) bool { return n < 0 })) +} + +func TestContainsByStruct(t *testing.T) { + type user struct { + ID int + Name string + } + users := []user{{1, "a"}, {2, "b"}, {3, "c"}} + assert.True(t, u.ContainsBy(users, func(u user) bool { return u.ID == 2 })) + assert.False(t, u.ContainsBy(users, func(u user) bool { return u.Name == "z" })) +} diff --git a/docs/content/collections/chunk.md b/docs/content/collections/chunk.md new file mode 100644 index 0000000..8772b9c --- /dev/null +++ b/docs/content/collections/chunk.md @@ -0,0 +1,19 @@ +--- +title: "Chunk" +date: 2025-09-01T00:00:00-00:00 +--- + +`Chunk` splits a slice into groups of size `n`. The last chunk may be smaller. + +```go +package main + +import ( + "fmt" + u "github.com/rjNemo/underscore" +) + +func main() { + fmt.Println(u.Chunk([]int{1,2,3,4,5}, 2)) // [[1 2] [3 4] [5]] +} +``` diff --git a/docs/content/collections/containsby.md b/docs/content/collections/containsby.md new file mode 100644 index 0000000..746426a --- /dev/null +++ b/docs/content/collections/containsby.md @@ -0,0 +1,20 @@ +--- +title: "ContainsBy" +date: 2025-09-01T00:00:00-00:00 +--- + +`ContainsBy` returns true if any element satisfies the predicate. + +```go +package main + +import ( + "fmt" + u "github.com/rjNemo/underscore" +) + +func main() { + nums := []int{1, 3, 5, 8} + fmt.Println(u.ContainsBy(nums, func(n int) bool { return n%2 == 0 })) // true +} +``` diff --git a/docs/content/collections/map.md b/docs/content/collections/map.md index ad63564..8fffa08 100644 --- a/docs/content/collections/map.md +++ b/docs/content/collections/map.md @@ -3,7 +3,8 @@ title: "Map" date: 2022-03-21T13:32:10-04:00 --- -`Map` produces a new slice of values by mapping each value in the slice through a transform function. +`Map` produces a new slice of values by mapping each value in the slice through a +transform function. ```go package main diff --git a/docs/content/collections/parallel_map.md b/docs/content/collections/parallel_map.md new file mode 100644 index 0000000..478d173 --- /dev/null +++ b/docs/content/collections/parallel_map.md @@ -0,0 +1,25 @@ +--- +title: "ParallelMap" +date: 2025-09-01T00:00:00-00:00 +--- + +`ParallelMap` applies a function to each element concurrently using a worker pool, +preserves order, and supports context cancellation. + +```go +package main + +import ( + "context" + "fmt" + u "github.com/rjNemo/underscore" +) + +func main() { + out, err := u.ParallelMap(context.Background(), + []int{1,2,3,4}, 4, func(ctx context.Context, n int) (int, error) { + return n*n, nil + }) + fmt.Println(out, err) // [1 4 9 16] +} +``` diff --git a/docs/content/collections/unique_by.md b/docs/content/collections/unique_by.md new file mode 100644 index 0000000..aca97eb --- /dev/null +++ b/docs/content/collections/unique_by.md @@ -0,0 +1,24 @@ +--- +title: "UniqueBy" +date: 2025-09-01T00:00:00-00:00 +--- + +`UniqueBy` returns a duplicate-free version of the slice using a key selector. +Order is preserved; the first occurrence of each key is kept. + +```go +package main + +import ( + "fmt" + u "github.com/rjNemo/underscore" +) + +type User struct{ ID int; Email string } + +func main() { + users := []User{{1, "a@x"}, {2, "b@x"}, {3, "a@x"}} + fmt.Println(u.UniqueBy(users, func(u User) string { return u.Email })) + // [{1 a@x} {2 b@x}] +} +``` diff --git a/docs/content/maps/_index.md b/docs/content/maps/_index.md new file mode 100644 index 0000000..8657744 --- /dev/null +++ b/docs/content/maps/_index.md @@ -0,0 +1,6 @@ +--- +title: "Map Helpers" +date: 2025-09-01T00:00:00-00:00 +--- + +Utilities for Go maps provided by the `maps` subpackage. diff --git a/docs/content/maps/keys.md b/docs/content/maps/keys.md new file mode 100644 index 0000000..d92d848 --- /dev/null +++ b/docs/content/maps/keys.md @@ -0,0 +1,19 @@ +--- +title: "Keys" +date: 2025-09-01T00:00:00-00:00 +--- + +`maps.Keys` returns the keys of a map in unspecified order. + +```go +package main + +import ( + "fmt" + m "github.com/rjNemo/underscore/maps" +) + +func main() { + fmt.Println(m.Keys(map[int]string{1:"a",2:"b"})) // e.g., [2 1] +} +``` diff --git a/docs/content/maps/values.md b/docs/content/maps/values.md new file mode 100644 index 0000000..9a47c23 --- /dev/null +++ b/docs/content/maps/values.md @@ -0,0 +1,19 @@ +--- +title: "Values" +date: 2025-09-01T00:00:00-00:00 +--- + +`maps.Values` returns the values of a map in unspecified order. + +```go +package main + +import ( + "fmt" + m "github.com/rjNemo/underscore/maps" +) + +func main() { + fmt.Println(m.Values(map[int]string{1:"a",2:"b"})) // e.g., ["b" "a"] +} +``` diff --git a/maps/keys_values.go b/maps/keys_values.go new file mode 100644 index 0000000..d169bc4 --- /dev/null +++ b/maps/keys_values.go @@ -0,0 +1,19 @@ +package maps + +// Keys returns the keys of the provided map in unspecified order. +func Keys[K comparable, V any](m map[K]V) []K { + ks := make([]K, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + return ks +} + +// Values returns the values of the provided map in unspecified order. +func Values[K comparable, V any](m map[K]V) []V { + vs := make([]V, 0, len(m)) + for _, v := range m { + vs = append(vs, v) + } + return vs +} diff --git a/maps/keys_values_test.go b/maps/keys_values_test.go new file mode 100644 index 0000000..922fa39 --- /dev/null +++ b/maps/keys_values_test.go @@ -0,0 +1,22 @@ +package maps_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + m "github.com/rjNemo/underscore/maps" +) + +func TestKeysValues(t *testing.T) { + in := map[int]string{1: "a", 2: "b", 3: "c"} + ks := m.Keys(in) + vs := m.Values(in) + + // Order is unspecified; verify content and lengths. + assert.Len(t, ks, 3) + assert.ElementsMatch(t, []int{1, 2, 3}, ks) + + assert.Len(t, vs, 3) + assert.ElementsMatch(t, []string{"a", "b", "c"}, vs) +} diff --git a/maps/map.go b/maps/map.go index ac3a404..4ee4680 100644 --- a/maps/map.go +++ b/maps/map.go @@ -1,5 +1,7 @@ package maps +import "maps" + type M[K comparable, V any] map[K]V // Map produces a new slice of values by mapping each value in the slice through @@ -8,9 +10,7 @@ func Map[K, Q comparable, V, W any](m M[K, V], f func(K, V) M[Q, W]) M[Q, W] { res := make(M[Q, W], len(m)) for k, v := range m { mm := f(k, v) - for k2, v2 := range mm { - res[k2] = v2 - } + maps.Copy(res, mm) } return res } diff --git a/maps/map_test.go b/maps/map_test.go index ee6e249..1cb77d5 100644 --- a/maps/map_test.go +++ b/maps/map_test.go @@ -22,7 +22,8 @@ func TestMap(t *testing.T) { "alice": false, "bob": false, "clara": false, - "david": true} + "david": true, + } assert.Equal(t, want, m.Map(scores, hasWon)) } diff --git a/parallel_map.go b/parallel_map.go new file mode 100644 index 0000000..caab917 --- /dev/null +++ b/parallel_map.go @@ -0,0 +1,72 @@ +package underscore + +import ( + "context" + "runtime" + "sync" +) + +// ParallelMap applies fn to each element of values using a worker pool and preserves order. +// If workers <= 0, it defaults to GOMAXPROCS. +// On error, the first error is returned and processing is canceled; partial results are discarded. +func ParallelMap[T, P any](ctx context.Context, values []T, workers int, fn func(context.Context, T) (P, error)) ([]P, error) { + if workers <= 0 { + workers = runtime.GOMAXPROCS(0) + } + type task struct { + idx int + val T + } + + res := make([]P, len(values)) + tasks := make(chan task) + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + var wg sync.WaitGroup + var once sync.Once + var firstErr error + + worker := func() { + defer wg.Done() + for t := range tasks { + select { + case <-ctx.Done(): + return + default: + } + v, err := fn(ctx, t.val) + if err != nil { + once.Do(func() { + firstErr = err + cancel() + }) + continue + } + res[t.idx] = v + } + } + + wg.Add(workers) + for i := 0; i < workers; i++ { + go worker() + } + +OUTER: + for i, v := range values { + select { + case <-ctx.Done(): + break OUTER + default: + tasks <- task{idx: i, val: v} + } + } + close(tasks) + wg.Wait() + + if firstErr != nil { + return nil, firstErr + } + return res, nil +} diff --git a/parallel_map_test.go b/parallel_map_test.go new file mode 100644 index 0000000..74a127f --- /dev/null +++ b/parallel_map_test.go @@ -0,0 +1,33 @@ +package underscore_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + + u "github.com/rjNemo/underscore" +) + +func TestParallelMap_OrderAndResult(t *testing.T) { + values := []int{1, 2, 3, 4, 5} + out, err := u.ParallelMap(context.Background(), values, 2, func(_ context.Context, n int) (int, error) { + return n * n, nil + }) + assert.NoError(t, err) + assert.Equal(t, []int{1, 4, 9, 16, 25}, out) +} + +func TestParallelMap_Error(t *testing.T) { + values := []int{1, 2, 3, 4, 5} + wantErr := errors.New("boom") + out, err := u.ParallelMap(context.Background(), values, 4, func(_ context.Context, n int) (int, error) { + if n == 3 { + return 0, wantErr + } + return n, nil + }) + assert.Error(t, err) + assert.Nil(t, out) +} diff --git a/unique_by.go b/unique_by.go new file mode 100644 index 0000000..2583376 --- /dev/null +++ b/unique_by.go @@ -0,0 +1,16 @@ +package underscore + +// UniqueBy returns a slice of unique values from the given slice using a key selector. +// The first occurrence of each key is kept and order is preserved. +func UniqueBy[T any, K comparable](values []T, key func(T) K) (uniques []T) { + seen := make(map[K]struct{}) + for _, v := range values { + k := key(v) + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + uniques = append(uniques, v) + } + return uniques +} diff --git a/unique_by_test.go b/unique_by_test.go new file mode 100644 index 0000000..6fdac31 --- /dev/null +++ b/unique_by_test.go @@ -0,0 +1,20 @@ +package underscore_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + u "github.com/rjNemo/underscore" +) + +func TestUniqueBy(t *testing.T) { + type user struct { + ID int + Email string + } + in := []user{{1, "a@x"}, {2, "b@x"}, {3, "a@x"}, {4, "c@x"}, {5, "b@x"}} + out := u.UniqueBy(in, func(u user) string { return u.Email }) + want := []user{{1, "a@x"}, {2, "b@x"}, {4, "c@x"}} + assert.Equal(t, want, out) +}