initial commit

This commit is contained in:
Joby 2024-07-10 20:45:48 -06:00
parent f6e4a96287
commit ff8bf631d2
8 changed files with 571 additions and 13 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/vendor
/composer.lock

24
LICENSE
View file

@ -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.

View file

@ -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.

22
composer.json Normal file
View file

@ -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"
}
}

164
src/Sorting/Sort.php Normal file
View file

@ -0,0 +1,164 @@
<?php
/**
* 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:
*
* 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.
*/
namespace Joby\Toolbox\Sorting;
/**
* Static class for sorting arrays using sets of callbacks. This is useful for
* cases where you want to sort an array by multiple criteria, and progressively
* move down the list of sorting criteria as needed to break ties.
*
* This class is a wrapper around the Sorter class, and is useful for cases
* where you only need to sort a single array and don't need to reuse the same
* set of sorters on multiple arrays. If you're going to be sorting many arrays
* using the same set of sorters it would be more efficient to instantiate a
* Sorter object and reuse it.
*/
class Sort
{
/**
* Sort an array using an array of callable sorters that will be passed
* pairs of items from the array, and the array will be sorted based on the
* results of the first sorter that returns a non-zero value for any
* given pair of items.
*
* This method takes its array by reference and sorts it in place to reduce
* memory use.
*
* Example sorting integers:
*
* ```php
* $data = [3, 1, 4, 1, 5, 9];
* Sort::sort($data, fn ($a, $b) => $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);
};
}
}

89
src/Sorting/Sorter.php Normal file
View file

@ -0,0 +1,89 @@
<?php
/**
* 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:
*
* 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.
*/
namespace Joby\Toolbox\Sorting;
/**
* Class for sorting arrays using sets of callbacks. This is useful for cases
* where you want to sort an array by multiple criteria, and progressively move
* down the list of sorting criteria as needed to break ties. Instantiating a
* Sorter object can be useful if you want to apply the same set of sorters to
* multiple input arrays.
*
* If you're only sorting one array, it may be more convenient to use the static
* form by calling Sort::sort() instead.
*/
class Sorter
{
protected array $comparisons = [];
/**
* Create a new Sorter object with the given list of sorting callbacks.
* The sorters will be called in order, and the array will be sorted based
* on the first one to return a non-zero value.
*/
public function __construct(callable ...$comparisons)
{
$this->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;
}
}

166
tests/Sorting/SortTest.php Normal file
View file

@ -0,0 +1,166 @@
<?php
/**
* 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:
*
* 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.
*/
namespace Joby\Toolbox\Sorting;
use PHPUnit\Framework\TestCase;
/**
* Test cases for the static Sort class, including its helpers for creating
* useful comparison callbacks.
*/
class SortTest extends TestCase
{
/**
* This is just ensuring that the static class is set up reasonably okay,
* because Sorter is tested elsewhere and this just uses that.
*/
function testSort()
{
$data = [3, 1, 4, 1, 5, 9];
Sort::sort($data, fn ($a, $b) => $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;
}
}

View file

@ -0,0 +1,110 @@
<?php
/**
* 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:
*
* 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.
*/
namespace Joby\Toolbox\Sorting;
use PHPUnit\Framework\TestCase;
/**
* Test cases for the Sorter class.
*/
class SorterTest extends TestCase
{
/**
* Confirm that sorting an empty array works.
*/
public function testEmptyArray()
{
$data = [];
$sorter = new Sorter();
$sorter->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);
}
}