feat: add Chunk, ContainsBy, UniqueBy, ParallelMap, map helpers

- Add `Chunk` to split slices into groups of size n.
- Add `ContainsBy` for predicate-based containment checks.
- Add `UniqueBy` to deduplicate slices by key selector.
- Add `ParallelMap` for concurrent mapping with context and error
handling.
- Add `maps.Keys` and `maps.Values` helpers for extracting map
keys/values.
- Update README and docs for new features.
- Refactor `Contains` to use `slices.Contains`.
This commit is contained in:
Ruidy 2025-09-01 18:03:38 -04:00
parent 8c78743f1a
commit 1031038d42
No known key found for this signature in database
GPG key ID: 705C24D202990805
21 changed files with 442 additions and 17 deletions

View file

@ -20,8 +20,8 @@ It is mostly a port from the `underscore.js` library based on generics brought b
Install the library using Install the library using
```shell ```sh
go get github.com/rjNemo/underscore@0.4.0 go get github.com/rjNemo/underscore@0.7.0
``` ```
Please check out the [examples](examples) to see how to use the library. 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 First clone the repository
```shell ```sh
git clone https://github.com/rjNemo/underscore.git git clone https://github.com/rjNemo/underscore.git
``` ```
Install dependencies Install dependencies
```shell ```sh
go mod download go mod download
``` ```
@ -80,7 +80,7 @@ And that's it.
To run the unit tests, you can simply run: To run the unit tests, you can simply run:
```shell ```sh
make test make test
``` ```
@ -92,7 +92,8 @@ make test
- `All` - `All`
- `Any` - `Any`
- `Contains` (only numerics values at the moment) - `Contains`
- `ContainsBy`
- `Each` - `Each`
- `Filter` - `Filter`
- `Flatmap` - `Flatmap`
@ -103,6 +104,9 @@ make test
- `Min` - `Min`
- `Partition` - `Partition`
- `Reduce` - `Reduce`
- `Unique`
- `UniqueBy`
- `Chunk`
### Pipe ### 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` Methods not returning a slice such as `Reduce`, `All`, `Any`, will break the `Chain`
and return `Value` instantly. 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] <nil>
}
```
### Subpackages
- `maps.Keys(m)` / `maps.Values(m)`: utilities to extract keys or values from maps.
## Built With ## Built With
- [Go](https://go.dev/) - Build fast, reliable, and efficient software at scale - [Go](https://go.dev/) - Build fast, reliable, and efficient software at scale

19
chunk.go Normal file
View file

@ -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
}

34
chunk_test.go Normal file
View file

@ -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))
}

View file

@ -1,11 +1,13 @@
package underscore package underscore
import "slices"
// Contains returns true if the value is present in the slice // Contains returns true if the value is present in the slice
func Contains[T comparable](values []T, value T) bool { func Contains[T comparable](values []T, value T) bool {
for _, v := range values { return slices.Contains(values, value)
if v == value { }
return true
} // ContainsBy returns true if any element in the slice satisfies the predicate.
} func ContainsBy[T any](values []T, predicate func(T) bool) bool {
return false return slices.ContainsFunc(values, predicate)
} }

25
contains_by_test.go Normal file
View file

@ -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" }))
}

View file

@ -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]]
}
```

View file

@ -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
}
```

View file

@ -3,7 +3,8 @@ title: "Map"
date: 2022-03-21T13:32:10-04:00 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 ```go
package main package main

View file

@ -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] <nil>
}
```

View file

@ -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}]
}
```

View file

@ -0,0 +1,6 @@
---
title: "Map Helpers"
date: 2025-09-01T00:00:00-00:00
---
Utilities for Go maps provided by the `maps` subpackage.

19
docs/content/maps/keys.md Normal file
View file

@ -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]
}
```

View file

@ -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"]
}
```

19
maps/keys_values.go Normal file
View file

@ -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
}

22
maps/keys_values_test.go Normal file
View file

@ -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)
}

View file

@ -1,5 +1,7 @@
package maps package maps
import "maps"
type M[K comparable, V any] map[K]V type M[K comparable, V any] map[K]V
// Map produces a new slice of values by mapping each value in the slice through // 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)) res := make(M[Q, W], len(m))
for k, v := range m { for k, v := range m {
mm := f(k, v) mm := f(k, v)
for k2, v2 := range mm { maps.Copy(res, mm)
res[k2] = v2
}
} }
return res return res
} }

View file

@ -22,7 +22,8 @@ func TestMap(t *testing.T) {
"alice": false, "alice": false,
"bob": false, "bob": false,
"clara": false, "clara": false,
"david": true} "david": true,
}
assert.Equal(t, want, m.Map(scores, hasWon)) assert.Equal(t, want, m.Map(scores, hasWon))
} }

72
parallel_map.go Normal file
View file

@ -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
}

33
parallel_map_test.go Normal file
View file

@ -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)
}

16
unique_by.go Normal file
View file

@ -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
}

20
unique_by_test.go Normal file
View file

@ -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)
}