Merge branch 'main' of https://github.com/joby-lol/php-toolbox
This commit is contained in:
commit
3db4f9cddb
8 changed files with 2393 additions and 4 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -13,7 +13,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: php-actions/composer@v6
|
||||
- uses: php-actions/phpstan@v3
|
||||
- uses: php-actions/phpunit@v3
|
||||
- uses: php-actions/phpunit@v4
|
||||
with:
|
||||
version: 10
|
||||
test_suffix: "Test.php"
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
# Joby's PHP Toolbox
|
||||
|
||||
[![CI](https://github.com/joby-lol/php-toolbox/actions/workflows/ci.yml/badge.svg)](https://github.com/joby-lol/php-toolbox/actions/workflows/ci.yml)
|
||||
|
||||
A lightweight collection of useful general purpose PHP tools with no dependencies. Committed to always at least having minimal dependencies.
|
||||
|
||||
## Development status
|
||||
|
|
417
src/Ranges/AbstractRange.php
Normal file
417
src/Ranges/AbstractRange.php
Normal file
|
@ -0,0 +1,417 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Joby's PHP Toolbox: https://code.byjoby.com/php-toolbox/
|
||||
* MIT License: 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\Ranges;
|
||||
|
||||
use RuntimeException;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* Class to represent a range of values, which consists of a start and an end
|
||||
* value, each of which may be null to indicate an open range in that direction.
|
||||
* Any class tat extends this must implement some kind of ordering/hashing
|
||||
* mechanism to convert the values you want to express into integers, so that
|
||||
* they can be compared and determined to be "adjacent".
|
||||
*
|
||||
* For example, a range of dates could be represented by converting the date
|
||||
* into a timestamp for if you wanted a resolution of 1 second, or by converting
|
||||
* it to the number of days since some early epoch start if you wanted day
|
||||
* resolution. The details of how things are converted mostly only matter if you
|
||||
* are going to be checking for adjacency. If you don't need adjacency checks it
|
||||
* only matters that your method maintains proper ordering.
|
||||
*
|
||||
* @template T of mixed
|
||||
*/
|
||||
abstract class AbstractRange implements Stringable
|
||||
{
|
||||
protected int|float $start;
|
||||
protected int|float $end;
|
||||
protected mixed $start_value;
|
||||
protected mixed $end_value;
|
||||
|
||||
/**
|
||||
* This must be essentially a hash function, which converts a given value
|
||||
* into an integer, which represents its ordering somehow.
|
||||
* @param T $value
|
||||
* @return int
|
||||
*/
|
||||
abstract protected static function valueToInteger(mixed $value): int;
|
||||
|
||||
/**
|
||||
* This must be the inverse of the valueToInteger method, which converts an
|
||||
* integer back into the original value.
|
||||
* @param int $integer
|
||||
* @return T
|
||||
*/
|
||||
abstract protected static function integerToValue(int $integer): mixed;
|
||||
|
||||
/**
|
||||
* This must prepare a value to be stored in this object, which may just be
|
||||
* passing it blindly, cloning an object, or rounding it, etc.
|
||||
* @param T $value
|
||||
* @return T
|
||||
*/
|
||||
abstract protected static function prepareValue(mixed $value): mixed;
|
||||
|
||||
/**
|
||||
* This must return the value that is immediately before a given integer.
|
||||
* Returns null if number is infinite.
|
||||
* @return T|null
|
||||
*/
|
||||
protected static function valueBefore(int|float $number): mixed
|
||||
{
|
||||
if ($number == INF) return null;
|
||||
if ($number == -INF) return null;
|
||||
return static::integerToValue((int)$number - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* This must return the value that is immediately after a given integer.
|
||||
* Returns null if number is infinite.
|
||||
* @return T|null
|
||||
*/
|
||||
protected static function valueAfter(int|float $number): mixed
|
||||
{
|
||||
if ($number == INF) return null;
|
||||
if ($number == -INF) return null;
|
||||
return static::integerToValue((int)$number + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param T|null $start
|
||||
* @param T|null $end
|
||||
*/
|
||||
final public function __construct($start, $end)
|
||||
{
|
||||
$this->setStart($start);
|
||||
$this->setEnd($end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a boolean AND operation on this range and another, returning the
|
||||
* range that they both cover, returns null if they do not overlap.
|
||||
* @param static $other
|
||||
*/
|
||||
public function booleanAnd(AbstractRange $other): static|null
|
||||
{
|
||||
if ($this->contains($other)) return new static($other->start(), $other->end());
|
||||
elseif ($other->contains($this)) return new static($this->start(), $this->end());
|
||||
elseif ($this->intersects($other)) {
|
||||
return new static(
|
||||
$this->extendsBefore($other) ? $other->start() : $this->start(),
|
||||
$this->extendsAfter($other) ? $other->end() : $this->end()
|
||||
);
|
||||
} else return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a boolean OR operation on this range and another, returning an
|
||||
* array of all areas that are covered by either range (if the ranges do not
|
||||
* overlap this array will contain both ranges separately). Separate objects
|
||||
* must be returned in ascending order.
|
||||
* @param static $other
|
||||
* @return RangeCollection<static>
|
||||
*/
|
||||
public function booleanOr(AbstractRange $other): RangeCollection
|
||||
{
|
||||
if ($this->intersects($other) || $this->adjacent($other)) {
|
||||
return RangeCollection::create(
|
||||
new static(
|
||||
$this->extendsBefore($other) ? $this->start() : $other->start(),
|
||||
$this->extendsAfter($other) ? $this->end() : $other->end()
|
||||
)
|
||||
);
|
||||
} else {
|
||||
if ($this->extendsBefore($other)) {
|
||||
return RangeCollection::create(
|
||||
new static($this->start(), $this->end()),
|
||||
new static($other->start(), $other->end())
|
||||
);
|
||||
} else {
|
||||
return RangeCollection::create(
|
||||
new static($other->start(), $other->end()),
|
||||
new static($this->start(), $this->end())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a boolean XOR operation on this range and another, returning an
|
||||
* array of all areas that are covered by either range but not both. If the
|
||||
* ranges do not overlap, this array will contain both ranges separately.
|
||||
* Separate objects must be returned in ascending order.
|
||||
* @param static $other
|
||||
* @return RangeCollection<static>
|
||||
*/
|
||||
public function booleanXor(AbstractRange $other): RangeCollection
|
||||
{
|
||||
// if the ranges are equal, return an empty array
|
||||
if ($this->equals($other)) return RangeCollection::createEmpty($other);
|
||||
// if the ranges are adjacent return a single range
|
||||
if ($this->adjacent($other)) return $this->booleanOr($other);
|
||||
// if the ranges do not overlap, return both ranges
|
||||
if (!$this->intersects($other)) {
|
||||
return RangeCollection::create(new static($this->start(), $this->end()), new static($other->start(), $other->end()));
|
||||
}
|
||||
// otherwise get the maximum bounds minus wherever these intersect
|
||||
$range = new static(
|
||||
$this->extendsBefore($other) ? $this->start() : $other->start(),
|
||||
$this->extendsAfter($other) ? $this->end() : $other->end()
|
||||
);
|
||||
if ($intersect = $this->booleanAnd($other)) {
|
||||
return $range->booleanNot($intersect);
|
||||
} else {
|
||||
return RangeCollection::create($range);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all areas that are covered by both this range and another, sliced
|
||||
* into up to three different ranges along the boundaries other boolean
|
||||
* operations would break on. Areas will be returned in ascending order, and
|
||||
* some information about the relationships between the ranges can be inferred
|
||||
* from the number fo ranges returned here:
|
||||
* - 1 range: the entered ranges are equal
|
||||
* - 2 ranges: the entered ranges are adjacent, disjoint, or overlap with a shared boundary
|
||||
* - 3 ranges: the entered ranges overlap with space on each end
|
||||
* @param static $other
|
||||
* @return RangeCollection<static>
|
||||
*/
|
||||
public function booleanSlice(AbstractRange $other): RangeCollection
|
||||
{
|
||||
// if the ranges are equal, return a single range
|
||||
if ($this->equals($other)) return RangeCollection::create(new static($this->start(), $this->end()));
|
||||
// if the ranges do not overlap, return two ranges
|
||||
if (!$this->intersects($other)) {
|
||||
return RangeCollection::create(
|
||||
new static($this->start(), $this->end()),
|
||||
new static($other->start(), $other->end())
|
||||
);
|
||||
}
|
||||
// otherwise get the maximum bounds minus wherever these intersect
|
||||
$overall_range = new static(
|
||||
$this->extendsBefore($other) ? $this->start() : $other->start(),
|
||||
$this->extendsAfter($other) ? $this->end() : $other->end()
|
||||
);
|
||||
$intersection = $this->booleanAnd($other);
|
||||
assert($intersection !== null);
|
||||
$xor = $overall_range->booleanNot($intersection);
|
||||
if (count($xor) == 2) {
|
||||
assert(isset($xor[0], $xor[1]));
|
||||
return RangeCollection::create($xor[0], $intersection, $xor[1]);
|
||||
} elseif (count($xor) == 1) {
|
||||
assert(isset($xor[0]));
|
||||
return RangeCollection::create($intersection, $xor[0]);
|
||||
}
|
||||
// throw an exception if we get in an unexpected state
|
||||
throw new RuntimeException(sprintf("Unexpected state (%s,%s) (%s,%s)", $this->start, $this->end, $other->start, $other->end));
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a boolean NOT operation on this range and another, returning an
|
||||
* array of all areas that are covered by this range but not the other. If
|
||||
* the other range completely covers this range, an empty array will be
|
||||
* returned. Separate objects must be returned in ascending order.
|
||||
* @param static $other
|
||||
* @return RangeCollection<static>
|
||||
*/
|
||||
public function booleanNot(AbstractRange $other): RangeCollection
|
||||
{
|
||||
// if this range is completely contained by the other, return an empty array
|
||||
if ($other->contains($this)) {
|
||||
return RangeCollection::createEmpty($other);
|
||||
}
|
||||
// if the ranges do not overlap, return this range
|
||||
if (!$this->intersects($other)) {
|
||||
return RangeCollection::create(new static($this->start(), $this->end()));
|
||||
}
|
||||
// if this range completely contains the other, return the range from the start of this range to the start of the other
|
||||
if ($this->contains($other)) {
|
||||
if ($this->start == $other->start) {
|
||||
return RangeCollection::create(new static(static::valueAfter($other->end), $this->end()));
|
||||
} elseif ($this->end == $other->end) {
|
||||
return RangeCollection::create(new static($this->start(), static::valueBefore($other->start)));
|
||||
} else {
|
||||
return RangeCollection::create(
|
||||
new static($this->start(), static::valueBefore($other->start)),
|
||||
new static(static::valueAfter($other->end), $this->end())
|
||||
);
|
||||
}
|
||||
}
|
||||
// if this range extends before the other, return the range from the start of this range to the start of the other
|
||||
if ($this->extendsBefore($other)) {
|
||||
return RangeCollection::create(new static($this->start(), static::valueBefore($other->start)));
|
||||
}
|
||||
// if this range extends after the other, return the range from the end of the other to the end of this range
|
||||
if ($this->extendsAfter($other)) {
|
||||
return RangeCollection::create(new static(static::valueAfter($other->end), $this->end()));
|
||||
}
|
||||
// throw an exception if we get in an unexpected state
|
||||
throw new RuntimeException(sprintf("Unexpected state (%s,%s) (%s,%s)", $this->start, $this->end, $other->start, $other->end));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this range has the same start and end as another range.
|
||||
* @param static $other
|
||||
*/
|
||||
public function equals(AbstractRange $other): bool
|
||||
{
|
||||
return $this->start == $other->start && $this->end == $other->end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any part of this range overlaps with another range.
|
||||
* @param static $other
|
||||
*/
|
||||
public function intersects(AbstractRange $other): bool
|
||||
{
|
||||
if ($this->start > $other->end) return false;
|
||||
if ($this->end < $other->start) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this range completely contains another range.
|
||||
* @param static $other
|
||||
*/
|
||||
public function contains(AbstractRange $other): bool
|
||||
{
|
||||
if ($this->start > $other->start) return false;
|
||||
if ($this->end < $other->end) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the end of this range is after the end of another range
|
||||
* @param static $other
|
||||
*/
|
||||
public function extendsAfter(AbstractRange $other): bool
|
||||
{
|
||||
return $this->end > $other->end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the start of this range is before the start of another range
|
||||
* @param static $other
|
||||
*/
|
||||
public function extendsBefore(AbstractRange $other): bool
|
||||
{
|
||||
return $this->start < $other->start;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this range is directly adjacent to but not overlapping another range. This
|
||||
* is equivalent to checking both abutsStartOf and abutsEndOf.
|
||||
* @param static $other
|
||||
*/
|
||||
public function adjacent(AbstractRange $other): bool
|
||||
{
|
||||
return $this->adjacentRightOf($other) || $this->adjacentLeftOf($other);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the start of this range directly abuts the end of another range.
|
||||
* This means that they do not overlap, but are directly adjacent.
|
||||
* @param static $other
|
||||
*/
|
||||
public function adjacentRightOf(AbstractRange $other): bool
|
||||
{
|
||||
if ($this->start == -INF || $other->end == INF) return false;
|
||||
return $this->start == $other->end + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the end of this range directly abuts the start of another range.
|
||||
* This means that they do not overlap, but are directly adjacent.
|
||||
* @param static $other
|
||||
*/
|
||||
public function adjacentLeftOf(AbstractRange $other): bool
|
||||
{
|
||||
if ($this->end == INF || $other->start == -INF) return false;
|
||||
return $this->end == $other->start - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param T|null $start
|
||||
* @return static
|
||||
*/
|
||||
public function setStart(mixed $start): static
|
||||
{
|
||||
$this->start = is_null($start) ? -INF
|
||||
: static::valueToInteger($start);
|
||||
$this->start_value = is_null($start) ? null
|
||||
: static::prepareValue($start);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param T|null $end
|
||||
* @return static
|
||||
*/
|
||||
public function setEnd(mixed $end): static
|
||||
{
|
||||
$this->end = is_null($end) ? INF
|
||||
: static::valueToInteger($end);
|
||||
$this->end_value = is_null($end) ? null
|
||||
: static::prepareValue($end);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return T|null
|
||||
*/
|
||||
public function start(): mixed
|
||||
{
|
||||
return $this->start_value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return T|null
|
||||
*/
|
||||
public function end(): mixed
|
||||
{
|
||||
return $this->end_value;
|
||||
}
|
||||
|
||||
public function startAsNumber(): int|float
|
||||
{
|
||||
return $this->start;
|
||||
}
|
||||
|
||||
public function endAsNumber(): int|float
|
||||
{
|
||||
return $this->end;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return sprintf(
|
||||
'[%s...%s]',
|
||||
$this->start === -INF ? '' : (is_string($this->start_value) ? $this->start_value : $this->start),
|
||||
$this->end === INF ? '' : (is_string($this->end_value) ? $this->end_value : $this->end)
|
||||
);
|
||||
}
|
||||
}
|
57
src/Ranges/IntegerRange.php
Normal file
57
src/Ranges/IntegerRange.php
Normal file
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Joby's PHP Toolbox: https://code.byjoby.com/php-toolbox/
|
||||
* MIT License: 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\Ranges;
|
||||
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* The simplest possible implementation of AbstractRange, which uses integers as
|
||||
* its values as well as it's internal hashes. All it does to convert values is
|
||||
* cast them to integers.
|
||||
*
|
||||
* This class is also effectively the test harness for AbstractRange. So it has
|
||||
* extremely comprehensive tests while other implementations might only test
|
||||
* for basic functionality and common off-by-one error locations.
|
||||
*
|
||||
* @extends AbstractRange<int>
|
||||
*/
|
||||
class IntegerRange extends AbstractRange
|
||||
{
|
||||
protected static function valueToInteger(mixed $value): int
|
||||
{
|
||||
return (int)$value;
|
||||
}
|
||||
|
||||
protected static function integerToValue(int $integer): mixed
|
||||
{
|
||||
return $integer;
|
||||
}
|
||||
|
||||
protected static function prepareValue(mixed $value): mixed
|
||||
{
|
||||
return (int)$value;
|
||||
}
|
||||
}
|
314
src/Ranges/RangeCollection.php
Normal file
314
src/Ranges/RangeCollection.php
Normal file
|
@ -0,0 +1,314 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Joby's PHP Toolbox: https://code.byjoby.com/php-toolbox/
|
||||
* MIT License: 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\Ranges;
|
||||
|
||||
use ArrayAccess;
|
||||
use ArrayIterator;
|
||||
use Countable;
|
||||
use InvalidArgumentException;
|
||||
use IteratorAggregate;
|
||||
use Joby\Toolbox\Sorting\Sorter;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* Stores a collection of ranges, which must be all of the same type, and
|
||||
* ensures that they are always sorted in ascencing chronological order. Also
|
||||
* provides a number of methods for manipulating the colleciton, such as merging
|
||||
* it with another collection, mapping, filtering, boolean operations with other
|
||||
* collections or ranges, etc.
|
||||
*
|
||||
* It also allows counting, array access, and iteration in foreach loops.
|
||||
*
|
||||
* @template T of AbstractRange
|
||||
* @implements ArrayAccess<int,T>
|
||||
* @implements IteratorAggregate<int,T>
|
||||
*/
|
||||
class RangeCollection implements Countable, ArrayAccess, IteratorAggregate, Stringable
|
||||
{
|
||||
/** @var class-string<T> */
|
||||
protected string $class;
|
||||
/** @var array<int,T> */
|
||||
protected $ranges = [];
|
||||
|
||||
/**
|
||||
* Create a new collection from any number of ranges. Must have at least one
|
||||
* argument, which is used to determine the type of range to store.
|
||||
*
|
||||
* @template RangeType of AbstractRange
|
||||
* @param RangeType $range
|
||||
* @param RangeType ...$ranges
|
||||
* @return RangeCollection<RangeType>
|
||||
*/
|
||||
public static function create(AbstractRange $range, AbstractRange ...$ranges): RangeCollection
|
||||
{
|
||||
return new RangeCollection($range::class, $range, ...$ranges);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty collection of a specific range type.
|
||||
*
|
||||
* @template RangeType of AbstractRange
|
||||
* @param RangeType|class-string<RangeType> $class
|
||||
* @return RangeCollection<RangeType>
|
||||
*/
|
||||
public static function createEmpty(AbstractRange|string $class): RangeCollection
|
||||
{
|
||||
if (is_object($class)) return new RangeCollection($class::class);
|
||||
else return new RangeCollection($class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<T> $class
|
||||
* @param T ...$ranges
|
||||
* @return void
|
||||
*/
|
||||
protected function __construct(string $class, AbstractRange ...$ranges)
|
||||
{
|
||||
foreach ($ranges as $range) {
|
||||
if (!($range instanceof $class)) {
|
||||
throw new InvalidArgumentException("Ranges must be of type $class");
|
||||
}
|
||||
}
|
||||
$this->class = $class;
|
||||
$this->ranges = array_values($ranges);
|
||||
$this->sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract the given range from all ranges in this collection.
|
||||
* @param T $other
|
||||
* @return RangeCollection<T>
|
||||
*/
|
||||
public function booleanNot(AbstractRange $other): RangeCollection
|
||||
{
|
||||
return $this->map(function (AbstractRange $range) use ($other) {
|
||||
return $range->booleanNot($other);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only the ranges that intersect with the given range from any
|
||||
* range in this colelction.
|
||||
* @param T $other
|
||||
* @return RangeCollection<T>
|
||||
*/
|
||||
public function booleanAnd(AbstractRange $other): RangeCollection
|
||||
{
|
||||
return $this->map(function (AbstractRange $range) use ($other) {
|
||||
return $range->booleanAnd($other);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform this collection into the set of ranges that fully contain all
|
||||
* ranges in this collection. This is done by merging overlapping or adjacent
|
||||
* ranges until no more merges are possible.
|
||||
*
|
||||
* @return RangeCollection<T>
|
||||
*/
|
||||
public function mergeRanges(): RangeCollection
|
||||
{
|
||||
return $this
|
||||
->mergeIntersectingRanges()
|
||||
->mergeAdjacentRanges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all ranges that intersect with each other into single continuous
|
||||
* ranges instead of a buch of separate chunks.
|
||||
*
|
||||
* @return RangeCollection<T>
|
||||
*/
|
||||
public function mergeIntersectingRanges(): RangeCollection
|
||||
{
|
||||
/** @var array<int,T> */
|
||||
$merged = [];
|
||||
foreach ($this->ranges as $range) {
|
||||
$found = false;
|
||||
foreach ($merged as $k => $m) {
|
||||
if ($range->intersects($m)) {
|
||||
$found = true;
|
||||
$merged[$k] = $m->booleanOr($range)[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$found) $merged[] = $range;
|
||||
}
|
||||
return new RangeCollection($this->class, ...$merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all ranges that are adjacent to each other into single continuous
|
||||
* ranges instead of a buch of separate chunks. Note that this does not
|
||||
* merge ranges that overlap, and strictly merges only ranges that are
|
||||
* adjacent. If ranges are adjacent to multiple other ranges only one will
|
||||
* be merged, and the others will remain separate. This method is protected
|
||||
* because its behavior is complex in the case of multiple adjacent ranges
|
||||
* and most users are probably looking for the behavior of mergeRanges()
|
||||
* or mergeIntersectingRanges() anyway.
|
||||
* @return RangeCollection<T>
|
||||
*/
|
||||
protected function mergeAdjacentRanges(): RangeCollection
|
||||
{
|
||||
/** @var array<int,T> */
|
||||
$merged = [];
|
||||
foreach ($this->ranges as $range) {
|
||||
$found = false;
|
||||
foreach ($merged as $k => $m) {
|
||||
if ($range->adjacent($m)) {
|
||||
$found = true;
|
||||
$merged[$k] = $m->booleanOr($range)[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$found) $merged[] = $range;
|
||||
}
|
||||
return new RangeCollection($this->class, ...$merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter this collection to only include ranges that return true when
|
||||
* passed to the provided callback.
|
||||
* @param callable(T):bool $callback
|
||||
* @return RangeCollection<T>
|
||||
*/
|
||||
public function filter(callable $callback): RangeCollection
|
||||
{
|
||||
return new RangeCollection($this->class, ...array_filter($this->ranges, $callback));
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect and modify each range in the collection using the provided callback. If the callback returns a collection it will be merged. If the callback returns null, the range will be removed.
|
||||
* @param callable(T):(T|RangeCollection<T>|null) $callback
|
||||
* @return RangeCollection<T>
|
||||
*/
|
||||
public function map(callable $callback): RangeCollection
|
||||
{
|
||||
$new_ranges = [];
|
||||
foreach ($this->ranges as $range) {
|
||||
$new_range = $callback($range);
|
||||
if ($new_range instanceof RangeCollection) {
|
||||
$new_ranges = array_merge($new_ranges, $new_range->toArray());
|
||||
} elseif ($new_range !== null) {
|
||||
$new_ranges[] = $new_range;
|
||||
}
|
||||
}
|
||||
return new RangeCollection($this->class, ...$new_ranges);
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return empty($this->ranges);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,T>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param T ...$ranges
|
||||
* @return RangeCollection<T>
|
||||
*/
|
||||
public function add(AbstractRange ...$ranges): RangeCollection
|
||||
{
|
||||
$ranges = array_merge($this->ranges, $ranges);
|
||||
return new RangeCollection($this->class, ...$ranges);
|
||||
}
|
||||
|
||||
protected function sort(): void
|
||||
{
|
||||
static $sorter;
|
||||
$sorter = $sorter ?? $sorter = new Sorter(
|
||||
fn (AbstractRange $a, AbstractRange $b): int => $a->startAsNumber() <=> $b->startAsNumber(),
|
||||
fn (AbstractRange $a, AbstractRange $b): int => $a->endAsNumber() <=> $b->endAsNumber(),
|
||||
);
|
||||
$sorter->sort($this->ranges);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->ranges);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $offset
|
||||
* @return bool
|
||||
*/
|
||||
public function offsetExists($offset): bool
|
||||
{
|
||||
return isset($this->ranges[$offset]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $offset
|
||||
* @return T|null
|
||||
*/
|
||||
public function offsetGet($offset): ?AbstractRange
|
||||
{
|
||||
return $this->ranges[$offset] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|null $offset
|
||||
* @param T $value
|
||||
*/
|
||||
public function offsetSet($offset, $value): void
|
||||
{
|
||||
if (is_null($offset)) $this->add($value);
|
||||
else {
|
||||
if (!($value instanceof $this->class)) {
|
||||
throw new InvalidArgumentException("Ranges must be of type $this->class");
|
||||
}
|
||||
$this->ranges[$offset] = $value;
|
||||
$this->sort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $offset
|
||||
*/
|
||||
public function offsetUnset($offset): void
|
||||
{
|
||||
unset($this->ranges[$offset]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ArrayIterator<int,T>
|
||||
*/
|
||||
public function getIterator(): ArrayIterator
|
||||
{
|
||||
return new ArrayIterator($this->ranges);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return implode(', ', $this->ranges);
|
||||
}
|
||||
}
|
|
@ -37,7 +37,7 @@ namespace Joby\Toolbox\Sorting;
|
|||
*/
|
||||
class Sorter
|
||||
{
|
||||
/** @var array<callable(mixed, mixed): int> */
|
||||
/** @var array<callable> */
|
||||
protected array $comparisons = [];
|
||||
|
||||
/**
|
||||
|
@ -45,7 +45,7 @@ class Sorter
|
|||
* The sorters will be called in order, and the array will be sorted based
|
||||
* on the first one to return a non-zero value.
|
||||
*
|
||||
* @param callable(mixed, mixed): int ...$comparisons
|
||||
* @param callable ...$comparisons
|
||||
*/
|
||||
public function __construct(callable ...$comparisons)
|
||||
{
|
||||
|
@ -56,7 +56,7 @@ class Sorter
|
|||
* Add one or more sorting callbacks to this sorter. The new callbacks will
|
||||
* be appended to the end of the existing list of sorters.
|
||||
*
|
||||
* @param callable(mixed, mixed): int ...$comparisons
|
||||
* @param callable ...$comparisons
|
||||
*/
|
||||
public function addComparison(callable ...$comparisons): static
|
||||
{
|
||||
|
|
1253
tests/Ranges/IntegerRangeTest.php
Normal file
1253
tests/Ranges/IntegerRangeTest.php
Normal file
File diff suppressed because it is too large
Load diff
346
tests/Ranges/RangeCollectionTest.php
Normal file
346
tests/Ranges/RangeCollectionTest.php
Normal file
|
@ -0,0 +1,346 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Joby's PHP Toolbox: https://code.byjoby.com/php-toolbox/
|
||||
* MIT License: 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 Joby\Toolbox\Ranges\IntegerRange;
|
||||
use Joby\Toolbox\Ranges\RangeCollection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class RangeCollectionTest extends TestCase
|
||||
{
|
||||
|
||||
public function testEmpty()
|
||||
{
|
||||
$collection = RangeCollection::createEmpty(IntegerRange::class);
|
||||
$this->assertEquals(
|
||||
'',
|
||||
(string)$collection
|
||||
);
|
||||
$this->assertEquals(
|
||||
0,
|
||||
count($collection)
|
||||
);
|
||||
$this->assertEquals(
|
||||
[],
|
||||
$collection->toArray()
|
||||
);
|
||||
}
|
||||
|
||||
public function testSorting()
|
||||
{
|
||||
// basic in-order sorting, sorted by start date
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 2),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4)
|
||||
);
|
||||
$this->assertEquals(
|
||||
'[1...2], [2...4], [3...4]',
|
||||
(string)$collection
|
||||
);
|
||||
// ties in the start date are broken by which has an earlier end date
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 2),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
new IntegerRange(2, 3)
|
||||
);
|
||||
$this->assertEquals(
|
||||
'[1...2], [2...3], [2...4], [3...4]',
|
||||
(string)$collection
|
||||
);
|
||||
// infinite start dates go first
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
new IntegerRange(2, 3),
|
||||
new IntegerRange(null, 2)
|
||||
);
|
||||
$this->assertEquals(
|
||||
'[...2], [2...3], [2...4], [3...4]',
|
||||
(string)$collection
|
||||
);
|
||||
// infinite end dates go last
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(2, 4),
|
||||
new IntegerRange(2, null),
|
||||
new IntegerRange(2, 3),
|
||||
);
|
||||
$this->assertEquals(
|
||||
'[2...3], [2...4], [2...]',
|
||||
(string)$collection
|
||||
);
|
||||
}
|
||||
|
||||
public function testFilter()
|
||||
{
|
||||
// filter a collection to only include ranges that start with 2
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 2),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
new IntegerRange(2, 3)
|
||||
);
|
||||
$filtered = $collection->filter(function ($range) {
|
||||
return $range->start() == 2;
|
||||
});
|
||||
$this->assertEquals(
|
||||
'[2...3], [2...4]',
|
||||
(string)$filtered
|
||||
);
|
||||
}
|
||||
|
||||
public function testMap()
|
||||
{
|
||||
// map a collection to include only the end date of each range
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 2),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
new IntegerRange(2, 3)
|
||||
);
|
||||
$mapped = $collection->map(function ($range) {
|
||||
return new IntegerRange(null, $range->end());
|
||||
});
|
||||
$this->assertEquals(
|
||||
'[...2], [...3], [...4], [...4]',
|
||||
(string)$mapped
|
||||
);
|
||||
// map a collection to make a one-unit collection of the start and end of each range
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 2),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
new IntegerRange(2, 3)
|
||||
);
|
||||
$mapped = $collection->map(function ($range) {
|
||||
return RangeCollection::create(
|
||||
new IntegerRange($range->start(), $range->start()),
|
||||
new IntegerRange($range->end(), $range->end())
|
||||
);
|
||||
});
|
||||
$this->assertEquals(
|
||||
'[1...1], [2...2], [2...2], [2...2], [3...3], [3...3], [4...4], [4...4]',
|
||||
(string)$mapped
|
||||
);
|
||||
// map a collection to remove any range that starts with 2
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 2),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
new IntegerRange(2, 3)
|
||||
);
|
||||
$mapped = $collection->map(function ($range) {
|
||||
if ($range->start() == 2) return null;
|
||||
return $range;
|
||||
});
|
||||
$this->assertEquals(
|
||||
'[1...2], [3...4]',
|
||||
(string)$mapped
|
||||
);
|
||||
}
|
||||
|
||||
public function testMergeIntersectingRanges()
|
||||
{
|
||||
// simple intersecting ranges
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 3),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
);
|
||||
$merged = $collection->mergeIntersectingRanges();
|
||||
$this->assertEquals(
|
||||
'[1...4]',
|
||||
(string)$merged
|
||||
);
|
||||
// two groups of intersecting ranges that are adjacent but not overlapping
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 2),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
new IntegerRange(2, 3),
|
||||
new IntegerRange(5, 6),
|
||||
new IntegerRange(7, 8),
|
||||
new IntegerRange(6, 8),
|
||||
new IntegerRange(6, 7)
|
||||
);
|
||||
$merged = $collection->mergeIntersectingRanges();
|
||||
$this->assertEquals(
|
||||
'[1...4], [5...8]',
|
||||
(string)$merged
|
||||
);
|
||||
}
|
||||
|
||||
public function testMergeRanges()
|
||||
{
|
||||
// this method does everything mergeIntersectingRanges does, plus it
|
||||
// merges adjacent ranges effectively this method turns a collection of
|
||||
// ranges into the smallest possible set of ranges that fully contain
|
||||
// all the original ranges
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 3),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
);
|
||||
$merged = $collection->mergeRanges();
|
||||
$this->assertEquals(
|
||||
'[1...4]',
|
||||
(string)$merged
|
||||
);
|
||||
// two groups of intersecting ranges that are adjacent but not overlapping
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 2),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
new IntegerRange(2, 3),
|
||||
new IntegerRange(5, 6),
|
||||
new IntegerRange(7, 8),
|
||||
new IntegerRange(6, 8),
|
||||
new IntegerRange(6, 7)
|
||||
);
|
||||
$merged = $collection->mergeRanges();
|
||||
$this->assertEquals(
|
||||
'[1...8]',
|
||||
(string)$merged
|
||||
);
|
||||
// two groups that are not adjacent
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 2),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
new IntegerRange(2, 3),
|
||||
new IntegerRange(5, 6),
|
||||
new IntegerRange(7, 8),
|
||||
new IntegerRange(6, 8),
|
||||
new IntegerRange(6, 7),
|
||||
new IntegerRange(10, 11),
|
||||
new IntegerRange(12, 13),
|
||||
new IntegerRange(11, 13),
|
||||
new IntegerRange(11, 12)
|
||||
);
|
||||
$merged = $collection->mergeRanges();
|
||||
$this->assertEquals(
|
||||
'[1...8], [10...13]',
|
||||
(string)$merged
|
||||
);
|
||||
}
|
||||
|
||||
public function testBooleanNot()
|
||||
{
|
||||
// subtract a range from a collection
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 3),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
);
|
||||
$subtracted = $collection->booleanNot(new IntegerRange(2, 3));
|
||||
$this->assertEquals(
|
||||
'[1...1], [4...4], [4...4]',
|
||||
(string)$subtracted
|
||||
);
|
||||
// subtract a range from a collection that is fully contained
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 3),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
);
|
||||
$subtracted = $collection->booleanNot(new IntegerRange(2, 4));
|
||||
$this->assertEquals(
|
||||
'[1...1]',
|
||||
(string)$subtracted
|
||||
);
|
||||
// subtract a range from a collection that fully contains the range
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 3),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
);
|
||||
$subtracted = $collection->booleanNot(new IntegerRange(1, 4));
|
||||
$this->assertEquals(
|
||||
'',
|
||||
(string)$subtracted
|
||||
);
|
||||
// subtract a range from a collection that is fully contained
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 3),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
);
|
||||
$subtracted = $collection->booleanNot(new IntegerRange(1, 3));
|
||||
$this->assertEquals(
|
||||
'[4...4], [4...4]',
|
||||
(string)$subtracted
|
||||
);
|
||||
}
|
||||
|
||||
public function testBooleanAnd()
|
||||
{
|
||||
// intersect a range with a collection
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 3),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
);
|
||||
$intersected = $collection->booleanAnd(new IntegerRange(2, 3));
|
||||
$this->assertEquals(
|
||||
'[2...3], [2...3], [3...3]',
|
||||
(string)$intersected
|
||||
);
|
||||
// intersect a range with a collection that is fully contained
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 3),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
);
|
||||
$intersected = $collection->booleanAnd(new IntegerRange(2, 4));
|
||||
$this->assertEquals(
|
||||
'[2...3], [2...4], [3...4]',
|
||||
(string)$intersected
|
||||
);
|
||||
// intersect a range with a collection that fully contains the range
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 3),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
);
|
||||
$intersected = $collection->booleanAnd(new IntegerRange(1, 4));
|
||||
$this->assertEquals(
|
||||
'[1...3], [2...4], [3...4]',
|
||||
(string)$intersected
|
||||
);
|
||||
// intersect a range with a collection that is fully contained
|
||||
$collection = RangeCollection::create(
|
||||
new IntegerRange(1, 3),
|
||||
new IntegerRange(3, 4),
|
||||
new IntegerRange(2, 4),
|
||||
);
|
||||
$intersected = $collection->booleanAnd(new IntegerRange(1, 3));
|
||||
$this->assertEquals(
|
||||
'[1...3], [2...3], [3...3]',
|
||||
(string)$intersected
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue