diff --git a/src/CollectionLogic.php b/src/CollectionLogic.php index 15e62f0..896af0c 100644 --- a/src/CollectionLogic.php +++ b/src/CollectionLogic.php @@ -139,48 +139,12 @@ public function containsAll(iterable $elements): bool return true; } - /** {@inheritDoc} */ - public function all(Closure $predicate): bool - { - foreach ($this as $i => $v) { - if (!$predicate($v, $i)) { - return false; - } - } - - return true; - } - - /** {@inheritDoc} */ - public function any(Closure $predicate): bool - { - foreach ($this as $i => $v) { - if ($predicate($v, $i)) { - return true; - } - } - - return false; - } - - /** {@inheritDoc} */ - public function none(Closure $predicate): bool - { - return !$this->any($predicate); - } - /** {@inheritDoc} */ public function isEmpty(): bool { return $this->store->isEmpty(); } - /** {@inheritDoc} */ - public function isNotEmpty(): bool - { - return !$this->isEmpty(); - } - /** * {@inheritDoc} * @return int<0, max> @@ -193,20 +157,6 @@ public function count(): int return $storeCount; } - /** {@inheritDoc} */ - public function countWhere(Closure $predicate): int - { - /** @var int<0, max> $count */ - $count = 0; - foreach ($this as $i => $v) { - if ($predicate($v, $i)) { - $count++; - } - } - - return $count; - } - // --- Aggregation --- /** {@inheritDoc} */ diff --git a/src/IterableTerminalsLogic.php b/src/IterableTerminalsLogic.php index 52382a9..a0f5319 100644 --- a/src/IterableTerminalsLogic.php +++ b/src/IterableTerminalsLogic.php @@ -16,9 +16,10 @@ * Terminal operations implemented by walking $this and nothing else, shared by the eager * Collection side and the lazy Sequence side. * - * A body belongs here only if it is identical for both, which excludes the family the eager side - * answers from its store in O(1) (first/last/contains/count). A message naming the subject is no - * longer a reason to split: the exception derives that noun from $this. + * A body belongs here only if it is identical for both, which excludes two families: the one the + * eager side answers from its store in O(1) (first/last/isEmpty/contains/count), and containsAll, + * whose one-lookup-per-value shape would cost a sequence one pass per value. A message naming the + * subject is no longer a reason to split: the exception derives that noun from $this. * * Consumers declare the contract, so the PHPDoc here is {@inheritDoc}: it resolves against * Collection or Sequence depending on who uses the trait. @@ -29,6 +30,8 @@ */ trait IterableTerminalsLogic { + // --- Element Access --- + /** {@inheritDoc} */ public function single() { @@ -97,4 +100,56 @@ public function expect(Closure $predicate) throw new NoSuchElementException('No element matching the predicate was found'); } + + // --- Querying --- + + /** {@inheritDoc} */ + public function isNotEmpty(): bool + { + return !$this->isEmpty(); + } + + /** {@inheritDoc} */ + public function all(Closure $predicate): bool + { + foreach ($this as $i => $v) { + if (!$predicate($v, $i)) { + return false; + } + } + + return true; + } + + /** {@inheritDoc} */ + public function any(Closure $predicate): bool + { + foreach ($this as $i => $v) { + if ($predicate($v, $i)) { + return true; + } + } + + return false; + } + + /** {@inheritDoc} */ + public function none(Closure $predicate): bool + { + return !$this->any($predicate); + } + + /** {@inheritDoc} */ + public function countWhere(Closure $predicate): int + { + /** @var int<0, max> $count */ + $count = 0; + foreach ($this as $i => $v) { + if ($predicate($v, $i)) { + $count++; + } + } + + return $count; + } } diff --git a/src/Sequence/Sequence.php b/src/Sequence/Sequence.php index 22368ac..dbb9c6d 100644 --- a/src/Sequence/Sequence.php +++ b/src/Sequence/Sequence.php @@ -39,10 +39,10 @@ * * Keys are positional: every pass yields fresh 0..n keys, whatever the source yields. * - * Deliberately neither Countable (counting would silently consume a pass; native - * count($seq) is a TypeError by design) nor JsonSerializable - * (json_encode would be a hidden materialization) - materialize explicitly with - * toList() or toArray() instead. + * Deliberately neither Countable (native count($seq) stays a TypeError by design; + * $seq->count() exists as an explicit O(n) terminal that drains a pass) nor + * JsonSerializable (json_encode would be a hidden materialization) - materialize + * explicitly with toList() or toArray() instead. * * @template E * @extends IteratorAggregate @@ -329,6 +329,95 @@ public function find(Closure $predicate): mixed; */ public function expect(Closure $predicate); + // --- Querying --- + + /** + * Whether the sequence does not contain any elements. Pulls exactly one element. + * + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function isEmpty(): bool; + + /** + * Whether the sequence contains at least one element. Pulls exactly one element. + * + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function isNotEmpty(): bool; + + /** + * Whether the sequence contains a value (strict comparison). + * Stops pulling at the first match. + * + * @param E $element + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function contains(mixed $element): bool; + + /** + * Whether the sequence contains all the provided values. + * Walks the sequence once, stopping as soon as none is left to look for. + * + * @param iterable $elements + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function containsAll(iterable $elements): bool; + + /** + * Returns true if all elements match the predicate. + * Stops pulling at the first element that does not. + * + * @param Closure(E, int):bool $predicate + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function all(Closure $predicate): bool; + + /** + * Returns true if any element matches the predicate. + * Stops pulling at the first match. + * + * @param Closure(E, int):bool $predicate + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function any(Closure $predicate): bool; + + /** + * Returns true if no element matches the predicate. + * Stops pulling at the first match. + * + * @param Closure(E, int):bool $predicate + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function none(Closure $predicate): bool; + + /** + * Returns the number of elements in the sequence, draining it. + * The O(n) counterpart of a Collection's O(1) count: a sequence has no length to read, only + * elements to pull, which is why the cost has to be asked for explicitly. + * + * @return int<0, max> + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function count(): int; + + /** + * Returns the number of elements matching the predicate, draining the sequence. + * + * @param Closure(E, int):bool $predicate + * @return int<0, max> + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function countWhere(Closure $predicate): int; + // --- Conversion --- /** diff --git a/src/Sequence/SequenceLogic.php b/src/Sequence/SequenceLogic.php index 487e303..f766fa0 100644 --- a/src/Sequence/SequenceLogic.php +++ b/src/Sequence/SequenceLogic.php @@ -321,6 +321,94 @@ public function lastOrNull(): mixed return $result; } + // --- Querying --- + + /** + * {@inheritDoc} + * + * The eager side reads its store; here a single element settles the question, so nothing + * beyond the first is pulled. + */ + public function isEmpty(): bool + { + foreach ($this as $ignored) { // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + return false; + } + + return true; + } + + /** + * {@inheritDoc} + * + * No hash index to ask, so this is the linear scan the eager side avoids - it does stop at + * the first match. + */ + public function contains(mixed $element): bool + { + foreach ($this as $v) { + if ($v === $element) { + return true; + } + } + + return false; + } + + /** + * {@inheritDoc} + * + * Unlike its Collection counterpart this cannot look each value up in turn - that would cost + * one pass per value, which a single-pass source cannot give. So the values still being looked + * for are carried through a single walk, and dropped as they are met. + */ + public function containsAll(iterable $elements): bool + { + $missing = []; + foreach ($elements as $element) { + $missing[] = $element; + } + + if ($missing === []) { + return true; + } + + foreach ($this as $v) { + if (!in_array($v, $missing, true)) { + continue; + } + + // Every equal entry drops, not just the first: containsAll([1, 1]) asks whether 1 is + // there, not whether it is there twice - the same answer the eager side gives. + $missing = array_filter($missing, static fn (mixed $wanted): bool => $wanted !== $v); + + if ($missing === []) { + return true; + } + } + + return false; + } + + /** + * {@inheritDoc} + * + * O(n) where the eager side is O(1), and draining: the count is only known once the source + * runs out. + * + * @return int<0, max> + */ + public function count(): int + { + /** @var int<0, max> $count */ + $count = 0; + foreach ($this as $_) { // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $count++; + } + + return $count; + } + // --- Conversion --- /** {@inheritDoc} */ diff --git a/tests/Sequence/SequenceTerminalTest.php b/tests/Sequence/SequenceAccessTest.php similarity index 99% rename from tests/Sequence/SequenceTerminalTest.php rename to tests/Sequence/SequenceAccessTest.php index 3847a2f..9e40e8a 100644 --- a/tests/Sequence/SequenceTerminalTest.php +++ b/tests/Sequence/SequenceAccessTest.php @@ -18,7 +18,7 @@ use function Noctud\Collection\listOf; use function Noctud\Collection\sequenceOf; -final class SequenceTerminalTest extends TestCase +final class SequenceAccessTest extends TestCase { #[Test] public function first_returns_the_first_element(): void diff --git a/tests/Sequence/SequenceQueryTest.php b/tests/Sequence/SequenceQueryTest.php new file mode 100644 index 0000000..ad32ba4 --- /dev/null +++ b/tests/Sequence/SequenceQueryTest.php @@ -0,0 +1,262 @@ +assertTrue(sequenceOf([])->isEmpty()); + $this->assertFalse(sequenceOf([])->isNotEmpty()); + + $this->assertFalse(sequenceOf([1, 2])->isEmpty()); + $this->assertTrue(sequenceOf([1, 2])->isNotEmpty()); + + // Emptied by a filter rather than empty at the source: the realistic way a pipeline ends + // up with nothing. + $this->assertTrue(sequenceOf([1, 2])->filter(static fn (int $v): bool => $v > 9)->isEmpty()); + } + + #[Test] + public function isEmpty_is_false_on_a_sequence_of_one_null(): void + { + // A sequence holding null is not an empty sequence - the distinction lastOrNull() cannot + // make, isEmpty() can. + $this->assertFalse(sequenceOf([null])->isEmpty()); + } + + #[Test] + public function contains_uses_strict_comparison(): void + { + $this->assertTrue(sequenceOf([1, 2, 3])->contains(2)); + $this->assertFalse(sequenceOf([1, 2, 3])->contains(9)); + + // '2' is not 2, and only a sequence that could hold either type can even be asked. + /** @var Sequence $mixed */ + $mixed = sequenceOf([1, 2, 3]); + $this->assertFalse($mixed->contains('2')); + } + + #[Test] + public function containsAll_answers_in_a_single_pass(): void + { + $this->assertTrue(sequenceOf([1, 2, 3])->containsAll([3, 1])); + $this->assertFalse(sequenceOf([1, 2, 3])->containsAll([1, 9])); + + // Nothing to look for is trivially satisfied, and pulls nothing. + $this->assertTrue(sequenceOf([1, 2, 3])->containsAll([])); + } + + #[Test] + public function containsAll_ignores_repeats_among_the_wanted_values(): void + { + // The question is whether 1 is there, not whether it is there twice - the same answer the + // eager side gives, which the parity assertion pins. + $this->assertTrue(sequenceOf([1, 2])->containsAll([1, 1])); + $this->assertSame(listOf([1, 2])->containsAll([1, 1]), sequenceOf([1, 2])->containsAll([1, 1])); + } + + #[Test] + public function containsAll_walks_a_one_shot_source_once(): void + { + $sequence = sequenceOf((static function (): Generator { + yield 1; + yield 2; + yield 3; + })()); + + // One lookup per value would need one pass per value, which this source cannot give. + $this->assertTrue($sequence->containsAll([1, 3])); + } + + #[Test] + public function all_any_and_none_on_an_empty_sequence(): void + { + $never = static fn (int $v): bool => false; + + $this->assertTrue(sequenceOf([])->all($never)); + $this->assertFalse(sequenceOf([])->any($never)); + $this->assertTrue(sequenceOf([])->none($never)); + } + + #[Test] + public function all_any_and_none_answer_the_predicate(): void + { + $even = static fn (int $v): bool => $v % 2 === 0; + + $this->assertTrue(sequenceOf([2, 4])->all($even)); + $this->assertFalse(sequenceOf([2, 3])->all($even)); + + $this->assertTrue(sequenceOf([1, 2])->any($even)); + $this->assertFalse(sequenceOf([1, 3])->any($even)); + + $this->assertTrue(sequenceOf([1, 3])->none($even)); + $this->assertFalse(sequenceOf([1, 2])->none($even)); + } + + #[Test] + public function predicates_receive_the_positional_index_of_their_own_stage(): void + { + $seen = []; + $sequence = sequenceOf([10, 20, 30])->filter(static fn (int $v): bool => $v > 10); + + $sequence->all(static function (int $v, int $i) use (&$seen): bool { + $seen[] = [$i, $v]; + + return true; + }); + + // The filter reindexes, so 20 is at 0 here rather than at its source position. + $this->assertSame([[0, 20], [1, 30]], $seen); + } + + #[Test] + public function count_and_countWhere_count_the_elements_of_their_own_stage(): void + { + $even = static fn (int $v): bool => $v % 2 === 0; + + $this->assertSame(0, sequenceOf([])->count()); + $this->assertSame(4, sequenceOf([1, 2, 3, 4])->count()); + $this->assertSame(2, sequenceOf([1, 2, 3, 4])->countWhere($even)); + $this->assertSame(2, sequenceOf([1, 2, 3, 4])->filter($even)->count()); + } + + #[Test] + public function querying_matches_its_collection_counterpart(): void + { + $data = [3, 1, 4, 1, 5]; + $even = static fn (int $v): bool => $v % 2 === 0; + + $this->assertSame(listOf($data)->isEmpty(), sequenceOf($data)->isEmpty()); + $this->assertSame(listOf([])->isEmpty(), sequenceOf([])->isEmpty()); + $this->assertSame(listOf($data)->isNotEmpty(), sequenceOf($data)->isNotEmpty()); + $this->assertSame(listOf($data)->contains(4), sequenceOf($data)->contains(4)); + $this->assertSame(listOf($data)->contains(9), sequenceOf($data)->contains(9)); + $this->assertSame(listOf($data)->containsAll([1, 4]), sequenceOf($data)->containsAll([1, 4])); + $this->assertSame(listOf($data)->containsAll([1, 9]), sequenceOf($data)->containsAll([1, 9])); + $this->assertSame(listOf($data)->all($even), sequenceOf($data)->all($even)); + $this->assertSame(listOf($data)->any($even), sequenceOf($data)->any($even)); + $this->assertSame(listOf($data)->none($even), sequenceOf($data)->none($even)); + $this->assertSame(listOf($data)->count(), sequenceOf($data)->count()); + $this->assertSame(listOf($data)->countWhere($even), sequenceOf($data)->countWhere($even)); + } + + #[Test] + public function isEmpty_pulls_exactly_one_element(): void + { + $pulled = []; + $sequence = $this->loggingSequence($pulled); + + $this->assertFalse($sequence->isEmpty()); + $this->assertSame([1], $pulled); + } + + #[Test] + public function contains_stops_pulling_at_the_first_match(): void + { + $pulled = []; + $sequence = $this->loggingSequence($pulled); + + $this->assertTrue($sequence->contains(2)); + $this->assertSame([1, 2], $pulled); + } + + #[Test] + public function any_stops_pulling_at_the_first_match(): void + { + $pulled = []; + $sequence = $this->loggingSequence($pulled); + + $this->assertTrue($sequence->any(static fn (int $v): bool => $v === 2)); + $this->assertSame([1, 2], $pulled); + } + + #[Test] + public function all_stops_pulling_at_the_first_failure(): void + { + $pulled = []; + $sequence = $this->loggingSequence($pulled); + + $this->assertFalse($sequence->all(static fn (int $v): bool => $v < 2)); + $this->assertSame([1, 2], $pulled); + } + + #[Test] + public function containsAll_stops_pulling_once_nothing_is_missing(): void + { + $pulled = []; + $sequence = $this->loggingSequence($pulled); + + $this->assertTrue($sequence->containsAll([2, 1])); + $this->assertSame([1, 2], $pulled); + } + + #[Test] + public function count_drains_the_source(): void + { + $pulled = []; + $sequence = $this->loggingSequence($pulled); + + $this->assertSame(4, $sequence->count()); + $this->assertSame([1, 2, 3, 4], $pulled); + } + + #[Test] + public function a_querying_terminal_consumes_a_pass_of_a_one_shot_source(): void + { + $sequence = sequenceOf((static function (): Generator { + yield 1; + yield 2; + })()); + + $this->assertTrue($sequence->contains(1)); + + // Even the partial pass contains() stopped short counts as consumed. + $this->expectException(NonReplayableSourceException::class); + + $sequence->isEmpty(); + } + + #[Test] + public function querying_replays_over_a_replayable_source(): void + { + $sequence = sequenceOf([1, 2, 3]); + + $this->assertSame(3, $sequence->count()); + $this->assertTrue($sequence->contains(1)); + $this->assertSame(3, $sequence->count()); + } + + /** + * A one-shot source logging what the terminal pulls out of it. + * + * @param list $pulled + * @return Sequence + */ + private function loggingSequence(array &$pulled): Sequence + { + return sequenceOf(static function () use (&$pulled): Generator { + foreach ([1, 2, 3, 4] as $value) { + $pulled[] = $value; + + yield $value; + } + }); + } +} diff --git a/tests/Type/SequenceTerminalTypeTest.php b/tests/Type/Sequence/AccessTypeTest.php similarity index 96% rename from tests/Type/SequenceTerminalTypeTest.php rename to tests/Type/Sequence/AccessTypeTest.php index ab23044..63c63cf 100644 --- a/tests/Type/SequenceTerminalTypeTest.php +++ b/tests/Type/Sequence/AccessTypeTest.php @@ -7,7 +7,7 @@ declare(strict_types=1); -namespace Noctud\Collection\Tests\Type; +namespace Noctud\Collection\Tests\Type\Sequence; use Noctud\Collection\Sequence\Sequence; use function Noctud\Collection\sequenceOf; diff --git a/tests/Type/Sequence/QueryTypeTest.php b/tests/Type/Sequence/QueryTypeTest.php new file mode 100644 index 0000000..ec568e9 --- /dev/null +++ b/tests/Type/Sequence/QueryTypeTest.php @@ -0,0 +1,22 @@ + $s */ +$s = sequenceOf(['a', 'b', 'c']); + +// count() and countWhere() pin their declared @return int<0, max>; the rest of the querying +// family is native `: bool`, so nothing else here needs a type test. +assertType('int<0, max>', $s->count()); +assertType('int<0, max>', $s->countWhere(fn (string $v): bool => $v !== '')); diff --git a/tests/Type/SequenceTransformTypeTest.php b/tests/Type/Sequence/TransformTypeTest.php similarity index 98% rename from tests/Type/SequenceTransformTypeTest.php rename to tests/Type/Sequence/TransformTypeTest.php index 85ee9c2..52fbf1c 100644 --- a/tests/Type/SequenceTransformTypeTest.php +++ b/tests/Type/Sequence/TransformTypeTest.php @@ -7,7 +7,7 @@ declare(strict_types=1); -namespace Noctud\Collection\Tests\Type; +namespace Noctud\Collection\Tests\Type\Sequence; use Noctud\Collection\Sequence\Sequence; use stdClass;