From c46deebab7f4a688b4b5cfc8d6edaadc23479643 Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Thu, 2 Apr 2026 18:41:06 -0300 Subject: [PATCH] Redesign Collection as immutable value object, simplify DSL Replace the broad DSL surface (chaining, __get, ArrayAccess, proxy methods) with a single immutable constructor: name, with, filter, required. Remove Filtered, CollectionNotBound, filterColumns, and all mapper coupling from Collection. --- phpstan.neon.dist | 9 +- src/AbstractMapper.php | 64 +-- src/CollectionIterator.php | 10 +- src/CollectionNotBound.php | 18 - src/Collections/Collection.php | 241 +++------ src/Collections/Composite.php | 26 +- src/Collections/Filtered.php | 30 -- src/Collections/Typed.php | 24 +- src/Hydrators/Nested.php | 6 +- src/Hydrators/PrestyledAssoc.php | 3 +- tests/AbstractMapperTest.php | 471 ++++++------------ tests/CollectionIteratorTest.php | 34 +- tests/Collections/CollectionTest.php | 325 +++--------- tests/Collections/CompositeTest.php | 21 +- tests/Collections/FilteredTest.php | 61 --- tests/Collections/TypedTest.php | 21 +- tests/Hydrators/NestedTest.php | 24 +- tests/Hydrators/PrestyledAssocTest.php | 26 +- tests/InMemoryMapper.php | 22 +- .../Styles/CakePHP/CakePHPIntegrationTest.php | 18 +- .../NorthWind/NorthWindIntegrationTest.php | 18 +- tests/Styles/Plural/PluralIntegrationTest.php | 18 +- tests/Styles/Sakila/SakilaIntegrationTest.php | 18 +- 23 files changed, 463 insertions(+), 1045 deletions(-) delete mode 100644 src/CollectionNotBound.php delete mode 100644 src/Collections/Filtered.php delete mode 100644 tests/Collections/FilteredTest.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 69e6024..f881cee 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,12 +4,11 @@ parameters: - src/ - tests/ ignoreErrors: - - message: '/Call to an undefined (static )?method Respect\\Data\\(AbstractMapper|InMemoryMapper|Collections\\(Collection|Filtered|Composite|Typed))::\w+\(\)\./' - - message: '/Access to an undefined property Respect\\Data\\(AbstractMapper|InMemoryMapper|Collections\\Collection)::\$\w+\./' + - message: '/Call to an undefined (static )?method Respect\\Data\\(AbstractMapper|InMemoryMapper|Collections\\(Collection|Composite|Typed))::\w+\(\)\./' - message: '/Unsafe usage of new static\(\)\./' - - - message: '/Expression .+ on a separate line does not do anything\./' - path: tests/ - message: '/Property .+ is never read, only written\./' path: tests/Stubs/ + - + message: '/(Cannot access property|Parameter #1 .+ expects object|Access to an undefined property object)/' + path: tests/ diff --git a/src/AbstractMapper.php b/src/AbstractMapper.php index 0ebda23..c6c44db 100644 --- a/src/AbstractMapper.php +++ b/src/AbstractMapper.php @@ -5,11 +5,8 @@ namespace Respect\Data; use Respect\Data\Collections\Collection; -use Respect\Data\Collections\Filtered; use SplObjectStorage; -use function array_flip; -use function array_intersect_key; use function count; use function ctype_digit; use function is_int; @@ -82,13 +79,6 @@ public function markTracked(object $entity, Collection $collection): bool public function persist(object $object, Collection $onCollection): object { - $connectsTo = $onCollection->connectsTo; - if ($onCollection instanceof Filtered && $connectsTo !== null) { - $this->persist($object, $connectsTo); - - return $object; - } - if ($this->isTracked($object)) { $currentOp = $this->pending[$object] ?? null; if ($currentOp !== 'insert') { @@ -128,31 +118,9 @@ public function isTracked(object $entity): bool public function registerCollection(string $alias, Collection $collection): void { - $collection->bindMapper($this); $this->collections[$alias] = $collection; } - /** - * @param array $columns - * - * @return array - */ - protected function filterColumns(array $columns, Collection $collection): array - { - if ( - !$collection instanceof Filtered - || !$collection->filters - || $collection->identifierOnly - || $collection->name === null - ) { - return $columns; - } - - $id = $this->style->identifier($collection->name); - - return array_intersect_key($columns, array_flip([...$collection->filters, $id])); - } - protected function registerInIdentityMap(object $entity, Collection $coll): void { if ($coll->name === null) { @@ -183,11 +151,11 @@ protected function evictFromIdentityMap(object $entity, Collection $coll): void protected function findInIdentityMap(Collection $collection): object|null { - if ($collection->name === null || !is_scalar($collection->condition) || $collection->hasMore) { + if ($collection->name === null || !is_scalar($collection->filter) || $collection->hasChildren) { return null; } - $condition = $this->normalizeIdValue($collection->condition); + $condition = $this->normalizeIdValue($collection->filter); if ($condition === null) { return null; } @@ -202,7 +170,7 @@ private function tryMergeWithIdentityMap(object $entity, Collection $coll): obje } $entityId = $this->entityIdValue($entity, $coll->name); - $idValue = $entityId ?? $this->normalizeIdValue($coll->condition); + $idValue = $entityId ?? $this->normalizeIdValue($coll->filter); if ($idValue === null) { return null; @@ -269,36 +237,22 @@ private function normalizeIdValue(mixed $value): int|string|null return null; } - public function __get(string $name): Collection - { - if (isset($this->collections[$name])) { - return $this->collections[$name]; - } - - $coll = new Collection($name); - - return $coll->bindMapper($this); - } - public function __isset(string $alias): bool { return isset($this->collections[$alias]); } - public function __set(string $alias, Collection $collection): void - { - $this->registerCollection($alias, $collection); - } - - /** @param list|scalar|null> $arguments */ + /** @param list $arguments */ public function __call(string $name, array $arguments): Collection { if (isset($this->collections[$name])) { - $collection = clone $this->collections[$name]; + if (empty($arguments)) { + return clone $this->collections[$name]; + } - return $collection->bindMapper($this)->with(...$arguments); + return $this->collections[$name]->derive(...$arguments); // @phpstan-ignore argument.type } - return Collection::__callstatic($name, $arguments)->bindMapper($this); + return new Collection($name, ...$arguments); // @phpstan-ignore argument.type } } diff --git a/src/CollectionIterator.php b/src/CollectionIterator.php index 178cefd..3d95763 100644 --- a/src/CollectionIterator.php +++ b/src/CollectionIterator.php @@ -51,19 +51,13 @@ public function key(): string public function hasChildren(): bool { - return $this->current()->hasMore; + return $this->current()->hasChildren; } public function getChildren(): RecursiveArrayIterator { - $c = $this->current(); - $pool = $c->hasChildren ? $c->children : []; - if ($c->connectsTo !== null) { - $pool[] = $c->connectsTo; - } - return new static( - array_filter($pool, static fn(Collection $c): bool => $c->name !== null), + array_filter($this->current()->with, static fn(Collection $c): bool => $c->name !== null), $this->namesCounts, ); } diff --git a/src/CollectionNotBound.php b/src/CollectionNotBound.php deleted file mode 100644 index 6a677f7..0000000 --- a/src/CollectionNotBound.php +++ /dev/null @@ -1,18 +0,0 @@ - */ -class Collection implements ArrayAccess +class Collection { - public private(set) bool $required = true; - - public private(set) AbstractMapper|null $mapper = null; - public private(set) Collection|null $parent = null; - public private(set) Collection|null $connectsTo = null; - - private Collection|null $last = null; - - /** @var Collection[] */ - public private(set) array $children = []; + /** @var list */ + public private(set) array $with; - public bool $hasChildren { get => !empty($this->children); } + public bool $hasChildren { get => !empty($this->with); } - public bool $hasMore { get => $this->hasChildren || $this->connectsTo !== null; } - - /** @var array|scalar|null */ - public private(set) array|int|float|string|bool|null $condition = []; - - /** @param (Collection|array|scalar|null) ...$args */ + /** + * @param list $with + * @param array|scalar|null $filter + */ public function __construct( - public private(set) string|null $name = null, - self|array|int|float|string|bool|null ...$args, + public readonly string|null $name = null, + array $with = [], + public readonly array|int|float|string|bool|null $filter = null, + public readonly bool $required = false, ) { - $this->with(...$args); - } - - public function addChild(Collection $child): void - { - $clone = clone $child; - $clone->required = false; - $clone->parent = $this; - $this->children[] = $clone; - } - - public function persist(object $object): object - { - return $this->resolveMapper()->persist($object, $this); - } - - public function remove(object $object): bool - { - return $this->resolveMapper()->remove($object, $this); - } - - public function fetch(mixed $extra = null): mixed - { - return $this->resolveMapper()->fetch($this, $extra); - } - - public function fetchAll(mixed $extra = null): mixed - { - return $this->resolveMapper()->fetchAll($this, $extra); - } - - public function offsetExists(mixed $offset): bool - { - return false; - } - - public function offsetGet(mixed $condition): mixed - { - $tail = $this->last ?? $this; - $tail->condition = $condition; - - return $this; - } - - public function offsetSet(mixed $offset, mixed $value): void - { - // no-op - } - - public function offsetUnset(mixed $offset): void - { - // no-op - } - - /** @internal Used by AbstractMapper to bind this collection */ - public function bindMapper(AbstractMapper $mapper): static - { - $this->mapper = $mapper; - - return $this; - } - - public function stack(Collection $collection): static - { - $tail = $this->last ?? $this; - $tail->setConnectsTo($collection); - $this->last = $collection->last ?? $collection; - - return $this; - } - - /** @param self|array|scalar|null ...$arguments */ - public function with(self|array|int|float|string|bool|null ...$arguments): static - { - foreach ($arguments as $arg) { - $arg instanceof Collection ? $this->addChild($arg) : $this->condition = $arg; - } - - return $this; - } - - private function findMapper(): AbstractMapper|null - { - $node = $this; - while ($node !== null) { - if ($node->mapper !== null) { - return $node->mapper; - } - - $node = $node->parent; + $this->with = $this->adoptChildren($with); + } + + /** + * @param list $with + * @param array|scalar|null $filter + */ + public function derive( + array $with = [], + array|int|float|string|bool|null $filter = null, + bool|null $required = null, + ): static { + return new static( + $this->name, + ...$this->deriveArgs( + with: $with, + filter: $filter, + required: $required, + ), + ); + } + + /** + * @param list $with + * @param array|scalar|null $filter + * + * @return array{with: list, filter: array|int|float|string|bool|null, required: bool} + */ + protected function deriveArgs( // @phpstan-ignore missingType.iterableValue + array $with = [], + array|int|float|string|bool|null $filter = null, + bool|null $required = null, + ): array { + return [ + 'with' => [...$this->with, ...$with], + 'filter' => $filter ?? $this->filter, + 'required' => $required ?? $this->required, + ]; + } + + /** + * @param list $children + * + * @return list + */ + private function adoptChildren(array $children): array + { + $adopted = []; + foreach ($children as $child) { + $c = clone $child; + $c->parent = $this; + $adopted[] = $c; } - return null; - } - - private function resolveMapper(): AbstractMapper - { - return $this->findMapper() ?? throw new CollectionNotBound($this->name); - } - - private function setConnectsTo(Collection $collection): void - { - $collection->parent = $this; - $this->connectsTo = $collection; + return $adopted; } /** @param array $arguments */ @@ -148,56 +86,9 @@ public static function __callStatic(string $name, array $arguments): static return new static($name, ...$arguments); } - public function __get(string $name): static - { - $mapper = $this->findMapper(); - if ($mapper !== null && isset($mapper->$name)) { - return $this->stack(clone $mapper->__get($name)); - } - - return $this->stack(new self($name)); - } - - /** @param list|scalar|null> $children */ - public function __call(string $name, array $children): static - { - if (!isset($this->name)) { - $this->name = $name; - - return $this->with(...$children); - } - - return $this->stack((new Collection())->__call($name, $children)); - } - public function __clone(): void { - if ($this->connectsTo !== null) { - $this->connectsTo = clone $this->connectsTo; - $this->connectsTo->parent = $this; - } - - $clonedChildren = []; - - foreach ($this->children as $child) { - $cloned = clone $child; - $cloned->parent = $this; - $clonedChildren[] = $cloned; - } - - $this->children = $clonedChildren; + $this->with = $this->adoptChildren($this->with); $this->parent = null; - - if ($this->last === null) { - return; - } - - $node = $this; - - while ($node->connectsTo !== null) { - $node = $node->connectsTo; - } - - $this->last = $node !== $this ? $node : null; } } diff --git a/src/Collections/Composite.php b/src/Collections/Composite.php index 8f7057c..4663244 100644 --- a/src/Collections/Composite.php +++ b/src/Collections/Composite.php @@ -8,12 +8,34 @@ final class Composite extends Collection { public const string COMPOSITION_MARKER = '_WITH_'; - /** @param array> $compositions */ + /** + * @param array> $compositions + * @param list $with + * @param array|scalar|null $filter + */ public function __construct( string $name, public private(set) readonly array $compositions = [], + array $with = [], + array|int|float|string|bool|null $filter = null, + bool $required = false, ) { - parent::__construct($name); + parent::__construct($name, $with, $filter, $required); + } + + /** + * @param list $with + * + * @return array + */ + protected function deriveArgs( + array $with = [], + array|int|float|string|bool|null $filter = null, + bool|null $required = null, + ): array { + $base = parent::deriveArgs($with, $filter, $required); + + return ['compositions' => $this->compositions] + $base; } /** @param array>> $arguments */ diff --git a/src/Collections/Filtered.php b/src/Collections/Filtered.php deleted file mode 100644 index 0a7bc77..0000000 --- a/src/Collections/Filtered.php +++ /dev/null @@ -1,30 +0,0 @@ - $this->filters === [self::IDENTIFIER_ONLY]; } - - /** @param list $filters */ - public function __construct( - string $name, - public private(set) readonly array $filters = [], - ) { - parent::__construct($name); - } - - /** @param array $arguments */ - public static function __callStatic(string $name, array $arguments): static - { - return new static($name, array_values($arguments)); - } -} diff --git a/src/Collections/Typed.php b/src/Collections/Typed.php index 1c45d22..c92886c 100644 --- a/src/Collections/Typed.php +++ b/src/Collections/Typed.php @@ -11,11 +11,18 @@ final class Typed extends Collection { + /** + * @param list $with + * @param array|scalar|null $filter + */ public function __construct( string $name, public private(set) readonly string $type = '', + array $with = [], + array|int|float|string|bool|null $filter = null, + bool $required = false, ) { - parent::__construct($name); + parent::__construct($name, $with, $filter, $required); } /** @@ -30,6 +37,21 @@ public function resolveEntityClass(EntityFactory $factory, object|array $row): s return $factory->resolveClass(is_string($name) ? $name : (string) $this->name); } + /** + * @param list $with + * + * @return array + */ + protected function deriveArgs( + array $with = [], + array|int|float|string|bool|null $filter = null, + bool|null $required = null, + ): array { + $base = parent::deriveArgs($with, $filter, $required); + + return ['type' => $this->type] + $base; + } + /** @param array $arguments */ public static function __callStatic(string $name, array $arguments): static { diff --git a/src/Hydrators/Nested.php b/src/Hydrators/Nested.php index ac89031..3ff2d0c 100644 --- a/src/Hydrators/Nested.php +++ b/src/Hydrators/Nested.php @@ -56,11 +56,7 @@ private function hydrateNode( $entities[$entity] = $collection; - if ($collection->connectsTo !== null) { - $this->hydrateChild($data, $collection->connectsTo, $entities); - } - - foreach ($collection->children as $child) { + foreach ($collection->with as $child) { $this->hydrateChild($data, $child, $entities); } } diff --git a/src/Hydrators/PrestyledAssoc.php b/src/Hydrators/PrestyledAssoc.php index 83137bd..5d9d8c0 100644 --- a/src/Hydrators/PrestyledAssoc.php +++ b/src/Hydrators/PrestyledAssoc.php @@ -8,7 +8,6 @@ use Respect\Data\CollectionIterator; use Respect\Data\Collections\Collection; use Respect\Data\Collections\Composite; -use Respect\Data\Collections\Filtered; use SplObjectStorage; use function array_keys; @@ -84,7 +83,7 @@ private function buildCollMap(Collection $collection): array $this->collMap = []; foreach (CollectionIterator::recursive($collection) as $spec => $c) { - if ($c->name === null || ($c instanceof Filtered && !$c->filters)) { + if ($c->name === null) { continue; } diff --git a/tests/AbstractMapperTest.php b/tests/AbstractMapperTest.php index f0fe9cc..9d57234 100644 --- a/tests/AbstractMapperTest.php +++ b/tests/AbstractMapperTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase; use ReflectionObject; use Respect\Data\Collections\Collection; -use Respect\Data\Collections\Filtered; +use Respect\Data\Collections\Composite; use Respect\Data\Hydrators\Nested; use Respect\Data\Styles\CakePHP; use Respect\Data\Styles\Standard; @@ -48,36 +48,45 @@ public function registerCollectionShouldAddCollectionToPool(): void $this->mapper->registerCollection('my_alias', $coll); $this->assertTrue(isset($this->mapper->my_alias)); - $this->assertEquals($coll, $this->mapper->my_alias); + $clone = $this->mapper->my_alias(); + $this->assertEquals($coll->name, $clone->name); } #[Test] - public function magicSetterShouldAddCollectionToPool(): void + public function callingRegisteredCollectionWithArgsDerives(): void { - $coll = Collection::foo(); - $this->mapper->my_alias = $coll; + $coll = Composite::post(['comment' => ['text']]); + $this->mapper->registerCollection('postComment', $coll); - $this->assertTrue(isset($this->mapper->my_alias)); + $derived = $this->mapper->postComment(filter: 5); - $this->assertEquals($coll, $this->mapper->my_alias); + $this->assertInstanceOf(Composite::class, $derived); + $this->assertEquals('post', $derived->name); + $this->assertEquals(['comment' => ['text']], $derived->compositions); + $this->assertEquals(5, $derived->filter); } #[Test] - public function magicCallShouldBypassToCollection(): void + public function callingRegisteredCollectionWithoutArgsClones(): void { - $collection = $this->mapper->author()->post()->comment(); - $this->assertEquals('author', $collection->name); - $this->assertEquals('post', $collection->connectsTo?->name); - $this->assertEquals('comment', $collection->connectsTo?->connectsTo?->name); + $coll = Collection::post(); + $this->mapper->registerCollection('post', $coll); + + $clone = $this->mapper->post(); + + $this->assertNotSame($coll, $clone); + $this->assertEquals('post', $clone->name); } #[Test] - public function magicGetterShouldBypassToCollection(): void + public function magicCallShouldBypassToCollection(): void { - $collection = $this->mapper->author->post->comment; + $collection = $this->mapper->author([$this->mapper->post([$this->mapper->comment()])]); $this->assertEquals('author', $collection->name); - $this->assertEquals('post', $collection->connectsTo?->name); - $this->assertEquals('comment', $collection->connectsTo?->connectsTo?->name); + $this->assertCount(1, $collection->with); + $this->assertEquals('post', $collection->with[0]->name); + $this->assertCount(1, $collection->with[0]->with); + $this->assertEquals('comment', $collection->with[0]->with[0]->name); } #[Test] @@ -202,14 +211,6 @@ public function issetShouldReturnFalseForUnregisteredCollection(): void $this->assertFalse(isset($this->mapper->nonexistent)); } - #[Test] - public function magicGetShouldReturnNewCollectionWhenNotRegistered(): void - { - $coll = $this->mapper->author; - $this->assertInstanceOf(Collection::class, $coll); - $this->assertEquals('author', $coll->name); - } - #[Test] public function hydrationWiresRelatedEntity(): void { @@ -223,7 +224,7 @@ public function hydrationWiresRelatedEntity(): void ['id' => 5, 'title' => 'Post'], ]); - $comment = $mapper->comment->post->fetch(); + $comment = $mapper->fetch($mapper->comment([$mapper->post()])); $this->assertIsObject($comment); // Related entity wired via collection tree @@ -247,16 +248,16 @@ public function persistAfterHydrationPreservesRelation(): void ]); // Fetch with relationship — hydrates $comment->post - $comment = $mapper->comment->post->fetch(); + $comment = $mapper->fetch($mapper->comment([$mapper->post()])); $this->assertIsObject($mapper->entityFactory->get($comment, 'post')); // Modify and persist $mapper->entityFactory->set($comment, 'text', 'Updated'); - $mapper->comment->persist($comment); + $mapper->persist($comment, $mapper->comment()); $mapper->flush(); // Re-fetch without relationship - $updated = $mapper->comment[1]->fetch(); + $updated = $mapper->fetch($mapper->comment(filter: 1)); $this->assertEquals('Updated', $mapper->entityFactory->get($updated, 'text')); } @@ -273,7 +274,7 @@ public function hydrationWithNoMatchLeavesRelationNull(): void ['id' => 5, 'title' => 'Post'], ]); - $comment = $mapper->comment->post->fetch(); + $comment = $mapper->fetch($mapper->comment([$mapper->post()])); $this->assertIsObject($comment); // No post with id=999 exists, so relation stays null $this->assertNull($mapper->entityFactory->get($comment, 'post')); @@ -292,7 +293,7 @@ public function hydrationWiresRelationWithStringPk(): void ['id' => '5', 'title' => 'Post'], ]); - $comment = $mapper->comment->post->fetch(); + $comment = $mapper->fetch($mapper->comment([$mapper->post()])); $this->assertIsObject($comment); $post = $mapper->entityFactory->get($comment, 'post'); $this->assertIsObject($post); @@ -300,47 +301,7 @@ public function hydrationWiresRelationWithStringPk(): void } #[Test] - public function callingRegisteredCollectionClonesAndAppliesCondition(): void - { - $mapper = new InMemoryMapper(new Nested(new EntityFactory( - entityNamespace: 'Respect\\Data\\Stubs\\', - ))); - $mapper->seed('post', [ - ['id' => 1, 'title' => 'Hello'], - ['id' => 2, 'title' => 'World'], - ]); - - $coll = Filtered::posts('title'); - $mapper->postTitles = $coll; - - $conditioned = $mapper->postTitles(['id' => 2]); - - $this->assertInstanceOf(Filtered::class, $conditioned); - $this->assertEquals('posts', $conditioned->name); - $this->assertEquals(['title'], $conditioned->filters); - $this->assertEquals(['id' => 2], $conditioned->condition); - $this->assertEquals([], $mapper->postTitles->condition, 'Original collection should be unchanged'); - } - - #[Test] - public function callingRegisteredCollectionWithoutConditionReturnsClone(): void - { - $mapper = new InMemoryMapper(new Nested(new EntityFactory( - entityNamespace: 'Respect\\Data\\Stubs\\', - ))); - $coll = Filtered::posts('title'); - $mapper->postTitles = $coll; - - $clone = $mapper->postTitles(); - - $this->assertInstanceOf(Filtered::class, $clone); - $this->assertNotSame($mapper->postTitles, $clone); - $this->assertEquals('posts', $clone->name); - $this->assertEquals(['title'], $clone->filters); - } - - #[Test] - public function callingRegisteredChainedCollectionDoesNotMutateTemplate(): void + public function callingRegisteredCollectionReturnsImmutableClone(): void { $mapper = new InMemoryMapper(new Nested(new EntityFactory( entityNamespace: 'Respect\\Data\\Stubs\\', @@ -349,39 +310,17 @@ public function callingRegisteredChainedCollectionDoesNotMutateTemplate(): void $mapper->seed('comment', []); $coll = Collection::posts(); - $mapper->commentedPosts = $coll->comment(); + $mapper->registerCollection('commentedPosts', $coll->derive(with: [Collection::comment()])); $clone = $mapper->commentedPosts(); - $clone->author; // stacks 'author' onto the clone's chain - - $original = $mapper->commentedPosts; - $this->assertNull( - $original->connectsTo?->connectsTo, - 'Stacking on a clone should not mutate the registered collection', - ); - } - - #[Test] - public function filteredPersistDelegatesToParentCollection(): void - { - $mapper = new InMemoryMapper(new Nested(new EntityFactory( - entityNamespace: 'Respect\\Data\\Stubs\\', - ))); - $mapper->seed('post', []); - $mapper->seed('author', []); - $mapper->authorsWithPosts = Filtered::post()->author(); - - $author = new Stubs\Author(); - $author->name = 'Test'; - $mapper->authorsWithPosts->persist($author); - $mapper->flush(); - $fetched = $mapper->author->fetch(); - $this->assertEquals('Test', $fetched->name); + // Clone has the child from the registered collection + $this->assertCount(1, $clone->with); + $this->assertEquals('comment', $clone->with[0]->name); } #[Test] - public function filteredWithoutConnectsToFallsBackToNormalPersist(): void + public function directPersistWithoutRegisteredCollection(): void { $mapper = new InMemoryMapper(new Nested(new EntityFactory( entityNamespace: 'Respect\\Data\\Stubs\\', @@ -390,132 +329,13 @@ public function filteredWithoutConnectsToFallsBackToNormalPersist(): void $post = new Stubs\Post(); $post->title = 'Direct'; - $mapper->post->persist($post); + $mapper->persist($post, $mapper->post()); $mapper->flush(); - $fetched = $mapper->post->fetch(); + $fetched = $mapper->fetch($mapper->post()); $this->assertEquals('Direct', $fetched->title); } - #[Test] - public function filteredUpdatePersistsOnlyFilteredColumns(): void - { - $mapper = new InMemoryMapper(new Nested(new EntityFactory( - entityNamespace: 'Respect\\Data\\Stubs\\', - ))); - $mapper->seed('post', [ - ['id' => 1, 'title' => 'Original', 'text' => 'Body'], - ]); - - $postTitles = Filtered::post('title'); - $mapper->postTitles = $postTitles; - $post = $mapper->postTitles()->fetch(); - $this->assertIsObject($post); - - $mapper->entityFactory->set($post, 'title', 'Changed'); - $mapper->postTitles()->persist($post); - $mapper->flush(); - - $fetched = $mapper->post->fetch(); - $this->assertEquals('Changed', $mapper->entityFactory->get($fetched, 'title')); - $this->assertEquals('Body', $mapper->entityFactory->get($fetched, 'text')); - } - - #[Test] - public function filteredInsertPersistsOnlyFilteredColumns(): void - { - $mapper = new InMemoryMapper(new Nested(new EntityFactory( - entityNamespace: 'Respect\\Data\\Stubs\\', - ))); - $mapper->seed('post', []); - - $postTitles = Filtered::post('title'); - $mapper->postTitles = $postTitles; - $post = new Stubs\Post(); - $post->id = 1; - $post->title = 'Partial'; - $post->text = 'Should not persist'; - $mapper->postTitles()->persist($post); - $mapper->flush(); - - $fetched = $mapper->post->fetch(); - $this->assertEquals('Partial', $mapper->entityFactory->get($fetched, 'title')); - $this->assertNull($mapper->entityFactory->get($fetched, 'text')); - } - - #[Test] - public function filterColumnsPassesThroughForPlainCollection(): void - { - $mapper = new InMemoryMapper(new Nested(new EntityFactory( - entityNamespace: 'Respect\\Data\\Stubs\\', - ))); - $mapper->seed('post', [ - ['id' => 1, 'title' => 'Original', 'text' => 'Body'], - ]); - - $post = $mapper->post->fetch(); - $this->assertIsObject($post); - - $mapper->entityFactory->set($post, 'title', 'Changed'); - $mapper->entityFactory->set($post, 'text', 'New Body'); - $mapper->post->persist($post); - $mapper->flush(); - - $fetched = $mapper->post->fetch(); - $this->assertEquals('Changed', $mapper->entityFactory->get($fetched, 'title')); - $this->assertEquals('New Body', $mapper->entityFactory->get($fetched, 'text')); - } - - #[Test] - public function filterColumnsPassesThroughForEmptyFilters(): void - { - $mapper = new InMemoryMapper(new Nested(new EntityFactory( - entityNamespace: 'Respect\\Data\\Stubs\\', - ))); - $mapper->seed('post', [ - ['id' => 1, 'title' => 'Original', 'text' => 'Body'], - ]); - - $allPosts = Filtered::post(); - $mapper->allPosts = $allPosts; - $post = $mapper->allPosts()->fetch(); - $this->assertIsObject($post); - - $mapper->entityFactory->set($post, 'title', 'Changed'); - $mapper->entityFactory->set($post, 'text', 'New Body'); - $mapper->allPosts()->persist($post); - $mapper->flush(); - - $fetched = $mapper->post->fetch(); - $this->assertEquals('Changed', $mapper->entityFactory->get($fetched, 'title')); - $this->assertEquals('New Body', $mapper->entityFactory->get($fetched, 'text')); - } - - #[Test] - public function filterColumnsPassesThroughForIdentifierOnly(): void - { - $mapper = new InMemoryMapper(new Nested(new EntityFactory( - entityNamespace: 'Respect\\Data\\Stubs\\', - ))); - $mapper->seed('post', [ - ['id' => 1, 'title' => 'Original', 'text' => 'Body'], - ]); - - $postIds = Filtered::post(Filtered::IDENTIFIER_ONLY); - $mapper->postIds = $postIds; - $post = $mapper->postIds()->fetch(); - $this->assertIsObject($post); - - $mapper->entityFactory->set($post, 'title', 'Changed'); - $mapper->entityFactory->set($post, 'text', 'New Body'); - $mapper->postIds()->persist($post); - $mapper->flush(); - - $fetched = $mapper->post->fetch(); - $this->assertEquals('Changed', $mapper->entityFactory->get($fetched, 'title')); - $this->assertEquals('New Body', $mapper->entityFactory->get($fetched, 'text')); - } - #[Test] public function fetchPopulatesIdentityMap(): void { @@ -529,10 +349,10 @@ public function fetchPopulatesIdentityMap(): void $this->assertSame(0, $mapper->identityMapCount()); - $mapper->post[1]->fetch(); + $mapper->fetch($mapper->post(filter: 1)); $this->assertSame(1, $mapper->identityMapCount()); - $mapper->post[2]->fetch(); + $mapper->fetch($mapper->post(filter: 2)); $this->assertSame(2, $mapper->identityMapCount()); } @@ -546,8 +366,8 @@ public function fetchReturnsCachedEntityFromIdentityMap(): void ['id' => 1, 'title' => 'First'], ]); - $first = $mapper->post[1]->fetch(); - $second = $mapper->post[1]->fetch(); + $first = $mapper->fetch($mapper->post(filter: 1)); + $second = $mapper->fetch($mapper->post(filter: 1)); $this->assertSame($first, $second); } @@ -563,7 +383,7 @@ public function fetchAllPopulatesIdentityMap(): void ['id' => 2, 'title' => 'Second'], ]); - $mapper->post->fetchAll(); + $mapper->fetchAll($mapper->post()); $this->assertSame(2, $mapper->identityMapCount()); } @@ -577,7 +397,7 @@ public function flushInsertRegistersInIdentityMap(): void $entity = new Stubs\Post(); $entity->title = 'New Post'; - $mapper->post->persist($entity); + $mapper->persist($entity, $mapper->post()); $mapper->flush(); $this->assertSame(1, $mapper->identityMapCount()); @@ -593,10 +413,10 @@ public function flushDeleteEvictsFromIdentityMap(): void ['id' => 1, 'title' => 'To Delete'], ]); - $entity = $mapper->post[1]->fetch(); + $entity = $mapper->fetch($mapper->post(filter: 1)); $this->assertSame(1, $mapper->identityMapCount()); - $mapper->post->remove($entity); + $mapper->remove($entity, $mapper->post()); $mapper->flush(); $this->assertSame(0, $mapper->identityMapCount()); @@ -612,7 +432,7 @@ public function clearIdentityMapEmptiesMap(): void ['id' => 1, 'title' => 'First'], ]); - $mapper->post[1]->fetch(); + $mapper->fetch($mapper->post(filter: 1)); $this->assertSame(1, $mapper->identityMapCount()); $mapper->clearIdentityMap(); @@ -629,7 +449,7 @@ public function resetDoesNotClearIdentityMap(): void ['id' => 1, 'title' => 'First'], ]); - $mapper->post[1]->fetch(); + $mapper->fetch($mapper->post(filter: 1)); $this->assertSame(1, $mapper->identityMapCount()); $mapper->reset(); @@ -649,24 +469,24 @@ public function pendingOperationTypes(): void $ref = new ReflectionObject($mapper); $pendingProp = $ref->getProperty('pending'); - // persist new entity → 'insert' + // persist new entity -> 'insert' $newEntity = new Stubs\Post(); $newEntity->title = 'New'; - $mapper->post->persist($newEntity); + $mapper->persist($newEntity, $mapper->post()); /** @var SplObjectStorage $pending */ $pending = $pendingProp->getValue($mapper); $this->assertSame('insert', $pending[$newEntity]); - // persist existing entity → 'update' - $existing = $mapper->post[1]->fetch(); - $mapper->post->persist($existing); + // persist existing entity -> 'update' + $existing = $mapper->fetch($mapper->post(filter: 1)); + $mapper->persist($existing, $mapper->post()); /** @var SplObjectStorage $pending */ $pending = $pendingProp->getValue($mapper); $this->assertSame('update', $pending[$existing]); - // remove entity → 'delete' - $mapper->post->remove($existing); + // remove entity -> 'delete' + $mapper->remove($existing, $mapper->post()); /** @var SplObjectStorage $pending */ $pending = $pendingProp->getValue($mapper); $this->assertSame('delete', $pending[$existing]); @@ -684,7 +504,7 @@ public function trackedCountReflectsTrackedEntities(): void $this->assertSame(0, $mapper->trackedCount()); - $mapper->post[1]->fetch(); + $mapper->fetch($mapper->post(filter: 1)); $this->assertSame(1, $mapper->trackedCount()); } @@ -716,7 +536,7 @@ public function registerSkipsEntityWithNoPkValue(): void // Entity with no 'id' set $entity = new Stubs\Post(); $entity->title = 'No PK'; - $mapper->post->persist($entity); + $mapper->persist($entity, $mapper->post()); // Before flush, entity has no PK — identity map should not contain it yet // (identity map registration happens during flush, after PK is assigned) @@ -733,16 +553,57 @@ public function deleteEvictsUsingTrackedCollection(): void ['id' => 1, 'title' => 'Test'], ]); - $entity = $mapper->post[1]->fetch(); + $entity = $mapper->fetch($mapper->post(filter: 1)); $this->assertSame(1, $mapper->identityMapCount()); // Remove via a different collection — flush uses the tracked one (name='post') - $mapper->post->remove($entity); + $mapper->remove($entity, $mapper->post()); + $mapper->flush(); + + $this->assertSame(0, $mapper->identityMapCount()); + } + + #[Test] + public function evictSkipsNullCollectionName(): void + { + $mapper = new InMemoryMapper(new Nested(new EntityFactory( + entityNamespace: 'Respect\\Data\\Stubs\\', + ))); + + // Track a new entity directly against a null-name collection + $entity = new Stubs\Foo(); + $entity->id = 1; + $nullColl = new Collection(); + $mapper->markTracked($entity, $nullColl); + $mapper->remove($entity, $nullColl); $mapper->flush(); + // Evict should be a no-op (null name), identity map stays empty $this->assertSame(0, $mapper->identityMapCount()); } + #[Test] + public function evictSkipsEntityWithNoPkValue(): void + { + $mapper = new InMemoryMapper(new Nested(new EntityFactory( + entityNamespace: 'Respect\\Data\\Stubs\\', + ))); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'Test'], + ]); + + $mapper->fetch($mapper->post(filter: 1)); + $this->assertSame(1, $mapper->identityMapCount()); + + // Entity with no PK — evict should be a no-op + $entity = new Stubs\Post(); + $entity->title = 'No PK'; + $mapper->remove($entity, $mapper->post()); + $mapper->flush(); + + $this->assertSame(1, $mapper->identityMapCount()); + } + #[Test] public function findInIdentityMapSkipsNonScalarCondition(): void { @@ -754,11 +615,11 @@ public function findInIdentityMapSkipsNonScalarCondition(): void ]); // Populate identity map - $mapper->post[1]->fetch(); + $mapper->fetch($mapper->post(filter: 1)); $this->assertSame(1, $mapper->identityMapCount()); // fetchAll uses array/null condition — should always hit the backend - $all = $mapper->post->fetchAll(); + $all = $mapper->fetchAll($mapper->post()); $this->assertNotEmpty($all); } @@ -776,7 +637,7 @@ public function findInIdentityMapSkipsCollectionWithChildren(): void ]); // Fetch with relationship (has children) — should bypass identity map - $comment = $mapper->comment->post->fetch(); + $comment = $mapper->fetch($mapper->comment([$mapper->post()])); $this->assertIsObject($comment); } @@ -791,7 +652,7 @@ public function persistUntrackedEntityWithMatchingPkUpdates(): void ]); // Populate identity map - $fetched = $mapper->post[1]->fetch(); + $fetched = $mapper->fetch($mapper->post(filter: 1)); $this->assertSame('Original', $fetched->title); // Create a NEW mutable entity with matching PK @@ -799,7 +660,7 @@ public function persistUntrackedEntityWithMatchingPkUpdates(): void $replacement->id = 1; $replacement->title = 'Updated'; - $mapper->post->persist($replacement); + $mapper->persist($replacement, $mapper->post()); $ref = new ReflectionObject($mapper); $pendingProp = $ref->getProperty('pending'); @@ -821,7 +682,7 @@ public function persistReadOnlyEntityInsertWorks(): void $mapper->seed('read_only_author', []); $entity = $mapper->entityFactory->create(Stubs\ReadOnlyAuthor::class, name: 'Alice'); - $mapper->read_only_author->persist($entity); + $mapper->persist($entity, $mapper->read_only_author()); $mapper->flush(); // PK should have been assigned (first assignment on uninitialized readonly $id) @@ -839,12 +700,12 @@ public function persistReadOnlyViaCollectionPkUpdates(): void ]); // Populate identity map - $fetched = $mapper->read_only_author[1]->fetch(); + $fetched = $mapper->fetch($mapper->read_only_author(filter: 1)); $this->assertSame('Original', $fetched->name); // Create new readonly entity (no PK) and persist via collection[pk] $updated = $mapper->entityFactory->create(Stubs\ReadOnlyAuthor::class, name: 'Updated', bio: 'new bio'); - $merged = $mapper->read_only_author[1]->persist($updated); + $merged = $mapper->persist($updated, $mapper->read_only_author(filter: 1)); // Merged entity should combine both: PK from fetched, changes from updated $this->assertSame(1, $merged->id); @@ -873,15 +734,15 @@ public function persistReadOnlyViaCollectionPkFlushUpdatesStorage(): void ['id' => 1, 'name' => 'Original', 'bio' => null], ]); - $mapper->read_only_author[1]->fetch(); + $mapper->fetch($mapper->read_only_author(filter: 1)); $updated = $mapper->entityFactory->create(Stubs\ReadOnlyAuthor::class, name: 'Updated', bio: 'new bio'); - $mapper->read_only_author[1]->persist($updated); + $mapper->persist($updated, $mapper->read_only_author(filter: 1)); $mapper->flush(); // Clear identity map and re-fetch to verify DB was updated $mapper->clearIdentityMap(); - $refetched = $mapper->read_only_author[1]->fetch(); + $refetched = $mapper->fetch($mapper->read_only_author(filter: 1)); $this->assertSame('Updated', $refetched->name); $this->assertSame('new bio', $refetched->bio); } @@ -896,11 +757,11 @@ public function identityMapReplaceEvictsOldEntity(): void ['id' => 1, 'name' => 'Alice', 'bio' => null], ]); - $mapper->read_only_author[1]->fetch(); + $mapper->fetch($mapper->read_only_author(filter: 1)); $this->assertSame(1, $mapper->identityMapCount()); $updated = $mapper->entityFactory->create(Stubs\ReadOnlyAuthor::class, name: 'Bob'); - $mapper->read_only_author[1]->persist($updated); + $mapper->persist($updated, $mapper->read_only_author(filter: 1)); // Identity map count stays 1 (swapped, not added) $this->assertSame(1, $mapper->identityMapCount()); @@ -916,7 +777,7 @@ public function identityMapReplaceFallsBackToInsertWhenNoPkMatch(): void // No identity map entries — should insert $entity = $mapper->entityFactory->create(Stubs\ReadOnlyAuthor::class, name: 'New'); - $mapper->read_only_author->persist($entity); + $mapper->persist($entity, $mapper->read_only_author()); $ref = new ReflectionObject($mapper); $pendingProp = $ref->getProperty('pending'); @@ -935,22 +796,22 @@ public function identityMapReplaceDetachesPreviouslyPendingEntity(): void ['id' => 1, 'title' => 'Original'], ]); - $fetched = $mapper->post[1]->fetch(); + $fetched = $mapper->fetch($mapper->post(filter: 1)); // Mark the fetched entity as pending 'update' - $mapper->post->persist($fetched); + $mapper->persist($fetched, $mapper->post()); // Now replace with a new entity — old must be detached from pending too $replacement = new Stubs\Post(); $replacement->id = 1; $replacement->title = 'Replaced'; - $mapper->post->persist($replacement); + $mapper->persist($replacement, $mapper->post()); // flush should not crash (old entity no longer in pending) $mapper->flush(); $mapper->clearIdentityMap(); - $refetched = $mapper->post[1]->fetch(); + $refetched = $mapper->fetch($mapper->post(filter: 1)); $this->assertSame('Replaced', $refetched->title); } @@ -964,10 +825,10 @@ public function identityMapReplaceSkipsSameEntity(): void ['id' => 1, 'title' => 'Test'], ]); - $fetched = $mapper->post[1]->fetch(); + $fetched = $mapper->fetch($mapper->post(filter: 1)); // Persist the same entity again — should take the isTracked() path, not replace - $mapper->post->persist($fetched); + $mapper->persist($fetched, $mapper->post()); $ref = new ReflectionObject($mapper); $pendingProp = $ref->getProperty('pending'); @@ -989,7 +850,7 @@ public function readOnlyNestedHydrationWiresRelation(): void ['id' => 5, 'title' => 'Hello', 'text' => 'World'], ]); - $comment = $mapper->comment->post->fetch(); + $comment = $mapper->fetch($mapper->comment([$mapper->post()])); $this->assertInstanceOf(Stubs\Immutable\Comment::class, $comment); $this->assertSame(1, $comment->id); @@ -1016,7 +877,7 @@ public function readOnlyNestedHydrationThreeLevels(): void ['id' => 3, 'name' => 'Alice', 'bio' => 'Writer'], ]); - $comment = $mapper->comment->post->author->fetch(); + $comment = $mapper->fetch($mapper->comment([$mapper->post([$mapper->author()])])); $this->assertInstanceOf(Stubs\Immutable\Comment::class, $comment); $this->assertSame(1, $comment->id); @@ -1047,20 +908,20 @@ public function readOnlyInsertWithRelationExtractsFk(): void ); // Insert author first so it gets a PK - $mapper->author->persist($author); + $mapper->persist($author, $mapper->author()); $mapper->flush(); $this->assertSame(1001, $author->id); - // Insert post — extractColumns should resolve $author → author_id FK - $mapper->post->persist($post); + // Insert post — extractColumns should resolve $author -> author_id FK + $mapper->persist($post, $mapper->post()); $mapper->flush(); $this->assertSame(1002, $post->id); // Re-fetch the post and verify FK was stored $mapper->clearIdentityMap(); - $fetchedPost = $mapper->post->author->fetch(); + $fetchedPost = $mapper->fetch($mapper->post([$mapper->author()])); $this->assertSame('Hello', $fetchedPost->title); $this->assertSame('Bob', $fetchedPost->author->name); } @@ -1079,7 +940,7 @@ public function readOnlyReplaceViaCollectionPkPreservesRelation(): void ]); // Fetch the full graph - $fetched = $mapper->post->author->fetch(); + $fetched = $mapper->fetch($mapper->post([$mapper->author()])); $this->assertSame('Original', $fetched->title); $this->assertSame('Alice', $fetched->author->name); @@ -1090,12 +951,12 @@ public function readOnlyReplaceViaCollectionPkPreservesRelation(): void text: 'New Body', author: $fetched->author, ); - $mapper->post[1]->persist($updated); + $mapper->persist($updated, $mapper->post(filter: 1)); $mapper->flush(); // Re-fetch and verify both post columns AND FK were updated correctly $mapper->clearIdentityMap(); - $refetched = $mapper->post->author->fetch(); + $refetched = $mapper->fetch($mapper->post([$mapper->author()])); $this->assertSame('Updated', $refetched->title); $this->assertSame('New Body', $refetched->text); $this->assertSame('Alice', $refetched->author->name); @@ -1116,11 +977,11 @@ public function readOnlyReplaceWithNewRelation(): void ['id' => 20, 'name' => 'Bob', 'bio' => 'Writer'], ]); - $fetched = $mapper->post->author->fetch(); + $fetched = $mapper->fetch($mapper->post([$mapper->author()])); $this->assertSame('Alice', $fetched->author->name); // Fetch the other author - $bob = $mapper->author[20]->fetch(); + $bob = $mapper->fetch($mapper->author(filter: 20)); // Replace post with a new author FK $updated = $mapper->entityFactory->create( @@ -1129,11 +990,11 @@ public function readOnlyReplaceWithNewRelation(): void text: 'Text', author: $bob, ); - $mapper->post[1]->persist($updated); + $mapper->persist($updated, $mapper->post(filter: 1)); $mapper->flush(); $mapper->clearIdentityMap(); - $refetched = $mapper->post->author->fetch(); + $refetched = $mapper->fetch($mapper->post([$mapper->author()])); $this->assertSame('Reassigned', $refetched->title); $this->assertSame('Bob', $refetched->author->name); $this->assertSame(20, $refetched->author->id); @@ -1153,16 +1014,16 @@ public function partialEntityPersistAutoUpdatesViaIdentityMap(): void ['id' => 20, 'name' => 'Bob', 'bio' => null], ]); - $mapper->post->author->fetch(); - $bob = $mapper->author[20]->fetch(); + $mapper->fetch($mapper->post([$mapper->author()])); + $bob = $mapper->fetch($mapper->author(filter: 20)); - // Partial entity with same PK → persist auto-detects update via identity map + // Partial entity with same PK -> persist auto-detects update via identity map $updated = $mapper->entityFactory->create(Stubs\Immutable\Post::class, id: 1, title: 'Changed', author: $bob); - $mapper->post->persist($updated); + $mapper->persist($updated, $mapper->post()); $mapper->flush(); $mapper->clearIdentityMap(); - $refetched = $mapper->post->author->fetch(); + $refetched = $mapper->fetch($mapper->post([$mapper->author()])); $this->assertSame('Changed', $refetched->title); $this->assertSame('Body', $refetched->text); $this->assertSame('Bob', $refetched->author->name); @@ -1180,7 +1041,7 @@ public function readOnlyMultipleEntitiesFetchAllTracksAll(): void ['id' => 3, 'name' => 'Carol', 'bio' => null], ]); - $authors = $mapper->author->fetchAll(); + $authors = $mapper->fetchAll($mapper->author()); $this->assertCount(3, $authors); // All entities should be tracked and in identity map @@ -1189,7 +1050,7 @@ public function readOnlyMultipleEntitiesFetchAllTracksAll(): void // Replace one by identity map lookup $updated = $mapper->entityFactory->create(Stubs\Immutable\Author::class, name: 'Alice Updated'); - $merged = $mapper->author[1]->persist($updated); + $merged = $mapper->persist($updated, $mapper->author(filter: 1)); // Original Alice should be evicted, merged entity takes its place $this->assertSame(3, $mapper->trackedCount()); @@ -1208,12 +1069,12 @@ public function identityMapReplaceSkipsSetWhenPkAlreadyInitialized(): void ['id' => 1, 'name' => 'Alice', 'bio' => null], ]); - $mapper->author[1]->fetch(); + $mapper->fetch($mapper->author(filter: 1)); $updated = new Stubs\Immutable\Author(id: 1, name: 'Bob'); // persist via collection[1] — PK already set, merge produces new entity - $merged = $mapper->author[1]->persist($updated); + $merged = $mapper->persist($updated, $mapper->author(filter: 1)); $this->assertSame(1, $merged->id); $this->assertSame('Bob', $merged->name); @@ -1230,12 +1091,12 @@ public function persistReturnsEntity(): void // Insert path $entity = new Stubs\Post(); $entity->title = 'Test'; - $result = $mapper->post->persist($entity); + $result = $mapper->persist($entity, $mapper->post()); $this->assertSame($entity, $result); // Update path (tracked entity) $mapper->flush(); - $result = $mapper->post->persist($entity); + $result = $mapper->persist($entity, $mapper->post()); $this->assertSame($entity, $result); } @@ -1249,17 +1110,17 @@ public function readOnlyDeleteEvictsFromIdentityMap(): void ['id' => 1, 'name' => 'Alice', 'bio' => null], ]); - $fetched = $mapper->author[1]->fetch(); + $fetched = $mapper->fetch($mapper->author(filter: 1)); $this->assertSame(1, $mapper->identityMapCount()); - $mapper->author->remove($fetched); + $mapper->remove($fetched, $mapper->author()); $mapper->flush(); $this->assertSame(0, $mapper->identityMapCount()); // Re-fetch returns false (no data) $mapper->clearIdentityMap(); - $refetched = $mapper->author[1]->fetch(); + $refetched = $mapper->fetch($mapper->author(filter: 1)); $this->assertFalse($refetched); } @@ -1272,14 +1133,14 @@ public function persistPartialEntityOnPendingInsertMergesViaIdentityMap(): void $mapper->seed('author', []); $author = $mapper->entityFactory->create(Stubs\Immutable\Author::class, id: 1, name: 'Alice'); - $mapper->author->persist($author); + $mapper->persist($author, $mapper->author()); // Partial entity with same PK merges via identity map, does not duplicate $updated = $mapper->entityFactory->create(Stubs\Immutable\Author::class, id: 1, name: 'Bob'); - $mapper->author->persist($updated); + $mapper->persist($updated, $mapper->author()); $mapper->flush(); - $all = $mapper->author->fetchAll(); + $all = $mapper->fetchAll($mapper->author()); $this->assertCount(1, $all); $this->assertSame('Bob', $all[0]->name); } @@ -1294,15 +1155,15 @@ public function persistPartialEntityOnTrackedUpdateMerges(): void ['id' => 1, 'name' => 'Alice', 'bio' => null], ]); - $mapper->author[1]->fetch(); + $mapper->fetch($mapper->author(filter: 1)); // Partial entity with same PK auto-detects update via identity map $partial = $mapper->entityFactory->create(Stubs\Immutable\Author::class, id: 1, name: 'Bob'); - $mapper->author->persist($partial); + $mapper->persist($partial, $mapper->author()); $mapper->flush(); $mapper->clearIdentityMap(); - $refetched = $mapper->author[1]->fetch(); + $refetched = $mapper->fetch($mapper->author(filter: 1)); $this->assertSame('Bob', $refetched->name); } @@ -1316,7 +1177,7 @@ public function mutableMergeAppliesOverlayPropertiesToExisting(): void ['id' => 1, 'name' => 'Alice', 'bio' => null], ]); - $fetched = $mapper->author[1]->fetch(); + $fetched = $mapper->fetch($mapper->author(filter: 1)); $this->assertSame('Alice', $fetched->name); // Persist a different mutable entity with same PK @@ -1325,7 +1186,7 @@ public function mutableMergeAppliesOverlayPropertiesToExisting(): void $overlay->name = 'Bob'; $overlay->bio = 'new bio'; - $result = $mapper->author->persist($overlay); + $result = $mapper->persist($overlay, $mapper->author()); // Existing entity is mutated in place and returned $this->assertSame($fetched, $result); @@ -1345,11 +1206,11 @@ public function readOnlyMergeNoDiffReturnsSameEntity(): void ['id' => 1, 'name' => 'Alice', 'bio' => null], ]); - $fetched = $mapper->author[1]->fetch(); + $fetched = $mapper->fetch($mapper->author(filter: 1)); // Persist readonly entity with identical properties $same = new Stubs\Immutable\Author(id: 1, name: 'Alice'); - $result = $mapper->author[1]->persist($same); + $result = $mapper->persist($same, $mapper->author(filter: 1)); // No clone needed — same entity returned $this->assertSame($fetched, $result); @@ -1366,10 +1227,10 @@ public function identityMapLookupNormalizesNumericStringCondition(): void ['id' => 1, 'name' => 'Alice', 'bio' => null], ]); - $fetched = $mapper->author[1]->fetch(); + $fetched = $mapper->fetch($mapper->author(filter: 1)); // Lookup with string "1" should hit the identity map - $fromString = $mapper->author['1']->fetch(); + $fromString = $mapper->fetch($mapper->author(filter: '1')); $this->assertSame($fetched, $fromString); } @@ -1383,10 +1244,10 @@ public function identityMapLookupReturnsNullForNonScalarCondition(): void ['id' => 1, 'name' => 'Alice', 'bio' => null], ]); - $mapper->author[1]->fetch(); + $mapper->fetch($mapper->author(filter: 1)); // Float condition should not match identity map - $result = $mapper->author[1.5]->fetch(); + $result = $mapper->fetch($mapper->author(filter: 1.5)); $this->assertNotSame(true, $result === null); } @@ -1401,7 +1262,7 @@ public function mutableMergeTracksExistingWhenNotYetTracked(): void ]); // Put entity in identity map via fetch, then untrack it manually - $fetched = $mapper->author[1]->fetch(); + $fetched = $mapper->fetch($mapper->author(filter: 1)); $ref = new ReflectionObject($mapper); $trackedProp = $ref->getProperty('tracked'); /** @var SplObjectStorage $tracked */ @@ -1414,7 +1275,7 @@ public function mutableMergeTracksExistingWhenNotYetTracked(): void $overlay->id = 1; $overlay->name = 'Bob'; - $result = $mapper->author->persist($overlay); + $result = $mapper->persist($overlay, $mapper->author()); $this->assertSame($fetched, $result); $this->assertTrue($mapper->isTracked($fetched)); @@ -1431,11 +1292,11 @@ public function mergeWithIdentityMapNormalizesConditionFallback(): void ['id' => 1, 'name' => 'Alice', 'bio' => null], ]); - $fetched = $mapper->author[1]->fetch(); + $fetched = $mapper->fetch($mapper->author(filter: 1)); // Persist readonly entity without PK, via string condition "1" $overlay = $mapper->entityFactory->create(Stubs\Immutable\Author::class, name: 'Updated'); - $merged = $mapper->author['1']->persist($overlay); + $merged = $mapper->persist($overlay, $mapper->author(filter: '1')); // Should have matched identity map via normalized condition $this->assertNotSame($fetched, $merged); diff --git a/tests/CollectionIteratorTest.php b/tests/CollectionIteratorTest.php index 0ca2d50..cf94560 100644 --- a/tests/CollectionIteratorTest.php +++ b/tests/CollectionIteratorTest.php @@ -51,15 +51,7 @@ public function hasChildrenConsiderEmpties(): void #[Test] public function hasChildrenUseCollectionChildren(): void { - $coll = Collection::foo(Collection::bar()); - $iterator = new CollectionIterator($coll); - $this->assertTrue($iterator->hasChildren()); - } - - #[Test] - public function hasChildrenUseCollectionNext(): void - { - $coll = Collection::foo()->bar; + $coll = Collection::foo([Collection::bar()]); $iterator = new CollectionIterator($coll); $this->assertTrue($iterator->hasChildren()); } @@ -73,27 +65,23 @@ public function getChildrenConsiderEmpties(): void } #[Test] - public function getChildrenUseCollectionChildren(): void + public function getChildrenUseCollectionWith(): void { - $coll = Collection::foo()->with(Collection::bar(), Collection::baz()); - [$fooChild, $barChild] = $coll->children; + $coll = Collection::foo([Collection::bar(), Collection::baz()]); $items = iterator_to_array(CollectionIterator::recursive($coll)); - $this->assertContains($fooChild, $items); - $this->assertContains($barChild, $items); - } + $names = []; + foreach ($items as $item) { + $names[] = $item->name; + } - #[Test] - public function getChildrenUseCollectionNext(): void - { - $coll = Collection::foo()->bar; - $iterator = new CollectionIterator($coll); - $this->assertTrue($iterator->hasChildren()); + $this->assertContains('bar', $names); + $this->assertContains('baz', $names); } #[Test] - public function recursiveTraversalShouldVisitNextChain(): void + public function recursiveTraversalShouldVisitNestedChildren(): void { - $coll = Collection::foo()->bar->baz; + $coll = Collection::foo([Collection::bar([Collection::baz()])]); $items = iterator_to_array(CollectionIterator::recursive($coll)); $this->assertCount(3, $items); } diff --git a/tests/Collections/CollectionTest.php b/tests/Collections/CollectionTest.php index 6dd39a6..f007b8e 100644 --- a/tests/Collections/CollectionTest.php +++ b/tests/Collections/CollectionTest.php @@ -7,19 +7,14 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -use Respect\Data\AbstractMapper; -use Respect\Data\CollectionNotBound; use Respect\Data\EntityFactory; use Respect\Data\Hydrators\Nested; use Respect\Data\InMemoryMapper; use Respect\Data\Stubs; -use Respect\Data\Stubs\Foo; use function count; -use function reset; #[CoversClass(Collection::class)] -#[CoversClass(CollectionNotBound::class)] class CollectionTest extends TestCase { #[Test] @@ -34,298 +29,112 @@ public function collectionCanBeCreatedStaticallyWithChildren(): void { $children1 = Collection::bar(); $children2 = Collection::baz(); - $coll = Collection::foo($children1, $children2); + $coll = Collection::foo([$children1, $children2]); $this->assertInstanceOf('Respect\Data\Collections\Collection', $coll); $this->assertTrue($coll->hasChildren); - $this->assertEquals(2, count($coll->children)); + $this->assertEquals(2, count($coll->with)); } #[Test] - public function collectionCanBeCreatedStaticallyWithCondition(): void + public function collectionCanBeCreatedStaticallyWithFilter(): void { - $coll = Collection::fooBar(42); + $coll = Collection::fooBar(filter: 42); $this->assertInstanceOf('Respect\Data\Collections\Collection', $coll); - $this->assertEquals(42, $coll->condition); - } - - #[Test] - public function multipleConditionsOnStaticCreationLeavesTheLast(): void - { - $coll = Collection::fooBar(42, 'Other dominant condition!!!'); - $this->assertInstanceOf('Respect\Data\Collections\Collection', $coll); - $this->assertEquals( - 'Other dominant condition!!!', - $coll->condition, - ); + $this->assertEquals(42, $coll->filter); } #[Test] public function objectConstructorShouldSetObjectAttributes(): void { $coll = new Collection('some_irrelevant_name'); - $this->assertEquals( - [], - $coll->condition, - 'Default condition should be an empty array', + $this->assertNull( + $coll->filter, + 'Default filter should be null', ); $this->assertEquals('some_irrelevant_name', $coll->name); } #[Test] - public function objectConstructorWithConditionShouldSetIt(): void - { - $coll = new Collection('some_irrelevant_name', 123); - $this->assertEquals(123, $coll->condition); - } - - #[Test] - public function dynamicGetterShouldStackCollection(): void - { - $coll = new Collection('hi'); - $coll->someTest; - $this->assertEquals( - 'someTest', - $coll->connectsTo?->name, - 'First time should change connectsTo item', - ); - } - - #[Test] - public function dynamicGetterShouldChainCollection(): void - { - $coll = new Collection('hi'); - $coll->someTest; - $this->assertEquals( - 'someTest', - $coll->connectsTo?->name, - 'First time should change connectsTo item', - ); - $coll->anotherTest; - $this->assertEquals( - 'someTest', - $coll->connectsTo?->name, - 'The connectsTo item on a chain should never be changed after first time', - ); - } - - #[Test] - public function settingConditionViaDynamicOffsetShouldUseLastNode(): void - { - $foo = Collection::foo()->bar->baz[42]; - $bar = $foo->connectsTo; - $baz = $bar->connectsTo; - $this->assertEmpty($foo->condition); - $this->assertEmpty($bar->condition); - $this->assertEquals(42, $baz->condition); - } - - #[Test] - public function dynamicMethodCallShouldAcceptChildren(): void - { - $coll = new Collection('some_name'); - $coll->fooBar( - Collection::some(), - Collection::children(), - Collection::here(), - ); - $connected = $coll->connectsTo; - $this->assertNotNull($connected); - $this->assertEquals(3, count($connected->children)); - } - - #[Test] - public function addChildShouldSetChildrenObjectProperties(): void + public function objectConstructorWithFilterShouldSetIt(): void { - $coll = new Collection('foo_collection'); - $coll->addChild(new Collection('bar_child')); - $children = $coll->children; - $child = reset($children); - $this->assertInstanceOf(Collection::class, $child); - $this->assertEquals(false, $child->required); - $this->assertEquals($coll->name, $child->parent?->name); + $coll = new Collection('some_irrelevant_name', filter: 123); + $this->assertEquals(123, $coll->filter); } #[Test] - public function childrenShouldMakeHasMoreTrue(): void + public function constructorCompositionShouldSetChildrenAndParent(): void { - $coll = Collection::foo(Collection::thisIsAChildren()); - $this->assertTrue($coll->hasMore); + $child = new Collection('bar_child'); + $coll = new Collection('foo_collection', [$child]); + $children = $coll->with; + $this->assertCount(1, $children); + $this->assertInstanceOf(Collection::class, $children[0]); + $this->assertEquals(false, $children[0]->required); + $this->assertEquals($coll->name, $children[0]->parent?->name); } #[Test] - public function chainingShouldMakeHasMoreTrue(): void + public function childrenShouldMakeHasChildrenTrue(): void { - $coll = Collection::foo()->barChain; - $this->assertTrue($coll->hasMore); - } - - #[Test] - public function arrayOffsetSetShouldNotDoAnything(): void - { - $touched = Collection::foo()->bar; - $touched['magic'] = 'FOOOO'; - $untouched = Collection::foo()->bar; - $this->assertEquals($untouched, $touched); - } - - #[Test] - public function arrayOffsetUnsetShouldNotDoAnything(): void - { - $touched = Collection::foo()->bar; - unset($touched['magic']); - unset($touched['bar']); - $untouched = Collection::foo()->bar; - $this->assertEquals($untouched, $touched); - } - - #[Test] - public function persistShouldPersistOnAttachedMapper(): void - { - $persisted = new Foo(); - $collection = new Collection('name_whatever'); - $mapperMock = $this->createMock(AbstractMapper::class); - $mapperMock->expects($this->once()) - ->method('persist') - ->with($persisted, $collection) - ->willReturn($persisted); - $collection->bindMapper($mapperMock); - $result = $collection->persist($persisted); - $this->assertSame($persisted, $result); - } - - #[Test] - public function removeShouldPersistOnAttachedMapper(): void - { - $removed = new Foo(); - $collection = new Collection('name_whatever'); - $mapperMock = $this->createMock(AbstractMapper::class); - $mapperMock->expects($this->once()) - ->method('remove') - ->with($removed, $collection) - ->willReturn(true); - $collection->bindMapper($mapperMock); - $collection->remove($removed); - } - - #[Test] - public function fetchShouldPersistOnAttachedMapper(): void - { - $result = 'result stub'; - $collection = new Collection('name_whatever'); - $mapperMock = $this->createMock(AbstractMapper::class); - $mapperMock->expects($this->once()) - ->method('fetch') - ->with($collection) - ->willReturn($result); - $collection->bindMapper($mapperMock); - $collection->fetch(); - } - - #[Test] - public function fetchShouldPersistOnAttachedMapperWithExtraParam(): void - { - $result = 'result stub'; - $extra = 'extra stub'; - $collection = new Collection('name_whatever'); - $mapperMock = $this->createMock(AbstractMapper::class); - $mapperMock->expects($this->once()) - ->method('fetch') - ->with($collection, $extra) - ->willReturn($result); - $collection->bindMapper($mapperMock); - $collection->fetch($extra); - } - - #[Test] - public function fetchAllShouldPersistOnAttachedMapper(): void - { - $collection = new Collection('name_whatever'); - $mapperMock = $this->createMock(AbstractMapper::class); - $mapperMock->expects($this->once()) - ->method('fetchAll') - ->with($collection) - ->willReturn([]); - $collection->bindMapper($mapperMock); - $collection->fetchAll(); + $coll = Collection::foo([Collection::thisIsAChildren()]); + $this->assertTrue($coll->hasChildren); } #[Test] - public function fetchAllShouldPersistOnAttachedMapperWithExtraParam(): void + public function noChildrenShouldMakeHasChildrenFalse(): void { - $extra = 'extra stub'; - $collection = new Collection('name_whatever'); - $mapperMock = $this->createMock(AbstractMapper::class); - $mapperMock->expects($this->once()) - ->method('fetchAll') - ->with($collection, $extra) - ->willReturn([]); - $collection->bindMapper($mapperMock); - $collection->fetchAll($extra); + $coll = Collection::foo(); + $this->assertFalse($coll->hasChildren); } #[Test] - public function arrayOffsetExistsShouldNotDoAnything(): void + public function getParentShouldReturnNullWhenNoParent(): void { - $touched = Collection::foo()->bar; - $this->assertFalse(isset($touched['magic'])); - $untouched = Collection::foo()->bar; - $this->assertEquals($untouched, $touched); + $coll = new Collection('foo'); + $this->assertNull($coll->parent); } #[Test] - public function persistOnCollectionShouldExceptionIfMapperDontExist(): void + public function deriveCreatesNewCollectionWithMergedWith(): void { - $this->expectException(CollectionNotBound::class); - Collection::foo()->persist(new Foo()); - } + $original = Collection::foo([Collection::bar()]); + $derived = $original->derive(with: [Collection::baz()]); - #[Test] - public function removeOnCollectionShouldExceptionIfMapperDontExist(): void - { - $this->expectException(CollectionNotBound::class); - Collection::foo()->remove(new Foo()); + $this->assertNotSame($original, $derived); + $this->assertEquals('foo', $derived->name); + $this->assertCount(2, $derived->with); + $this->assertEquals('bar', $derived->with[0]->name); + $this->assertEquals('baz', $derived->with[1]->name); + $this->assertCount(1, $original->with, 'Original should be unchanged'); } #[Test] - public function fetchOnCollectionShouldExceptionIfMapperDontExist(): void + public function derivePreservesFilterAndOverridesWhenProvided(): void { - $this->expectException(CollectionNotBound::class); - Collection::foo()->fetch(); - } + $original = new Collection('foo', filter: 42, required: true); - #[Test] - public function fetchAllOnCollectionShouldExceptionIfMapperDontExist(): void - { - $this->expectException(CollectionNotBound::class); - Collection::foo()->fetchAll(); - } + $sameFilter = $original->derive(); + $this->assertEquals(42, $sameFilter->filter); + $this->assertTrue($sameFilter->required); - #[Test] - public function getParentShouldReturnNullWhenNoParent(): void - { - $coll = new Collection('foo'); - $this->assertNull($coll->parent); + $newFilter = $original->derive(filter: 99, required: false); + $this->assertEquals(99, $newFilter->filter); + $this->assertFalse($newFilter->required); } #[Test] - public function getConnectsToShouldReturnNullWhenNoConnectsTo(): void + public function cloneDeepClonesChildrenAndClearsParent(): void { - $coll = new Collection('foo'); - $this->assertNull($coll->connectsTo); - } + $parent = Collection::foo([Collection::bar([Collection::baz()])]); + $clone = clone $parent; - #[Test] - public function magicGetShouldUseRegisteredCollectionFromMapper(): void - { - $registered = Collection::bar(); - $mapperMock = $this->createMock(AbstractMapper::class); - $mapperMock->method('__isset')->with('bar')->willReturn(true); - $mapperMock->method('__get')->with('bar')->willReturn($registered); + $this->assertNull($clone->parent); + $this->assertNotSame($parent->with[0], $clone->with[0]); + $this->assertEquals('bar', $clone->with[0]->name); + $this->assertSame($clone, $clone->with[0]->parent); - $coll = new Collection('foo'); - $coll->bindMapper($mapperMock); - $result = $coll->bar; - $this->assertEquals('bar', $result->connectsTo?->name); + $this->assertNotSame($parent->with[0]->with[0], $clone->with[0]->with[0]); + $this->assertEquals('baz', $clone->with[0]->with[0]->name); } #[Test] @@ -337,7 +146,7 @@ public function persistReturnsSameEntity(): void $mapper->seed('author', []); $entity = $mapper->entityFactory->create(Stubs\Immutable\Author::class, name: 'Alice'); - $result = $mapper->author->persist($entity); + $result = $mapper->persist($entity, $mapper->author()); $this->assertSame($entity, $result); } @@ -351,11 +160,11 @@ public function persistPartialEntityMergesViaIdentityMap(): void ['id' => 1, 'name' => 'Alice', 'bio' => null], ]); - $fetched = $mapper->author[1]->fetch(); + $fetched = $mapper->fetch($mapper->author(filter: 1)); $this->assertSame('Alice', $fetched->name); $partial = $mapper->entityFactory->create(Stubs\Immutable\Author::class, id: 1, name: 'Bob'); - $result = $mapper->author->persist($partial); + $result = $mapper->persist($partial, $mapper->author()); $this->assertNotSame($fetched, $result); $this->assertSame('Bob', $result->name); @@ -372,14 +181,14 @@ public function persistPartialEntityFlushesUpdate(): void ['id' => 1, 'name' => 'Alice', 'bio' => null], ]); - $mapper->author[1]->fetch(); + $mapper->fetch($mapper->author(filter: 1)); $partial = $mapper->entityFactory->create(Stubs\Immutable\Author::class, id: 1, name: 'Bob', bio: 'Writer'); - $mapper->author->persist($partial); + $mapper->persist($partial, $mapper->author()); $mapper->flush(); $mapper->clearIdentityMap(); - $refetched = $mapper->author[1]->fetch(); + $refetched = $mapper->fetch($mapper->author(filter: 1)); $this->assertSame('Bob', $refetched->name); $this->assertSame('Writer', $refetched->bio); } @@ -398,17 +207,17 @@ public function persistPartialEntityOnGraphUpdatesRelation(): void ['id' => 20, 'name' => 'Bob', 'bio' => null], ]); - $mapper->post->author->fetch(); - $bob = $mapper->author[20]->fetch(); + $mapper->fetch($mapper->post([$mapper->author()])); + $bob = $mapper->fetch($mapper->author(filter: 20)); $updated = $mapper->entityFactory->create(Stubs\Immutable\Post::class, id: 1, title: 'Changed', author: $bob); - $result = $mapper->post->persist($updated); + $result = $mapper->persist($updated, $mapper->post()); $mapper->flush(); $this->assertSame(1, $result->id); $mapper->clearIdentityMap(); - $refetched = $mapper->post->author->fetch(); + $refetched = $mapper->fetch($mapper->post([$mapper->author()])); $this->assertSame('Changed', $refetched->title); $this->assertSame('Bob', $refetched->author->name); } @@ -423,15 +232,15 @@ public function persistPartialEntityNullValueApplied(): void ['id' => 1, 'name' => 'Alice', 'bio' => 'has bio'], ]); - $fetched = $mapper->author[1]->fetch(); + $fetched = $mapper->fetch($mapper->author(filter: 1)); $this->assertSame('has bio', $fetched->bio); $partial = $mapper->entityFactory->create(Stubs\Immutable\Author::class, id: 1, name: 'Alice', bio: null); - $mapper->author->persist($partial); + $mapper->persist($partial, $mapper->author()); $mapper->flush(); $mapper->clearIdentityMap(); - $refetched = $mapper->author[1]->fetch(); + $refetched = $mapper->fetch($mapper->author(filter: 1)); $this->assertNull($refetched->bio); } } diff --git a/tests/Collections/CompositeTest.php b/tests/Collections/CompositeTest.php index f47ed6f..503daef 100644 --- a/tests/Collections/CompositeTest.php +++ b/tests/Collections/CompositeTest.php @@ -17,18 +17,31 @@ class CompositeTest extends TestCase public function collectionCanBeCreatedStaticallyWithChildren(): void { $children1 = Composite::bar(['foo' => ['bar']]); - $children2 = Composite::baz(['bat' => ['bar']])->bat(); - $coll = Collection::foo($children1, $children2)->bar(); + $children2 = Composite::baz(['bat' => ['bar']]); + $coll = Collection::foo([$children1, $children2]); $this->assertInstanceOf(Collection::class, $coll); - $this->assertInstanceOf(Collection::class, $coll->connectsTo); $this->assertInstanceOf(Composite::class, $children1); $this->assertInstanceOf(Composite::class, $children2); $this->assertTrue($coll->hasChildren); - $this->assertEquals(2, count($coll->children)); + $this->assertEquals(2, count($coll->with)); $this->assertEquals(['foo' => ['bar']], $children1->compositions); $this->assertEquals(['bat' => ['bar']], $children2->compositions); } + #[Test] + public function derivePreservesCompositions(): void + { + $original = Composite::post(['comment' => ['text']]); + $derived = $original->derive(with: [Collection::author()], filter: 5); + + $this->assertInstanceOf(Composite::class, $derived); + $this->assertEquals('post', $derived->name); + $this->assertEquals(['comment' => ['text']], $derived->compositions); + $this->assertCount(1, $derived->with); + $this->assertEquals('author', $derived->with[0]->name); + $this->assertEquals(5, $derived->filter); + } + #[Test] public function callStaticShouldCreateCompositeCollectionWithName(): void { diff --git a/tests/Collections/FilteredTest.php b/tests/Collections/FilteredTest.php deleted file mode 100644 index b117d29..0000000 --- a/tests/Collections/FilteredTest.php +++ /dev/null @@ -1,61 +0,0 @@ -bat(); - $coll = Collection::foo($children1, $children2)->bar(); - $this->assertInstanceOf(Collection::class, $coll); - $this->assertInstanceOf(Collection::class, $coll->connectsTo); - $this->assertInstanceOf(Filtered::class, $children1); - $this->assertInstanceOf(Filtered::class, $children2); - $this->assertTrue($coll->hasChildren); - $this->assertEquals(2, count($coll->children)); - $this->assertEquals(['bar'], $children1->filters); - $this->assertEquals(['bat'], $children2->filters); - } - - #[Test] - public function callStaticShouldCreateFilteredCollectionWithName(): void - { - $coll = Filtered::items(); - $this->assertInstanceOf(Filtered::class, $coll); - $this->assertEquals('items', $coll->name); - $this->assertEquals([], $coll->filters); - } - - #[Test] - public function isIdentifierOnlyReturnsTrueForIdentifierOnlyFilter(): void - { - $coll = Filtered::post(Filtered::IDENTIFIER_ONLY); - $this->assertTrue($coll->identifierOnly); - } - - #[Test] - public function isIdentifierOnlyReturnsFalseForNamedFilters(): void - { - $coll = Filtered::post('title'); - $this->assertFalse($coll->identifierOnly); - } - - #[Test] - public function isIdentifierOnlyReturnsFalseForEmptyFilters(): void - { - $coll = Filtered::post(); - $this->assertFalse($coll->identifierOnly); - } -} diff --git a/tests/Collections/TypedTest.php b/tests/Collections/TypedTest.php index 246cf59..b10271e 100644 --- a/tests/Collections/TypedTest.php +++ b/tests/Collections/TypedTest.php @@ -18,18 +18,31 @@ class TypedTest extends TestCase public function collectionCanBeCreatedStaticallyWithChildren(): void { $children1 = Typed::bar('a'); - $children2 = Typed::baz('b')->bat(); - $coll = Collection::foo($children1, $children2)->bar(); + $children2 = Typed::baz('b'); + $coll = Collection::foo([$children1, $children2]); $this->assertInstanceOf(Collection::class, $coll); - $this->assertInstanceOf(Collection::class, $coll->connectsTo); $this->assertInstanceOf(Typed::class, $children1); $this->assertInstanceOf(Typed::class, $children2); $this->assertTrue($coll->hasChildren); - $this->assertEquals(2, count($coll->children)); + $this->assertEquals(2, count($coll->with)); $this->assertEquals('a', $children1->type); $this->assertEquals('b', $children2->type); } + #[Test] + public function derivePreservesType(): void + { + $original = Typed::issues('type'); + $derived = $original->derive(with: [Collection::author()], filter: 1); + + $this->assertInstanceOf(Typed::class, $derived); + $this->assertEquals('issues', $derived->name); + $this->assertEquals('type', $derived->type); + $this->assertCount(1, $derived->with); + $this->assertEquals('author', $derived->with[0]->name); + $this->assertEquals(1, $derived->filter); + } + #[Test] public function callStaticShouldCreateTypedCollectionWithName(): void { diff --git a/tests/Hydrators/NestedTest.php b/tests/Hydrators/NestedTest.php index ebf05aa..ab9e22f 100644 --- a/tests/Hydrators/NestedTest.php +++ b/tests/Hydrators/NestedTest.php @@ -60,8 +60,7 @@ public function hydrateWithNestedChild(): void 'title' => 'Post Title', 'author' => ['id' => 5, 'name' => 'Author'], ]; - $collection = Collection::post(); - $collection->stack(Collection::author()); + $collection = Collection::post([Collection::author()]); $result = $this->hydrator->hydrateAll($raw, $collection); @@ -73,8 +72,7 @@ public function hydrateWithNestedChild(): void public function hydrateWithMissingNestedKeyReturnsPartial(): void { $raw = ['id' => 1, 'title' => 'Post Title']; - $collection = Collection::post(); - $collection->stack(Collection::author()); + $collection = Collection::post([Collection::author()]); $result = $this->hydrator->hydrateAll($raw, $collection); @@ -94,10 +92,9 @@ public function hydrateDeeplyNested(): void 'author' => ['id' => 100, 'name' => 'Author'], ], ]; - $collection = Collection::comment(); - $post = Collection::post(); - $post->stack(Collection::author()); - $collection->stack($post); + $collection = Collection::comment([ + Collection::post([Collection::author()]), + ]); $result = $this->hydrator->hydrateAll($raw, $collection); @@ -116,7 +113,7 @@ public function hydrateWithChildren(): void ]; $authorColl = Collection::author(); $categoryColl = Collection::category(); - $collection = Collection::post($authorColl, $categoryColl); + $collection = Collection::post([$authorColl, $categoryColl]); $result = $this->hydrator->hydrateAll($raw, $collection); @@ -141,8 +138,7 @@ public function hydrateChildWithNullNameIsSkipped(): void { $raw = ['id' => 1]; $child = new Collection(); - $collection = Collection::post(); - $collection->addChild($child); + $collection = Collection::post([$child]); $result = $this->hydrator->hydrateAll($raw, $collection); @@ -154,8 +150,7 @@ public function hydrateChildWithNullNameIsSkipped(): void public function hydrateScalarNestedValueIsIgnored(): void { $raw = ['id' => 1, 'author' => 'not-an-array']; - $collection = Collection::post(); - $collection->stack(Collection::author()); + $collection = Collection::post([Collection::author()]); $result = $this->hydrator->hydrateAll($raw, $collection); @@ -188,8 +183,7 @@ public function hydrateReturnsRootWithWiredRelation(): void 'title' => 'Post', 'author' => ['id' => 5, 'name' => 'Author'], ]; - $collection = Collection::post(); - $collection->stack(Collection::author()); + $collection = Collection::post([Collection::author()]); $result = $this->hydrator->hydrate($raw, $collection); diff --git a/tests/Hydrators/PrestyledAssocTest.php b/tests/Hydrators/PrestyledAssocTest.php index 0119e60..f6dc913 100644 --- a/tests/Hydrators/PrestyledAssocTest.php +++ b/tests/Hydrators/PrestyledAssocTest.php @@ -10,7 +10,6 @@ use PHPUnit\Framework\TestCase; use Respect\Data\Collections\Collection; use Respect\Data\Collections\Composite; -use Respect\Data\Collections\Filtered; use Respect\Data\Collections\Typed; use Respect\Data\EntityFactory; use Respect\Data\Stubs\Author; @@ -61,7 +60,7 @@ public function hydrateSingleEntity(): void public function hydrateMultipleEntitiesFromJoinedRow(): void { $hydrator = new PrestyledAssoc($this->factory); - $collection = Collection::author()->post; + $collection = Collection::author([Collection::post()]); $result = $hydrator->hydrateAll( [ @@ -92,7 +91,7 @@ public function hydrateMultipleEntitiesFromJoinedRow(): void public function hydrateWiresRelationships(): void { $hydrator = new PrestyledAssoc($this->factory); - $collection = Collection::post()->author; + $collection = Collection::post([Collection::author()]); $result = $hydrator->hydrateAll( [ @@ -117,7 +116,7 @@ public function hydrateWiresRelationships(): void public function hydrateReturnsRootRegardlessOfColumnOrder(): void { $hydrator = new PrestyledAssoc($this->factory); - $collection = Collection::post()->author; + $collection = Collection::post([Collection::author()]); // Author columns appear before post columns $result = $hydrator->hydrate( @@ -152,28 +151,11 @@ public function hydrateResolvesTypedEntities(): void $this->assertInstanceOf(Bug::class, $result->current()); } - #[Test] - public function hydrateSkipsUnfilteredFilteredCollections(): void - { - $hydrator = new PrestyledAssoc($this->factory); - $filtered = Filtered::post(); - $collection = Collection::author(); - $collection->stack($filtered); - - $result = $hydrator->hydrateAll( - ['author__id' => 1, 'author__name' => 'Alice'], - $collection, - ); - - $this->assertNotFalse($result); - $this->assertCount(1, $result); - } - #[Test] public function hydrateCompositeEntity(): void { $hydrator = new PrestyledAssoc($this->factory); - $composite = Composite::author(['profile' => ['bio']])->post; + $composite = Composite::author(['profile' => ['bio']], [Collection::post()]); $result = $hydrator->hydrateAll( [ diff --git a/tests/InMemoryMapper.php b/tests/InMemoryMapper.php index b73dce7..8737ca9 100644 --- a/tests/InMemoryMapper.php +++ b/tests/InMemoryMapper.php @@ -34,7 +34,7 @@ public function fetch(Collection $collection, mixed $extra = null): mixed } } - $row = $this->findRow((string) $collection->name, $collection->condition); + $row = $this->findRow((string) $collection->name, $collection->filter); return $row !== null ? $this->hydrateRow($row, $collection) : false; } @@ -42,7 +42,7 @@ public function fetch(Collection $collection, mixed $extra = null): mixed /** @return array */ public function fetchAll(Collection $collection, mixed $extra = null): array { - $rows = $this->findRows((string) $collection->name, $collection->condition); + $rows = $this->findRows((string) $collection->name, $collection->filter); $result = []; foreach ($rows as $row) { @@ -84,10 +84,7 @@ public function flush(): void private function insertEntity(object $entity, Collection $collection, string $tableName, string $id): void { - $row = $this->filterColumns( - $this->entityFactory->extractColumns($entity), - $collection, - ); + $row = $this->entityFactory->extractColumns($entity); if (!isset($row[$id])) { ++$this->lastInsertId; @@ -101,10 +98,7 @@ private function insertEntity(object $entity, Collection $collection, string $ta private function updateEntity(object $entity, Collection $collection, string $tableName, string $id): void { $idValue = $this->entityFactory->get($entity, $id); - $row = $this->filterColumns( - $this->entityFactory->extractColumns($entity), - $collection, - ); + $row = $this->entityFactory->extractColumns($entity); foreach ($this->tables[$tableName] as $index => $existing) { if (isset($existing[$id]) && $existing[$id] == $idValue) { @@ -155,11 +149,7 @@ private function hydrateRow(array $row, Collection $collection): object|false /** @param array $parentRow */ private function attachRelated(array &$parentRow, Collection $collection): void { - if ($collection->connectsTo !== null) { - $this->attachChild($parentRow, $collection->connectsTo); - } - - foreach ($collection->children as $child) { + foreach ($collection->with as $child) { $this->attachChild($parentRow, $child); } } @@ -181,7 +171,7 @@ private function attachChild(array &$parentRow, Collection $child): void return; } - if ($child->hasMore) { + if ($child->hasChildren) { $this->attachRelated($childRow, $child); } diff --git a/tests/Styles/CakePHP/CakePHPIntegrationTest.php b/tests/Styles/CakePHP/CakePHPIntegrationTest.php index bb238ed..bf65db3 100644 --- a/tests/Styles/CakePHP/CakePHPIntegrationTest.php +++ b/tests/Styles/CakePHP/CakePHPIntegrationTest.php @@ -48,17 +48,17 @@ protected function setUp(): void public function testFetchingEntityTyped(): void { $mapper = $this->mapper; - $comment = $mapper->comments[8]->fetch(); + $comment = $mapper->fetch($mapper->comments(filter: 8)); $this->assertInstanceOf(Comment::class, $comment); } public function testFetchingAllEntityTyped(): void { $mapper = $this->mapper; - $comment = $mapper->comments->fetchAll(); + $comment = $mapper->fetchAll($mapper->comments()); $this->assertInstanceOf(Comment::class, $comment[1]); - $categories = $mapper->post_categories->categories->fetch(); + $categories = $mapper->fetch($mapper->post_categories([$mapper->categories()])); $this->assertInstanceOf(PostCategory::class, $categories); $this->assertInstanceOf(Category::class, $categories->category); } @@ -66,7 +66,7 @@ public function testFetchingAllEntityTyped(): void public function testFetchingAllEntityTypedNested(): void { $mapper = $this->mapper; - $comment = $mapper->comments->posts->authors->fetchAll(); + $comment = $mapper->fetchAll($mapper->comments([$mapper->posts([$mapper->authors()])])); $this->assertInstanceOf(Comment::class, $comment[0]); $this->assertInstanceOf(Post::class, $comment[0]->post); $this->assertInstanceOf(Author::class, $comment[0]->post->author); @@ -75,13 +75,13 @@ public function testFetchingAllEntityTypedNested(): void public function testPersistingEntityTyped(): void { $mapper = $this->mapper; - $comment = $mapper->comments[8]->fetch(); + $comment = $mapper->fetch($mapper->comments(filter: 8)); $this->assertInstanceOf(Comment::class, $comment); $comment->text = 'HeyHey'; - $mapper->comments->persist($comment); + $mapper->persist($comment, $mapper->comments()); $mapper->flush(); - $updated = $mapper->comments[8]->fetch(); + $updated = $mapper->fetch($mapper->comments(filter: 8)); $this->assertInstanceOf(Comment::class, $updated); $this->assertEquals('HeyHey', $updated->text); } @@ -91,11 +91,11 @@ public function testPersistingNewEntityTyped(): void $mapper = $this->mapper; $comment = new Comment(); $comment->text = 'HeyHey'; - $mapper->comments->persist($comment); + $mapper->persist($comment, $mapper->comments()); $mapper->flush(); $this->assertGreaterThan(0, $comment->id); - $allComments = $mapper->comments->fetchAll(); + $allComments = $mapper->fetchAll($mapper->comments()); $this->assertCount(3, $allComments); } } diff --git a/tests/Styles/NorthWind/NorthWindIntegrationTest.php b/tests/Styles/NorthWind/NorthWindIntegrationTest.php index bb3b4c3..ab29d72 100644 --- a/tests/Styles/NorthWind/NorthWindIntegrationTest.php +++ b/tests/Styles/NorthWind/NorthWindIntegrationTest.php @@ -48,17 +48,17 @@ protected function setUp(): void public function testFetchingEntityTyped(): void { $mapper = $this->mapper; - $comment = $mapper->Comments[8]->fetch(); + $comment = $mapper->fetch($mapper->Comments(filter: 8)); $this->assertInstanceOf(Comments::class, $comment); } public function testFetchingAllEntityTyped(): void { $mapper = $this->mapper; - $comment = $mapper->Comments->fetchAll(); + $comment = $mapper->fetchAll($mapper->Comments()); $this->assertInstanceOf(Comments::class, $comment[1]); - $categories = $mapper->PostCategories->Categories->fetch(); + $categories = $mapper->fetch($mapper->PostCategories([$mapper->Categories()])); $this->assertInstanceOf(PostCategories::class, $categories); $this->assertInstanceOf(Categories::class, $categories->Category); } @@ -66,7 +66,7 @@ public function testFetchingAllEntityTyped(): void public function testFetchingAllEntityTypedNested(): void { $mapper = $this->mapper; - $comment = $mapper->Comments->Posts->Authors->fetchAll(); + $comment = $mapper->fetchAll($mapper->Comments([$mapper->Posts([$mapper->Authors()])])); $this->assertInstanceOf(Comments::class, $comment[0]); $this->assertInstanceOf(Posts::class, $comment[0]->Post); $this->assertInstanceOf(Authors::class, $comment[0]->Post->Author); @@ -75,13 +75,13 @@ public function testFetchingAllEntityTypedNested(): void public function testPersistingEntityTyped(): void { $mapper = $this->mapper; - $comment = $mapper->Comments[8]->fetch(); + $comment = $mapper->fetch($mapper->Comments(filter: 8)); $this->assertInstanceOf(Comments::class, $comment); $comment->Text = 'HeyHey'; - $mapper->Comments->persist($comment); + $mapper->persist($comment, $mapper->Comments()); $mapper->flush(); - $updated = $mapper->Comments[8]->fetch(); + $updated = $mapper->fetch($mapper->Comments(filter: 8)); $this->assertInstanceOf(Comments::class, $updated); $this->assertEquals('HeyHey', $updated->Text); } @@ -91,11 +91,11 @@ public function testPersistingNewEntityTyped(): void $mapper = $this->mapper; $comment = new Comments(); $comment->Text = 'HeyHey'; - $mapper->Comments->persist($comment); + $mapper->persist($comment, $mapper->Comments()); $mapper->flush(); $this->assertGreaterThan(0, $comment->CommentID); - $allComments = $mapper->Comments->fetchAll(); + $allComments = $mapper->fetchAll($mapper->Comments()); $this->assertCount(3, $allComments); } } diff --git a/tests/Styles/Plural/PluralIntegrationTest.php b/tests/Styles/Plural/PluralIntegrationTest.php index ce88fa7..1e656d5 100644 --- a/tests/Styles/Plural/PluralIntegrationTest.php +++ b/tests/Styles/Plural/PluralIntegrationTest.php @@ -48,17 +48,17 @@ protected function setUp(): void public function testFetchingEntityTyped(): void { $mapper = $this->mapper; - $comment = $mapper->comments[8]->fetch(); + $comment = $mapper->fetch($mapper->comments(filter: 8)); $this->assertInstanceOf(Comment::class, $comment); } public function testFetchingAllEntityTyped(): void { $mapper = $this->mapper; - $comment = $mapper->comments->fetchAll(); + $comment = $mapper->fetchAll($mapper->comments()); $this->assertInstanceOf(Comment::class, $comment[1]); - $categories = $mapper->posts_categories->categories->fetch(); + $categories = $mapper->fetch($mapper->posts_categories([$mapper->categories()])); $this->assertInstanceOf(PostCategory::class, $categories); $this->assertInstanceOf(Category::class, $categories->category); } @@ -66,7 +66,7 @@ public function testFetchingAllEntityTyped(): void public function testFetchingAllEntityTypedNested(): void { $mapper = $this->mapper; - $comment = $mapper->comments->posts->authors->fetchAll(); + $comment = $mapper->fetchAll($mapper->comments([$mapper->posts([$mapper->authors()])])); $this->assertInstanceOf(Comment::class, $comment[0]); $this->assertInstanceOf(Post::class, $comment[0]->post); $this->assertInstanceOf(Author::class, $comment[0]->post->author); @@ -75,13 +75,13 @@ public function testFetchingAllEntityTypedNested(): void public function testPersistingEntityTyped(): void { $mapper = $this->mapper; - $comment = $mapper->comments[8]->fetch(); + $comment = $mapper->fetch($mapper->comments(filter: 8)); $this->assertInstanceOf(Comment::class, $comment); $comment->text = 'HeyHey'; - $mapper->comments->persist($comment); + $mapper->persist($comment, $mapper->comments()); $mapper->flush(); - $updated = $mapper->comments[8]->fetch(); + $updated = $mapper->fetch($mapper->comments(filter: 8)); $this->assertInstanceOf(Comment::class, $updated); $this->assertEquals('HeyHey', $updated->text); } @@ -91,11 +91,11 @@ public function testPersistingNewEntityTyped(): void $mapper = $this->mapper; $comment = new Comment(); $comment->text = 'HeyHey'; - $mapper->comments->persist($comment); + $mapper->persist($comment, $mapper->comments()); $mapper->flush(); $this->assertGreaterThan(0, $comment->id); - $allComments = $mapper->comments->fetchAll(); + $allComments = $mapper->fetchAll($mapper->comments()); $this->assertCount(3, $allComments); } } diff --git a/tests/Styles/Sakila/SakilaIntegrationTest.php b/tests/Styles/Sakila/SakilaIntegrationTest.php index d06c550..37ae49f 100644 --- a/tests/Styles/Sakila/SakilaIntegrationTest.php +++ b/tests/Styles/Sakila/SakilaIntegrationTest.php @@ -48,17 +48,17 @@ protected function setUp(): void public function testFetchingEntityTyped(): void { $mapper = $this->mapper; - $comment = $mapper->comment[8]->fetch(); + $comment = $mapper->fetch($mapper->comment(filter: 8)); $this->assertInstanceOf(Comment::class, $comment); } public function testFetchingAllEntityTyped(): void { $mapper = $this->mapper; - $comment = $mapper->comment->fetchAll(); + $comment = $mapper->fetchAll($mapper->comment()); $this->assertInstanceOf(Comment::class, $comment[1]); - $categories = $mapper->post_category->category->fetch(); + $categories = $mapper->fetch($mapper->post_category([$mapper->category()])); $this->assertInstanceOf(PostCategory::class, $categories); $this->assertInstanceOf(Category::class, $categories->category); } @@ -66,7 +66,7 @@ public function testFetchingAllEntityTyped(): void public function testFetchingAllEntityTypedNested(): void { $mapper = $this->mapper; - $comment = $mapper->comment->post->author->fetchAll(); + $comment = $mapper->fetchAll($mapper->comment([$mapper->post([$mapper->author()])])); $this->assertInstanceOf(Comment::class, $comment[0]); $this->assertInstanceOf(Post::class, $comment[0]->post); $this->assertInstanceOf(Author::class, $comment[0]->post->author); @@ -75,13 +75,13 @@ public function testFetchingAllEntityTypedNested(): void public function testPersistingEntityTyped(): void { $mapper = $this->mapper; - $comment = $mapper->comment[8]->fetch(); + $comment = $mapper->fetch($mapper->comment(filter: 8)); $this->assertInstanceOf(Comment::class, $comment); $comment->text = 'HeyHey'; - $mapper->comment->persist($comment); + $mapper->persist($comment, $mapper->comment()); $mapper->flush(); - $updated = $mapper->comment[8]->fetch(); + $updated = $mapper->fetch($mapper->comment(filter: 8)); $this->assertInstanceOf(Comment::class, $updated); $this->assertEquals('HeyHey', $updated->text); } @@ -91,11 +91,11 @@ public function testPersistingNewEntityTyped(): void $mapper = $this->mapper; $comment = new Comment(); $comment->text = 'HeyHey'; - $mapper->comment->persist($comment); + $mapper->persist($comment, $mapper->comment()); $mapper->flush(); $this->assertGreaterThan(0, $comment->commentId); - $allComments = $mapper->comment->fetchAll(); + $allComments = $mapper->fetchAll($mapper->comment()); $this->assertCount(3, $allComments); } }