From 3da02ded1c7a5c357359dac5a2a78b08a88dd122 Mon Sep 17 00:00:00 2001 From: Jason Marble Date: Thu, 23 Oct 2025 09:56:43 -0700 Subject: [PATCH] Add uniqueStrings() method to collections Adds optimized string deduplication method to Collection and LazyCollection. Uses array_unique(SORT_STRING) and isset() hash lookups for significant performance improvements over unique() when working with strings. Supports keys, closures, and nested property access. Avoids SORT_REGULAR instability issue: php/php-src#20262 --- src/Illuminate/Collections/Collection.php | 27 +++ src/Illuminate/Collections/LazyCollection.php | 25 +++ tests/Support/SupportCollectionTest.php | 157 ++++++++++++++++++ 3 files changed, 209 insertions(+) diff --git a/src/Illuminate/Collections/Collection.php b/src/Illuminate/Collections/Collection.php index 08a852fe19fa..fc5f9b6ea091 100644 --- a/src/Illuminate/Collections/Collection.php +++ b/src/Illuminate/Collections/Collection.php @@ -1788,6 +1788,33 @@ public function unique($key = null, $strict = false) }); } + /** + * Return only unique items from the collection array using string comparison. + * + * @param (callable(TValue, TKey): string)|string|null $key + * @return static + */ + public function uniqueStrings($key = null) + { + if (is_null($key)) { + return new static(array_unique($this->items, SORT_STRING)); + } + + $callback = $this->valueRetriever($key); + + $exists = []; + + return $this->reject(function ($item, $key) use ($callback, &$exists) { + $id = $callback($item, $key); + + if (isset($exists[$id])) { + return true; + } + + $exists[$id] = true; + }); + } + /** * Reset the keys on the underlying array. * diff --git a/src/Illuminate/Collections/LazyCollection.php b/src/Illuminate/Collections/LazyCollection.php index 95b61720afc4..6b84c4ccc36d 100644 --- a/src/Illuminate/Collections/LazyCollection.php +++ b/src/Illuminate/Collections/LazyCollection.php @@ -1760,6 +1760,31 @@ public function unique($key = null, $strict = false) }); } + /** + * Return only unique items from the collection array using string comparison. + * + * @param (callable(TValue, TKey): string)|string|null $key + * @return static + */ + public function uniqueStrings($key = null) + { + $callback = $this->valueRetriever($key); + + return new static(function () use ($callback) { + $exists = []; + + foreach ($this as $key => $item) { + $id = $callback($item, $key); + + if (! isset($exists[$id])) { + yield $key => $item; + + $exists[$id] = true; + } + } + }); + } + /** * Reset the keys on the underlying array. * diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php index 81a6a3fd7dbe..36d89c9088d5 100755 --- a/tests/Support/SupportCollectionTest.php +++ b/tests/Support/SupportCollectionTest.php @@ -1760,6 +1760,163 @@ public function testUniqueStrict($collection) ], $c->uniqueStrict('id')->all()); } + #[DataProvider('collectionClassProvider')] + public function testUniqueStrings($collection) + { + $c = new $collection(['Hello', 'World', 'World', 'Hello']); + $this->assertEquals(['Hello', 'World'], $c->uniqueStrings()->all()); + + $c = new $collection(['user@example.com', 'admin@example.com', 'user@example.com']); + $this->assertEquals(['user@example.com', 'admin@example.com'], $c->uniqueStrings()->all()); + + $c = new $collection(['SKU-001', 'SKU-002', 'SKU-001', 'SKU-003']); + $this->assertEquals(['SKU-001', 'SKU-002', 'SKU-003'], $c->uniqueStrings()->values()->all()); + + $c = new $collection(['5', '10', '5', '3A', '5', '5']); + $this->assertEquals(['5', '10', '3A'], $c->uniqueStrings()->values()->all()); + + $c = new $collection([ + 'a' => 'foo', + 'b' => 'bar', + 'c' => 'foo', + 'd' => 'baz', + ]); + $this->assertEquals([ + 'a' => 'foo', + 'b' => 'bar', + 'd' => 'baz', + ], $c->uniqueStrings()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testUniqueStringsWithKey($collection) + { + $c = new $collection([ + 1 => ['id' => 1, 'email' => 'taylor@example.com', 'name' => 'Taylor'], + 2 => ['id' => 2, 'email' => 'abigail@example.com', 'name' => 'Abigail'], + 3 => ['id' => 3, 'email' => 'taylor@example.com', 'name' => 'Taylor Otwell'], + 4 => ['id' => 4, 'email' => 'jess@example.com', 'name' => 'Jess'], + ]); + + $this->assertEquals([ + 1 => ['id' => 1, 'email' => 'taylor@example.com', 'name' => 'Taylor'], + 2 => ['id' => 2, 'email' => 'abigail@example.com', 'name' => 'Abigail'], + 4 => ['id' => 4, 'email' => 'jess@example.com', 'name' => 'Jess'], + ], $c->uniqueStrings('email')->all()); + + $c = new $collection([ + ['user' => ['email' => 'foo@example.com']], + ['user' => ['email' => 'bar@example.com']], + ['user' => ['email' => 'foo@example.com']], + ]); + + $result = $c->uniqueStrings('user.email')->values()->all(); + $this->assertCount(2, $result); + $this->assertEquals('foo@example.com', $result[0]['user']['email']); + $this->assertEquals('bar@example.com', $result[1]['user']['email']); + } + + #[DataProvider('collectionClassProvider')] + public function testUniqueStringsWithCallback($collection) + { + $c = new $collection([ + 1 => ['id' => 1, 'sku' => 'SKU-001', 'name' => 'Product 1'], + 2 => ['id' => 2, 'sku' => 'SKU-002', 'name' => 'Product 2'], + 3 => ['id' => 3, 'sku' => 'SKU-001', 'name' => 'Product 1 Duplicate'], + 4 => ['id' => 4, 'sku' => 'SKU-003', 'name' => 'Product 3'], + ]); + + // Dedupe by SKU using closure + $this->assertEquals([ + 1 => ['id' => 1, 'sku' => 'SKU-001', 'name' => 'Product 1'], + 2 => ['id' => 2, 'sku' => 'SKU-002', 'name' => 'Product 2'], + 4 => ['id' => 4, 'sku' => 'SKU-003', 'name' => 'Product 3'], + ], $c->uniqueStrings(function ($item) { + return $item['sku']; + })->all()); + + // Concatenating multiple fields + $c = new $collection([ + ['first' => 'Taylor', 'last' => 'Otwell'], + ['first' => 'Abigail', 'last' => 'Otwell'], + ['first' => 'Taylor', 'last' => 'Otwell'], + ['first' => 'Taylor', 'last' => 'Swift'], + ]); + + $this->assertEquals([ + ['first' => 'Taylor', 'last' => 'Otwell'], + ['first' => 'Abigail', 'last' => 'Otwell'], + ['first' => 'Taylor', 'last' => 'Swift'], + ], $c->uniqueStrings(function ($item) { + return $item['first'].$item['last']; + })->values()->all()); + + // With key parameter in closure + $c = new $collection([ + 'a' => ['code' => 'A1'], + 'b' => ['code' => 'B2'], + 'c' => ['code' => 'A1'], + 'd' => ['code' => 'D4'], + ]); + + $result = $c->uniqueStrings(function ($item, $key) { + return $item['code']; + })->all(); + + $this->assertCount(3, $result); + $this->assertArrayHasKey('a', $result); + $this->assertArrayHasKey('b', $result); + $this->assertArrayHasKey('d', $result); + } + + #[DataProvider('collectionClassProvider')] + public function testUniqueStringsPreservesKeys($collection) + { + // Numeric keys + $c = new $collection([ + 10 => 'apple', + 20 => 'banana', + 30 => 'apple', + 40 => 'cherry', + ]); + + $result = $c->uniqueStrings()->all(); + $this->assertEquals([ + 10 => 'apple', + 20 => 'banana', + 40 => 'cherry', + ], $result); + + // String keys + $c = new $collection([ + 'first' => 'foo', + 'second' => 'bar', + 'third' => 'foo', + 'fourth' => 'baz', + ]); + + $result = $c->uniqueStrings()->all(); + $this->assertEquals([ + 'first' => 'foo', + 'second' => 'bar', + 'fourth' => 'baz', + ], $result); + } + + #[DataProvider('collectionClassProvider')] + public function testUniqueStringsEmptyCollection($collection) + { + $c = new $collection([]); + $this->assertEquals([], $c->uniqueStrings()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testUniqueStringsSingleItem($collection) + { + $c = new $collection(['only-one']); + $this->assertEquals(['only-one'], $c->uniqueStrings()->all()); + } + #[DataProvider('collectionClassProvider')] public function testCollapse($collection) {