diff --git a/src/AbstractMapper.php b/src/AbstractMapper.php index 4f02d5f..8880cf3 100644 --- a/src/AbstractMapper.php +++ b/src/AbstractMapper.php @@ -10,6 +10,7 @@ use function array_flip; use function array_intersect_key; +use function assert; use function count; use function is_int; use function is_scalar; @@ -23,7 +24,7 @@ abstract class AbstractMapper /** @var SplObjectStorage Maps entity → 'insert'|'update'|'delete' */ protected SplObjectStorage $pending; - /** @var array> PK-indexed identity map: [collectionName][pkValue] → entity */ + /** @var array> Identity-indexed map: [collectionName][idValue] → entity */ protected array $identityMap = []; /** @var array */ @@ -77,13 +78,13 @@ public function markTracked(object $entity, Collection $collection): bool return true; } - public function persist(object $object, Collection $onCollection): bool + public function persist(object $object, Collection $onCollection): object { $next = $onCollection->next; if ($onCollection instanceof Filtered && $next !== null) { $this->persist($object, $next); - return true; + return $object; } if ($this->isTracked($object)) { @@ -92,13 +93,17 @@ public function persist(object $object, Collection $onCollection): bool $this->pending[$object] = 'update'; } - return true; + return $object; + } + + if ($onCollection->name !== null && $this->tryReplaceFromIdentityMap($object, $onCollection)) { + return $object; } $this->pending[$object] = 'insert'; $this->markTracked($object, $onCollection); - return true; + return $object; } public function remove(object $object, Collection $fromCollection): bool @@ -117,6 +122,18 @@ public function isTracked(object $entity): bool return $this->tracked->offsetExists($entity); } + public function replaceTracked(object $old, object $new, Collection $onCollection): void + { + $op = $this->pending[$old] ?? 'update'; + $this->tracked->offsetUnset($old); + $this->pending->offsetUnset($old); + $this->evictFromIdentityMap($old, $onCollection); + + $this->markTracked($new, $onCollection); + $this->registerInIdentityMap($new, $onCollection); + $this->pending[$new] = $op; + } + public function registerCollection(string $alias, Collection $collection): void { $collection->mapper = $this; @@ -141,9 +158,9 @@ protected function filterColumns(array $columns, Collection $collection): array return $columns; } - $pk = $this->style->identifier($collection->name); + $id = $this->style->identifier($collection->name); - return array_intersect_key($columns, array_flip([...$collection->filters, $pk])); + return array_intersect_key($columns, array_flip([...$collection->filters, $id])); } protected function resolveHydrator(Collection $collection): Hydrator @@ -157,12 +174,12 @@ protected function registerInIdentityMap(object $entity, Collection $coll): void return; } - $pkValue = $this->entityPkValue($entity, $coll->name); - if ($pkValue === null) { + $idValue = $this->entityIdValue($entity, $coll->name); + if ($idValue === null) { return; } - $this->identityMap[$coll->name][$pkValue] = $entity; + $this->identityMap[$coll->name][$idValue] = $entity; } protected function evictFromIdentityMap(object $entity, Collection $coll): void @@ -171,12 +188,12 @@ protected function evictFromIdentityMap(object $entity, Collection $coll): void return; } - $pkValue = $this->entityPkValue($entity, $coll->name); - if ($pkValue === null) { + $idValue = $this->entityIdValue($entity, $coll->name); + if ($idValue === null) { return; } - unset($this->identityMap[$coll->name][$pkValue]); + unset($this->identityMap[$coll->name][$idValue]); } protected function findInIdentityMap(Collection $collection): object|null @@ -193,11 +210,45 @@ protected function findInIdentityMap(Collection $collection): object|null return $this->identityMap[$collection->name][$condition] ?? null; } - private function entityPkValue(object $entity, string $collName): int|string|null + private function tryReplaceFromIdentityMap(object $entity, Collection $coll): bool + { + assert($coll->name !== null); + $entityId = $this->entityIdValue($entity, $coll->name); + $idValue = $entityId; + + if ($idValue === null && is_scalar($coll->condition)) { + $idValue = $coll->condition; + } + + if ($idValue === null || (!is_int($idValue) && !is_string($idValue))) { + return false; + } + + $existing = $this->identityMap[$coll->name][$idValue] ?? null; + if ($existing === null || $existing === $entity) { + return false; + } + + if ($entityId === null) { + $idName = $this->style->identifier($coll->name); + $this->entityFactory->set($entity, $idName, $idValue); + } + + $this->tracked->offsetUnset($existing); + $this->pending->offsetUnset($existing); + $this->evictFromIdentityMap($existing, $coll); + $this->markTracked($entity, $coll); + $this->registerInIdentityMap($entity, $coll); + $this->pending[$entity] = 'update'; + + return true; + } + + private function entityIdValue(object $entity, string $collName): int|string|null { - $pkValue = $this->entityFactory->get($entity, $this->style->identifier($collName)); + $idValue = $this->entityFactory->get($entity, $this->style->identifier($collName)); - return is_int($pkValue) || is_string($pkValue) ? $pkValue : null; + return is_int($idValue) || is_string($idValue) ? $idValue : null; } public function __get(string $name): Collection diff --git a/src/Collections/Collection.php b/src/Collections/Collection.php index 2adc5d2..b256e49 100644 --- a/src/Collections/Collection.php +++ b/src/Collections/Collection.php @@ -6,7 +6,6 @@ use ArrayAccess; use Respect\Data\AbstractMapper; -use Respect\Data\EntityFactory; use Respect\Data\Hydrator; use RuntimeException; @@ -51,9 +50,24 @@ public function addChild(Collection $child): void $this->children[] = $clone; } - public function persist(object $object): bool + public function persist(object $object, mixed ...$changes): object { - return $this->resolveMapper()->persist($object, $this); + $mapper = $this->resolveMapper(); + + if ($changes) { + $original = $object; + $object = $mapper->entityFactory->withChanges($original, ...$changes); + + if ($mapper->isTracked($original)) { + $mapper->replaceTracked($original, $object, $this); + + return $object; + } + } + + $mapper->persist($object, $this); + + return $object; } public function remove(object $object): bool @@ -71,12 +85,6 @@ public function fetchAll(mixed $extra = null): mixed return $this->resolveMapper()->fetchAll($this, $extra); } - /** @param object|array $row */ - public function resolveEntityName(EntityFactory $factory, object|array $row): string - { - return $this->name ?? ''; - } - public function offsetExists(mixed $offset): bool { return false; diff --git a/src/Collections/Filtered.php b/src/Collections/Filtered.php index e898802..0a7bc77 100644 --- a/src/Collections/Filtered.php +++ b/src/Collections/Filtered.php @@ -8,7 +8,7 @@ final class Filtered extends Collection { - /** Fetch only the entity identifier (primary key, document ID, etc.) */ + /** Fetch only the entity identifier */ public const string IDENTIFIER_ONLY = '*'; // phpcs:ignore PSR2.Classes.PropertyDeclaration diff --git a/src/Collections/Typed.php b/src/Collections/Typed.php index 13a36d7..1c45d22 100644 --- a/src/Collections/Typed.php +++ b/src/Collections/Typed.php @@ -18,12 +18,16 @@ public function __construct( parent::__construct($name); } - /** @param object|array $row */ - public function resolveEntityName(EntityFactory $factory, object|array $row): string + /** + * @param object|array $row + * + * @return class-string + */ + public function resolveEntityClass(EntityFactory $factory, object|array $row): string { $name = is_array($row) ? ($row[$this->type] ?? null) : $factory->get($row, $this->type); - return is_string($name) ? $name : ($this->name ?? ''); + return $factory->resolveClass(is_string($name) ? $name : (string) $this->name); } /** @param array $arguments */ diff --git a/src/EntityFactory.php b/src/EntityFactory.php index 266aeeb..05f4939 100644 --- a/src/EntityFactory.php +++ b/src/EntityFactory.php @@ -10,7 +10,10 @@ use ReflectionProperty; use ReflectionUnionType; +use function array_key_exists; +use function array_keys; use function class_exists; +use function implode; use function is_array; use function is_bool; use function is_float; @@ -29,15 +32,25 @@ class EntityFactory /** @var array> */ private array $propertyCache = []; + /** @var array */ + private array $resolveCache = []; + + /** @var array> */ + private array $relationCache = []; + public function __construct( public readonly Styles\Stylable $style = new Styles\Standard(), private readonly string $entityNamespace = '\\', - private readonly bool $disableConstructor = false, ) { } - public function createByName(string $name): object + /** @return class-string */ + public function resolveClass(string $name): string { + if (isset($this->resolveCache[$name])) { + return $this->resolveCache[$name]; + } + $entityName = $this->style->styledName($name); $entityClass = $this->entityNamespace . $entityName; @@ -45,13 +58,7 @@ public function createByName(string $name): object throw new DomainException('Entity class ' . $entityClass . ' not found for ' . $name); } - $ref = $this->reflectClass($entityClass); - - if (!$this->disableConstructor) { - return $ref->newInstanceArgs(); - } - - return $ref->newInstanceWithoutConstructor(); + return $this->resolveCache[$name] = $entityClass; } public function set(object $entity, string $prop, mixed $value): void @@ -69,6 +76,12 @@ public function set(object $entity, string $prop, mixed $value): void return; } + if ($mirror->isReadOnly() && $mirror->isInitialized($entity)) { + throw new ReadOnlyViolation( + 'Cannot modify readonly property ' . $entity::class . '::$' . $mirror->getName(), + ); + } + $mirror->setValue($entity, $coerced); } @@ -84,8 +97,67 @@ public function get(object $entity, string $prop): mixed return $mirror->getValue($entity); } + public function isReadOnly(object $entity): bool + { + return $this->reflectClass($entity::class)->isReadOnly(); + } + /** - * Extract persistable columns, resolving entity objects to their FK representations. + * @param class-string $class + * + * @return T + * + * @template T of object + */ + public function create(string $class, mixed ...$properties): object + { + /** @phpstan-var T $entity */ + $entity = $this->reflectClass($class)->newInstanceWithoutConstructor(); + + foreach ($properties as $prop => $value) { + $this->set($entity, (string) $prop, $value); + } + + return $entity; + } + + public function withChanges(object $entity, mixed ...$changes): object + { + $clone = $this->reflectClass($entity::class)->newInstanceWithoutConstructor(); + $styledChanges = []; + foreach ($changes as $prop => $value) { + $styledChanges[$this->style->styledProperty((string) $prop)] = $value; + } + + foreach ($this->reflectProperties($entity::class) as $name => $prop) { + if (array_key_exists($name, $styledChanges)) { + $value = $styledChanges[$name]; + $coerced = $this->coerce($prop, $value); + + if ($coerced === null && !($prop->getType()?->allowsNull() ?? false)) { + throw new DomainException( + 'Invalid value for ' . $entity::class . '::$' . $name, + ); + } + + $prop->setValue($clone, $coerced); + unset($styledChanges[$name]); + } elseif ($prop->isInitialized($entity)) { + $prop->setValue($clone, $prop->getValue($entity)); + } + } + + if ($styledChanges) { + throw new DomainException( + 'Unknown properties for ' . $entity::class . ': ' . implode(', ', array_keys($styledChanges)), + ); + } + + return $clone; + } + + /** + * Extract persistable columns, resolving entity objects to their reference representations. * * @return array */ @@ -99,10 +171,10 @@ public function extractColumns(object $entity): array continue; } - $fk = $this->style->remoteIdentifier($key); + $ref = $this->style->remoteIdentifier($key); if (is_object($value)) { - $cols[$fk] = $this->get($value, $this->style->identifier($key)); + $cols[$ref] = $this->get($value, $this->style->identifier($key)); } unset($cols[$key]); @@ -127,24 +199,13 @@ public function extractProperties(object $entity): array return $props; } - public function hydrate(object $source, string $entityName): object - { - $entity = $this->createByName($entityName); - - foreach ($this->reflectProperties($source::class) as $name => $prop) { - if (!$prop->isInitialized($source)) { - continue; - } - - $this->set($entity, $name, $prop->getValue($source)); - } - - return $entity; - } - /** @return array */ private function detectRelationProperties(string $class): array { + if (isset($this->relationCache[$class])) { + return $this->relationCache[$class]; + } + $relations = []; foreach ($this->reflectProperties($class) as $name => $prop) { @@ -158,7 +219,7 @@ private function detectRelationProperties(string $class): array } } - return $relations; + return $this->relationCache[$class] = $relations; } /** @return ReflectionClass */ diff --git a/src/Hydrators/Base.php b/src/Hydrators/Base.php index 79f11ce..2acaf79 100644 --- a/src/Hydrators/Base.php +++ b/src/Hydrators/Base.php @@ -5,6 +5,7 @@ namespace Respect\Data\Hydrators; use Respect\Data\Collections\Collection; +use Respect\Data\Collections\Typed; use Respect\Data\EntityFactory; use Respect\Data\Hydrator; use SplObjectStorage; @@ -39,8 +40,8 @@ protected function wireRelationships(SplObjectStorage $entities, EntityFactory $ continue; } - $pk = $entityFactory->get($other, $style->identifier($otherColl->name)); - if ($pk === null) { + $id = $entityFactory->get($other, $style->identifier($otherColl->name)); + if ($id === null) { continue; } @@ -48,4 +49,21 @@ protected function wireRelationships(SplObjectStorage $entities, EntityFactory $ } } } + + /** + * @param object|array $row + * + * @return class-string + */ + protected function resolveEntityClass( + Collection $collection, + EntityFactory $entityFactory, + object|array $row, + ): string { + if ($collection instanceof Typed) { + return $collection->resolveEntityClass($entityFactory, $row); + } + + return $entityFactory->resolveClass((string) $collection->name); + } } diff --git a/src/Hydrators/Flat.php b/src/Hydrators/Flat.php index e2877cc..a640671 100644 --- a/src/Hydrators/Flat.php +++ b/src/Hydrators/Flat.php @@ -17,7 +17,7 @@ use function is_array; /** - * Decomposes a flat row into multiple entity instances using PK boundaries. + * Decomposes a flat row into multiple entity instances using identity boundaries. * * Subclasses define how column names are resolved from the raw data format. */ @@ -75,7 +75,7 @@ public function hydrate( /** Resolve the column name for a given reference (numeric index, namespaced key, etc.) */ abstract protected function resolveColumnName(mixed $reference, mixed $raw): string; - /** Check if this column is the last one for the current entity (table boundary without PK) */ + /** Check if this column is the last one for the current entity (boundary without identity) */ protected function isEntityBoundary(mixed $col, mixed $raw): bool { return false; @@ -95,16 +95,22 @@ private function resolveTypedEntities( foreach ($entities as $entity) { $coll = $entities[$entity]; - $entityName = $coll->resolveEntityName($entityFactory, $entity); - $defaultName = (string) $coll->name; - if ($entityName === $defaultName) { + $defaultClass = $entityFactory->resolveClass((string) $coll->name); + $entityClass = $this->resolveEntityClass($coll, $entityFactory, $entity); + + if ($entityClass === $defaultClass) { $resolved[$entity] = $coll; continue; } - $resolved[$entityFactory->hydrate($entity, $entityName)] = $coll; + $typed = $entityFactory->create($entityClass); + foreach ($entityFactory->extractProperties($entity) as $name => $value) { + $entityFactory->set($typed, $name, $value); + } + + $resolved[$typed] = $coll; } return $resolved; @@ -127,7 +133,7 @@ private function buildEntitiesInstances( continue; } - $entityInstance = $entityFactory->createByName($c->name); + $entityInstance = $entityFactory->create($entityFactory->resolveClass($c->name)); if ($c instanceof Composite) { $compositionCount = count($c->compositions); diff --git a/src/Hydrators/Nested.php b/src/Hydrators/Nested.php index 80fa35f..2c76d5f 100644 --- a/src/Hydrators/Nested.php +++ b/src/Hydrators/Nested.php @@ -45,8 +45,9 @@ private function hydrateNode( EntityFactory $entityFactory, SplObjectStorage $entities, ): void { - $entityName = $collection->resolveEntityName($entityFactory, $data); - $entity = $entityFactory->createByName($entityName); + $entity = $entityFactory->create( + $this->resolveEntityClass($collection, $entityFactory, $data), + ); foreach ($data as $key => $value) { if (is_array($value)) { diff --git a/src/ReadOnlyViolation.php b/src/ReadOnlyViolation.php new file mode 100644 index 0000000..a8e0bb5 --- /dev/null +++ b/src/ReadOnlyViolation.php @@ -0,0 +1,11 @@ +mapper->foo()->bar()->baz(); - $expected = Collection::foo(); - $expected->mapper = $this->mapper; - $this->assertEquals($expected->bar->baz, $collection); + $collection = $this->mapper->author()->post()->comment(); + $this->assertEquals('author', $collection->name); + $this->assertEquals('post', $collection->next?->name); + $this->assertEquals('comment', $collection->next?->next?->name); } #[Test] public function magicGetterShouldBypassToCollection(): void { - $collection = $this->mapper->foo->bar->baz; - $expected = Collection::foo(); - $expected->mapper = $this->mapper; - $this->assertEquals($expected->bar->baz, $collection); + $collection = $this->mapper->author->post->comment; + $this->assertEquals('author', $collection->name); + $this->assertEquals('post', $collection->next?->name); + $this->assertEquals('comment', $collection->next?->next?->name); } #[Test] @@ -138,13 +138,13 @@ public function persistShouldMarkObjectAsTracked(): void } #[Test] - public function persistAlreadyTrackedShouldReturnTrue(): void + public function persistAlreadyTrackedShouldReturnEntity(): void { $entity = new Stubs\Foo(); $collection = Collection::foo(); $this->mapper->markTracked($entity, $collection); $result = $this->mapper->persist($entity, $collection); - $this->assertTrue($result); + $this->assertSame($entity, $result); } #[Test] @@ -215,9 +215,9 @@ public function issetShouldReturnFalseForUnregisteredCollection(): void #[Test] public function magicGetShouldReturnNewCollectionWhenNotRegistered(): void { - $coll = $this->mapper->unregistered; + $coll = $this->mapper->author; $this->assertInstanceOf(Collection::class, $coll); - $this->assertEquals('unregistered', $coll->name); + $this->assertEquals('author', $coll->name); } #[Test] @@ -310,7 +310,8 @@ public function callingRegisteredCollectionClonesAndAppliesCondition(): void ['id' => 2, 'title' => 'World'], ]); - $mapper->postTitles = Filtered::posts('title'); + $coll = Filtered::posts('title'); + $mapper->postTitles = $coll; $conditioned = $mapper->postTitles(['id' => 2]); @@ -325,7 +326,8 @@ public function callingRegisteredCollectionClonesAndAppliesCondition(): void public function callingRegisteredCollectionWithoutConditionReturnsClone(): void { $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); - $mapper->postTitles = Filtered::posts('title'); + $coll = Filtered::posts('title'); + $mapper->postTitles = $coll; $clone = $mapper->postTitles(); @@ -342,7 +344,8 @@ public function callingRegisteredChainedCollectionDoesNotMutateTemplate(): void $mapper->seed('post', []); $mapper->seed('comment', []); - $mapper->commentedPosts = Collection::posts()->comment(); + $coll = Collection::posts(); + $mapper->commentedPosts = $coll->comment(); $clone = $mapper->commentedPosts(); $clone->author; // stacks 'author' onto the clone's chain @@ -394,7 +397,8 @@ public function filteredUpdatePersistsOnlyFilteredColumns(): void ['id' => 1, 'title' => 'Original', 'text' => 'Body'], ]); - $mapper->postTitles = Filtered::post('title'); + $postTitles = Filtered::post('title'); + $mapper->postTitles = $postTitles; $post = $mapper->postTitles()->fetch(); $this->assertIsObject($post); @@ -413,7 +417,8 @@ public function filteredInsertPersistsOnlyFilteredColumns(): void $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); $mapper->seed('post', []); - $mapper->postTitles = Filtered::post('title'); + $postTitles = Filtered::post('title'); + $mapper->postTitles = $postTitles; $post = new Stubs\Post(); $post->id = 1; $post->title = 'Partial'; @@ -455,7 +460,8 @@ public function filterColumnsPassesThroughForEmptyFilters(): void ['id' => 1, 'title' => 'Original', 'text' => 'Body'], ]); - $mapper->allPosts = Filtered::post(); + $allPosts = Filtered::post(); + $mapper->allPosts = $allPosts; $post = $mapper->allPosts()->fetch(); $this->assertIsObject($post); @@ -477,7 +483,8 @@ public function filterColumnsPassesThroughForIdentifierOnly(): void ['id' => 1, 'title' => 'Original', 'text' => 'Body'], ]); - $mapper->postIds = Filtered::post(Filtered::IDENTIFIER_ONLY); + $postIds = Filtered::post(Filtered::IDENTIFIER_ONLY); + $mapper->postIds = $postIds; $post = $mapper->postIds()->fetch(); $this->assertIsObject($post); @@ -726,4 +733,484 @@ public function findInIdentityMapSkipsCollectionWithChildren(): void $comment = $mapper->comment->post->fetch(); $this->assertIsObject($comment); } + + #[Test] + public function persistUntrackedEntityWithMatchingPkUpdates(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'Original'], + ]); + + // Populate identity map + $fetched = $mapper->post[1]->fetch(); + $this->assertSame('Original', $fetched->title); + + // Create a NEW mutable entity with matching PK + $replacement = new Stubs\Post(); + $replacement->id = 1; + $replacement->title = 'Updated'; + + $mapper->post->persist($replacement); + + $ref = new ReflectionObject($mapper); + $pendingProp = $ref->getProperty('pending'); + /** @var SplObjectStorage $pending */ + $pending = $pendingProp->getValue($mapper); + + $this->assertSame('update', $pending[$replacement]); + $this->assertFalse($mapper->isTracked($fetched)); + $this->assertTrue($mapper->isTracked($replacement)); + } + + #[Test] + public function persistReadOnlyEntityInsertWorks(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); + $mapper->seed('read_only_author', []); + + $entity = $mapper->entityFactory->create(Stubs\ReadOnlyAuthor::class, name: 'Alice'); + $mapper->read_only_author->persist($entity); + $mapper->flush(); + + // PK should have been assigned (first assignment on uninitialized readonly $id) + $this->assertSame(1001, $entity->id); + } + + #[Test] + public function persistReadOnlyViaCollectionPkUpdates(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); + $mapper->seed('read_only_author', [ + ['id' => 1, 'name' => 'Original', 'bio' => null], + ]); + + // Populate identity map + $fetched = $mapper->read_only_author[1]->fetch(); + $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'); + $mapper->read_only_author[1]->persist($updated); + + // PK should have been set from collection condition + $this->assertSame(1, $updated->id); + + // Old entity should be evicted + $this->assertFalse($mapper->isTracked($fetched)); + $this->assertTrue($mapper->isTracked($updated)); + + $ref = new ReflectionObject($mapper); + $pendingProp = $ref->getProperty('pending'); + /** @var SplObjectStorage $pending */ + $pending = $pendingProp->getValue($mapper); + $this->assertSame('update', $pending[$updated]); + } + + #[Test] + public function persistReadOnlyViaCollectionPkFlushUpdatesStorage(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); + $mapper->seed('read_only_author', [ + ['id' => 1, 'name' => 'Original', 'bio' => null], + ]); + + $mapper->read_only_author[1]->fetch(); + + $updated = $mapper->entityFactory->create(Stubs\ReadOnlyAuthor::class, name: 'Updated', bio: 'new bio'); + $mapper->read_only_author[1]->persist($updated); + $mapper->flush(); + + // Clear identity map and re-fetch to verify DB was updated + $mapper->clearIdentityMap(); + $refetched = $mapper->read_only_author[1]->fetch(); + $this->assertSame('Updated', $refetched->name); + $this->assertSame('new bio', $refetched->bio); + } + + #[Test] + public function identityMapReplaceEvictsOldEntity(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); + $mapper->seed('read_only_author', [ + ['id' => 1, 'name' => 'Alice', 'bio' => null], + ]); + + $mapper->read_only_author[1]->fetch(); + $this->assertSame(1, $mapper->identityMapCount()); + + $updated = $mapper->entityFactory->create(Stubs\ReadOnlyAuthor::class, name: 'Bob'); + $mapper->read_only_author[1]->persist($updated); + + // Identity map count stays 1 (swapped, not added) + $this->assertSame(1, $mapper->identityMapCount()); + } + + #[Test] + public function identityMapReplaceFallsBackToInsertWhenNoPkMatch(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); + $mapper->seed('read_only_author', []); + + // No identity map entries — should insert + $entity = $mapper->entityFactory->create(Stubs\ReadOnlyAuthor::class, name: 'New'); + $mapper->read_only_author->persist($entity); + + $ref = new ReflectionObject($mapper); + $pendingProp = $ref->getProperty('pending'); + /** @var SplObjectStorage $pending */ + $pending = $pendingProp->getValue($mapper); + $this->assertSame('insert', $pending[$entity]); + } + + #[Test] + public function identityMapReplaceDetachesPreviouslyPendingEntity(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'Original'], + ]); + + $fetched = $mapper->post[1]->fetch(); + + // Mark the fetched entity as pending 'update' + $mapper->post->persist($fetched); + + // 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); + + // flush should not crash (old entity no longer in pending) + $mapper->flush(); + + $mapper->clearIdentityMap(); + $refetched = $mapper->post[1]->fetch(); + $this->assertSame('Replaced', $refetched->title); + } + + #[Test] + public function identityMapReplaceSkipsSameEntity(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'Test'], + ]); + + $fetched = $mapper->post[1]->fetch(); + + // Persist the same entity again — should take the isTracked() path, not replace + $mapper->post->persist($fetched); + + $ref = new ReflectionObject($mapper); + $pendingProp = $ref->getProperty('pending'); + /** @var SplObjectStorage $pending */ + $pending = $pendingProp->getValue($mapper); + $this->assertSame('update', $pending[$fetched]); + } + + #[Test] + public function readOnlyNestedHydrationWiresRelation(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\Immutable\\')); + $mapper->seed('comment', [ + ['id' => 1, 'text' => 'Great post', 'post_id' => 5], + ]); + $mapper->seed('post', [ + ['id' => 5, 'title' => 'Hello', 'text' => 'World'], + ]); + + $comment = $mapper->comment->post->fetch(); + + $this->assertInstanceOf(Stubs\Immutable\Comment::class, $comment); + $this->assertSame(1, $comment->id); + $this->assertSame('Great post', $comment->text); + + $this->assertInstanceOf(Stubs\Immutable\Post::class, $comment->post); + $this->assertSame(5, $comment->post->id); + $this->assertSame('Hello', $comment->post->title); + } + + #[Test] + public function readOnlyNestedHydrationThreeLevels(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\Immutable\\')); + $mapper->seed('comment', [ + ['id' => 1, 'text' => 'Nice', 'post_id' => 5], + ]); + $mapper->seed('post', [ + ['id' => 5, 'title' => 'Post', 'text' => 'Body', 'author_id' => 3], + ]); + $mapper->seed('author', [ + ['id' => 3, 'name' => 'Alice', 'bio' => 'Writer'], + ]); + + $comment = $mapper->comment->post->author->fetch(); + + $this->assertInstanceOf(Stubs\Immutable\Comment::class, $comment); + $this->assertSame(1, $comment->id); + + $this->assertInstanceOf(Stubs\Immutable\Post::class, $comment->post); + $this->assertSame(5, $comment->post->id); + + $this->assertInstanceOf(Stubs\Immutable\Author::class, $comment->post->author); + $this->assertSame(3, $comment->post->author->id); + $this->assertSame('Alice', $comment->post->author->name); + } + + #[Test] + public function readOnlyInsertWithRelationExtractsFk(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\Immutable\\')); + $mapper->seed('post', []); + $mapper->seed('author', []); + + $author = $mapper->entityFactory->create(Stubs\Immutable\Author::class, name: 'Bob'); + $post = $mapper->entityFactory->create( + Stubs\Immutable\Post::class, + title: 'Hello', + text: 'World', + author: $author, + ); + + // Insert author first so it gets a PK + $mapper->author->persist($author); + $mapper->flush(); + + $this->assertSame(1001, $author->id); + + // Insert post — extractColumns should resolve $author → author_id FK + $mapper->post->persist($post); + $mapper->flush(); + + $this->assertSame(1002, $post->id); + + // Re-fetch the post and verify FK was stored + $mapper->clearIdentityMap(); + $fetchedPost = $mapper->post->author->fetch(); + $this->assertSame('Hello', $fetchedPost->title); + $this->assertSame('Bob', $fetchedPost->author->name); + } + + #[Test] + public function readOnlyReplaceViaCollectionPkPreservesRelation(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\Immutable\\')); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'Original', 'text' => 'Body', 'author_id' => 10], + ]); + $mapper->seed('author', [ + ['id' => 10, 'name' => 'Alice', 'bio' => null], + ]); + + // Fetch the full graph + $fetched = $mapper->post->author->fetch(); + $this->assertSame('Original', $fetched->title); + $this->assertSame('Alice', $fetched->author->name); + + // Replace the post, keeping the same author relation + $updated = $mapper->entityFactory->create( + Stubs\Immutable\Post::class, + title: 'Updated', + text: 'New Body', + author: $fetched->author, + ); + $mapper->post[1]->persist($updated); + $mapper->flush(); + + // Re-fetch and verify both post columns AND FK were updated correctly + $mapper->clearIdentityMap(); + $refetched = $mapper->post->author->fetch(); + $this->assertSame('Updated', $refetched->title); + $this->assertSame('New Body', $refetched->text); + $this->assertSame('Alice', $refetched->author->name); + $this->assertSame(10, $refetched->author->id); + } + + #[Test] + public function readOnlyReplaceWithNewRelation(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\Immutable\\')); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'Original', 'text' => 'Body', 'author_id' => 10], + ]); + $mapper->seed('author', [ + ['id' => 10, 'name' => 'Alice', 'bio' => null], + ['id' => 20, 'name' => 'Bob', 'bio' => 'Writer'], + ]); + + $fetched = $mapper->post->author->fetch(); + $this->assertSame('Alice', $fetched->author->name); + + // Fetch the other author + $bob = $mapper->author[20]->fetch(); + + // Replace post with a new author FK + $updated = $mapper->entityFactory->create( + Stubs\Immutable\Post::class, + title: 'Reassigned', + text: 'Text', + author: $bob, + ); + $mapper->post[1]->persist($updated); + $mapper->flush(); + + $mapper->clearIdentityMap(); + $refetched = $mapper->post->author->fetch(); + $this->assertSame('Reassigned', $refetched->title); + $this->assertSame('Bob', $refetched->author->name); + $this->assertSame(20, $refetched->author->id); + } + + #[Test] + public function withChangesAndPersistAutoUpdatesViaIdentityMap(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\Immutable\\')); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'Original', 'text' => 'Body', 'author_id' => 10], + ]); + $mapper->seed('author', [ + ['id' => 10, 'name' => 'Alice', 'bio' => null], + ['id' => 20, 'name' => 'Bob', 'bio' => null], + ]); + + $post = $mapper->post->author->fetch(); + $bob = $mapper->author[20]->fetch(); + + // withChanges preserves PK → persist auto-detects update via identity map + $updated = $mapper->entityFactory->withChanges($post, title: 'Changed', author: $bob); + $mapper->post->persist($updated); + $mapper->flush(); + + $mapper->clearIdentityMap(); + $refetched = $mapper->post->author->fetch(); + $this->assertSame('Changed', $refetched->title); + $this->assertSame('Body', $refetched->text); + $this->assertSame('Bob', $refetched->author->name); + } + + #[Test] + public function readOnlyMultipleEntitiesFetchAllTracksAll(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\Immutable\\')); + $mapper->seed('author', [ + ['id' => 1, 'name' => 'Alice', 'bio' => null], + ['id' => 2, 'name' => 'Bob', 'bio' => null], + ['id' => 3, 'name' => 'Carol', 'bio' => null], + ]); + + $authors = $mapper->author->fetchAll(); + $this->assertCount(3, $authors); + + // All entities should be tracked and in identity map + $this->assertSame(3, $mapper->trackedCount()); + $this->assertSame(3, $mapper->identityMapCount()); + + // Replace one by identity map lookup + $updated = $mapper->entityFactory->create(Stubs\Immutable\Author::class, name: 'Alice Updated'); + $mapper->author[1]->persist($updated); + + // Original Alice should be evicted, updated Alice takes its place + $this->assertSame(3, $mapper->trackedCount()); + $this->assertTrue($mapper->isTracked($updated)); + $this->assertFalse($mapper->isTracked($authors[0])); + } + + #[Test] + public function identityMapReplaceSkipsSetWhenPkAlreadyInitialized(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\Immutable\\')); + $mapper->seed('author', [ + ['id' => 1, 'name' => 'Alice', 'bio' => null], + ]); + + $mapper->author[1]->fetch(); + + $updated = new Stubs\Immutable\Author(id: 1, name: 'Bob'); + + // persist via collection[1] — PK already set, should NOT try set() again + $mapper->author[1]->persist($updated); + + $this->assertSame(1, $updated->id); + $this->assertTrue($mapper->isTracked($updated)); + } + + #[Test] + public function persistReturnsEntity(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\')); + + // Insert path + $entity = new Stubs\Post(); + $entity->title = 'Test'; + $result = $mapper->post->persist($entity); + $this->assertSame($entity, $result); + + // Update path (tracked entity) + $mapper->flush(); + $result = $mapper->post->persist($entity); + $this->assertSame($entity, $result); + } + + #[Test] + public function readOnlyDeleteEvictsFromIdentityMap(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\Immutable\\')); + $mapper->seed('author', [ + ['id' => 1, 'name' => 'Alice', 'bio' => null], + ]); + + $fetched = $mapper->author[1]->fetch(); + $this->assertSame(1, $mapper->identityMapCount()); + + $mapper->author->remove($fetched); + $mapper->flush(); + + $this->assertSame(0, $mapper->identityMapCount()); + + // Re-fetch returns false (no data) + $mapper->clearIdentityMap(); + $refetched = $mapper->author[1]->fetch(); + $this->assertFalse($refetched); + } + + #[Test] + public function persistWithChangesOnPendingInsertReplacesOriginal(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\Immutable\\')); + $mapper->seed('author', []); + + $author = $mapper->entityFactory->create(Stubs\Immutable\Author::class, name: 'Alice'); + $mapper->author->persist($author); + + // Persist with changes on a pending-insert entity must replace, not duplicate + $updated = $mapper->author->persist($author, name: 'Bob'); + $mapper->flush(); + + $all = $mapper->author->fetchAll(); + $this->assertCount(1, $all); + $this->assertSame('Bob', $all[0]->name); + $this->assertFalse($mapper->isTracked($author)); + $this->assertTrue($mapper->isTracked($updated)); + } + + #[Test] + public function persistWithChangesOnTrackedUpdateReplacesOriginal(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\Immutable\\')); + $mapper->seed('author', [ + ['id' => 1, 'name' => 'Alice', 'bio' => null], + ]); + + $fetched = $mapper->author[1]->fetch(); + + // Persist with changes on a tracked (fetched) entity + $mapper->author->persist($fetched, name: 'Bob'); + $mapper->flush(); + + $mapper->clearIdentityMap(); + $refetched = $mapper->author[1]->fetch(); + $this->assertSame('Bob', $refetched->name); + } } diff --git a/tests/Collections/CollectionTest.php b/tests/Collections/CollectionTest.php index b157dbb..747a846 100644 --- a/tests/Collections/CollectionTest.php +++ b/tests/Collections/CollectionTest.php @@ -10,6 +10,8 @@ use Respect\Data\AbstractMapper; use Respect\Data\EntityFactory; use Respect\Data\Hydrators\Nested; +use Respect\Data\InMemoryMapper; +use Respect\Data\Stubs; use Respect\Data\Stubs\Foo; use RuntimeException; @@ -184,9 +186,10 @@ public function persistShouldPersistOnAttachedMapper(): void $mapperMock->expects($this->once()) ->method('persist') ->with($persisted, $collection) - ->willReturn(true); + ->willReturn($persisted); $collection->mapper = $mapperMock; - $collection->persist($persisted); + $result = $collection->persist($persisted); + $this->assertSame($persisted, $result); } #[Test] @@ -333,18 +336,94 @@ public function hydrateFromSetsHydrator(): void } #[Test] - public function resolveEntityNameReturnsCollectionName(): void + public function persistWithoutChangesReturnsSameEntity(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\Immutable\\')); + $mapper->seed('author', []); + + $entity = $mapper->entityFactory->create(Stubs\Immutable\Author::class, name: 'Alice'); + $result = $mapper->author->persist($entity); + $this->assertSame($entity, $result); + } + + #[Test] + public function persistWithChangesReturnsModifiedCopy(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\Immutable\\')); + $mapper->seed('author', [ + ['id' => 1, 'name' => 'Alice', 'bio' => null], + ]); + + $fetched = $mapper->author[1]->fetch(); + + $result = $mapper->author[1]->persist($fetched, name: 'Bob'); + + $this->assertNotSame($fetched, $result); + $this->assertSame('Bob', $result->name); + $this->assertSame(1, $result->id); + $this->assertSame('Alice', $fetched->name); + } + + #[Test] + public function persistWithChangesFlushesUpdate(): void { - $coll = Collection::author(); - $factory = new EntityFactory(); - $this->assertEquals('author', $coll->resolveEntityName($factory, new Foo())); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\Immutable\\')); + $mapper->seed('author', [ + ['id' => 1, 'name' => 'Alice', 'bio' => null], + ]); + + $fetched = $mapper->author[1]->fetch(); + $mapper->author[1]->persist($fetched, name: 'Bob', bio: 'Writer'); + $mapper->flush(); + + $mapper->clearIdentityMap(); + $refetched = $mapper->author[1]->fetch(); + $this->assertSame('Bob', $refetched->name); + $this->assertSame('Writer', $refetched->bio); } #[Test] - public function resolveEntityNameReturnsEmptyForNullName(): void + public function persistWithChangesOnGraphUpdatesRelation(): void { - $coll = new Collection(); - $factory = new EntityFactory(); - $this->assertEquals('', $coll->resolveEntityName($factory, new Foo())); + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\Immutable\\')); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'Original', 'text' => 'Body', 'author_id' => 10], + ]); + $mapper->seed('author', [ + ['id' => 10, 'name' => 'Alice', 'bio' => null], + ['id' => 20, 'name' => 'Bob', 'bio' => null], + ]); + + $post = $mapper->post->author->fetch(); + $bob = $mapper->author[20]->fetch(); + + $updated = $mapper->post->persist($post, title: 'Changed', author: $bob); + $mapper->flush(); + + $this->assertSame(1, $updated->id); + + $mapper->clearIdentityMap(); + $refetched = $mapper->post->author->fetch(); + $this->assertSame('Changed', $refetched->title); + $this->assertSame('Bob', $refetched->author->name); + } + + #[Test] + public function persistWithChangesNullValueApplied(): void + { + $mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\Immutable\\')); + $mapper->seed('author', [ + ['id' => 1, 'name' => 'Alice', 'bio' => 'has bio'], + ]); + + $fetched = $mapper->author[1]->fetch(); + $this->assertSame('has bio', $fetched->bio); + + $mapper->author[1]->persist($fetched, bio: null); + $mapper->flush(); + + $mapper->clearIdentityMap(); + $refetched = $mapper->author[1]->fetch(); + $this->assertNull($refetched->bio); } } diff --git a/tests/Collections/TypedTest.php b/tests/Collections/TypedTest.php index aaf703d..7b6abeb 100644 --- a/tests/Collections/TypedTest.php +++ b/tests/Collections/TypedTest.php @@ -40,18 +40,18 @@ public function callStaticShouldCreateTypedCollectionWithName(): void } #[Test] - public function resolveEntityNameReturnsDiscriminatorValue(): void + public function resolveEntityClassReturnsDiscriminatorClass(): void { + $factory = new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\'); $coll = Typed::issues('type'); - $factory = new EntityFactory(); - $this->assertEquals('Bug', $coll->resolveEntityName($factory, ['type' => 'Bug'])); + $this->assertEquals('Respect\\Data\\Stubs\\Bug', $coll->resolveEntityClass($factory, ['type' => 'Bug'])); } #[Test] - public function resolveEntityNameFallsBackToCollectionName(): void + public function resolveEntityClassFallsBackToCollectionName(): void { - $coll = Typed::issues('type'); - $factory = new EntityFactory(); - $this->assertEquals('issues', $coll->resolveEntityName($factory, [])); + $factory = new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\'); + $coll = Typed::issue('type'); + $this->assertEquals('Respect\\Data\\Stubs\\Issue', $coll->resolveEntityClass($factory, [])); } } diff --git a/tests/EntityFactoryTest.php b/tests/EntityFactoryTest.php index 6613711..5a8bedc 100644 --- a/tests/EntityFactoryTest.php +++ b/tests/EntityFactoryTest.php @@ -11,33 +11,35 @@ use ReflectionProperty; use stdClass; +use function assert; + #[CoversClass(EntityFactory::class)] +#[CoversClass(ReadOnlyViolation::class)] class EntityFactoryTest extends TestCase { #[Test] - public function createByNameThrowsForUnknownClass(): void + public function resolveClassThrowsForUnknownClass(): void { $factory = new EntityFactory(); $this->expectException(DomainException::class); - $factory->createByName('nonexistent_table'); + $factory->resolveClass('nonexistent_table'); } #[Test] - public function createByNameReturnsCorrectClassWhenFound(): void + public function resolveClassReturnsCorrectClassWhenFound(): void { $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); - $entity = $factory->createByName('typed_entity'); - $this->assertInstanceOf(Stubs\TypedEntity::class, $entity); + $class = $factory->resolveClass('typed_entity'); + $this->assertSame(Stubs\TypedEntity::class, $class); } #[Test] - public function createByNameWithDisabledConstructorSkipsConstructor(): void + public function createWithResolvedClassSkipsConstructor(): void { $factory = new EntityFactory( entityNamespace: __NAMESPACE__ . '\\Stubs\\', - disableConstructor: true, ); - $entity = $factory->createByName('typed_entity'); + $entity = $factory->create($factory->resolveClass('typed_entity')); $this->assertInstanceOf(Stubs\TypedEntity::class, $entity); $this->assertNull($entity->value); } @@ -46,7 +48,7 @@ public function createByNameWithDisabledConstructorSkipsConstructor(): void public function setAndGetWorkOnTypedProperties(): void { $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); - $entity = $factory->createByName('typed_entity'); + $entity = $factory->create($factory->resolveClass('typed_entity')); $factory->set($entity, 'value', 'hello'); $this->assertEquals('hello', $factory->get($entity, 'value')); } @@ -72,7 +74,7 @@ public function getReturnsNullForMissingProperty(): void public function extractPropertiesReturnsAllProperties(): void { $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); - $entity = $factory->createByName('typed_entity'); + $entity = $factory->create($factory->resolveClass('typed_entity')); $factory->set($entity, 'value', 'test'); $props = $factory->extractProperties($entity); $this->assertArrayHasKey('value', $props); @@ -83,7 +85,7 @@ public function extractPropertiesReturnsAllProperties(): void public function extractPropertiesRespectsNotPersistableAttribute(): void { $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); - $entity = $factory->createByName('entity_with_excluded'); + $entity = $factory->create($factory->resolveClass('entity_with_excluded')); $factory->set($entity, 'name', 'visible'); $props = $factory->extractProperties($entity); $this->assertArrayHasKey('name', $props); @@ -91,26 +93,34 @@ public function extractPropertiesRespectsNotPersistableAttribute(): void } #[Test] - public function hydrateCreatesEntityWithSourceProperties(): void + public function createAndCopyPropertiesReproducesEntity(): void { $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); $source = new Stubs\Author(); $source->id = 1; $source->name = 'test'; - $entity = $factory->hydrate($source, 'author'); + $entity = $factory->create($factory->resolveClass('author')); + foreach ($factory->extractProperties($source) as $name => $value) { + $factory->set($entity, $name, $value); + } + $this->assertEquals(1, $factory->get($entity, 'id')); $this->assertEquals('test', $factory->get($entity, 'name')); } #[Test] - public function hydrateSkipsUninitializedSourceProperties(): void + public function createAndCopySkipsUninitializedSourceProperties(): 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'); + // $source->author is uninitialized — extractProperties skips it + $entity = $factory->create($factory->resolveClass('post')); + foreach ($factory->extractProperties($source) as $name => $value) { + $factory->set($entity, $name, $value); + } + $this->assertEquals(1, $factory->get($entity, 'id')); $this->assertEquals('Test', $factory->get($entity, 'title')); $this->assertNull($factory->get($entity, 'author')); @@ -128,7 +138,7 @@ public function getStyleReturnsConfiguredStyle(): void public function extractPropertiesSkipsStaticProperties(): void { $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); - $entity = $factory->createByName('edge_case_entity'); + $entity = $factory->create($factory->resolveClass('edge_case_entity')); $props = $factory->extractProperties($entity); $this->assertArrayNotHasKey('static', $props); } @@ -137,7 +147,7 @@ public function extractPropertiesSkipsStaticProperties(): void public function extractPropertiesSkipsUninitializedProperties(): void { $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); - $entity = $factory->createByName('edge_case_entity'); + $entity = $factory->create($factory->resolveClass('edge_case_entity')); $props = $factory->extractProperties($entity); $this->assertArrayHasKey('initialized', $props); $this->assertArrayNotHasKey('uninitialized', $props); @@ -147,7 +157,7 @@ public function extractPropertiesSkipsUninitializedProperties(): void public function extractPropertiesIncludesNonPublicProperties(): void { $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); - $entity = $factory->createByName('edge_case_entity'); + $entity = $factory->create($factory->resolveClass('edge_case_entity')); $props = $factory->extractProperties($entity); $this->assertEquals('prot_val', $props['protected']); $this->assertEquals('priv_val', $props['private']); @@ -157,7 +167,7 @@ public function extractPropertiesIncludesNonPublicProperties(): void public function getReturnsNullForUninitializedTypedProperty(): void { $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); - $entity = $factory->createByName('edge_case_entity'); + $entity = $factory->create($factory->resolveClass('edge_case_entity')); $this->assertNull($factory->get($entity, 'uninitialized')); } @@ -295,4 +305,235 @@ public function unionLossyCoercionKicksInWhenExactMatchFails(): void $factory->set($entity, 'narrow_union', '42'); $this->assertSame(42, $entity->narrowUnion); } + + #[Test] + public function isReadOnlyDetectsReadOnlyClass(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $this->assertTrue($factory->isReadOnly($factory->create(Stubs\ReadOnlyAuthor::class, name: 'test'))); + $this->assertFalse($factory->isReadOnly(new Stubs\Author())); + } + + #[Test] + public function resolveClassAutoDetectsReadOnly(): void + { + $factory = new EntityFactory( + entityNamespace: __NAMESPACE__ . '\\Stubs\\', + ); + $class = $factory->resolveClass('read_only_author'); + $this->assertSame(Stubs\ReadOnlyAuthor::class, $class); + $entity = $factory->create($class); + $this->assertInstanceOf(Stubs\ReadOnlyAuthor::class, $entity); + $ref = new ReflectionProperty($entity, 'name'); + $this->assertFalse($ref->isInitialized($entity)); + } + + #[Test] + public function setOnUninitializedReadOnlyPropertySucceeds(): void + { + $factory = new EntityFactory( + entityNamespace: __NAMESPACE__ . '\\Stubs\\', + ); + $entity = $factory->create($factory->resolveClass('read_only_author')); + assert($entity instanceof Stubs\ReadOnlyAuthor); + $factory->set($entity, 'id', 42); + $factory->set($entity, 'name', 'Alice'); + $this->assertSame(42, $entity->id); + $this->assertSame('Alice', $entity->name); + } + + #[Test] + public function setOnInitializedReadOnlyPropertyThrowsReadOnlyViolation(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $entity = new Stubs\ReadOnlyAuthor(id: 1, name: 'Alice'); + + $this->expectException(ReadOnlyViolation::class); + $this->expectExceptionMessage('Cannot modify readonly property'); + $factory->set($entity, 'name', 'Bob'); + } + + #[Test] + public function extractPropertiesWorksOnReadOnlyEntity(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $entity = new Stubs\ReadOnlyAuthor(id: 5, name: 'Alice', bio: 'bio text'); + + $props = $factory->extractProperties($entity); + $this->assertEquals(['id' => 5, 'name' => 'Alice', 'bio' => 'bio text'], $props); + } + + #[Test] + public function extractColumnsResolvesReadOnlyRelationFk(): void + { + $factory = new EntityFactory( + entityNamespace: __NAMESPACE__ . '\\Stubs\\Immutable\\', + ); + + $author = new Stubs\Immutable\Author(id: 3, name: 'Alice'); + + $post = $factory->create($factory->resolveClass('post')); + $factory->set($post, 'id', 10); + $factory->set($post, 'title', 'Test'); + $factory->set($post, 'author', $author); + + $cols = $factory->extractColumns($post); + $this->assertEquals(3, $cols['author_id']); + $this->assertArrayNotHasKey('author', $cols); + $this->assertEquals(10, $cols['id']); + $this->assertEquals('Test', $cols['title']); + } + + #[Test] + public function withChangesCreatesModifiedCopy(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $entity = new Stubs\ReadOnlyAuthor(id: 1, name: 'Alice', bio: 'bio'); + + $copy = $factory->withChanges($entity, name: 'Bob'); + assert($copy instanceof Stubs\ReadOnlyAuthor); + + $this->assertSame(1, $copy->id); + $this->assertSame('Bob', $copy->name); + $this->assertSame('bio', $copy->bio); + $this->assertSame('Alice', $entity->name); + } + + #[Test] + public function withChangesPreservesPkForIdentityMapLookup(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\Immutable\\'); + + $author = new Stubs\Immutable\Author(id: 5, name: 'Alice'); + + $post = new Stubs\Immutable\Post(id: 10, title: 'Hello', text: 'World', author: $author); + + $bob = new Stubs\Immutable\Author(id: 6, name: 'Bob'); + + $copy = $factory->withChanges($post, title: 'Changed', author: $bob); + assert($copy instanceof Stubs\Immutable\Post); + + $this->assertSame(10, $copy->id); + $this->assertSame('Changed', $copy->title); + $this->assertSame('World', $copy->text); + $this->assertInstanceOf(Stubs\Immutable\Author::class, $copy->author); + $this->assertSame('Bob', $copy->author->name); + $this->assertSame(6, $copy->author->id); + } + + #[Test] + public function withChangesWorksOnMutableEntities(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $author = new Stubs\Author(); + $author->id = 1; + $author->name = 'Alice'; + $author->bio = 'bio'; + + $copy = $factory->withChanges($author, name: 'Bob'); + assert($copy instanceof Stubs\Author); + $this->assertSame(1, $copy->id); + $this->assertSame('Bob', $copy->name); + $this->assertSame('bio', $copy->bio); + } + + #[Test] + public function withChangesThrowsOnUnknownProperty(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $entity = new Stubs\ReadOnlyAuthor(id: 1, name: 'Alice'); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Unknown properties'); + $factory->withChanges($entity, nname: 'Bob'); + } + + #[Test] + public function withChangesAppliesNullValue(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $entity = new Stubs\ReadOnlyAuthor(id: 1, name: 'Alice', bio: 'has bio'); + + $copy = $factory->withChanges($entity, bio: null); + assert($copy instanceof Stubs\ReadOnlyAuthor); + $this->assertNull($copy->bio); + $this->assertSame('Alice', $copy->name); + $this->assertSame(1, $copy->id); + } + + #[Test] + public function withChangesPreservesUninitializedProperties(): void + { + $factory = new EntityFactory( + entityNamespace: __NAMESPACE__ . '\\Stubs\\', + ); + + $entity = $factory->create($factory->resolveClass('read_only_author')); + $factory->set($entity, 'name', 'Alice'); + // $id and $bio are uninitialized + + $copy = $factory->withChanges($entity, name: 'Bob'); + assert($copy instanceof Stubs\ReadOnlyAuthor); + $this->assertSame('Bob', $copy->name); + $this->assertFalse((new ReflectionProperty($copy, 'id'))->isInitialized($copy)); + $this->assertFalse((new ReflectionProperty($copy, 'bio'))->isInitialized($copy)); + } + + #[Test] + public function withChangesWithEmptyChangesReturnsCopy(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $entity = new Stubs\ReadOnlyAuthor(id: 1, name: 'Alice', bio: 'bio'); + + $copy = $factory->withChanges($entity); + assert($copy instanceof Stubs\ReadOnlyAuthor); + $this->assertNotSame($entity, $copy); + $this->assertSame(1, $copy->id); + $this->assertSame('Alice', $copy->name); + $this->assertSame('bio', $copy->bio); + } + + #[Test] + public function createAndCopyWorksOnReadOnlyEntity(): void + { + $factory = new EntityFactory( + entityNamespace: __NAMESPACE__ . '\\Stubs\\', + ); + + $source = $factory->create($factory->resolveClass('read_only_author')); + assert($source instanceof Stubs\ReadOnlyAuthor); + $factory->set($source, 'id', 1); + $factory->set($source, 'name', 'Source'); + + $entity = $factory->create($factory->resolveClass('read_only_author')); + foreach ($factory->extractProperties($source) as $name => $value) { + $factory->set($entity, $name, $value); + } + + assert($entity instanceof Stubs\ReadOnlyAuthor); + $this->assertSame(1, $entity->id); + $this->assertSame('Source', $entity->name); + } + + #[Test] + public function withChangesCoercesTypes(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $entity = new Stubs\ReadOnlyAuthor(id: 1, name: 'Alice'); + + $copy = $factory->withChanges($entity, name: 42); + assert($copy instanceof Stubs\ReadOnlyAuthor); + $this->assertSame('42', $copy->name); + } + + #[Test] + public function withChangesThrowsOnInvalidValue(): void + { + $factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\'); + $entity = new Stubs\ReadOnlyAuthor(id: 1, name: 'Alice'); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Invalid value'); + $factory->withChanges($entity, name: null); + } } diff --git a/tests/Hydrators/FlatTest.php b/tests/Hydrators/FlatTest.php index 00679b4..3e7b024 100644 --- a/tests/Hydrators/FlatTest.php +++ b/tests/Hydrators/FlatTest.php @@ -28,10 +28,11 @@ protected function setUp(): void public function hydrateReturnsFalseForEmpty(): void { $hydrator = $this->hydrator(['id']); + $coll = Collection::author(); - $this->assertFalse($hydrator->hydrate(null, Collection::author(), $this->factory)); - $this->assertFalse($hydrator->hydrate([], Collection::author(), $this->factory)); - $this->assertFalse($hydrator->hydrate(false, Collection::author(), $this->factory)); + $this->assertFalse($hydrator->hydrate(null, $coll, $this->factory)); + $this->assertFalse($hydrator->hydrate([], $coll, $this->factory)); + $this->assertFalse($hydrator->hydrate(false, $coll, $this->factory)); } #[Test] @@ -63,7 +64,8 @@ public function hydrateSingleEntity(): void public function hydrateMultipleEntitiesWithPkBoundary(): void { $hydrator = $this->hydrator(['id', 'name', 'author_id', 'id', 'title']); - $collection = Collection::author()->post; + $collection = Collection::author(); + $collection->stack(Collection::post()); $result = $hydrator->hydrate([1, 'Author', 1, 10, 'Post Title'], $collection, $this->factory); @@ -85,7 +87,8 @@ public function hydrateMultipleEntitiesWithPkBoundary(): void public function hydrateSkipsWiringForNullPkChild(): void { $hydrator = $this->hydrator(['id', 'text', 'post_id', 'id', 'title']); - $collection = Collection::comment()->post; + $collection = Collection::comment(); + $collection->stack(Collection::post()); $result = $hydrator->hydrate([1, 'Hello', 5, null, null], $collection, $this->factory); @@ -101,6 +104,7 @@ public function hydrateSkipsUnfilteredFilteredCollections(): void { $hydrator = $this->hydrator(['id', 'title']); $filtered = Filtered::post(); + // No filters set — Filtered without filters is skipped $collection = Collection::author(); $collection->stack($filtered); @@ -127,11 +131,10 @@ public function hydrateFilteredCollectionWithFilters(): void #[Test] public function hydrateResolvesTypedEntities(): void { - $factory = new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\'); $hydrator = $this->hydrator(['id', 'type', 'title']); $collection = Typed::issue('type'); - $result = $hydrator->hydrate([1, 'Bug', 'Bug Report'], $collection, $factory); + $result = $hydrator->hydrate([1, 'Bug', 'Bug Report'], $collection, $this->factory); $this->assertNotFalse($result); $result->rewind(); diff --git a/tests/Hydrators/NestedTest.php b/tests/Hydrators/NestedTest.php index b9882be..7293f2c 100644 --- a/tests/Hydrators/NestedTest.php +++ b/tests/Hydrators/NestedTest.php @@ -59,7 +59,8 @@ public function hydrateWithNestedChild(): void 'title' => 'Post Title', 'author' => ['id' => 5, 'name' => 'Author'], ]; - $collection = Collection::post()->author; + $collection = Collection::post(); + $collection->stack(Collection::author()); $result = $this->hydrator->hydrate($raw, $collection, $this->factory); @@ -71,7 +72,8 @@ public function hydrateWithNestedChild(): void public function hydrateWithMissingNestedKeyReturnsPartial(): void { $raw = ['id' => 1, 'title' => 'Post Title']; - $collection = Collection::post()->author; + $collection = Collection::post(); + $collection->stack(Collection::author()); $result = $this->hydrator->hydrate($raw, $collection, $this->factory); @@ -91,7 +93,10 @@ public function hydrateDeeplyNested(): void 'author' => ['id' => 100, 'name' => 'Author'], ], ]; - $collection = Collection::comment()->post->author; + $collection = Collection::comment(); + $post = Collection::post(); + $post->stack(Collection::author()); + $collection->stack($post); $result = $this->hydrator->hydrate($raw, $collection, $this->factory); @@ -108,7 +113,9 @@ public function hydrateWithChildren(): void 'author' => ['id' => 5, 'name' => 'Author'], 'category' => ['id' => 3, 'label' => 'Tech'], ]; - $collection = Collection::post(Collection::author(), Collection::category()); + $authorColl = Collection::author(); + $categoryColl = Collection::category(); + $collection = Collection::post($authorColl, $categoryColl); $result = $this->hydrator->hydrate($raw, $collection, $this->factory); @@ -133,7 +140,8 @@ public function hydrateChildWithNullNameIsSkipped(): void { $raw = ['id' => 1]; $child = new Collection(); - $collection = Collection::post($child); + $collection = Collection::post(); + $collection->addChild($child); $result = $this->hydrator->hydrate($raw, $collection, $this->factory); @@ -145,7 +153,8 @@ public function hydrateChildWithNullNameIsSkipped(): void public function hydrateScalarNestedValueIsIgnored(): void { $raw = ['id' => 1, 'author' => 'not-an-array']; - $collection = Collection::post()->author; + $collection = Collection::post(); + $collection->stack(Collection::author()); $result = $this->hydrator->hydrate($raw, $collection, $this->factory); diff --git a/tests/InMemoryMapper.php b/tests/InMemoryMapper.php index 8102e90..8a26b87 100644 --- a/tests/InMemoryMapper.php +++ b/tests/InMemoryMapper.php @@ -64,12 +64,12 @@ public function flush(): void $op = $this->pending[$entity]; $collection = $this->tracked[$entity]; $tableName = (string) $collection->name; - $pk = $this->style->identifier($tableName); + $id = $this->style->identifier($tableName); match ($op) { - 'insert' => $this->insertEntity($entity, $collection, $tableName, $pk), - 'update' => $this->updateEntity($entity, $collection, $tableName, $pk), - 'delete' => $this->deleteEntity($entity, $tableName, $pk), + 'insert' => $this->insertEntity($entity, $collection, $tableName, $id), + 'update' => $this->updateEntity($entity, $collection, $tableName, $id), + 'delete' => $this->deleteEntity($entity, $tableName, $id), default => null, }; @@ -88,32 +88,32 @@ protected function defaultHydrator(Collection $collection): Hydrator return new Nested(); } - private function insertEntity(object $entity, Collection $collection, string $tableName, string $pk): void + private function insertEntity(object $entity, Collection $collection, string $tableName, string $id): void { $row = $this->filterColumns( $this->entityFactory->extractColumns($entity), $collection, ); - if (!isset($row[$pk])) { + if (!isset($row[$id])) { ++$this->lastInsertId; - $this->entityFactory->set($entity, $pk, $this->lastInsertId); - $row[$pk] = $this->lastInsertId; + $this->entityFactory->set($entity, $id, $this->lastInsertId); + $row[$id] = $this->lastInsertId; } $this->tables[$tableName][] = $row; } - private function updateEntity(object $entity, Collection $collection, string $tableName, string $pk): void + private function updateEntity(object $entity, Collection $collection, string $tableName, string $id): void { - $pkValue = $this->entityFactory->get($entity, $pk); + $idValue = $this->entityFactory->get($entity, $id); $row = $this->filterColumns( $this->entityFactory->extractColumns($entity), $collection, ); foreach ($this->tables[$tableName] as $index => $existing) { - if (isset($existing[$pk]) && $existing[$pk] == $pkValue) { + if (isset($existing[$id]) && $existing[$id] == $idValue) { $this->tables[$tableName][$index] = array_merge($existing, $row); break; @@ -121,13 +121,13 @@ private function updateEntity(object $entity, Collection $collection, string $ta } } - private function deleteEntity(object $entity, string $tableName, string $pk): void + private function deleteEntity(object $entity, string $tableName, string $id): void { - $pkValue = $this->entityFactory->get($entity, $pk); + $idValue = $this->entityFactory->get($entity, $id); $rows = $this->tables[$tableName]; foreach ($rows as $index => $existing) { - if (isset($existing[$pk]) && $existing[$pk] == $pkValue) { + if (isset($existing[$id]) && $existing[$id] == $idValue) { unset($rows[$index]); /** @var list> $reindexed */ $reindexed = array_values($rows); @@ -174,14 +174,14 @@ private function attachRelated(array &$parentRow, Collection $collection): void private function attachChild(array &$parentRow, Collection $child): void { $childName = (string) $child->name; - $fkValue = $parentRow[$this->style->remoteIdentifier($childName)] ?? null; + $refValue = $parentRow[$this->style->remoteIdentifier($childName)] ?? null; - if ($fkValue === null) { + if ($refValue === null) { return; } - $pk = $this->style->identifier($childName); - $childRow = $this->findRowByPk($childName, $pk, $fkValue); + $id = $this->style->identifier($childName); + $childRow = $this->findRowById($childName, $id, $refValue); if ($childRow === null) { return; @@ -209,20 +209,20 @@ private function findRows(string $table, mixed $condition): array return $rows; } - $pk = $this->style->identifier($table); - $pkValue = is_array($condition) ? reset($condition) : $condition; + $id = $this->style->identifier($table); + $idValue = is_array($condition) ? reset($condition) : $condition; return array_values(array_filter( $rows, - static fn(array $row): bool => isset($row[$pk]) && $row[$pk] == $pkValue, + static fn(array $row): bool => isset($row[$id]) && $row[$id] == $idValue, )); } /** @return array|null */ - private function findRowByPk(string $table, string $pk, mixed $pkValue): array|null + private function findRowById(string $table, string $id, mixed $idValue): array|null { foreach ($this->tables[$table] ?? [] as $row) { - if (isset($row[$pk]) && $row[$pk] == $pkValue) { + if (isset($row[$id]) && $row[$id] == $idValue) { return $row; } } diff --git a/tests/Stubs/Immutable/Author.php b/tests/Stubs/Immutable/Author.php new file mode 100644 index 0000000..e02f642 --- /dev/null +++ b/tests/Stubs/Immutable/Author.php @@ -0,0 +1,15 @@ +