Skip to content

Add grouped(by:) and keyed(by:) #197

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 5 commits into from
Nov 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions Guides/Grouped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Grouped

[[Source](https://github.com/apple/swift-algorithms/blob/main/Sources/Algorithms/Grouped.swift) |
[Tests](https://github.com/apple/swift-algorithms/blob/main/Tests/SwiftAlgorithmsTests/GroupedTests.swift)]

Groups up elements of a sequence into a new Dictionary, whose values are Arrays of grouped elements, each keyed by the result of the given closure.

```swift
let fruits = ["Apricot", "Banana", "Apple", "Cherry", "Avocado", "Coconut"]
let fruitsByLetter = fruits.grouped(by: { $0.first! })
// Results in:
// [
// "B": ["Banana"],
// "A": ["Apricot", "Apple", "Avocado"],
// "C": ["Cherry", "Coconut"],
// ]
```

If you wish to achieve a similar effect but for single values (instead of Arrays of grouped values), see [`keyed(by:)`](Keyed.md).

## Detailed Design

The `grouped(by:)` method is declared as a `Sequence` extension returning
`[GroupKey: [Element]]`.

```swift
extension Sequence {
public func grouped<GroupKey>(
by keyForValue: (Element) throws -> GroupKey
) rethrows -> [GroupKey: [Element]]
}
```

### Complexity

Calling `grouped(by:)` is an O(_n_) operation.

### Comparison with other languages

| Language | Grouping API |
|---------------|--------------|
| Java | [`groupingBy`](https://docs.oracle.com/en/java/javase/20/docs/api/java.base/java/util/stream/Collectors.html#groupingBy(java.util.function.Function)) |
| Kotlin | [`groupBy`](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/group-by.html) |
| C# | [`GroupBy`](https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.groupby?view=net-7.0#system-linq-enumerable-groupby) |
| Rust | [`group_by`](https://doc.rust-lang.org/std/primitive.slice.html#method.group_by) |
| Ruby | [`group_by`](https://ruby-doc.org/3.2.2/Enumerable.html#method-i-group_by) |
| Python | [`groupby`](https://docs.python.org/3/library/itertools.html#itertools.groupby) |
| PHP (Laravel) | [`groupBy`](https://laravel.com/docs/10.x/collections#method-groupby) |

#### Naming

All the surveyed languages name this operation with a variant of "grouped" or "grouping". The past tense `grouped(by:)` best fits [Swift's API Design Guidelines](https://www.swift.org/documentation/api-design-guidelines/).

#### Customization points

Java and C# are interesting in that they provide multiple overloads with several points of customization:

1. Changing the type of the groups.
1. E.g. the groups can be Sets instead of Arrays.
1. Akin to calling `.transformValues { group in Set(group) }` on the resultant dictionary, but avoiding the intermediate allocation of Arrays of each group.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forward

I explored all this really just for my own edification, but it might be helpful to include some summary of this in your notes here, to explain why the group(by:) API doesn't and cannot currently support this functionality. Adding all the necessary infrastructure - whether a Collector protocol or whatever else - seems like a more fundamental addition to the Swift standard library that should be tackled in its own patch & pitch.

Rabbit hole

I like the idea of supporting this, but it doesn't seem like it's possible [elegantly] in Swift today…? Swift's standard library & Foundation don't include an equivalent to Java's Collector interface, for example. And even though I'm not a big fan of that approach anyway, you fundamentally do need a way to say "any collection that can be initialised and added to", which Swift does not have.

Specifically: you can easily genericise group(by:) with some ValueCollection type, but what protocol would it have to conform to? Collection specifies only the APIs for reading from a given collection, not for constructing one or adding to it. MutableCollection (surprisingly) has the exact same limitations, and feels inappropriate anyway since it forces the resulting Dictionary to have a mutable type for its values.

There is RangeReplaceableCollection as a half measure - it does include an init method, but only a limited subset of standard collections conform to it (notably not including Set). Still, with that you can do:

extension Sequence {
    public func grouped<GroupKey, ValueCollection: RangeReplaceableCollection<Element>>(
        by keyForValue: (Element) throws -> GroupKey
    ) rethrows -> [GroupKey: ValueCollection] {
        var result = [GroupKey: ValueCollection]()

        for value in self {
            result[try keyForValue(value), default: ValueCollection()].append(value)
        }

        return result
    }
}

Semantically it's a hack, of course - there's nothing about the range replacement functionality actually needed here, it's just used because it exposes other APIs almost as a side-effect.

And anyway, the ergonomics are poor since Swift doesn't support specifying a default value for a generic parameter, requiring boilerplate in many cases:

[1, 2, 3, 4].grouped { $0 % 2 } // ❌ Generic parameter 'ValueCollection' could not be inferred

let result: [Int: [Int]] = [1, 2, 3, 4].grouped { $0 % 2 } // Works, but awkward for many use-cases.

One could work around the initialisation problem by having the caller explicitly provide an initialiser, but you still need a protocol that all collections support which specifies some kind of append method, and that doesn't currently exist.

It is possible to support arbitrary collection types, but only by having the caller provide a reducer explicitly in order to work around the aforementioned limitations, e.g.:

extension Sequence {
    public func grouped<GroupKey, ValueCollection: Collection>(
        by keyForValue: (Element) throws -> GroupKey,
        collectionInitialiser: () -> ValueCollection = Array<Element>.init,
        collectionReducer: (inout ValueCollection, Element) -> ValueCollection
    ) rethrows -> [GroupKey: ValueCollection] where ValueCollection.Element == Element {
        var result = [GroupKey: ValueCollection]()

        for value in self {
            collectionReducer(&result[try keyForValue(value), default: collectionInitialiser()], value)
        }

        return result
    }
}

let result: [Int: [Int]] = [1, 2, 3, 4].grouped(by: { $0 % 2 },
                                                collectionReducer: { $0.append($1); return $0 })

This is essentially now a functional superset of group(by:) and keyed(by:).

You can't provide a default reducer since there's no way to generically add a value to any type of collection (per the earlier point). So this'd have to be a special parallel version of group(by:), in addition to the 'simple' one that just hard-codes use of Array and doesn't require that a reducer be specified.

I'm not a fan of this approach, technically capable as it may be, though admittedly that's from a subjective standpoint. I think it'd be more elegant, and cleaner for library authors, to evolve Swift (or the Swift standard library) to better support generalising across all collection types.

Copy link
Contributor Author

@amomchilov amomchilov Jul 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very interesting findings, thanks for sharing this with me! How do you think it would be best to communicate these in the library? Perhaps an addendum to the new md files I'm adding in this PR?

but you still need a protocol that all collections support which specifies some kind of append method, and that doesn't currently exist.

Oh wow, I never noticed this, but you're right!

It is possible to support arbitrary collection types, but only by having the caller provide a reducer explicitly in order to work around the aforementioned limitations, e.g.:

This is what I had in mind (or use a new protocol with method requirements, instead of two separate closure params).

This is more general, because the groups might not be collections at all. The histogram example comes to mind, where you'd want something like:

[1, 2, 3, 4].grouped(
    by: { $0 % 2 },
    collectionInitialiser: { 0 }, // not a collection, but w/e
    collectionReducer: { counter, _ in counter += 1 },
)

If there were a protocol for this, then there might be some CountingCollector that could be used.

C# and Java's APIs are particularly interesting in this area, though I think a lot of their designs were constrained by the lack of tuples (at the time of their design).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how best to summarise that - obviously what I wrote, as a kind of deep-dive on this tangent, is way too much for these documents. Maybe it suffices to say something like "This would require either (a) a new protocol, supported by all standard library collection types, which represents an initialisable object that can be added to, or (b) a variant method which accepts a custom 'reducer' closure". If you want to say anything about it at all. Perhaps this thread here on the pull request is sufficient documentation for posterity (which could be hyperlinked to from the markdown file).

Having used Java's version of this a few times in real-world code, my main comment is that these sorts of APIs - groupby etc - should never require explicit specification of the return types (or a custom reducer). They can allow it, through overloads or optional parameters, but it needs to work effortlessly for the vastly most common case of just returning a Dictionary of Arrays. A lot of Java APIs explicitly require you to pass a collector which is almost always Collectors.toList()… it gets very repetitive, and verbose.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, as a counter-argument (Devil's advocate style) for the use of this custom-reducer grouped(by:) variant, you can achieve the same thing with:

[1, 2, 3, 4].reduce(into: [Int: Int]()) { $0[$1, default: 0] += 1 }

(and the explicit typing of the Result type can be omitted in some cases where it's implied by surrounding context, making this even terser)

Personally I don't mind if a language or library provides multiple ways to achieve the same goals, but I'm not sure a fancier group(by:) adds any benefit over the existing reduce(into:_:) approach.

That said, you can implement group(by:) using reduce(into:_:) too, e.g.:

[1, 2, 3, 4].reduce(into: [Int: [Int]]()) {
    $0[$1 % 2, default: []].append($1)
}

…yet I still feel like a purpose-built group(by:) is worth having. Perhaps it's technically just a "convenience", a bit more ergonomic - but it feels like it's worth it for this simple, common case. I guess I'm just not sure where to draw the line between a more powerful group(by:) and just having people fall back to reduce(into:_:) for uncommon needs.

Counter-counter-argument: even though reduce(_:_:) and reduce(into:_:) can be used for a huge variety of tasks, I usually find them hard to interpret when reading the code. A large number of Sequence methods can be implemented using just the reducer methods - e.g. allSatisfy, min / max, and even map! - yet we still have all those purpose-built methods. And I think Swift is better for them - they make code much more readable, even if they're technically redundant.

Copy link
Contributor Author

@amomchilov amomchilov Jul 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I'm just not sure where to draw the line between a more powerful group(by:) and just having people fall back to reduce(into:_:) for uncommon needs.

Likewise. I have conviction about the base group(by:) and its semantics, but I get less confident the further out I get on the landscape extensibility. Java's a prime example, where the collectors are maximally flexible, but a complete chore in 99% of the cases.

Counter-counter-argument: even though reduce(_:_:) and reduce(into:_:) can be used for a huge variety of tasks, I usually find them hard to interpret when reading the code. A large number of Sequence methods can be implemented using just the reducer methods - e.g. allSatisfy, min / max, and even map! - yet we still have all those purpose-built methods. And I think Swift is better for them - they make code much more readable, even if they're technically redundant.

Heh I have an entire little blog post about this: https://github.com/amomchilov/Blog/blob/master/Don't%20abuse%20reduce.md

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this research something you'd like to commit? You deserve to have your name on your findings :)

2. Picking which elements end up in the groupings.
1. The default is the elements of the input sequence, but can be changed.
2. Akin to calling `.transformValues { group in group.map(someTransform) }` on the resultant dictionary, but avoiding the intermediate allocation of Arrays of each group.
Comment on lines +61 to +63
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side thought: If grouped(by:) was instead implemented to be lazy, the keyed(by: \.key) could be replaced with grouped(by: \.key).transformValues(\.last). It's still wordy though, so I think keyed(by:) is keeping as-is.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd still be notably less efficient, due (mainly) to the construction of the intermediary arrays. I assume the Swift compiler won't be clever enough to eliminate those.

Aside from that, how would lazy execution work? Wouldn't you have to run through the complete input sequence in order to know when you have all the values for any given key?

3. Changing the type of the outermost collection.
1. E.g using an `OrderedDictionary`, `SortedDictionary` or `TreeDictionary` instead of the default (hashed, unordered) `Dictionary`.
2. There's no great way to achieve this with the `grouped(by:)`. One could wrap the resultant dictionary in an initializer to one of the other dictionary types, but that isn't sufficient: Once the `Dictionary` loses the ordering, there's no way to get it back when constructing one of the ordered dictionary variants.

It is not clear which of these points of customization are worth supporting, or what the best way to express them might be.
78 changes: 78 additions & 0 deletions Guides/Keyed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Keyed

[[Source](https://github.com/apple/swift-algorithms/blob/main/Sources/Algorithms/Keyed.swift) |
[Tests](https://github.com/apple/swift-algorithms/blob/main/Tests/SwiftAlgorithmsTests/KeyedTests.swift)]

Stores the elements of a sequence as the values of a Dictionary, keyed by the result of the given closure.

```swift
let fruits = ["Apricot", "Banana", "Apple", "Cherry", "Blackberry", "Avocado", "Coconut"]
let fruitByLetter = fruits.keyed(by: { $0.first! })
// Results in:
// [
// "A": "Avocado",
// "B": "Blackberry",
// "C": "Coconut",
// ]
```

On a key-collision, the latest element is kept by default. Alternatively, you can provide a closure which specifies which value to keep:

```swift
let fruits = ["Apricot", "Banana", "Apple", "Cherry", "Blackberry", "Avocado", "Coconut"]
let fruitsByLetter = fruits.keyed(
by: { $0.first! },
resolvingConflictsWith: { key, old, new in old } // Always pick the first fruit
)
// Results in:
// [
// "A": "Apricot",
// "B": "Banana",
// "C": "Cherry",
// ]
```

## Detailed Design

The `keyed(by:)` and `keyed(by:resolvingConflictsWith:)` methods are declared in an `Sequence` extension, both returning `[Key: Element]`.

```swift
extension Sequence {
public func keyed<Key>(
by keyForValue: (Element) throws -> Key
) rethrows -> [Key: Element]

public func keyed<Key>(
by keyForValue: (Element) throws -> Key,
resolvingConflictsWith resolve: ((Key, Element, Element) throws -> Element)? = nil
) rethrows -> [Key: Element]
}
```

### Complexity

Calling `keyed(by:)` is an O(_n_) operation.

### Comparison with other languages

| Language | "Keying" API |
|---------------|-------------|
| Java | [`toMap`](https://docs.oracle.com/en/java/javase/20/docs/api/java.base/java/util/stream/Collectors.html#toMap(java.util.function.Function,java.util.function.Function)) |
| Kotlin | [`associatedBy`](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/associate-by.html) |
| C# | [`ToDictionary`](https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.todictionary?view=net-7.0#system-linq-enumerable-todictionary) |
| Ruby (ActiveSupport) | [`index_by`](https://rubydoc.info/gems/activesupport/7.0.5/Enumerable#index_by-instance_method) |
| PHP (Laravel) | [`keyBy`](https://laravel.com/docs/10.x/collections#method-keyby) |

#### Rejected alternative names

1. Java's `toMap` is referring to `Map`/`HashMap`, their naming for Dictionaries and other associative collections. It's easy to confuse with the transformation function, `Sequence.map(_:)`.
2. C#'s `toXXX()` naming doesn't suite Swift well, which tends to prefer `Foo.init` over `toFoo()` methods.
3. Ruby's `index_by` naming doesn't fit Swift well, where "index" is a specific term (e.g. the `associatedtype Index` on `Collection`). There is also a [`index(by:)`](Index.md) method in swift-algorithms, is specifically to do with matching elements up with their indices, and not any arbitrary derived value.

#### Alternative names

Kotlin's `associatedBy` naming is a good alterative, and matches the past tense of [Swift's API Design Guidelines](https://www.swift.org/documentation/api-design-guidelines/), though perhaps we'd spell it `associated(by:)`.

#### Customization points

Java and C# are interesting in that they provide overloads that let you customize the type of the outermost collection. E.g. using an `OrderedDictionary` instead of the default (hashed, unordered) `Dictionary`.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ Read more about the package, and the intent behind it, in the [announcement on s
- [`adjacentPairs()`](https://github.com/apple/swift-algorithms/blob/main/Guides/AdjacentPairs.md): Lazily iterates over tuples of adjacent elements.
- [`chunked(by:)`, `chunked(on:)`, `chunks(ofCount:)`](https://github.com/apple/swift-algorithms/blob/main/Guides/Chunked.md): Eager and lazy operations that break a collection into chunks based on either a binary predicate or when the result of a projection changes or chunks of a given count.
- [`firstNonNil(_:)`](https://github.com/apple/swift-algorithms/blob/main/Guides/FirstNonNil.md): Returns the first non-`nil` result from transforming a sequence's elements.
- [`grouped(by:)](https://github.com/apple/swift-algorithms/blob/main/Guides/Grouped.md): Group up elements using the given closure, returning a Dictionary of those groups, keyed by the results of the closure.
- [`indexed()`](https://github.com/apple/swift-algorithms/blob/main/Guides/Indexed.md): Iterate over tuples of a collection's indices and elements.
- [`interspersed(with:)`](https://github.com/apple/swift-algorithms/blob/main/Guides/Intersperse.md): Place a value between every two elements of a sequence.
- [`keyed(by:)`, `keyed(by:resolvingConflictsBy:)`](https://github.com/apple/swift-algorithms/blob/main/Guides/Keyed.md): Returns a Dictionary that associates elements of a sequence with the keys returned by the given closure.
- [`partitioningIndex(where:)`](https://github.com/apple/swift-algorithms/blob/main/Guides/Partition.md): Returns the starting index of the partition of a collection that matches a predicate.
- [`reductions(_:)`, `reductions(_:_:)`](https://github.com/apple/swift-algorithms/blob/main/Guides/Reductions.md): Returns all the intermediate states of reducing the elements of a sequence or collection.
- [`split(maxSplits:omittingEmptySubsequences:whereSeparator)`, `split(separator:maxSplits:omittingEmptySubsequences)`](https://github.com/apple/swift-algorithms/blob/main/Guides/Split.md): Lazy versions of the Standard Library's eager operations that split sequences and collections into subsequences separated by the specified separator element.
Expand Down
25 changes: 25 additions & 0 deletions Sources/Algorithms/Grouped.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift Algorithms open source project
//
// Copyright (c) 2021 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

extension Sequence {
/// Groups up elements of `self` into a new Dictionary,
/// whose values are Arrays of grouped elements,
/// each keyed by the group key returned by the given closure.
/// - Parameters:
/// - keyForValue: A closure that returns a key for each element in
/// `self`.
/// - Returns: A dictionary containing grouped elements of self, keyed by
/// the keys derived by the `keyForValue` closure.
@inlinable
public func grouped<GroupKey>(by keyForValue: (Element) throws -> GroupKey) rethrows -> [GroupKey: [Element]] {
try Dictionary(grouping: self, by: keyForValue)
}
}
65 changes: 65 additions & 0 deletions Sources/Algorithms/Keyed.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift Algorithms open source project
//
// Copyright (c) 2020 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

extension Sequence {
/// Creates a new Dictionary from the elements of `self`, keyed by the
/// results returned by the given `keyForValue` closure.
///
/// If the key derived for a new element collides with an existing key from a previous element,
/// the latest value will be kept.
///
/// - Parameters:
/// - keyForValue: A closure that returns a key for each element in `self`.
@inlinable
public func keyed<Key>(
by keyForValue: (Element) throws -> Key
) rethrows -> [Key: Element] {
return try self.keyed(by: keyForValue, resolvingConflictsWith: { _, old, new in new })
}

/// Creates a new Dictionary from the elements of `self`, keyed by the
/// results returned by the given `keyForValue` closure. As the dictionary is
/// built, the initializer calls the `resolve` closure with the current and
/// new values for any duplicate keys. Pass a closure as `resolve` that
/// returns the value to use in the resulting dictionary: The closure can
/// choose between the two values, combine them to produce a new value, or
/// even throw an error.
///
/// - Parameters:
/// - keyForValue: A closure that returns a key for each element in `self`.
/// - resolve: A closure that is called with the values for any duplicate
/// keys that are encountered. The closure returns the desired value for
/// the final dictionary.
@inlinable
public func keyed<Key>(
by keyForValue: (Element) throws -> Key,
resolvingConflictsWith resolve: (Key, Element, Element) throws -> Element
) rethrows -> [Key: Element] {
var result = [Key: Element]()

for element in self {
let key = try keyForValue(element)

if let oldValue = result.updateValue(element, forKey: key) {
let valueToKeep = try resolve(key, oldValue, element)

// This causes a second look-up for the same key. The standard library can avoid that
// by calling `mutatingFind` to get access to the bucket where the value will end up,
// and updating in place.
// Swift Algorithms doesn't have access to that API, so we make do.
// When this gets merged into the standard library, we should optimize this.
result[key] = valueToKeep
}
}

return result
}
}
46 changes: 46 additions & 0 deletions Tests/SwiftAlgorithmsTests/GroupedTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift Algorithms open source project
//
// Copyright (c) 2020 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

import XCTest
import Algorithms

final class GroupedTests: XCTestCase {
private class SampleError: Error {}

// Based on https://github.com/apple/swift/blob/4d1d8a9de5ebc132a17aee9fc267461facf89bf8/validation-test/stdlib/Dictionary.swift#L1974-L1988

func testGroupedBy() {
let r = 0..<10

let d1 = r.grouped(by: { $0 % 3 })
XCTAssertEqual(3, d1.count)
XCTAssertEqual(d1[0]!, [0, 3, 6, 9])
XCTAssertEqual(d1[1]!, [1, 4, 7])
XCTAssertEqual(d1[2]!, [2, 5, 8])

let d2 = r.grouped(by: { $0 })
XCTAssertEqual(10, d2.count)

let d3 = (0..<0).grouped(by: { $0 })
XCTAssertEqual(0, d3.count)
}

func testThrowingFromKeyFunction() {
let input = ["Apple", "Banana", "Cherry"]
let error = SampleError()

XCTAssertThrowsError(
try input.grouped(by: { (_: String) -> Character in throw error })
) { thrownError in
XCTAssertIdentical(error, thrownError as? SampleError)
}
}
}
88 changes: 88 additions & 0 deletions Tests/SwiftAlgorithmsTests/KeyedTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift Algorithms open source project
//
// Copyright (c) 2020 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

import XCTest
import Algorithms

final class KeyedTests: XCTestCase {
private class SampleError: Error {}

func testUniqueKeys() {
let d = ["Apple", "Banana", "Cherry"].keyed(by: { $0.first! })
XCTAssertEqual(d.count, 3)
XCTAssertEqual(d["A"]!, "Apple")
XCTAssertEqual(d["B"]!, "Banana")
XCTAssertEqual(d["C"]!, "Cherry")
XCTAssertNil(d["D"])
}

func testEmpty() {
let d = EmptyCollection<String>().keyed(by: { $0.first! })
XCTAssertEqual(d.count, 0)
}

func testNonUniqueKeys() throws {
let d = ["Apple", "Avocado", "Banana", "Cherry"].keyed(by: { $0.first! })
XCTAssertEqual(d.count, 3)
XCTAssertEqual(d["A"]!, "Avocado", "On a key-collision, keyed(by:) should take the latest value.")
XCTAssertEqual(d["B"]!, "Banana")
XCTAssertEqual(d["C"]!, "Cherry")
}

func testNonUniqueKeysWithMergeFunction() {
var resolveCallHistory = [(key: Character, current: String, new: String)]()
let expectedCallHistory = [
(key: "A", current: "Apple", new: "Avocado"),
(key: "C", current: "Cherry", new: "Coconut"),
]

let d = ["Apple", "Avocado", "Banana", "Cherry", "Coconut"].keyed(
by: { $0.first! },
resolvingConflictsWith: { key, older, newer in
resolveCallHistory.append((key, older, newer))
return "\(older)-\(newer)"
}
)

XCTAssertEqual(d.count, 3)
XCTAssertEqual(d["A"]!, "Apple-Avocado")
XCTAssertEqual(d["B"]!, "Banana")
XCTAssertEqual(d["C"]!, "Cherry-Coconut")
XCTAssertNil(d["D"])

XCTAssertEqual(
resolveCallHistory.map(String.init(describing:)), // quick/dirty workaround: tuples aren't Equatable
expectedCallHistory.map(String.init(describing:))
)
}

func testThrowingFromKeyFunction() {
let input = ["Apple", "Banana", "Cherry"]
let error = SampleError()

XCTAssertThrowsError(
try input.keyed(by: { (_: String) -> Character in throw error })
) { thrownError in
XCTAssertIdentical(error, thrownError as? SampleError)
}
}

func testThrowingFromCombineFunction() {
let input = ["Apple", "Avocado", "Banana", "Cherry"]
let error = SampleError()

XCTAssertThrowsError(
try input.keyed(by: { $0.first! }, resolvingConflictsWith: { _, _, _ in throw error })
) { thrownError in
XCTAssertIdentical(error, thrownError as? SampleError)
}
}
}