feat(sieve): add the remaining collection helpers and docs mentions
- Add TryMap, Filter, Reduce, KeyBy, GroupBy, Uniq, UniqBy, Partition and Find, plus the Set type built with SetOf (Has, Add, Slice). - Add unit tests at 100% statement coverage and a runnable example for every function and Set method. - Complete the module README: contracts, use cases, Pluck rationale, standard-library pointers and the full API reference. - docs/architecture/introduction.md: add a Utilities row for sieve. - docs/setup/coming-from-wintercms.md: map Laravel collection methods to sieve and narrow the not-provided row to the fluent collection object.
This commit is contained in:
@@ -30,6 +30,7 @@ Each framework module is one Go package under `modules/`, documented by its READ
|
||||
| Admin | [cabana](../../modules/cabana/README.md), [boardwalk](../../modules/boardwalk/README.md) |
|
||||
| Services | [phrasebook](../../modules/phrasebook/README.md), [postcard](../../modules/postcard/README.md), [conga](../../modules/conga/README.md), [lighthouse](../../modules/lighthouse/README.md), [flare](../../modules/flare/README.md), [sunscreen](../../modules/sunscreen/README.md) |
|
||||
| Console and tooling | [bonfire](../../modules/bonfire/README.md), [tide](../../modules/tide/README.md) |
|
||||
| Utilities | [sieve](../../modules/sieve/README.md) |
|
||||
|
||||
## What runs where
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ Each row names the WinterCMS concept, the SummerCMS identifiers that replace it,
|
||||
| `registerPermissions`, `registerNavigation` | `pact.HasPermissions` returning `pact.Permission` values, `pact.HasNavigation` returning `pact.NavigationItem` values | [Users and permissions](../backend/users-and-permissions.md), [pact](../../modules/pact/README.md) |
|
||||
| `version.yaml` and the `updates/` directory | `pact.HasMigrations` returning an ordered gormigrate set; `lagoon.Migrate` runs every plugin's set and `lagoon.RollbackLast` undoes the last one | [Migrations](../database/migrations.md), [lagoon](../../modules/lagoon/README.md) |
|
||||
| Eloquent models | GORM structs with lagoon helpers: `lagoon.Fill` for mass assignment, `lagoon.Validate` for rules, `lagoon.Jsonable` for JSON columns, `lagoon.Page` for pagination | [Models](../database/models.md), [Casts and validation](../database/casts-and-validation.md), [lagoon](../../modules/lagoon/README.md) |
|
||||
| Laravel collections: `map`, `filter`, `reduce`, `keyBy`, `groupBy`, `unique`, `partition`, `first` and `pluck` | Generic functions on plain slices and maps: `sieve.Map`, `sieve.TryMap`, `sieve.Filter`, `sieve.Reduce`, `sieve.KeyBy`, `sieve.GroupBy`, `sieve.Uniq`, `sieve.Partition` and `sieve.Find`. Pluck is `sieve.Map` with a field accessor, unique by key is `sieve.UniqBy`, `chunk` is the standard `slices.Chunk`, and sets are built with `sieve.SetOf` | [sieve](../../modules/sieve/README.md) |
|
||||
| `fields.yaml` and `columns.yaml` | The same YAML, embedded in the plugin through `pact.AdminAssets` and compiled at boot into a `cabana.CompiledController` | [Forms](../backend/forms.md), [Lists and filters](../backend/lists-and-filters.md), [cabana](../../modules/cabana/README.md) |
|
||||
| Backend controllers with the Form, List and Relation behaviours | A `pact.AdminController` returned from `pact.HasAdminControllers`; the generic admin API replaces the behaviours, and hooks such as `pact.FormBeforeCreate` and `pact.ListExtendQuery` replace behaviour overrides | [Admin controllers](../backend/admin-controllers.md), [cabana](../../modules/cabana/README.md) |
|
||||
| `routes.php` | `pact.HasRoutes`, declaring routes on a `pact.Router` with groups, middleware names and `pact.Router.Where` constraints | [Routing](../services/routing.md), [surf](../../modules/surf/README.md) |
|
||||
@@ -53,7 +54,7 @@ SummerCMS does not port the WinterCMS frontend or the PHP helpers that Go alread
|
||||
| The media manager | Not provided. Store uploads as model attachments; see [Attachments](../database/attachments.md). |
|
||||
| Import and export in backend lists | Not provided. |
|
||||
| Record sorting (the Reorder behaviour) | Not provided. |
|
||||
| Collections | Not provided. Use Go slices and the `slices` and `maps` packages. |
|
||||
| The fluent collection object (the Collection class and method chaining) | Not provided. Call the [sieve](../../modules/sieve/README.md) functions and the standard `slices` and `maps` packages on plain slices and maps. |
|
||||
| Behaviours and dynamic class extension | Not provided. Use Go interfaces and composition. |
|
||||
| Cache | Not provided. Use the Go standard library or a service another plugin publishes. |
|
||||
| Session | Not provided. The API is stateless and authenticates with tokens; see [Frontend and AJAX](../services/frontend-and-ajax.md). |
|
||||
|
||||
@@ -8,38 +8,123 @@ Generic, dependency-free collection helpers that the standard library slices and
|
||||
|
||||
## Overview
|
||||
|
||||
`sieve` gives plain Go slices the collection operations that WinterCMS code reaches for on Laravel collections. Every function is eager and never modifies its input, results never share a backing array with the input, callbacks run once per element in input order, and nil or empty input yields a non-nil empty result, so `encoding/json` writes `[]` rather than `null`.
|
||||
`sieve` gives plain Go slices the collection operations that WinterCMS code reaches for on Laravel collections, the objects that Eloquent queries return (`map`, `filter`, `reduce`, `keyBy`, `groupBy`, `unique`, `partition`, `first`). It is a set of generic functions rather than a fluent collection object, so type inference works from the slice argument and no wrapper type leaks into your models.
|
||||
|
||||
Four contracts hold for every function:
|
||||
|
||||
- It is eager and never modifies its input.
|
||||
- Its result never shares a backing array with the input, so changing or appending to a result leaves the caller's slice unchanged.
|
||||
- Callbacks run once per element, in input order. `sieve.TryMap` and `sieve.Find` stop early.
|
||||
- Nil or empty input yields a non-nil empty slice or map, so `encoding/json` writes `[]` or `{}` rather than `null`.
|
||||
|
||||
## Role in the framework
|
||||
|
||||
A dependency-free leaf with no imports, so any module, plugin or application can use it without import cycles.
|
||||
A dependency-free leaf with no imports, so any module, plugin or application can use it without import cycles. It complements the standard `slices` and `maps` packages rather than wrapping them: nothing here duplicates a function those packages already provide.
|
||||
|
||||
Use cases:
|
||||
|
||||
- Shape API responses from models, for example turning a slice of records into a slice of response structs.
|
||||
- Key records by ID to join two result sets without a nested loop.
|
||||
- Group records by a field, such as posts by author.
|
||||
- Deduplicate IDs before an `IN` query.
|
||||
- Check set membership, such as whether an ID is in an allow list.
|
||||
|
||||
## Features
|
||||
|
||||
- `sieve.Map` applies a function to every element and returns the results in order; with a field accessor it is the pluck operation.
|
||||
- `sieve.Map` applies a function to every element; with a field accessor it is the pluck operation.
|
||||
- `sieve.TryMap` maps with a function that can fail and stops at the first error, returning it unchanged.
|
||||
- `sieve.Filter` keeps the elements that match a predicate, and `sieve.Partition` splits a slice into the matches and the rest.
|
||||
- `sieve.Reduce` folds a slice from left to right into an accumulator of any type.
|
||||
- `sieve.KeyBy` builds a map from a key to an element (later items win), and `sieve.GroupBy` builds a map from a key to the elements with that key, in input order.
|
||||
- `sieve.Uniq` removes duplicates of comparable values, and `sieve.UniqBy` removes elements whose key was already seen; both keep the first occurrence.
|
||||
- `sieve.Find` returns the first element that matches a predicate, with a found flag.
|
||||
- `sieve.Set`, built with `sieve.SetOf`, is a plain map-backed set with `sieve.Set.Has`, `sieve.Set.Add` and `sieve.Set.Slice`.
|
||||
- Functions that keep the element type (`sieve.Filter`, `sieve.Uniq`, `sieve.UniqBy`, `sieve.Partition` and the groups of `sieve.GroupBy`) return the caller's named slice type.
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
package blog
|
||||
|
||||
import "git.golem15.com/golem15/summercms/modules/sieve"
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/sieve"
|
||||
)
|
||||
|
||||
type Post struct {
|
||||
ID int
|
||||
Title string
|
||||
ID int
|
||||
Author string
|
||||
Title string
|
||||
Published bool
|
||||
}
|
||||
|
||||
func titles(posts []Post) []string {
|
||||
return sieve.Map(posts, func(p Post) string { return p.Title })
|
||||
type PostResponse struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func shape(posts []Post, rawIDs []string) error {
|
||||
// Keep the published posts and turn them into response structs.
|
||||
published := sieve.Filter(posts, func(p Post) bool { return p.Published })
|
||||
body := sieve.Map(published, func(p Post) PostResponse {
|
||||
return PostResponse{ID: p.ID, Title: p.Title}
|
||||
})
|
||||
|
||||
// Key by ID for lookups, group by author, keep one post per author.
|
||||
byID := sieve.KeyBy(posts, func(p Post) int { return p.ID })
|
||||
byAuthor := sieve.GroupBy(posts, func(p Post) string { return p.Author })
|
||||
firstPerAuthor := sieve.UniqBy(posts, func(p Post) string { return p.Author })
|
||||
|
||||
// Split drafts from published posts, and find the first draft.
|
||||
live, drafts := sieve.Partition(posts, func(p Post) bool { return p.Published })
|
||||
firstDraft, found := sieve.Find(posts, func(p Post) bool { return !p.Published })
|
||||
|
||||
// Parse IDs from a query string, stopping at the first bad one.
|
||||
ids, err := sieve.TryMap(rawIDs, strconv.Atoi)
|
||||
if err != nil {
|
||||
return fmt.Errorf("blog: bad post id: %w", err)
|
||||
}
|
||||
wanted := sieve.SetOf(ids...)
|
||||
|
||||
fmt.Println(len(body), len(byID), len(byAuthor), len(firstPerAuthor),
|
||||
len(live), len(drafts), firstDraft.Title, found, wanted.Has(1))
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
There is no Pluck. Laravel's `pluck('title')` takes a field name as a string, which in Go would need reflection and would give up compile-time type checking. The typed equivalent is `sieve.Map(posts, func(p Post) string { return p.Title })`.
|
||||
|
||||
What the standard library already covers:
|
||||
|
||||
- `slices.Chunk` splits a slice into chunks lazily; use `slices.Collect(slices.Chunk(s, n))` for an eager `[][]T`.
|
||||
- `slices.Contains` and `slices.IndexFunc` test for and locate elements.
|
||||
- `slices.Sort` and `slices.SortFunc` sort in place.
|
||||
- `slices.Min` and `slices.Max` return the smallest and largest element.
|
||||
- `slices.Compact` removes consecutive duplicates only, in place.
|
||||
- `slices.DeleteFunc` filters in place.
|
||||
- `maps.Keys` and `maps.Values` iterate a map; wrap them in `slices.Sorted` for ordered output.
|
||||
|
||||
## API reference
|
||||
|
||||
| Identifier | Description |
|
||||
|------------|-------------|
|
||||
| `sieve.Map` | Returns f applied to each element, in input order. Nil input gives a non-nil empty slice. |
|
||||
| `sieve.TryMap` | Like Map with a fallible f. Stops at the first error and returns nil with that error unchanged. Nil input gives a non-nil empty slice and a nil error. |
|
||||
| `sieve.Filter` | Returns the elements for which keep is true, in input order, as the caller's slice type. Nil input or no matches gives a non-nil empty slice. |
|
||||
| `sieve.Reduce` | Folds the elements from left to right, starting from init. The accumulator type may differ from the element type. Nil input returns init. |
|
||||
| `sieve.KeyBy` | Returns a map from key to element; when two elements share a key, the later one wins. Nil input gives a non-nil empty map. |
|
||||
| `sieve.GroupBy` | Returns a map from key to the elements with that key, each group in input order and of the caller's slice type. Nil input gives a non-nil empty map. |
|
||||
| `sieve.Uniq` | Returns the elements without duplicates, keeping the first occurrence, in input order. Nil input gives a non-nil empty slice. |
|
||||
| `sieve.UniqBy` | Returns the elements whose key was not seen before, keeping the first occurrence of each key, in input order. Nil input gives a non-nil empty slice. |
|
||||
| `sieve.Partition` | Returns (matched, rest), both in input order and both non-nil, even for nil input. |
|
||||
| `sieve.Find` | Returns the first matching element and true; the predicate is not called after the first match. No match or nil input gives the zero value and false. |
|
||||
| `sieve.Set` | A set backed by `map[T]struct{}`; `len`, `range`, `delete` and `maps.Keys` work on it directly. Has and Slice work on a nil Set; Add on a nil Set panics. |
|
||||
| `sieve.SetOf` | Returns a new set holding the given items without duplicates. With no arguments it returns an empty, non-nil set that Add can use. |
|
||||
| `sieve.Set.Has` | Reports whether a value is in the set. A nil Set reports false. |
|
||||
| `sieve.Set.Add` | Adds one or more values; values already present are ignored. Panics on a nil Set. |
|
||||
| `sieve.Set.Slice` | Returns each element exactly once, in unspecified order. Non-nil even for an empty or nil Set. |
|
||||
|
||||
## Dependencies
|
||||
|
||||
@@ -53,4 +138,4 @@ func titles(posts []Post) []string {
|
||||
go test ./modules/sieve/...
|
||||
```
|
||||
|
||||
The tests are in-memory and need no external services.
|
||||
The tests are in-memory and need no external services. They include runnable examples for every function and `sieve.Set` method, and cover every statement.
|
||||
|
||||
@@ -2,13 +2,22 @@ package sieve_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/sieve"
|
||||
)
|
||||
|
||||
type post struct {
|
||||
ID int
|
||||
Title string
|
||||
ID int
|
||||
Author string
|
||||
Title string
|
||||
}
|
||||
|
||||
var posts = []post{
|
||||
{ID: 1, Author: "ana", Title: "Hello"},
|
||||
{ID: 2, Author: "bob", Title: "Summer"},
|
||||
{ID: 3, Author: "ana", Title: "Goodbye"},
|
||||
}
|
||||
|
||||
func ExampleMap() {
|
||||
@@ -22,3 +31,96 @@ func ExampleMap() {
|
||||
fmt.Println(titles)
|
||||
// Output: [Hello Summer Goodbye]
|
||||
}
|
||||
|
||||
func ExampleTryMap() {
|
||||
nums, err := sieve.TryMap([]string{"1", "2", "3"}, strconv.Atoi)
|
||||
fmt.Println(nums, err)
|
||||
|
||||
nums, err = sieve.TryMap([]string{"1", "x", "3"}, strconv.Atoi)
|
||||
fmt.Println(nums, err)
|
||||
// Output:
|
||||
// [1 2 3] <nil>
|
||||
// [] strconv.Atoi: parsing "x": invalid syntax
|
||||
}
|
||||
|
||||
func ExampleFilter() {
|
||||
byAna := sieve.Filter(posts, func(p post) bool { return p.Author == "ana" })
|
||||
fmt.Println(len(byAna), byAna[0].Title, byAna[1].Title)
|
||||
// Output: 2 Hello Goodbye
|
||||
}
|
||||
|
||||
func ExampleReduce() {
|
||||
total := sieve.Reduce([]int{1, 2, 3, 4}, 0, func(sum, v int) int { return sum + v })
|
||||
fmt.Println(total)
|
||||
// Output: 10
|
||||
}
|
||||
|
||||
func ExampleKeyBy() {
|
||||
byID := sieve.KeyBy(posts, func(p post) int { return p.ID })
|
||||
fmt.Println(byID[2].Title, len(byID))
|
||||
// Output: Summer 3
|
||||
}
|
||||
|
||||
func ExampleGroupBy() {
|
||||
byAuthor := sieve.GroupBy(posts, func(p post) string { return p.Author })
|
||||
titles := make(map[string][]string, len(byAuthor))
|
||||
for author, group := range byAuthor {
|
||||
titles[author] = sieve.Map(group, func(p post) string { return p.Title })
|
||||
}
|
||||
fmt.Println(titles)
|
||||
// Output: map[ana:[Hello Goodbye] bob:[Summer]]
|
||||
}
|
||||
|
||||
func ExampleUniq() {
|
||||
fmt.Println(sieve.Uniq([]int{3, 1, 3, 2, 1}))
|
||||
// Output: [3 1 2]
|
||||
}
|
||||
|
||||
func ExampleUniqBy() {
|
||||
firstPerAuthor := sieve.UniqBy(posts, func(p post) string { return p.Author })
|
||||
fmt.Println(sieve.Map(firstPerAuthor, func(p post) string { return p.Title }))
|
||||
// Output: [Hello Summer]
|
||||
}
|
||||
|
||||
func ExamplePartition() {
|
||||
even, odd := sieve.Partition([]int{1, 2, 3, 4, 5}, func(v int) bool { return v%2 == 0 })
|
||||
fmt.Println(even, odd)
|
||||
// Output: [2 4] [1 3 5]
|
||||
}
|
||||
|
||||
func ExampleFind() {
|
||||
p, ok := sieve.Find(posts, func(p post) bool { return p.Author == "bob" })
|
||||
fmt.Println(p.Title, ok)
|
||||
|
||||
_, ok = sieve.Find(posts, func(p post) bool { return p.Author == "acme" })
|
||||
fmt.Println(ok)
|
||||
// Output:
|
||||
// Summer true
|
||||
// false
|
||||
}
|
||||
|
||||
func ExampleSetOf() {
|
||||
tags := sieve.SetOf("blog", "news", "blog")
|
||||
fmt.Println(len(tags))
|
||||
// Output: 2
|
||||
}
|
||||
|
||||
func ExampleSet_Has() {
|
||||
tags := sieve.SetOf("blog", "news")
|
||||
fmt.Println(tags.Has("blog"), tags.Has("acme"))
|
||||
// Output: true false
|
||||
}
|
||||
|
||||
func ExampleSet_Add() {
|
||||
tags := sieve.SetOf[string]()
|
||||
tags.Add("blog", "news", "blog")
|
||||
fmt.Println(len(tags), tags.Has("news"))
|
||||
// Output: 2 true
|
||||
}
|
||||
|
||||
func ExampleSet_Slice() {
|
||||
ids := sieve.SetOf(3, 1, 2, 3).Slice()
|
||||
slices.Sort(ids)
|
||||
fmt.Println(ids)
|
||||
// Output: [1 2 3]
|
||||
}
|
||||
|
||||
43
modules/sieve/set.go
Normal file
43
modules/sieve/set.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package sieve
|
||||
|
||||
// Set is a set of comparable values backed by a plain map, so len, range,
|
||||
// delete and maps.Keys work on it directly. Create sets with SetOf: Has and
|
||||
// Slice work on a nil Set, but Add on a nil Set panics like any write to a nil
|
||||
// map. Slice returns the elements in unspecified order; use
|
||||
// slices.Sorted(maps.Keys(set)) for ordered output when T is ordered.
|
||||
type Set[T comparable] map[T]struct{}
|
||||
|
||||
// SetOf returns a new set holding items, without duplicates. SetOf() with no
|
||||
// arguments returns an empty, non-nil set that Add can use. It accepts a slice
|
||||
// spread with ..., including a named slice type.
|
||||
func SetOf[T comparable](items ...T) Set[T] {
|
||||
s := make(Set[T], len(items))
|
||||
for _, v := range items {
|
||||
s[v] = struct{}{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Has reports whether v is in the set. A nil Set has no elements.
|
||||
func (s Set[T]) Has(v T) bool {
|
||||
_, ok := s[v]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Add puts items into the set; items already present are ignored. Add on a nil
|
||||
// Set panics, so create sets with SetOf.
|
||||
func (s Set[T]) Add(items ...T) {
|
||||
for _, v := range items {
|
||||
s[v] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Slice returns a new slice holding each element of the set exactly once, in
|
||||
// unspecified order. It is non-nil even for an empty or nil Set.
|
||||
func (s Set[T]) Slice() []T {
|
||||
out := make([]T, 0, len(s))
|
||||
for v := range s {
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -20,3 +20,133 @@ func Map[S ~[]T, T, U any](s S, f func(T) U) []U {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TryMap returns a new slice holding f applied to each element of s, in order.
|
||||
// It stops at the first error and returns nil together with that error,
|
||||
// unchanged; f is not called for the remaining elements. A caller that needs
|
||||
// the failing index wraps the error inside f. A nil or empty s yields a
|
||||
// non-nil empty slice and a nil error.
|
||||
func TryMap[S ~[]T, T, U any](s S, f func(T) (U, error)) ([]U, error) {
|
||||
out := make([]U, 0, len(s))
|
||||
for _, v := range s {
|
||||
u, err := f(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Filter returns a new slice holding the elements of s for which keep returns
|
||||
// true, in input order. The result has the caller's slice type. A nil or empty
|
||||
// s, or one with no matches, yields a non-nil empty slice.
|
||||
func Filter[S ~[]T, T any](s S, keep func(T) bool) S {
|
||||
out := make(S, 0)
|
||||
for _, v := range s {
|
||||
if keep(v) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Reduce folds s from left to right: it starts with init and replaces the
|
||||
// accumulator with f(accumulator, element) for each element in order. The
|
||||
// accumulator type may differ from the element type. A nil or empty s returns
|
||||
// init unchanged.
|
||||
func Reduce[S ~[]T, T, A any](s S, init A, f func(A, T) A) A {
|
||||
acc := init
|
||||
for _, v := range s {
|
||||
acc = f(acc, v)
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
// KeyBy returns a new map from key(element) to element. When two elements
|
||||
// share a key, the later one wins. key is called once per element, in order.
|
||||
// A nil or empty s yields a non-nil empty map.
|
||||
func KeyBy[S ~[]T, T any, K comparable](s S, key func(T) K) map[K]T {
|
||||
out := make(map[K]T, len(s))
|
||||
for _, v := range s {
|
||||
out[key(v)] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GroupBy returns a new map from key(element) to the elements with that key.
|
||||
// Each group keeps input order and has the caller's slice type; groups are
|
||||
// independent of the input and of each other. key is called once per element,
|
||||
// in order. A nil or empty s yields a non-nil empty map.
|
||||
func GroupBy[S ~[]T, T any, K comparable](s S, key func(T) K) map[K]S {
|
||||
out := make(map[K]S)
|
||||
for _, v := range s {
|
||||
k := key(v)
|
||||
out[k] = append(out[k], v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Uniq returns a new slice holding the elements of s without duplicates,
|
||||
// keeping the first occurrence of each, in input order. The result has the
|
||||
// caller's slice type. A nil or empty s yields a non-nil empty slice.
|
||||
func Uniq[S ~[]T, T comparable](s S) S {
|
||||
out := make(S, 0, len(s))
|
||||
seen := make(map[T]struct{}, len(s))
|
||||
for _, v := range s {
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UniqBy returns a new slice holding the elements of s whose key(element) has
|
||||
// not been seen before, keeping the first occurrence of each key, in input
|
||||
// order. key is called once per element. The result has the caller's slice
|
||||
// type. A nil or empty s yields a non-nil empty slice.
|
||||
func UniqBy[S ~[]T, T any, K comparable](s S, key func(T) K) S {
|
||||
out := make(S, 0, len(s))
|
||||
seen := make(map[K]struct{}, len(s))
|
||||
for _, v := range s {
|
||||
k := key(v)
|
||||
if _, ok := seen[k]; ok {
|
||||
continue
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Partition splits s into two new slices: matched holds the elements for which
|
||||
// match returns true and rest holds the others, both in input order and both
|
||||
// with the caller's slice type. match is called once per element. Both results
|
||||
// are non-nil, even when empty, and for a nil or empty s.
|
||||
func Partition[S ~[]T, T any](s S, match func(T) bool) (matched, rest S) {
|
||||
matched = make(S, 0)
|
||||
rest = make(S, 0)
|
||||
for _, v := range s {
|
||||
if match(v) {
|
||||
matched = append(matched, v)
|
||||
} else {
|
||||
rest = append(rest, v)
|
||||
}
|
||||
}
|
||||
return matched, rest
|
||||
}
|
||||
|
||||
// Find returns the first element of s for which match returns true, and true.
|
||||
// match is not called after the first match. When nothing matches, or s is nil
|
||||
// or empty, Find returns the zero value of T and false.
|
||||
func Find[S ~[]T, T any](s S, match func(T) bool) (T, bool) {
|
||||
for _, v := range s {
|
||||
if match(v) {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
|
||||
303
modules/sieve/sieve_test.go
Normal file
303
modules/sieve/sieve_test.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package sieve
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type ids []int
|
||||
|
||||
type item struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
|
||||
func TestMap(t *testing.T) {
|
||||
t.Run("nil input", func(t *testing.T) {
|
||||
got := Map([]int(nil), func(v int) string { return strconv.Itoa(v) })
|
||||
if got == nil || len(got) != 0 {
|
||||
t.Fatalf("Map(nil) = %#v, want non-nil empty", got)
|
||||
}
|
||||
})
|
||||
t.Run("order and call count", func(t *testing.T) {
|
||||
in := []int{3, 1, 2}
|
||||
snap := slices.Clone(in)
|
||||
calls := 0
|
||||
got := Map(in, func(v int) int { calls++; return v * 10 })
|
||||
if !slices.Equal(got, []int{30, 10, 20}) {
|
||||
t.Fatalf("Map = %v", got)
|
||||
}
|
||||
if calls != len(in) {
|
||||
t.Fatalf("calls = %d, want %d", calls, len(in))
|
||||
}
|
||||
got[0] = 99
|
||||
if !slices.Equal(in, snap) {
|
||||
t.Fatalf("input changed: %v", in)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTryMap(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
got, err := TryMap([]string{"1", "2", "3"}, strconv.Atoi)
|
||||
if err != nil || !slices.Equal(got, []int{1, 2, 3}) {
|
||||
t.Fatalf("TryMap = %v, %v", got, err)
|
||||
}
|
||||
})
|
||||
t.Run("nil input", func(t *testing.T) {
|
||||
got, err := TryMap([]string(nil), strconv.Atoi)
|
||||
if err != nil || got == nil || len(got) != 0 {
|
||||
t.Fatalf("TryMap(nil) = %#v, %v", got, err)
|
||||
}
|
||||
})
|
||||
sentinel := errors.New("boom")
|
||||
for _, failAt := range []int{0, 2} {
|
||||
t.Run("fails at "+strconv.Itoa(failAt), func(t *testing.T) {
|
||||
calls := 0
|
||||
got, err := TryMap([]int{0, 1, 2, 3}, func(v int) (int, error) {
|
||||
calls++
|
||||
if v == failAt {
|
||||
return 0, sentinel
|
||||
}
|
||||
return v, nil
|
||||
})
|
||||
if got != nil {
|
||||
t.Fatalf("result = %#v, want nil", got)
|
||||
}
|
||||
if err != sentinel || !errors.Is(err, sentinel) {
|
||||
t.Fatalf("err = %v, want sentinel", err)
|
||||
}
|
||||
if calls != failAt+1 {
|
||||
t.Fatalf("calls = %d, want %d", calls, failAt+1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilter(t *testing.T) {
|
||||
even := func(v int) bool { return v%2 == 0 }
|
||||
tests := []struct {
|
||||
name string
|
||||
in []int
|
||||
keep func(int) bool
|
||||
want []int
|
||||
}{
|
||||
{"nil input", nil, even, []int{}},
|
||||
{"no matches", []int{1, 3}, even, []int{}},
|
||||
{"some matches", []int{1, 2, 3, 4}, even, []int{2, 4}},
|
||||
{"all match", []int{2, 4}, even, []int{2, 4}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
snap := slices.Clone(tt.in)
|
||||
got := Filter(tt.in, tt.keep)
|
||||
if got == nil || !slices.Equal(got, tt.want) {
|
||||
t.Fatalf("Filter = %#v, want %v", got, tt.want)
|
||||
}
|
||||
if len(got) > 0 {
|
||||
got[0] = -1
|
||||
}
|
||||
if !slices.Equal(tt.in, snap) {
|
||||
t.Fatalf("input changed: %v", tt.in)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("named type", func(t *testing.T) {
|
||||
var got ids = Filter(ids{1, 2, 3}, even)
|
||||
if !slices.Equal(got, ids{2}) {
|
||||
t.Fatalf("Filter = %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReduce(t *testing.T) {
|
||||
sum := func(a, v int) int { return a + v }
|
||||
if got := Reduce([]int{1, 2, 3}, 0, sum); got != 6 {
|
||||
t.Fatalf("sum = %d", got)
|
||||
}
|
||||
if got := Reduce([]int(nil), 42, sum); got != 42 {
|
||||
t.Fatalf("Reduce(nil) = %d, want init", got)
|
||||
}
|
||||
if got := Reduce([]string{"a", "b", "c"}, "", func(a, v string) string { return a + v }); got != "abc" {
|
||||
t.Fatalf("concat = %q", got)
|
||||
}
|
||||
if got := Reduce([]int{1, 2}, "", func(a string, v int) string { return a + strconv.Itoa(v) }); got != "12" {
|
||||
t.Fatalf("string accumulator = %q", got)
|
||||
}
|
||||
counts := Reduce([]int{1, 2, 1}, map[int]int{}, func(m map[int]int, v int) map[int]int { m[v]++; return m })
|
||||
if counts[1] != 2 || counts[2] != 1 {
|
||||
t.Fatalf("map accumulator = %v", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBy(t *testing.T) {
|
||||
in := []item{{1, "a"}, {2, "b"}, {1, "c"}}
|
||||
calls := 0
|
||||
got := KeyBy(in, func(it item) int { calls++; return it.ID })
|
||||
if len(got) != 2 || got[1].Name != "c" || got[2].Name != "b" {
|
||||
t.Fatalf("KeyBy = %v", got)
|
||||
}
|
||||
if calls != len(in) {
|
||||
t.Fatalf("calls = %d", calls)
|
||||
}
|
||||
empty := KeyBy([]item(nil), func(it item) int { return it.ID })
|
||||
if empty == nil || len(empty) != 0 {
|
||||
t.Fatalf("KeyBy(nil) = %#v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupBy(t *testing.T) {
|
||||
in := ids{1, 2, 3, 4, 5}
|
||||
snap := slices.Clone(in)
|
||||
var got map[bool]ids = GroupBy(in, func(v int) bool { return v%2 == 0 })
|
||||
if !slices.Equal(got[false], ids{1, 3, 5}) || !slices.Equal(got[true], ids{2, 4}) {
|
||||
t.Fatalf("GroupBy = %v", got)
|
||||
}
|
||||
odd := got[false]
|
||||
got[false] = append(got[false], 7)
|
||||
got[false][0] = 100
|
||||
odd[1] = 200
|
||||
if !slices.Equal(in, snap) {
|
||||
t.Fatalf("input changed: %v", in)
|
||||
}
|
||||
if !slices.Equal(got[true], ids{2, 4}) {
|
||||
t.Fatalf("other group changed: %v", got[true])
|
||||
}
|
||||
empty := GroupBy([]int(nil), func(v int) int { return v })
|
||||
if empty == nil || len(empty) != 0 {
|
||||
t.Fatalf("GroupBy(nil) = %#v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniq(t *testing.T) {
|
||||
in := []int{3, 1, 3, 2, 1}
|
||||
snap := slices.Clone(in)
|
||||
if got := Uniq(in); !slices.Equal(got, []int{3, 1, 2}) {
|
||||
t.Fatalf("Uniq = %v", got)
|
||||
}
|
||||
if !slices.Equal(in, snap) {
|
||||
t.Fatalf("input changed: %v", in)
|
||||
}
|
||||
distinct := []int{1, 2, 3}
|
||||
got := Uniq(distinct)
|
||||
got[0] = 9
|
||||
if distinct[0] != 1 {
|
||||
t.Fatalf("Uniq aliased its input")
|
||||
}
|
||||
empty := Uniq([]int(nil))
|
||||
if empty == nil || len(empty) != 0 {
|
||||
t.Fatalf("Uniq(nil) = %#v", empty)
|
||||
}
|
||||
var named ids = Uniq(ids{1, 1})
|
||||
if !slices.Equal(named, ids{1}) {
|
||||
t.Fatalf("Uniq(ids) = %v", named)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniqBy(t *testing.T) {
|
||||
in := []item{{1, "a"}, {2, "b"}, {1, "c"}, {3, "d"}, {2, "e"}}
|
||||
calls := 0
|
||||
got := UniqBy(in, func(it item) int { calls++; return it.ID })
|
||||
want := []item{{1, "a"}, {2, "b"}, {3, "d"}}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("UniqBy = %v", got)
|
||||
}
|
||||
if calls != len(in) {
|
||||
t.Fatalf("calls = %d", calls)
|
||||
}
|
||||
empty := UniqBy([]item(nil), func(it item) int { return it.ID })
|
||||
if empty == nil || len(empty) != 0 {
|
||||
t.Fatalf("UniqBy(nil) = %#v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartition(t *testing.T) {
|
||||
even := func(v int) bool { return v%2 == 0 }
|
||||
matched, rest := Partition([]int{1, 2, 3, 4, 5}, even)
|
||||
if !slices.Equal(matched, []int{2, 4}) || !slices.Equal(rest, []int{1, 3, 5}) {
|
||||
t.Fatalf("Partition = %v, %v", matched, rest)
|
||||
}
|
||||
matched, rest = Partition([]int(nil), even)
|
||||
if matched == nil || rest == nil || len(matched) != 0 || len(rest) != 0 {
|
||||
t.Fatalf("Partition(nil) = %#v, %#v", matched, rest)
|
||||
}
|
||||
in := []int{2, 4}
|
||||
matched, rest = Partition(in, even)
|
||||
if rest == nil || len(rest) != 0 || !slices.Equal(matched, in) {
|
||||
t.Fatalf("Partition(all) = %#v, %#v", matched, rest)
|
||||
}
|
||||
matched[0] = 9
|
||||
if in[0] != 2 {
|
||||
t.Fatalf("Partition aliased its input")
|
||||
}
|
||||
var m, r ids = Partition(ids{1, 2}, even)
|
||||
if !slices.Equal(m, ids{2}) || !slices.Equal(r, ids{1}) {
|
||||
t.Fatalf("Partition(ids) = %v, %v", m, r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFind(t *testing.T) {
|
||||
calls := 0
|
||||
got, ok := Find([]int{1, 4, 6, 8}, func(v int) bool { calls++; return v%2 == 0 })
|
||||
if !ok || got != 4 {
|
||||
t.Fatalf("Find = %d, %v", got, ok)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("calls = %d, want 2", calls)
|
||||
}
|
||||
got, ok = Find([]int{1, 3}, func(v int) bool { return v%2 == 0 })
|
||||
if ok || got != 0 {
|
||||
t.Fatalf("Find(no match) = %d, %v", got, ok)
|
||||
}
|
||||
s, ok := Find([]string(nil), func(string) bool { return true })
|
||||
if ok || s != "" {
|
||||
t.Fatalf("Find(nil) = %q, %v", s, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSet(t *testing.T) {
|
||||
s := SetOf(1, 2, 2, 3)
|
||||
if len(s) != 3 {
|
||||
t.Fatalf("len = %d", len(s))
|
||||
}
|
||||
if !s.Has(2) || s.Has(4) {
|
||||
t.Fatalf("Has wrong: %v", s)
|
||||
}
|
||||
s.Add(4, 5, 4)
|
||||
if len(s) != 5 || !s.Has(5) {
|
||||
t.Fatalf("Add wrong: %v", s)
|
||||
}
|
||||
delete(s, 1)
|
||||
if s.Has(1) {
|
||||
t.Fatalf("delete had no effect")
|
||||
}
|
||||
got := s.Slice()
|
||||
slices.Sort(got)
|
||||
if !slices.Equal(got, []int{2, 3, 4, 5}) {
|
||||
t.Fatalf("Slice = %v", got)
|
||||
}
|
||||
|
||||
empty := SetOf[string]()
|
||||
if empty == nil {
|
||||
t.Fatalf("SetOf() is nil")
|
||||
}
|
||||
empty.Add("a")
|
||||
if !empty.Has("a") {
|
||||
t.Fatalf("Add on SetOf() failed")
|
||||
}
|
||||
|
||||
from := SetOf(ids{7, 7, 8}...)
|
||||
if len(from) != 2 {
|
||||
t.Fatalf("SetOf(ids...) = %v", from)
|
||||
}
|
||||
|
||||
var zero Set[int]
|
||||
if zero.Has(1) {
|
||||
t.Fatalf("nil Set Has true")
|
||||
}
|
||||
if sl := zero.Slice(); sl == nil || len(sl) != 0 {
|
||||
t.Fatalf("nil Set Slice = %#v", sl)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user