2026-03-30 16:29:57 +00:00

728 lines
19 KiB
PHP

<?php
/**
* League.Period (https://period.thephpleague.com)
*
* (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Period;
use ArrayAccess;
use Countable;
use Iterator;
use IteratorAggregate;
use JsonSerializable;
use TypeError;
use function array_filter;
use function array_merge;
use function array_splice;
use function array_unshift;
use function array_values;
use function count;
use function reset;
use function sprintf;
use function uasort;
use function usort;
use const ARRAY_FILTER_USE_BOTH;
/**
* A class to manipulate interval collection.
*
* @package League.period
* @author Ignace Nyamagana Butera <nyamsprod@gmail.com>
* @since 4.1.0
*/
final class Sequence implements ArrayAccess, Countable, IteratorAggregate, JsonSerializable
{
/** @var array<Period> */
private $intervals;
public function __construct(Period ...$intervals)
{
$this->intervals = $intervals;
}
/**
* Returns the sequence length as a Period instance.
*
* If the sequence contains no interval null is returned.
*
* @return ?Period
*/
public function length(): ?Period
{
$period = reset($this->intervals);
if (false === $period) {
return null;
}
return $period->merge(...$this->intervals);
}
/**
* DEPRECATION WARNING! This method will be removed in the next major point release.
*
* @deprecated 4.12.0 This method will be removed in the next major point release
* @see Sequence::length()
*
* Returns the sequence length as a Period instance.
*
* If the sequence contains no interval null is returned.
*
* @return ?Period
*/
public function boundaries(): ?Period
{
return $this->length();
}
/**
* Returns the gaps inside the instance.
*/
public function gaps(): self
{
$sequence = new self();
$interval = null;
foreach ($this->sorted([$this, 'sortByStartDate']) as $period) {
if (null === $interval) {
$interval = $period;
continue;
}
if (!$interval->overlaps($period) && !$interval->abuts($period)) {
$sequence->push($interval->gap($period));
}
if (!$interval->contains($period)) {
$interval = $period;
}
}
return $sequence;
}
/**
* Sorts two Interval instance using their start datepoint.
*/
private function sortByStartDate(Period $interval1, Period $interval2): int
{
return $interval1->getStartDate() <=> $interval2->getStartDate();
}
/**
* Returns the intersections inside the instance.
*/
public function intersections(): self
{
$sequence = new self();
$current = null;
$isPreviouslyContained = false;
foreach ($this->sorted([$this, 'sortByStartDate']) as $period) {
if (null === $current) {
$current = $period;
continue;
}
$isContained = $current->contains($period);
if ($isContained && $isPreviouslyContained) {
$sequence->push($current->intersect($period));
continue;
}
if ($current->overlaps($period)) {
$sequence->push($current->intersect($period));
}
$isPreviouslyContained = $isContained;
if (!$isContained) {
$current = $period;
}
}
return $sequence;
}
/**
* Returns the unions inside the instance.
*/
public function unions(): self
{
$sequence = $this
->sorted([$this, 'sortByStartDate'])
->reduce([$this, 'calculateUnion'], new self())
;
if ($sequence->intervals === $this->intervals) {
return $this;
}
return $sequence;
}
/**
* Iteratively calculate the union sequence.
*/
private function calculateUnion(Sequence $sequence, Period $period): Sequence
{
if ($sequence->isEmpty()) {
$sequence->push($period);
return $sequence;
}
$index = $sequence->count() - 1;
$interval = $sequence->get($index);
if ($interval->overlaps($period) || $interval->abuts($period)) {
$sequence->set($index, $interval->merge($period));
return $sequence;
}
$sequence->push($period);
return $sequence;
}
/**
* DEPRECATION WARNING! This method will be removed in the next major point release.
*
* @deprecated 4.9.0 This method will be removed in the next major point release
* @see Period::subtract
*/
public function substract(Sequence $sequence): self
{
return $this->subtract($sequence);
}
/**
* Subtract a Sequence from the current instance.
*
* This method MUST retain the state of the current instance, and return
* an instance that contains subtracted intervals.
*/
public function subtract(Sequence $sequence): self
{
if ($this->isEmpty()) {
return $this;
}
$new = $sequence->reduce([$this, 'subtractOne'], $this);
if ($new->intervals === $this->intervals) {
return $this;
}
return $new;
}
/**
* Substract an Interval from a Sequence.
*/
private function subtractOne(Sequence $sequence, Period $interval): self
{
if ($sequence->isEmpty()) {
return $sequence;
}
$reducer = function (Sequence $sequence, Period $period) use ($interval): Sequence {
$subtract = $period->subtract($interval);
if (!$subtract->isEmpty()) {
$sequence->push(...$subtract);
}
return $sequence;
};
return $sequence->reduce($reducer, new self());
}
/**
* Returns the sequence length as a Period instance.
*
* DEPRECATION WARNING! This method will be removed in the next major point release
*
* @deprecated 4.4.0 This method will be removed in the next major point release
* @see Sequence::boundaries
*
* If the sequence contains no interval null is returned.
*
* @return ?Period
*/
public function getBoundaries(): ?Period
{
return $this->length();
}
/**
* Returns the intersections inside the instance.
*
* DEPRECATION WARNING! This method will be removed in the next major point release
*
* @deprecated 4.4.0 This method will be removed in the next major point release
* @see Sequence::intersections
*/
public function getIntersections(): self
{
return $this->intersections();
}
/**
* Returns the gaps inside the instance.
*
* DEPRECATION WARNING! This method will be removed in the next major point release
*
* @deprecated 4.4.0 This method will be removed in the next major point release
* @see Sequence::gaps
*/
public function getGaps(): self
{
return $this->gaps();
}
/**
* Returns the sum of all instances durations as expressed in seconds.
*/
public function totalTimeDuration(): float
{
$retval = 0.0;
foreach ($this->intervals as $interval) {
$retval += $interval->timeDuration();
}
return $retval;
}
/**
* DEPRECATION WARNING! This method will be removed in the next major point release.
*
* @deprecated 4.12.0 This method will be removed in the next major point release
* @see Sequence::totalTimeDuration()
*
* Returns the sum of all instances durations as expressed in seconds.
*/
public function getTotalTimestampInterval(): float
{
return $this->totalTimeDuration();
}
/**
* Tells whether some intervals in the current instance satisfies the predicate.
*/
public function some(callable $predicate): bool
{
foreach ($this->intervals as $offset => $interval) {
if (true === $predicate($interval, $offset)) {
return true;
}
}
return false;
}
/**
* Tells whether all intervals in the current instance satisfies the predicate.
*/
public function every(callable $predicate): bool
{
foreach ($this->intervals as $offset => $interval) {
if (true !== $predicate($interval, $offset)) {
return false;
}
}
return [] !== $this->intervals;
}
/**
* Returns the array representation of the sequence.
*
* @return array<int, Period>
*/
public function toArray(): array
{
return $this->intervals;
}
/**
* {@inheritDoc}
*/
public function jsonSerialize(): array
{
return $this->intervals;
}
/**
* {@inheritDoc}
*/
public function getIterator(): Iterator
{
foreach ($this->intervals as $offset => $interval) {
yield $offset => $interval;
}
}
/**
* {@inheritDoc}
*/
public function count(): int
{
return count($this->intervals);
}
/**
* @inheritDoc
*
* @param mixed $offset the integer index of the Period instance to validate.
*/
public function offsetExists($offset): bool
{
if (!is_int($offset)) {
throw new TypeError('Argument #1 ($offset) must be of type integer, '.gettype($offset).' given.');
}
return null !== $this->filterOffset($offset);
}
/**
* Filter and format the Sequence offset.
*
* This methods allows the support of negative offset
*
* if no offset is found null is returned otherwise the return type is int
*/
private function filterOffset(int $offset): ?int
{
if ([] === $this->intervals) {
return null;
}
$max = count($this->intervals);
if (0 > $max + $offset) {
return null;
}
if (0 > $max - $offset - 1) {
return null;
}
if (0 > $offset) {
return $max + $offset;
}
return $offset;
}
/**
* @inheritDoc
* @see Sequence::get
*
* @param mixed $offset the integer index of the Period instance to retrieve.
*
* @throws InvalidIndex If the offset is illegal for the current sequence
*/
public function offsetGet($offset): Period
{
if (!is_int($offset)) {
throw new TypeError('Argument #1 ($offset) must be of type integer, '.gettype($offset).' given.');
}
return $this->get($offset);
}
/**
* @inheritDoc
* @see Sequence::remove
*
* @param mixed $offset the integer index of the Period instance to remove
*
* @throws InvalidIndex If the offset is illegal for the current sequence
*/
public function offsetUnset($offset): void
{
if (!is_int($offset)) {
throw new TypeError('Argument #1 ($offset) must be of type integer, '.gettype($offset).' given.');
}
$this->remove($offset);
}
/**
* @inheritDoc
* @param mixed $offset the integer index of the Period to add or update
* @param mixed $value the Period instance to add
*
* @throws InvalidIndex If the offset is illegal for the current sequence
*
* @see Sequence::push
* @see Sequence::set
*/
public function offsetSet($offset, $value): void
{
if (!is_int($offset) && !is_null($offset)) {
throw new TypeError('Argument #1 ($offset) must be of type integer, '.gettype($value).' given.');
}
if (!$value instanceof Period) {
throw new TypeError('Argument #2 ($interval) must be of type League\Period\Period, '.gettype($value).' given.');
}
if (null !== $offset) {
$this->set($offset, $value);
return;
}
$this->push($value);
}
/**
* Tells whether the sequence is empty.
*/
public function isEmpty(): bool
{
return [] === $this->intervals;
}
/**
* Tells whether the given interval is present in the sequence.
*
* @param Period ...$intervals
*/
public function contains(Period ...$intervals): bool
{
foreach ($intervals as $period) {
if (false === $this->indexOf($period)) {
return false;
}
}
return [] !== $intervals;
}
/**
* Attempts to find the first offset attached to the submitted interval.
*
* If no offset is found the method returns boolean false.
*
* @return int|bool
*/
public function indexOf(Period $interval)
{
foreach ($this->intervals as $offset => $period) {
if ($period->equals($interval)) {
return $offset;
}
}
return false;
}
/**
* Returns the interval specified at a given offset.
*
* @throws InvalidIndex If the offset is illegal for the current sequence
*/
public function get(int $offset): Period
{
$index = $this->filterOffset($offset);
if (null === $index) {
throw new InvalidIndex(sprintf('%s is an invalid offset in the current sequence', $offset));
}
return $this->intervals[$index];
}
/**
* Sort the current instance according to the given comparison callable
* and maintain index association.
*
* Returns true on success or false on failure
*/
public function sort(callable $compare): bool
{
return uasort($this->intervals, $compare);
}
/**
* Adds new intervals at the front of the sequence.
*
* The sequence is re-indexed after addition
*
* @param Period ...$intervals
*/
public function unshift(Period ...$intervals): void
{
$this->intervals = array_merge($intervals, $this->intervals);
}
/**
* Adds new intervals at the end of the sequence.
*
* @param Period ...$intervals
*/
public function push(Period ...$intervals): void
{
$this->intervals = array_merge($this->intervals, $intervals);
}
/**
* Inserts new intervals at the specified offset of the sequence.
*
* The sequence is re-indexed after addition
*
* @param Period ...$intervals
*
* @throws InvalidIndex If the offset is illegal for the current sequence.
*/
public function insert(int $offset, Period $interval, Period ...$intervals): void
{
if (0 === $offset) {
$this->unshift($interval, ...$intervals);
return;
}
if (count($this->intervals) === $offset) {
$this->push($interval, ...$intervals);
return;
}
$index = $this->filterOffset($offset);
if (null === $index) {
throw new InvalidIndex(sprintf('%s is an invalid offset in the current sequence', $offset));
}
array_unshift($intervals, $interval);
array_splice($this->intervals, $index, 0, $intervals);
}
/**
* Updates the interval at the specify offset.
*
* @throws InvalidIndex If the offset is illegal for the current sequence.
*/
public function set(int $offset, Period $interval): void
{
$index = $this->filterOffset($offset);
if (null === $index) {
throw new InvalidIndex(sprintf('%s is an invalid offset in the current sequence', $offset));
}
$this->intervals[$index] = $interval;
}
/**
* Removes an interval from the sequence at the given offset and returns it.
*
* The sequence is re-indexed after removal
*
* @throws InvalidIndex If the offset is illegal for the current sequence.
*/
public function remove(int $offset): Period
{
$index = $this->filterOffset($offset);
if (null === $index) {
throw new InvalidIndex(sprintf('%s is an invalid offset in the current sequence', $offset));
}
$interval = $this->intervals[$index];
unset($this->intervals[$index]);
$this->intervals = array_values($this->intervals);
return $interval;
}
/**
* Filters the sequence according to the given predicate.
*
* This method MUST retain the state of the current instance, and return
* an instance that contains the interval which validate the predicate.
*/
public function filter(callable $predicate): self
{
$intervals = array_filter($this->intervals, $predicate, ARRAY_FILTER_USE_BOTH);
if ($intervals === $this->intervals) {
return $this;
}
return new self(...$intervals);
}
/**
* Removes all intervals from the sequence.
*/
public function clear(): void
{
$this->intervals = [];
}
/**
* Returns an instance sorted according to the given comparison callable
* but does not maintain index association.
*
* This method MUST retain the state of the current instance, and return
* an instance that contains the sorted intervals. The key are re-indexed
*/
public function sorted(callable $compare): self
{
$intervals = $this->intervals;
usort($intervals, $compare);
if ($intervals === $this->intervals) {
return $this;
}
return new self(...$intervals);
}
/**
* Returns an instance where the given function is applied to each element in
* the collection. The callable MUST return a Period object and takes a Period
* and its associated key as argument.
*
* This method MUST retain the state of the current instance, and return
* an instance that contains the returned intervals.
*/
public function map(callable $func): self
{
$intervals = [];
foreach ($this->intervals as $offset => $interval) {
$intervals[$offset] = $func($interval, $offset);
}
if ($intervals === $this->intervals) {
return $this;
}
$mapped = new self();
$mapped->intervals = $intervals;
return $mapped;
}
/**
* Iteratively reduces the sequence to a single value using a callback.
*
* @template TReduceInitial
* @template TReduceReturnType
*
* @param callable(TReduceInitial|TReduceReturnType, Period, array-key=): TReduceReturnType $func
* @param TReduceInitial $carry
*
* @return TReduceInitial|TReduceReturnType
*/
public function reduce(callable $func, $carry = null)
{
foreach ($this->intervals as $offset => $interval) {
$carry = $func($carry, $interval, $offset);
}
return $carry;
}
}