diff --git a/docs/architecture/introduction.md b/docs/architecture/introduction.md index a2c2d5f..869435d 100644 --- a/docs/architecture/introduction.md +++ b/docs/architecture/introduction.md @@ -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 diff --git a/docs/setup/coming-from-wintercms.md b/docs/setup/coming-from-wintercms.md index 243f04e..08851dc 100644 --- a/docs/setup/coming-from-wintercms.md +++ b/docs/setup/coming-from-wintercms.md @@ -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). | diff --git a/modules/sieve/README.md b/modules/sieve/README.md index 3f9ebe1..020b0b4 100644 --- a/modules/sieve/README.md +++ b/modules/sieve/README.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. diff --git a/modules/sieve/example_test.go b/modules/sieve/example_test.go index 2e1c740..b1e671d 100644 --- a/modules/sieve/example_test.go +++ b/modules/sieve/example_test.go @@ -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] + // [] 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] +} diff --git a/modules/sieve/set.go b/modules/sieve/set.go new file mode 100644 index 0000000..a711fcf --- /dev/null +++ b/modules/sieve/set.go @@ -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 +} diff --git a/modules/sieve/sieve.go b/modules/sieve/sieve.go index a6225bd..d59ba8e 100644 --- a/modules/sieve/sieve.go +++ b/modules/sieve/sieve.go @@ -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 +} diff --git a/modules/sieve/sieve_test.go b/modules/sieve/sieve_test.go new file mode 100644 index 0000000..bcd23ed --- /dev/null +++ b/modules/sieve/sieve_test.go @@ -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) + } +}