diff --git a/src/AbstractMapper.php b/src/AbstractMapper.php index eb19a92..4f02d5f 100644 --- a/src/AbstractMapper.php +++ b/src/AbstractMapper.php @@ -10,20 +10,21 @@ use function array_flip; use function array_intersect_key; +use function count; +use function is_int; +use function is_scalar; +use function is_string; abstract class AbstractMapper { - /** @var SplObjectStorage */ - protected SplObjectStorage $new; - - /** @var SplObjectStorage */ + /** @var SplObjectStorage Maps entity → source Collection */ protected SplObjectStorage $tracked; - /** @var SplObjectStorage */ - protected SplObjectStorage $changed; + /** @var SplObjectStorage Maps entity → 'insert'|'update'|'delete' */ + protected SplObjectStorage $pending; - /** @var SplObjectStorage */ - protected SplObjectStorage $removed; + /** @var array> PK-indexed identity map: [collectionName][pkValue] → entity */ + protected array $identityMap = []; /** @var array */ private array $collections = []; @@ -33,10 +34,8 @@ abstract class AbstractMapper public function __construct( public readonly EntityFactory $entityFactory = new EntityFactory(), ) { - $this->tracked = new SplObjectStorage(); - $this->changed = new SplObjectStorage(); - $this->removed = new SplObjectStorage(); - $this->new = new SplObjectStorage(); + $this->tracked = new SplObjectStorage(); + $this->pending = new SplObjectStorage(); } abstract public function flush(): void; @@ -48,9 +47,27 @@ abstract public function fetchAll(Collection $collection, mixed $extra = null): public function reset(): void { - $this->changed = new SplObjectStorage(); - $this->removed = new SplObjectStorage(); - $this->new = new SplObjectStorage(); + $this->pending = new SplObjectStorage(); + } + + public function clearIdentityMap(): void + { + $this->identityMap = []; + } + + public function trackedCount(): int + { + return count($this->tracked); + } + + public function identityMapCount(): int + { + $total = 0; + foreach ($this->identityMap as $entries) { + $total += count($entries); + } + + return $total; } public function markTracked(object $entity, Collection $collection): bool @@ -69,13 +86,16 @@ public function persist(object $object, Collection $onCollection): bool return true; } - $this->changed[$object] = true; - if ($this->isTracked($object)) { + $currentOp = $this->pending[$object] ?? null; + if ($currentOp !== 'insert') { + $this->pending[$object] = 'update'; + } + return true; } - $this->new[$object] = true; + $this->pending[$object] = 'insert'; $this->markTracked($object, $onCollection); return true; @@ -83,15 +103,12 @@ public function persist(object $object, Collection $onCollection): bool public function remove(object $object, Collection $fromCollection): bool { - $this->changed[$object] = true; - $this->removed[$object] = true; + $this->pending[$object] = 'delete'; - if ($this->isTracked($object)) { - return true; + if (!$this->isTracked($object)) { + $this->markTracked($object, $fromCollection); } - $this->markTracked($object, $fromCollection); - return true; } @@ -134,6 +151,55 @@ protected function resolveHydrator(Collection $collection): Hydrator return $collection->hydrator ?? $this->defaultHydrator($collection); } + protected function registerInIdentityMap(object $entity, Collection $coll): void + { + if ($coll->name === null) { + return; + } + + $pkValue = $this->entityPkValue($entity, $coll->name); + if ($pkValue === null) { + return; + } + + $this->identityMap[$coll->name][$pkValue] = $entity; + } + + protected function evictFromIdentityMap(object $entity, Collection $coll): void + { + if ($coll->name === null) { + return; + } + + $pkValue = $this->entityPkValue($entity, $coll->name); + if ($pkValue === null) { + return; + } + + unset($this->identityMap[$coll->name][$pkValue]); + } + + protected function findInIdentityMap(Collection $collection): object|null + { + if ($collection->name === null || !is_scalar($collection->condition) || $collection->more) { + return null; + } + + $condition = $collection->condition; + if (!is_int($condition) && !is_string($condition)) { + return null; + } + + return $this->identityMap[$collection->name][$condition] ?? null; + } + + private function entityPkValue(object $entity, string $collName): int|string|null + { + $pkValue = $this->entityFactory->get($entity, $this->style->identifier($collName)); + + return is_int($pkValue) || is_string($pkValue) ? $pkValue : null; + } + public function __get(string $name): Collection { if (isset($this->collections[$name])) { diff --git a/tests/AbstractMapperTest.php b/tests/AbstractMapperTest.php index 827dc43..93d6b9e 100644 --- a/tests/AbstractMapperTest.php +++ b/tests/AbstractMapperTest.php @@ -182,7 +182,7 @@ public function markTrackedShouldReturnTrue(): void } #[Test] - public function resetShouldClearChangedRemovedAndNew(): void + public function resetShouldClearPending(): void { $entity = new stdClass(); $collection = Collection::foo(); @@ -192,20 +192,10 @@ public function resetShouldClearChangedRemovedAndNew(): void $ref = new ReflectionObject($this->mapper); - $newProp = $ref->getProperty('new'); - /** @var SplObjectStorage $newStorage */ - $newStorage = $newProp->getValue($this->mapper); - $this->assertCount(0, $newStorage); - - $changedProp = $ref->getProperty('changed'); - /** @var SplObjectStorage $changedStorage */ - $changedStorage = $changedProp->getValue($this->mapper); - $this->assertCount(0, $changedStorage); - - $removedProp = $ref->getProperty('removed'); - /** @var SplObjectStorage $removedStorage */ - $removedStorage = $removedProp->getValue($this->mapper); - $this->assertCount(0, $removedStorage); + $pendingProp = $ref->getProperty('pending'); + /** @var SplObjectStorage $pendingStorage */ + $pendingStorage = $pendingProp->getValue($this->mapper); + $this->assertCount(0, $pendingStorage); } #[Test] @@ -512,4 +502,256 @@ public function filterColumnsPassesThroughForIdentifierOnly(): void $this->assertEquals('Changed', $mapper->entityFactory->get($fetched, 'title')); $this->assertEquals('New Body', $mapper->entityFactory->get($fetched, 'text')); } + + #[Test] + public function fetchPopulatesIdentityMap(): void + { + $mapper = new InMemoryMapper(); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'First'], + ['id' => 2, 'title' => 'Second'], + ]); + + $this->assertSame(0, $mapper->identityMapCount()); + + $mapper->post[1]->fetch(); + $this->assertSame(1, $mapper->identityMapCount()); + + $mapper->post[2]->fetch(); + $this->assertSame(2, $mapper->identityMapCount()); + } + + #[Test] + public function fetchReturnsCachedEntityFromIdentityMap(): void + { + $mapper = new InMemoryMapper(); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'First'], + ]); + + $first = $mapper->post[1]->fetch(); + $second = $mapper->post[1]->fetch(); + + $this->assertSame($first, $second); + } + + #[Test] + public function fetchAllPopulatesIdentityMap(): void + { + $mapper = new InMemoryMapper(); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'First'], + ['id' => 2, 'title' => 'Second'], + ]); + + $mapper->post->fetchAll(); + $this->assertSame(2, $mapper->identityMapCount()); + } + + #[Test] + public function flushInsertRegistersInIdentityMap(): void + { + $mapper = new InMemoryMapper(); + $mapper->seed('post', []); + + $entity = new stdClass(); + $entity->title = 'New Post'; + $mapper->post->persist($entity); + $mapper->flush(); + + $this->assertSame(1, $mapper->identityMapCount()); + } + + #[Test] + public function flushDeleteEvictsFromIdentityMap(): void + { + $mapper = new InMemoryMapper(); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'To Delete'], + ]); + + $entity = $mapper->post[1]->fetch(); + $this->assertSame(1, $mapper->identityMapCount()); + + $mapper->post->remove($entity); + $mapper->flush(); + + $this->assertSame(0, $mapper->identityMapCount()); + } + + #[Test] + public function clearIdentityMapEmptiesMap(): void + { + $mapper = new InMemoryMapper(); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'First'], + ]); + + $mapper->post[1]->fetch(); + $this->assertSame(1, $mapper->identityMapCount()); + + $mapper->clearIdentityMap(); + $this->assertSame(0, $mapper->identityMapCount()); + } + + #[Test] + public function resetDoesNotClearIdentityMap(): void + { + $mapper = new InMemoryMapper(); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'First'], + ]); + + $mapper->post[1]->fetch(); + $this->assertSame(1, $mapper->identityMapCount()); + + $mapper->reset(); + $this->assertSame(1, $mapper->identityMapCount()); + } + + #[Test] + public function pendingOperationTypes(): void + { + $mapper = new InMemoryMapper(); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'Existing'], + ]); + + $ref = new ReflectionObject($mapper); + $pendingProp = $ref->getProperty('pending'); + + // persist new entity → 'insert' + $newEntity = new stdClass(); + $newEntity->title = 'New'; + $mapper->post->persist($newEntity); + + /** @var SplObjectStorage $pending */ + $pending = $pendingProp->getValue($mapper); + $this->assertSame('insert', $pending[$newEntity]); + + // persist existing entity → 'update' + $existing = $mapper->post[1]->fetch(); + $mapper->post->persist($existing); + /** @var SplObjectStorage $pending */ + $pending = $pendingProp->getValue($mapper); + $this->assertSame('update', $pending[$existing]); + + // remove entity → 'delete' + $mapper->post->remove($existing); + /** @var SplObjectStorage $pending */ + $pending = $pendingProp->getValue($mapper); + $this->assertSame('delete', $pending[$existing]); + } + + #[Test] + public function trackedCountReflectsTrackedEntities(): void + { + $mapper = new InMemoryMapper(); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'First'], + ]); + + $this->assertSame(0, $mapper->trackedCount()); + + $mapper->post[1]->fetch(); + $this->assertSame(1, $mapper->trackedCount()); + } + + #[Test] + public function registerSkipsEntityWithNullCollectionName(): void + { + $mapper = new InMemoryMapper(); + $entity = new stdClass(); + $entity->id = 1; + + // Collection with null name — register should be a no-op + $coll = new Collection(); + $mapper->persist($entity, $coll); + $mapper->flush(); + + $this->assertSame(0, $mapper->identityMapCount()); + } + + #[Test] + public function registerSkipsEntityWithNoPkValue(): void + { + $mapper = new InMemoryMapper(); + $mapper->seed('post', []); + + // Entity with no 'id' property + $entity = new stdClass(); + $entity->title = 'No PK'; + $mapper->post->persist($entity); + + // Before flush, entity has no PK — identity map should not contain it yet + // (identity map registration happens during flush, after PK is assigned) + $this->assertSame(0, $mapper->identityMapCount()); + } + + #[Test] + public function deleteEvictsUsingTrackedCollection(): void + { + $mapper = new InMemoryMapper(); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'Test'], + ]); + + $entity = $mapper->post[1]->fetch(); + $this->assertSame(1, $mapper->identityMapCount()); + + // Remove via a different collection — flush uses the tracked one (name='post') + $mapper->post->remove($entity); + $mapper->flush(); + + $this->assertSame(0, $mapper->identityMapCount()); + } + + #[Test] + public function findInIdentityMapSkipsNonScalarCondition(): void + { + $mapper = new InMemoryMapper(); + $mapper->seed('post', [ + ['id' => 1, 'title' => 'First'], + ]); + + // Populate identity map + $mapper->post[1]->fetch(); + $this->assertSame(1, $mapper->identityMapCount()); + + // fetchAll uses array/null condition — should always hit the backend + $all = $mapper->post->fetchAll(); + $this->assertNotEmpty($all); + } + + #[Test] + public function registerSkipsEntityWithNonScalarPk(): void + { + $mapper = new InMemoryMapper(); + $mapper->seed('post', []); + + $entity = new stdClass(); + $entity->id = ['not', 'scalar']; + $entity->title = 'Bad PK'; + $mapper->post->persist($entity); + $mapper->flush(); + + // Entity with non-scalar PK should not enter identity map + $this->assertSame(0, $mapper->identityMapCount()); + } + + #[Test] + public function findInIdentityMapSkipsCollectionWithChildren(): void + { + $mapper = new InMemoryMapper(); + $mapper->seed('comment', [ + ['id' => 1, 'text' => 'Hello', 'post_id' => 5], + ]); + $mapper->seed('post', [ + ['id' => 5, 'title' => 'Post'], + ]); + + // Fetch with relationship (has children) — should bypass identity map + $comment = $mapper->comment->post->fetch(); + $this->assertIsObject($comment); + } } diff --git a/tests/InMemoryMapper.php b/tests/InMemoryMapper.php index ae90ef8..8a0b00d 100644 --- a/tests/InMemoryMapper.php +++ b/tests/InMemoryMapper.php @@ -28,6 +28,13 @@ public function seed(string $table, array $rows): void public function fetch(Collection $collection, mixed $extra = null): mixed { + if ($extra === null) { + $cached = $this->findInIdentityMap($collection); + if ($cached !== null) { + return $cached; + } + } + $row = $this->findRow((string) $collection->name, $collection->condition); return $row !== null ? $this->hydrateRow($row, $collection) : false; @@ -53,72 +60,82 @@ public function fetchAll(Collection $collection, mixed $extra = null): array public function flush(): void { - foreach ($this->new as $entity) { + foreach ($this->pending as $entity) { + $op = $this->pending[$entity]; $collection = $this->tracked[$entity]; $tableName = (string) $collection->name; $pk = $this->style->identifier($tableName); - $row = $this->filterColumns( - $this->entityFactory->extractProperties($entity), - $collection, - ); - - if (!isset($row[$pk])) { - ++$this->lastInsertId; - $this->entityFactory->set($entity, $pk, $this->lastInsertId); - $row[$pk] = $this->lastInsertId; - } - $this->tables[$tableName][] = $row; + match ($op) { + 'insert' => $this->insertEntity($entity, $collection, $tableName, $pk), + 'update' => $this->updateEntity($entity, $collection, $tableName, $pk), + 'delete' => $this->deleteEntity($entity, $tableName, $pk), + default => null, + }; + + if ($op === 'delete') { + $this->evictFromIdentityMap($entity, $collection); + } else { + $this->registerInIdentityMap($entity, $collection); + } } - foreach ($this->changed as $entity) { - if ($this->new->offsetExists($entity) || $this->removed->offsetExists($entity)) { - continue; - } + $this->reset(); + } - $collection = $this->tracked[$entity]; - $tableName = (string) $collection->name; - $pk = $this->style->identifier($tableName); - $pkValue = $this->entityFactory->get($entity, $pk); - $row = $this->filterColumns( - $this->entityFactory->extractProperties($entity), - $collection, - ); - - foreach ($this->tables[$tableName] as $index => $existing) { - if (isset($existing[$pk]) && $existing[$pk] == $pkValue) { - $this->tables[$tableName][$index] = array_merge($existing, $row); - - break; - } - } + protected function defaultHydrator(Collection $collection): Hydrator + { + return new Nested(); + } + + private function insertEntity(object $entity, Collection $collection, string $tableName, string $pk): void + { + $row = $this->filterColumns( + $this->entityFactory->extractProperties($entity), + $collection, + ); + + if (!isset($row[$pk])) { + ++$this->lastInsertId; + $this->entityFactory->set($entity, $pk, $this->lastInsertId); + $row[$pk] = $this->lastInsertId; } - foreach ($this->removed as $entity) { - $collection = $this->tracked[$entity]; - $tableName = (string) $collection->name; - $pk = $this->style->identifier($tableName); - $pkValue = $this->entityFactory->get($entity, $pk); - - $rows = $this->tables[$tableName]; - foreach ($rows as $index => $existing) { - if (isset($existing[$pk]) && $existing[$pk] == $pkValue) { - unset($rows[$index]); - /** @var list> $reindexed */ - $reindexed = array_values($rows); - $this->tables[$tableName] = $reindexed; - - break; - } + $this->tables[$tableName][] = $row; + } + + private function updateEntity(object $entity, Collection $collection, string $tableName, string $pk): void + { + $pkValue = $this->entityFactory->get($entity, $pk); + $row = $this->filterColumns( + $this->entityFactory->extractProperties($entity), + $collection, + ); + + foreach ($this->tables[$tableName] as $index => $existing) { + if (isset($existing[$pk]) && $existing[$pk] == $pkValue) { + $this->tables[$tableName][$index] = array_merge($existing, $row); + + break; } } - - $this->reset(); } - protected function defaultHydrator(Collection $collection): Hydrator + private function deleteEntity(object $entity, string $tableName, string $pk): void { - return new Nested(); + $pkValue = $this->entityFactory->get($entity, $pk); + $rows = $this->tables[$tableName]; + + foreach ($rows as $index => $existing) { + if (isset($existing[$pk]) && $existing[$pk] == $pkValue) { + unset($rows[$index]); + /** @var list> $reindexed */ + $reindexed = array_values($rows); + $this->tables[$tableName] = $reindexed; + + break; + } + } } /** @param array $row */ @@ -133,6 +150,7 @@ private function hydrateRow(array $row, Collection $collection): object|false foreach ($entities as $entity) { $this->markTracked($entity, $entities[$entity]); + $this->registerInIdentityMap($entity, $entities[$entity]); } $entities->rewind();