From b3cff7678752c9434842f7bfc36e8bfdd245b5a0 Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Wed, 25 Mar 2026 19:24:54 -0300 Subject: [PATCH] Remove stdClass support, enable pure entity trees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop stdClass as entity fallback — EntityFactory now throws DomainException for unknown classes. Remove dynamic property fallbacks from set/get/extractProperties, eliminate persistableCache and reflectPersistable(). Add EntityFactory::extractColumns() to derive FK values from entity object properties (e.g. $author -> author_id). Rewrite wireRelationships to use collection-tree parent matching instead of FK property scanning. Update resolveEntityName to accept object|array for Nested hydrator compatibility. Entity stubs use pure entity trees: relation properties ($post, $author) replace FK scalars ($post_id, $author_id). Remove redundant tests, add extractColumns unit coverage. --- src/Collections/Collection.php | 3 +- src/Collections/Typed.php | 6 +- src/EntityFactory.php | 92 ++++++++++---------- src/Hydrators/Base.php | 45 ++++------ src/Hydrators/Nested.php | 2 +- tests/AbstractMapperTest.php | 120 ++++++++++++--------------- tests/Collections/CollectionTest.php | 21 ++--- tests/Collections/TypedTest.php | 8 +- tests/EntityFactoryTest.php | 93 +++++++++++++++++---- tests/Hydrators/FlatTest.php | 25 ++++-- tests/Hydrators/NestedTest.php | 4 +- tests/InMemoryMapper.php | 4 +- tests/Stubs/Author.php | 14 ++++ tests/Stubs/Bug.php | 14 ++++ tests/Stubs/Category.php | 16 ++++ tests/Stubs/Comment.php | 14 ++++ tests/Stubs/Foo.php | 16 ++++ tests/Stubs/Issue.php | 14 ++++ tests/Stubs/Post.php | 16 ++++ 19 files changed, 341 insertions(+), 186 deletions(-) create mode 100644 tests/Stubs/Author.php create mode 100644 tests/Stubs/Bug.php create mode 100644 tests/Stubs/Category.php create mode 100644 tests/Stubs/Comment.php create mode 100644 tests/Stubs/Foo.php create mode 100644 tests/Stubs/Issue.php create mode 100644 tests/Stubs/Post.php diff --git a/src/Collections/Collection.php b/src/Collections/Collection.php index 3efd0d3..2adc5d2 100644 --- a/src/Collections/Collection.php +++ b/src/Collections/Collection.php @@ -71,7 +71,8 @@ public function fetchAll(mixed $extra = null): mixed return $this->resolveMapper()->fetchAll($this, $extra); } - public function resolveEntityName(EntityFactory $factory, object $row): string + /** @param object|array $row */ + public function resolveEntityName(EntityFactory $factory, object|array $row): string { return $this->name ?? ''; } diff --git a/src/Collections/Typed.php b/src/Collections/Typed.php index 982da3f..13a36d7 100644 --- a/src/Collections/Typed.php +++ b/src/Collections/Typed.php @@ -6,6 +6,7 @@ use Respect\Data\EntityFactory; +use function is_array; use function is_string; final class Typed extends Collection @@ -17,9 +18,10 @@ public function __construct( parent::__construct($name); } - public function resolveEntityName(EntityFactory $factory, object $row): string + /** @param object|array $row */ + public function resolveEntityName(EntityFactory $factory, object|array $row): string { - $name = $factory->get($row, $this->type); + $name = is_array($row) ? ($row[$this->type] ?? null) : $factory->get($row, $this->type); return is_string($name) ? $name : ($this->name ?? ''); } diff --git a/src/EntityFactory.php b/src/EntityFactory.php index 10ddf7c..fe7fcc6 100644 --- a/src/EntityFactory.php +++ b/src/EntityFactory.php @@ -4,13 +4,12 @@ namespace Respect\Data; +use DomainException; use ReflectionClass; -use ReflectionException; use ReflectionProperty; -use stdClass; use function class_exists; -use function get_object_vars; +use function is_object; /** Creates and manipulates entity objects using Style-based naming conventions */ class EntityFactory @@ -21,9 +20,6 @@ class EntityFactory /** @var array> */ private array $propertyCache = []; - /** @var array> */ - private array $persistableCache = []; - public function __construct( public readonly Styles\Stylable $style = new Styles\Standard(), private readonly string $entityNamespace = '\\', @@ -35,7 +31,11 @@ public function createByName(string $name): object { $entityName = $this->style->styledName($name); $entityClass = $this->entityNamespace . $entityName; - $entityClass = class_exists($entityClass) ? $entityClass : stdClass::class; + + if (!class_exists($entityClass)) { + throw new DomainException('Entity class ' . $entityClass . ' not found for ' . $name); + } + $ref = $this->reflectClass($entityClass); if (!$this->disableConstructor) { @@ -47,42 +47,56 @@ public function createByName(string $name): object public function set(object $entity, string $prop, mixed $value): void { - $properties = $this->reflectProperties($entity::class); - - if (isset($properties[$prop])) { - $properties[$prop]->setValue($entity, $value); - - return; - } + $mirror = $this->reflectProperties($entity::class)[$prop] ?? null; - $entity->{$prop} = $value; + $mirror?->setValue($entity, $value); } public function get(object $entity, string $prop): mixed { $mirror = $this->reflectProperties($entity::class)[$prop] ?? null; - if ($mirror !== null) { - return $mirror->isInitialized($entity) ? $mirror->getValue($entity) : null; + if ($mirror === null || !$mirror->isInitialized($entity)) { + return null; } - try { - return (new ReflectionProperty($entity, $prop))->getValue($entity); - } catch (ReflectionException) { - return null; + return $mirror->getValue($entity); + } + + /** + * Extract persistable columns, resolving entity objects to their FK representations. + * + * @return array + */ + public function extractColumns(object $entity): array + { + $cols = $this->extractProperties($entity); + + foreach ($cols as $key => $value) { + if (!is_object($value)) { + continue; + } + + if ($this->style->isRelationProperty($key)) { + $fk = $this->style->remoteIdentifier($key); + $cols[$fk] = $this->get($value, $this->style->identifier($key)); + unset($cols[$key]); + } else { + $table = $this->style->remoteFromIdentifier($key) ?? $key; + $cols[$key] = $this->get($value, $this->style->identifier($table)); + } } + + return $cols; } /** @return array */ public function extractProperties(object $entity): array { - $props = get_object_vars($entity); - $persistable = $this->reflectPersistable($entity::class); + $props = []; foreach ($this->reflectProperties($entity::class) as $name => $prop) { - if (!isset($persistable[$name]) || !$prop->isInitialized($entity)) { - unset($props[$name]); - + if (!$prop->isInitialized($entity) || $prop->getAttributes(NotPersistable::class)) { continue; } @@ -96,8 +110,12 @@ public function hydrate(object $source, string $entityName): object { $entity = $this->createByName($entityName); - foreach (get_object_vars($source) as $prop => $value) { - $this->set($entity, $prop, $value); + foreach ($this->reflectProperties($source::class) as $name => $prop) { + if (!$prop->isInitialized($source)) { + continue; + } + + $this->set($entity, $name, $prop->getValue($source)); } return $entity; @@ -126,22 +144,4 @@ private function reflectProperties(string $class): array return $this->propertyCache[$class]; } - - /** @return array */ - private function reflectPersistable(string $class): array - { - if (!isset($this->persistableCache[$class])) { - $this->persistableCache[$class] = []; - - foreach ($this->reflectProperties($class) as $name => $prop) { - if ($prop->getAttributes(NotPersistable::class)) { - continue; - } - - $this->persistableCache[$class][$name] = $prop; - } - } - - return $this->persistableCache[$class]; - } } diff --git a/src/Hydrators/Base.php b/src/Hydrators/Base.php index 7be44c6..79f11ce 100644 --- a/src/Hydrators/Base.php +++ b/src/Hydrators/Base.php @@ -9,51 +9,42 @@ use Respect\Data\Hydrator; use SplObjectStorage; -use function is_object; - -/** Base hydrator providing FK-to-entity wiring shared by all strategies */ +/** Base hydrator providing collection-tree entity wiring */ abstract class Base implements Hydrator { /** @param SplObjectStorage $entities */ protected function wireRelationships(SplObjectStorage $entities, EntityFactory $entityFactory): void { $style = $entityFactory->style; - $entitiesClone = clone $entities; + $others = clone $entities; + + foreach ($entities as $entity) { + $coll = $entities[$entity]; - foreach ($entities as $instance) { - foreach ($entityFactory->extractProperties($instance) as $field => $v) { - if (!$style->isRemoteIdentifier($field)) { + foreach ($others as $other) { + if ($other === $entity) { continue; } - foreach ($entitiesClone as $sub) { - if ($sub === $instance) { - continue; - } - - $tableName = (string) $entities[$sub]->name; - $primaryName = $style->identifier($tableName); - - if ( - $tableName !== $style->remoteFromIdentifier($field) - || $entityFactory->get($sub, $primaryName) != $v - ) { - continue; - } - - $v = $sub; + $otherColl = $others[$other]; + if ($otherColl->parent !== $coll || $otherColl->name === null) { + continue; } - if (!is_object($v)) { + $relationName = $style->relationProperty( + $style->remoteIdentifier($otherColl->name), + ); + + if ($relationName === null) { continue; } - $relationName = $style->relationProperty($field); - if ($relationName === null) { + $pk = $entityFactory->get($other, $style->identifier($otherColl->name)); + if ($pk === null) { continue; } - $entityFactory->set($instance, $relationName, $v); + $entityFactory->set($entity, $relationName, $other); } } } diff --git a/src/Hydrators/Nested.php b/src/Hydrators/Nested.php index 9ef5c69..80fa35f 100644 --- a/src/Hydrators/Nested.php +++ b/src/Hydrators/Nested.php @@ -45,7 +45,7 @@ private function hydrateNode( EntityFactory $entityFactory, SplObjectStorage $entities, ): void { - $entityName = $collection->resolveEntityName($entityFactory, (object) $data); + $entityName = $collection->resolveEntityName($entityFactory, $data); $entity = $entityFactory->createByName($entityName); foreach ($data as $key => $value) { diff --git a/tests/AbstractMapperTest.php b/tests/AbstractMapperTest.php index 93d6b9e..344ccad 100644 --- a/tests/AbstractMapperTest.php +++ b/tests/AbstractMapperTest.php @@ -14,7 +14,6 @@ use Respect\Data\Styles\CakePHP; use Respect\Data\Styles\Standard; use SplObjectStorage; -use stdClass; #[CoversClass(AbstractMapper::class)] class AbstractMapperTest extends TestCase @@ -23,7 +22,8 @@ class AbstractMapperTest extends TestCase protected function setUp(): void { - $this->mapper = new class extends AbstractMapper { + $factory = new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\'); + $this->mapper = new class ($factory) extends AbstractMapper { public function flush(): void { } @@ -131,7 +131,7 @@ protected function defaultHydrator(Collection $collection): Hydrator #[Test] public function persistShouldMarkObjectAsTracked(): void { - $entity = new stdClass(); + $entity = new Stubs\Foo(); $collection = Collection::foo(); $this->mapper->persist($entity, $collection); $this->assertTrue($this->mapper->isTracked($entity)); @@ -140,7 +140,7 @@ public function persistShouldMarkObjectAsTracked(): void #[Test] public function persistAlreadyTrackedShouldReturnTrue(): void { - $entity = new stdClass(); + $entity = new Stubs\Foo(); $collection = Collection::foo(); $this->mapper->markTracked($entity, $collection); $result = $this->mapper->persist($entity, $collection); @@ -150,7 +150,7 @@ public function persistAlreadyTrackedShouldReturnTrue(): void #[Test] public function removeShouldMarkObjectAsTracked(): void { - $entity = new stdClass(); + $entity = new Stubs\Foo(); $collection = Collection::foo(); $result = $this->mapper->remove($entity, $collection); $this->assertTrue($result); @@ -160,7 +160,7 @@ public function removeShouldMarkObjectAsTracked(): void #[Test] public function removeAlreadyTrackedShouldReturnTrue(): void { - $entity = new stdClass(); + $entity = new Stubs\Foo(); $collection = Collection::foo(); $this->mapper->markTracked($entity, $collection); $result = $this->mapper->remove($entity, $collection); @@ -170,13 +170,13 @@ public function removeAlreadyTrackedShouldReturnTrue(): void #[Test] public function isTrackedShouldReturnFalseForUntrackedEntity(): void { - $this->assertFalse($this->mapper->isTracked(new stdClass())); + $this->assertFalse($this->mapper->isTracked(new Stubs\Foo())); } #[Test] public function markTrackedShouldReturnTrue(): void { - $entity = new stdClass(); + $entity = new Stubs\Foo(); $collection = Collection::foo(); $this->assertTrue($this->mapper->markTracked($entity, $collection)); } @@ -184,7 +184,7 @@ public function markTrackedShouldReturnTrue(): void #[Test] public function resetShouldClearPending(): void { - $entity = new stdClass(); + $entity = new Stubs\Foo(); $collection = Collection::foo(); $this->mapper->persist($entity, $collection); $this->mapper->remove($entity, $collection); @@ -221,9 +221,9 @@ public function magicGetShouldReturnNewCollectionWhenNotRegistered(): void } #[Test] - public function hydrationWiresFkWithMatchingEntity(): void + public function hydrationWiresRelatedEntity(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('comment', [ ['id' => 1, 'text' => 'Hello', 'post_id' => 5], ]); @@ -233,12 +233,8 @@ public function hydrationWiresFkWithMatchingEntity(): void $comment = $mapper->comment->post->fetch(); $this->assertIsObject($comment); - // FK stays as its original scalar value, never overwritten with an object - $fk = $mapper->entityFactory->get($comment, 'post_id'); - $this->assertIsNotObject($fk); - $this->assertEquals(5, $fk); - // Related entity goes to the derived relation property + // Related entity wired via collection tree $post = $mapper->entityFactory->get($comment, 'post'); $this->assertIsObject($post); $this->assertEquals(5, $mapper->entityFactory->get($post, 'id')); @@ -246,9 +242,9 @@ public function hydrationWiresFkWithMatchingEntity(): void } #[Test] - public function persistAfterHydrationPreservesFkAndIgnoresRelation(): void + public function persistAfterHydrationPreservesRelation(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('comment', [ ['id' => 1, 'text' => 'Hello', 'post_id' => 5], ]); @@ -268,17 +264,12 @@ public function persistAfterHydrationPreservesFkAndIgnoresRelation(): void // Re-fetch without relationship $updated = $mapper->comment[1]->fetch(); $this->assertEquals('Updated', $mapper->entityFactory->get($updated, 'text')); - - // FK stayed as scalar - $fk = $mapper->entityFactory->get($updated, 'post_id'); - $this->assertIsNotObject($fk); - $this->assertEquals(5, $fk); } #[Test] - public function hydrationLeavesFkUnchangedWhenNoMatch(): void + public function hydrationWithNoMatchLeavesRelationNull(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('comment', [ ['id' => 1, 'text' => 'Hello', 'post_id' => 999], ]); @@ -288,13 +279,14 @@ public function hydrationLeavesFkUnchangedWhenNoMatch(): void $comment = $mapper->comment->post->fetch(); $this->assertIsObject($comment); - $this->assertEquals(999, $mapper->entityFactory->get($comment, 'post_id')); + // No post with id=999 exists, so relation stays null + $this->assertNull($mapper->entityFactory->get($comment, 'post')); } #[Test] - public function hydrationMatchesIntFkToStringPk(): void + public function hydrationWiresRelationWithStringPk(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('comment', [ ['id' => 1, 'text' => 'Hello', 'post_id' => 5], ]); @@ -304,8 +296,6 @@ public function hydrationMatchesIntFkToStringPk(): void $comment = $mapper->comment->post->fetch(); $this->assertIsObject($comment); - // FK stays as int, relation goes to derived property - $this->assertEquals(5, $mapper->entityFactory->get($comment, 'post_id')); $post = $mapper->entityFactory->get($comment, 'post'); $this->assertIsObject($post); $this->assertEquals('5', $mapper->entityFactory->get($post, 'id')); @@ -314,7 +304,7 @@ public function hydrationMatchesIntFkToStringPk(): void #[Test] public function callingRegisteredCollectionClonesAndAppliesCondition(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', [ ['id' => 1, 'title' => 'Hello'], ['id' => 2, 'title' => 'World'], @@ -334,7 +324,7 @@ public function callingRegisteredCollectionClonesAndAppliesCondition(): void #[Test] public function callingRegisteredCollectionWithoutConditionReturnsClone(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->postTitles = Filtered::posts('title'); $clone = $mapper->postTitles(); @@ -348,7 +338,7 @@ public function callingRegisteredCollectionWithoutConditionReturnsClone(): void #[Test] public function callingRegisteredChainedCollectionDoesNotMutateTemplate(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', []); $mapper->seed('comment', []); @@ -367,13 +357,12 @@ public function callingRegisteredChainedCollectionDoesNotMutateTemplate(): void #[Test] public function filteredPersistDelegatesToParentCollection(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', []); $mapper->seed('author', []); $mapper->authorsWithPosts = Filtered::post()->author(); - $author = new stdClass(); - $author->id = null; + $author = new Stubs\Author(); $author->name = 'Test'; $mapper->authorsWithPosts->persist($author); $mapper->flush(); @@ -385,11 +374,10 @@ public function filteredPersistDelegatesToParentCollection(): void #[Test] public function filteredWithoutNextFallsBackToNormalPersist(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', []); - $post = new stdClass(); - $post->id = null; + $post = new Stubs\Post(); $post->title = 'Direct'; $mapper->post->persist($post); $mapper->flush(); @@ -401,7 +389,7 @@ public function filteredWithoutNextFallsBackToNormalPersist(): void #[Test] public function filteredUpdatePersistsOnlyFilteredColumns(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', [ ['id' => 1, 'title' => 'Original', 'text' => 'Body'], ]); @@ -422,11 +410,11 @@ public function filteredUpdatePersistsOnlyFilteredColumns(): void #[Test] public function filteredInsertPersistsOnlyFilteredColumns(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', []); $mapper->postTitles = Filtered::post('title'); - $post = new stdClass(); + $post = new Stubs\Post(); $post->id = 1; $post->title = 'Partial'; $post->text = 'Should not persist'; @@ -441,7 +429,7 @@ public function filteredInsertPersistsOnlyFilteredColumns(): void #[Test] public function filterColumnsPassesThroughForPlainCollection(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', [ ['id' => 1, 'title' => 'Original', 'text' => 'Body'], ]); @@ -462,7 +450,7 @@ public function filterColumnsPassesThroughForPlainCollection(): void #[Test] public function filterColumnsPassesThroughForEmptyFilters(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', [ ['id' => 1, 'title' => 'Original', 'text' => 'Body'], ]); @@ -484,7 +472,7 @@ public function filterColumnsPassesThroughForEmptyFilters(): void #[Test] public function filterColumnsPassesThroughForIdentifierOnly(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', [ ['id' => 1, 'title' => 'Original', 'text' => 'Body'], ]); @@ -506,7 +494,7 @@ public function filterColumnsPassesThroughForIdentifierOnly(): void #[Test] public function fetchPopulatesIdentityMap(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', [ ['id' => 1, 'title' => 'First'], ['id' => 2, 'title' => 'Second'], @@ -524,7 +512,7 @@ public function fetchPopulatesIdentityMap(): void #[Test] public function fetchReturnsCachedEntityFromIdentityMap(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', [ ['id' => 1, 'title' => 'First'], ]); @@ -538,7 +526,7 @@ public function fetchReturnsCachedEntityFromIdentityMap(): void #[Test] public function fetchAllPopulatesIdentityMap(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', [ ['id' => 1, 'title' => 'First'], ['id' => 2, 'title' => 'Second'], @@ -551,10 +539,10 @@ public function fetchAllPopulatesIdentityMap(): void #[Test] public function flushInsertRegistersInIdentityMap(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', []); - $entity = new stdClass(); + $entity = new Stubs\Post(); $entity->title = 'New Post'; $mapper->post->persist($entity); $mapper->flush(); @@ -565,7 +553,7 @@ public function flushInsertRegistersInIdentityMap(): void #[Test] public function flushDeleteEvictsFromIdentityMap(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', [ ['id' => 1, 'title' => 'To Delete'], ]); @@ -582,7 +570,7 @@ public function flushDeleteEvictsFromIdentityMap(): void #[Test] public function clearIdentityMapEmptiesMap(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', [ ['id' => 1, 'title' => 'First'], ]); @@ -597,7 +585,7 @@ public function clearIdentityMapEmptiesMap(): void #[Test] public function resetDoesNotClearIdentityMap(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', [ ['id' => 1, 'title' => 'First'], ]); @@ -612,7 +600,7 @@ public function resetDoesNotClearIdentityMap(): void #[Test] public function pendingOperationTypes(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', [ ['id' => 1, 'title' => 'Existing'], ]); @@ -621,7 +609,7 @@ public function pendingOperationTypes(): void $pendingProp = $ref->getProperty('pending'); // persist new entity → 'insert' - $newEntity = new stdClass(); + $newEntity = new Stubs\Post(); $newEntity->title = 'New'; $mapper->post->persist($newEntity); @@ -646,7 +634,7 @@ public function pendingOperationTypes(): void #[Test] public function trackedCountReflectsTrackedEntities(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', [ ['id' => 1, 'title' => 'First'], ]); @@ -660,8 +648,8 @@ public function trackedCountReflectsTrackedEntities(): void #[Test] public function registerSkipsEntityWithNullCollectionName(): void { - $mapper = new InMemoryMapper(); - $entity = new stdClass(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); + $entity = new Stubs\Foo(); $entity->id = 1; // Collection with null name — register should be a no-op @@ -675,11 +663,11 @@ public function registerSkipsEntityWithNullCollectionName(): void #[Test] public function registerSkipsEntityWithNoPkValue(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', []); - // Entity with no 'id' property - $entity = new stdClass(); + // Entity with no 'id' set + $entity = new Stubs\Post(); $entity->title = 'No PK'; $mapper->post->persist($entity); @@ -691,7 +679,7 @@ public function registerSkipsEntityWithNoPkValue(): void #[Test] public function deleteEvictsUsingTrackedCollection(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', [ ['id' => 1, 'title' => 'Test'], ]); @@ -709,7 +697,7 @@ public function deleteEvictsUsingTrackedCollection(): void #[Test] public function findInIdentityMapSkipsNonScalarCondition(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', [ ['id' => 1, 'title' => 'First'], ]); @@ -726,10 +714,10 @@ public function findInIdentityMapSkipsNonScalarCondition(): void #[Test] public function registerSkipsEntityWithNonScalarPk(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', []); - $entity = new stdClass(); + $entity = new Stubs\Post(); $entity->id = ['not', 'scalar']; $entity->title = 'Bad PK'; $mapper->post->persist($entity); @@ -742,7 +730,7 @@ public function registerSkipsEntityWithNonScalarPk(): void #[Test] public function findInIdentityMapSkipsCollectionWithChildren(): void { - $mapper = new InMemoryMapper(); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('comment', [ ['id' => 1, 'text' => 'Hello', 'post_id' => 5], ]); diff --git a/tests/Collections/CollectionTest.php b/tests/Collections/CollectionTest.php index 9544a71..b157dbb 100644 --- a/tests/Collections/CollectionTest.php +++ b/tests/Collections/CollectionTest.php @@ -10,8 +10,8 @@ use Respect\Data\AbstractMapper; use Respect\Data\EntityFactory; use Respect\Data\Hydrators\Nested; +use Respect\Data\Stubs\Foo; use RuntimeException; -use stdClass; use function count; use function reset; @@ -178,7 +178,7 @@ public function arrayOffsetUnsetShouldNotDoAnything(): void #[Test] public function persistShouldPersistOnAttachedMapper(): void { - $persisted = new stdClass(); + $persisted = new Foo(); $collection = new Collection('name_whatever'); $mapperMock = $this->createMock(AbstractMapper::class); $mapperMock->expects($this->once()) @@ -192,7 +192,7 @@ public function persistShouldPersistOnAttachedMapper(): void #[Test] public function removeShouldPersistOnAttachedMapper(): void { - $removed = new stdClass(); + $removed = new Foo(); $collection = new Collection('name_whatever'); $mapperMock = $this->createMock(AbstractMapper::class); $mapperMock->expects($this->once()) @@ -272,14 +272,14 @@ public function arrayOffsetExistsShouldNotDoAnything(): void public function persistOnCollectionShouldExceptionIfMapperDontExist(): void { $this->expectException(RuntimeException::class); - Collection::foo()->persist(new stdClass()); + Collection::foo()->persist(new Foo()); } #[Test] public function removeOnCollectionShouldExceptionIfMapperDontExist(): void { $this->expectException(RuntimeException::class); - Collection::foo()->remove(new stdClass()); + Collection::foo()->remove(new Foo()); } #[Test] @@ -310,13 +310,6 @@ public function getNextShouldReturnNullWhenNoNext(): void $this->assertNull($coll->next); } - #[Test] - public function getNextShouldReturnNullWhenNone(): void - { - $coll = new Collection('foo'); - $this->assertNull($coll->next); - } - #[Test] public function magicGetShouldUseRegisteredCollectionFromMapper(): void { @@ -344,7 +337,7 @@ public function resolveEntityNameReturnsCollectionName(): void { $coll = Collection::author(); $factory = new EntityFactory(); - $this->assertEquals('author', $coll->resolveEntityName($factory, new stdClass())); + $this->assertEquals('author', $coll->resolveEntityName($factory, new Foo())); } #[Test] @@ -352,6 +345,6 @@ public function resolveEntityNameReturnsEmptyForNullName(): void { $coll = new Collection(); $factory = new EntityFactory(); - $this->assertEquals('', $coll->resolveEntityName($factory, new stdClass())); + $this->assertEquals('', $coll->resolveEntityName($factory, new Foo())); } } diff --git a/tests/Collections/TypedTest.php b/tests/Collections/TypedTest.php index 6807461..aaf703d 100644 --- a/tests/Collections/TypedTest.php +++ b/tests/Collections/TypedTest.php @@ -8,7 +8,6 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Respect\Data\EntityFactory; -use stdClass; use function count; @@ -45,9 +44,7 @@ public function resolveEntityNameReturnsDiscriminatorValue(): void { $coll = Typed::issues('type'); $factory = new EntityFactory(); - $row = new stdClass(); - $row->type = 'Bug'; - $this->assertEquals('Bug', $coll->resolveEntityName($factory, $row)); + $this->assertEquals('Bug', $coll->resolveEntityName($factory, ['type' => 'Bug'])); } #[Test] @@ -55,7 +52,6 @@ public function resolveEntityNameFallsBackToCollectionName(): void { $coll = Typed::issues('type'); $factory = new EntityFactory(); - $row = new stdClass(); - $this->assertEquals('issues', $coll->resolveEntityName($factory, $row)); + $this->assertEquals('issues', $coll->resolveEntityName($factory, [])); } } diff --git a/tests/EntityFactoryTest.php b/tests/EntityFactoryTest.php index ad12b54..a5ff5fb 100644 --- a/tests/EntityFactoryTest.php +++ b/tests/EntityFactoryTest.php @@ -4,20 +4,20 @@ namespace Respect\Data; +use DomainException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -use stdClass; #[CoversClass(EntityFactory::class)] class EntityFactoryTest extends TestCase { #[Test] - public function createByNameReturnsStdClassForUnknownClass(): void + public function createByNameThrowsForUnknownClass(): void { $factory = new EntityFactory(); - $entity = $factory->createByName('nonexistent_table'); - $this->assertInstanceOf(stdClass::class, $entity); + $this->expectException(DomainException::class); + $factory->createByName('nonexistent_table'); } #[Test] @@ -50,19 +50,19 @@ public function setAndGetWorkOnTypedProperties(): void } #[Test] - public function setAndGetWorkOnDynamicProperties(): void + public function setIgnoresUndeclaredProperties(): void { - $factory = new EntityFactory(); - $entity = new stdClass(); - $factory->set($entity, 'dynamic', 42); - $this->assertEquals(42, $factory->get($entity, 'dynamic')); + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $entity = new Stubs\Foo(); + $factory->set($entity, 'nonexistent', 42); + $this->assertNull($factory->get($entity, 'nonexistent')); } #[Test] public function getReturnsNullForMissingProperty(): void { - $factory = new EntityFactory(); - $entity = new stdClass(); + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $entity = new Stubs\Foo(); $this->assertNull($factory->get($entity, 'nonexistent')); } @@ -91,15 +91,29 @@ public function extractPropertiesRespectsNotPersistableAttribute(): void #[Test] public function hydrateCreatesEntityWithSourceProperties(): void { - $factory = new EntityFactory(); - $source = new stdClass(); + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $source = new Stubs\Author(); $source->id = 1; $source->name = 'test'; - $entity = $factory->hydrate($source, 'some_table'); + $entity = $factory->hydrate($source, 'author'); $this->assertEquals(1, $factory->get($entity, 'id')); $this->assertEquals('test', $factory->get($entity, 'name')); } + #[Test] + public function hydrateSkipsUninitializedSourceProperties(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $source = new Stubs\Post(); + $source->id = 1; + $source->title = 'Test'; + // $source->author is uninitialized — should not be copied + $entity = $factory->hydrate($source, 'post'); + $this->assertEquals(1, $factory->get($entity, 'id')); + $this->assertEquals('Test', $factory->get($entity, 'title')); + $this->assertNull($factory->get($entity, 'author')); + } + #[Test] public function getStyleReturnsConfiguredStyle(): void { @@ -144,4 +158,55 @@ public function getReturnsNullForUninitializedTypedProperty(): void $entity = $factory->createByName('edge_case_entity'); $this->assertNull($factory->get($entity, 'uninitialized')); } + + #[Test] + public function extractColumnsDerivesRelationFk(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $post = new Stubs\Post(); + $post->id = 10; + $post->title = 'Test'; + + $author = new Stubs\Author(); + $author->id = 1; + $author->name = 'Alice'; + $factory->set($post, 'author', $author); + + $cols = $factory->extractColumns($post); + $this->assertEquals(1, $cols['author_id']); + $this->assertArrayNotHasKey('author', $cols); + $this->assertEquals(10, $cols['id']); + $this->assertEquals('Test', $cols['title']); + } + + #[Test] + public function extractColumnsResolvesFkObjectInPlace(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $parent = new Stubs\Category(); + $parent->id = 3; + $parent->name = 'Parent'; + + $child = new Stubs\Category(); + $child->id = 8; + $child->name = 'Child'; + $child->category_id = $parent; + + $cols = $factory->extractColumns($child); + $this->assertEquals(3, $cols['category_id']); + $this->assertEquals(8, $cols['id']); + $this->assertEquals('Child', $cols['name']); + } + + #[Test] + public function extractColumnsPassesScalarsThrough(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $author = new Stubs\Author(); + $author->id = 5; + $author->name = 'Bob'; + + $cols = $factory->extractColumns($author); + $this->assertEquals(['id' => 5, 'name' => 'Bob', 'bio' => null], $cols); + } } diff --git a/tests/Hydrators/FlatTest.php b/tests/Hydrators/FlatTest.php index 83be81b..00679b4 100644 --- a/tests/Hydrators/FlatTest.php +++ b/tests/Hydrators/FlatTest.php @@ -12,7 +12,7 @@ use Respect\Data\Collections\Filtered; use Respect\Data\Collections\Typed; use Respect\Data\EntityFactory; -use stdClass; +use Respect\Data\Stubs\Bug; #[CoversClass(Flat::class)] class FlatTest extends TestCase @@ -21,7 +21,7 @@ class FlatTest extends TestCase protected function setUp(): void { - $this->factory = new EntityFactory(); + $this->factory = new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\'); } #[Test] @@ -81,6 +81,21 @@ public function hydrateMultipleEntitiesWithPkBoundary(): void $this->assertEquals('Post Title', $this->factory->get($entities[1], 'title')); } + #[Test] + public function hydrateSkipsWiringForNullPkChild(): void + { + $hydrator = $this->hydrator(['id', 'text', 'post_id', 'id', 'title']); + $collection = Collection::comment()->post; + + $result = $hydrator->hydrate([1, 'Hello', 5, null, null], $collection, $this->factory); + + $this->assertNotFalse($result); + $result->rewind(); + $entity = $result->current(); + $this->assertEquals(1, $this->factory->get($entity, 'id')); + $this->assertNull($this->factory->get($entity, 'post')); + } + #[Test] public function hydrateSkipsUnfilteredFilteredCollections(): void { @@ -112,15 +127,15 @@ public function hydrateFilteredCollectionWithFilters(): void #[Test] public function hydrateResolvesTypedEntities(): void { - $factory = new EntityFactory(entityNamespace: 'Respect\Data\Hydrators\\'); + $factory = new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\'); $hydrator = $this->hydrator(['id', 'type', 'title']); $collection = Typed::issue('type'); - $result = $hydrator->hydrate([1, 'stdClass', 'Bug Report'], $collection, $factory); + $result = $hydrator->hydrate([1, 'Bug', 'Bug Report'], $collection, $factory); $this->assertNotFalse($result); $result->rewind(); - $this->assertInstanceOf(stdClass::class, $result->current()); + $this->assertInstanceOf(Bug::class, $result->current()); } #[Test] diff --git a/tests/Hydrators/NestedTest.php b/tests/Hydrators/NestedTest.php index a5d5fce..b9882be 100644 --- a/tests/Hydrators/NestedTest.php +++ b/tests/Hydrators/NestedTest.php @@ -21,7 +21,7 @@ class NestedTest extends TestCase protected function setUp(): void { $this->hydrator = new Nested(); - $this->factory = new EntityFactory(); + $this->factory = new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\'); } #[Test] @@ -119,7 +119,7 @@ public function hydrateWithChildren(): void #[Test] public function hydrateWithTypedCollection(): void { - $raw = ['id' => 1, 'title' => 'Issue', 'type' => 'stdClass']; + $raw = ['id' => 1, 'title' => 'Issue', 'type' => 'Bug']; $collection = Typed::issue('type'); $result = $this->hydrator->hydrate($raw, $collection, $this->factory); diff --git a/tests/InMemoryMapper.php b/tests/InMemoryMapper.php index 8a0b00d..8102e90 100644 --- a/tests/InMemoryMapper.php +++ b/tests/InMemoryMapper.php @@ -91,7 +91,7 @@ protected function defaultHydrator(Collection $collection): Hydrator private function insertEntity(object $entity, Collection $collection, string $tableName, string $pk): void { $row = $this->filterColumns( - $this->entityFactory->extractProperties($entity), + $this->entityFactory->extractColumns($entity), $collection, ); @@ -108,7 +108,7 @@ private function updateEntity(object $entity, Collection $collection, string $ta { $pkValue = $this->entityFactory->get($entity, $pk); $row = $this->filterColumns( - $this->entityFactory->extractProperties($entity), + $this->entityFactory->extractColumns($entity), $collection, ); diff --git a/tests/Stubs/Author.php b/tests/Stubs/Author.php new file mode 100644 index 0000000..13cee09 --- /dev/null +++ b/tests/Stubs/Author.php @@ -0,0 +1,14 @@ +