mirror of
https://github.com/rjNemo/underscore
synced 2026-06-06 02:26:42 +00:00
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:
parent
8c78743f1a
commit
1031038d42
21 changed files with 442 additions and 17 deletions
41
README.md
41
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] <nil>
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
|
|
|
|||
19
chunk.go
Normal file
19
chunk.go
Normal 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
34
chunk_test.go
Normal 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))
|
||||
}
|
||||
14
contains.go
14
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)
|
||||
}
|
||||
|
|
|
|||
25
contains_by_test.go
Normal file
25
contains_by_test.go
Normal 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" }))
|
||||
}
|
||||
19
docs/content/collections/chunk.md
Normal file
19
docs/content/collections/chunk.md
Normal 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]]
|
||||
}
|
||||
```
|
||||
20
docs/content/collections/containsby.md
Normal file
20
docs/content/collections/containsby.md
Normal 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
|
||||
}
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
25
docs/content/collections/parallel_map.md
Normal file
25
docs/content/collections/parallel_map.md
Normal 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>
|
||||
}
|
||||
```
|
||||
24
docs/content/collections/unique_by.md
Normal file
24
docs/content/collections/unique_by.md
Normal 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}]
|
||||
}
|
||||
```
|
||||
6
docs/content/maps/_index.md
Normal file
6
docs/content/maps/_index.md
Normal 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
19
docs/content/maps/keys.md
Normal 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]
|
||||
}
|
||||
```
|
||||
19
docs/content/maps/values.md
Normal file
19
docs/content/maps/values.md
Normal 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
19
maps/keys_values.go
Normal 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
22
maps/keys_values_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
72
parallel_map.go
Normal file
72
parallel_map.go
Normal 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
33
parallel_map_test.go
Normal 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
16
unique_by.go
Normal 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
20
unique_by_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue