From ff8bf631d2849d661c94f117a557803e8be64912 Mon Sep 17 00:00:00 2001 From: Joby Elliott Date: Wed, 10 Jul 2024 20:45:48 -0600 Subject: [PATCH] initial commit --- .gitignore | 2 + LICENSE | 24 ++--- README.md | 7 +- composer.json | 22 +++++ src/Sorting/Sort.php | 164 ++++++++++++++++++++++++++++++++++ src/Sorting/Sorter.php | 89 +++++++++++++++++++ tests/Sorting/SortTest.php | 166 +++++++++++++++++++++++++++++++++++ tests/Sorting/SorterTest.php | 110 +++++++++++++++++++++++ 8 files changed, 571 insertions(+), 13 deletions(-) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 src/Sorting/Sort.php create mode 100644 src/Sorting/Sorter.php create mode 100644 tests/Sorting/SortTest.php create mode 100644 tests/Sorting/SorterTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7602b69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/vendor +/composer.lock \ No newline at end of file diff --git a/LICENSE b/LICENSE index 249bf02..67d2315 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ MIT License +Joby's PHP Toolbox: https://code.byjoby.com/php-toolbox/ Copyright (c) 2024 Joby Elliott -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index f5872c4..695432a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,7 @@ -# php-toolbox +# Joby's PHP Toolbox + A lightweight collection of useful general purpose PHP tools with no dependencies. Committed to always at least having minimal dependencies. + +## Development status + +Anything that's in the `main` branch should have tests and be a stable API. I haven't set a version number yet because I don't want to call it 1.0 until I actually have a more significant number of features in here. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c1aa221 --- /dev/null +++ b/composer.json @@ -0,0 +1,22 @@ +{ + "name": "joby/toolbox", + "type": "library", + "license": "MIT", + "autoload": { + "psr-4": { + "Joby\\Toolbox\\": "src/" + } + }, + "authors": [ + { + "name": "Joby Elliot", + "email": "code@byjoby.com" + } + ], + "require": { + "php": "~8.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.2" + } +} diff --git a/src/Sorting/Sort.php b/src/Sorting/Sort.php new file mode 100644 index 0000000..3a7fe3b --- /dev/null +++ b/src/Sorting/Sort.php @@ -0,0 +1,164 @@ + $a <=> $b); + * ``` + * + * Example sorting integers with even numbers first: + * + * ```php + * $data = [3, 1, 4, 1, 5, 9]; + * Sort::sort($data, fn ($a, $b) => $a % 2 <=> $b % 2, fn ($a, $b) => $a <=> $b); + * ``` + */ + public static function sort(array &$data, callable ...$comparisons): void + { + (new Sorter(...$comparisons))->sort($data); + } + + /** + * Reverse the order of a sorting callback. + * + * Example sorting integers in reverse order: + * + * ```php + * $data = [3, 1, 4, 1, 5, 9]; + * Sort::sort($data, Sort::reverse(fn ($a, $b) => $a <=> $b)); + * ``` + */ + public static function reverse(callable $comparison): callable + { + return function ($a, $b) use ($comparison) { + return $comparison($b, $a); + }; + } + + /** + * Create a comparison callback that will call the same method on two + * objects and compare the results for sorting, optionally passing arguments + * to the methods. + * + * Example given a class with a method `getNumber()`: + * + * ```php + * $data = [...]; // array of objects with getNumber() method + * Sort::sort($data, Sort::compareMethods('getNumber')); + * ``` + */ + public static function compareMethods(string $method_name, mixed ...$args): callable + { + return function (object $a, object $b) use ($method_name, $args): int { + return call_user_func_array([$a, $method_name], $args) <=> call_user_func_array([$b, $method_name], $args); + }; + } + + /** + * Create a comparison callback that will compare the values of the same + * property on two objects for sorting. + * + * Example given a class with a property `itemName`: + * + * ```php + * $data = [...]; // array of objects with itemName property + * Sort::sort($data, Sort::compareProperties('itemName')); + * ``` + */ + public static function compareProperties(string $property_name): callable + { + return function (object $a, object $b) use ($property_name): int { + return $a->$property_name <=> $b->$property_name; + }; + } + + /** + * Create a comparison callback that will compare the values of the same + * key in two arrays for sorting. + * + * Example sorting by the 'name' key: + * + * ```php + * $data = [ + * ['name' => 'apple'], + * ['name' => 'banana'], + * ['name' => 'cherry'], + * ]; + * Sort::sort($data, Sort::compareArrayValues('name')); + * ``` + */ + public static function compareArrayValues(string $key): callable + { + return function (array $a, array $b) use ($key): int { + return @$a[$key] <=> @$b[$key]; + }; + } + + /** + * Create a comparison callback that will run a callback on items and + * compare the results for sorting. + * + * Example sorting by the length of strings: + * + * ```php + * $data = ['apple', 'banana', 'cherry', 'date', 'elderberry']; + * Sort::sort($data, Sort::compareCallbackResults(strlen(...))); + * ``` + */ + public static function compareCallbackResults(callable $callback): callable + { + return function (mixed $a, mixed $b) use ($callback): int { + return $callback($a) <=> $callback($b); + }; + } +} diff --git a/src/Sorting/Sorter.php b/src/Sorting/Sorter.php new file mode 100644 index 0000000..8eecfa5 --- /dev/null +++ b/src/Sorting/Sorter.php @@ -0,0 +1,89 @@ +comparisons = $comparisons; + } + + /** + * Add one or more sorting callbacks to this sorter. The new callbacks will + * be appended to the end of the existing list of sorters. + */ + public function addComparison(callable ...$comparisons): static + { + foreach ($comparisons as $sorter) { + $this->comparisons[] = $sorter; + } + return $this; + } + + /** + * Sort an array using the current list of callbacks. This method takes its + * array by reference and sorts it in place to reduce memory use. + */ + public function sort(array &$data): static + { + usort($data, static::sortItems(...)); + return $this; + } + + /** + * Determine which of two items should go first, or if they are a tie. + */ + protected function sortItems($a, $b): int + { + foreach ($this->comparisons as $comparison) { + $result = intval($comparison($a, $b)); + if ($result !== 0) { + return $result; + } + } + return 0; + } +} diff --git a/tests/Sorting/SortTest.php b/tests/Sorting/SortTest.php new file mode 100644 index 0000000..01d2016 --- /dev/null +++ b/tests/Sorting/SortTest.php @@ -0,0 +1,166 @@ + $a <=> $b); + $this->assertEquals([1, 1, 3, 4, 5, 9], $data); + } + + /** + * Test the reverse method to ensure it flips the order of a comparison. + */ + function testReverse() + { + $data = [3, 1, 4, 1, 5, 9]; + Sort::sort($data, Sort::reverse(fn ($a, $b) => $a <=> $b)); + $this->assertEquals([9, 5, 4, 3, 1, 1], $data); + } + + /** + * Test the compareProperties method to ensure it creates a comparison callback + * that calls the same method on two objects and compares the results. + */ + function testCompareProperties() + { + $data = [ + (object) ['name' => 'apple'], + (object) ['name' => 'banana'], + (object) ['name' => 'cherry'], + (object) ['name' => 'date'], + (object) ['name' => 'elderberry'], + ]; + Sort::sort($data, Sort::compareProperties('name')); + $this->assertEquals([ + (object) ['name' => 'apple'], + (object) ['name' => 'banana'], + (object) ['name' => 'cherry'], + (object) ['name' => 'date'], + (object) ['name' => 'elderberry'], + ], $data); + } + + function testCompareMethods() + { + $data = [ + new SortTestComparePropertiesHarness(9), + new SortTestComparePropertiesHarness(117), + new SortTestComparePropertiesHarness(28), + new SortTestComparePropertiesHarness(6), + new SortTestComparePropertiesHarness(212), + new SortTestComparePropertiesHarness(323), + ]; + // default value of method is mod 10, so it should sort by the last digit + Sort::sort($data, Sort::compareMethods('value')); + $this->assertEquals([ + new SortTestComparePropertiesHarness(212), + new SortTestComparePropertiesHarness(323), + new SortTestComparePropertiesHarness(6), + new SortTestComparePropertiesHarness(117), + new SortTestComparePropertiesHarness(28), + new SortTestComparePropertiesHarness(9), + ], $data); + // now sort passing an argument, mod 100 so it should sort by the last 2 digits + Sort::sort($data, Sort::compareMethods('value', 100)); + $this->assertEquals([ + new SortTestComparePropertiesHarness(6), + new SortTestComparePropertiesHarness(9), + new SortTestComparePropertiesHarness(212), + new SortTestComparePropertiesHarness(117), + new SortTestComparePropertiesHarness(323), + new SortTestComparePropertiesHarness(28), + + ], $data); + } + + /** + * Test that the callbacks created by compareArrayValues() succesfully sort + * arrays by a particular value within them. + */ + function testCompareArrayValues() + { + $data = [ + ['name' => 'apple'], + ['foo' => 'bar', 'name' => 'banana'], + ['name' => 'cherry'], + ['name' => 'date', 'buzz' => 'baz'], + ['name' => 'elderberry'], + ]; + Sort::sort($data, Sort::compareArrayValues('name')); + $this->assertEquals([ + ['name' => 'apple'], + ['foo' => 'bar', 'name' => 'banana'], + ['name' => 'cherry'], + ['name' => 'date', 'buzz' => 'baz'], + ['name' => 'elderberry'], + ], $data); + } + + /** + * Test that the compareCallbackResults() method works as expected by + * sorting a list of integers by their value mod 10. + */ + function testCompareCallbackResults() + { + $data = [9, 17, 28, 6, 12, 23]; + Sort::sort($data, Sort::compareCallbackResults(fn ($a) => $a % 10)); + $this->assertEquals([12, 23, 6, 17, 28, 9], $data); + } +} + +/** + * Harness object for testing the compareProperties method. + */ +class SortTestComparePropertiesHarness +{ + public int $value; + + public function __construct(int $value) + { + $this->value = $value; + } + + public function value(int $mod = 10): int + { + return $this->value % $mod; + } +} diff --git a/tests/Sorting/SorterTest.php b/tests/Sorting/SorterTest.php new file mode 100644 index 0000000..a1aa9b2 --- /dev/null +++ b/tests/Sorting/SorterTest.php @@ -0,0 +1,110 @@ +sort($data); + $this->assertEquals([], $data); + } + + /** + * Confirm that sorting an array of integers works. + */ + public function testSortingIntegers() + { + $data = [3, 1, 4, 1, 5, 9]; + $sorter = new Sorter(fn ($a, $b) => $a <=> $b); + $sorter->sort($data); + $this->assertEquals([1, 1, 3, 4, 5, 9], $data); + } + + /** + * Confirm that sorting an array of strings works. + */ + public function testSortingStrings() + { + $data = ['apple', 'banana', 'cherry', 'date', 'elderberry']; + $sorter = new Sorter(fn ($a, $b) => strcmp($a, $b)); + $sorter->sort($data); + $this->assertEquals(['apple', 'banana', 'cherry', 'date', 'elderberry'], $data); + } + + /** + * Confirm that sorting an array of strings by length works. + */ + public function testSortingByLength() + { + $data = ['apple', 'banana', 'cherry', 'date', 'elderberry']; + $sorter = new Sorter(fn ($a, $b) => strlen($a) <=> strlen($b)); + $sorter->sort($data); + $this->assertEquals(['date', 'apple', 'banana', 'cherry', 'elderberry'], $data); + } + + /** + * Confirm that sorting an array of strings by length works with a second comparison + * using strcmp to sort alphabetically in the case of a tie. + */ + public function testSortingByLengthWithTieBreaker() + { + $data = ['cc', 'c', 'ccc', 'aaa', 'aa', 'a', 'b', 'bb', 'bbb']; + $sorter = new Sorter( + fn ($a, $b) => strlen($a) <=> strlen($b), + fn ($a, $b) => strcmp($a, $b) + ); + $sorter->sort($data); + $this->assertEquals(['a', 'b', 'c', 'aa', 'bb', 'cc', 'aaa', 'bbb', 'ccc'], $data); + } + + /** + * Confirm that adding sorters using addSorter() works as expected. + */ + public function testAddingSorters() + { + $data = ['cc', 'c', 'ccc', 'aaa', 'aa', 'a', 'b', 'bb', 'bbb']; + $sorter = new Sorter(); + $sorter->addComparison( + fn ($a, $b) => strlen($a) <=> strlen($b), + fn ($a, $b) => strcmp($a, $b) + ); + $sorter->sort($data); + $this->assertEquals(['a', 'b', 'c', 'aa', 'bb', 'cc', 'aaa', 'bbb', 'ccc'], $data); + } +}