Skip to content

Commit 7dc6d7f

Browse files
committed
Improve identity map merging, error handling, and type safety
Replace identity map entity swapping with per-property merging: mutable entities are updated in-place, readonly entities produce a new merged instance only when properties actually differ. Normalize PK types (numeric strings cast to int) for consistent identity map lookups. Add CollectionNotBound exception, cycle-safe persist return values, and class-string annotations removing phpstan-ignore.
1 parent d1664a8 commit 7dc6d7f

6 files changed

Lines changed: 158 additions & 52 deletions

File tree

src/AbstractMapper.php

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010

1111
use function array_flip;
1212
use function array_intersect_key;
13-
use function assert;
1413
use function count;
14+
use function ctype_digit;
1515
use function is_int;
1616
use function is_scalar;
1717
use function is_string;
@@ -96,8 +96,9 @@ public function persist(object $object, Collection $onCollection): object
9696
return $object;
9797
}
9898

99-
if ($onCollection->name !== null && $this->tryReplaceFromIdentityMap($object, $onCollection)) {
100-
return $object;
99+
$merged = $this->tryMergeWithIdentityMap($object, $onCollection);
100+
if ($merged !== null) {
101+
return $merged;
101102
}
102103

103104
$this->pending[$object] = 'insert';
@@ -202,53 +203,84 @@ protected function findInIdentityMap(Collection $collection): object|null
202203
return null;
203204
}
204205

205-
$condition = $collection->condition;
206-
if (!is_int($condition) && !is_string($condition)) {
206+
$condition = $this->normalizeIdValue($collection->condition);
207+
if ($condition === null) {
207208
return null;
208209
}
209210

210211
return $this->identityMap[$collection->name][$condition] ?? null;
211212
}
212213

213-
private function tryReplaceFromIdentityMap(object $entity, Collection $coll): bool
214+
private function tryMergeWithIdentityMap(object $entity, Collection $coll): object|null
214215
{
215-
assert($coll->name !== null);
216-
$entityId = $this->entityIdValue($entity, $coll->name);
217-
$idValue = $entityId;
218-
219-
if ($idValue === null && is_scalar($coll->condition)) {
220-
$idValue = $coll->condition;
216+
if ($coll->name === null) {
217+
return null;
221218
}
222219

223-
if ($idValue === null || (!is_int($idValue) && !is_string($idValue))) {
224-
return false;
220+
$entityId = $this->entityIdValue($entity, $coll->name);
221+
$idValue = $entityId ?? $this->normalizeIdValue($coll->condition);
222+
223+
if ($idValue === null) {
224+
return null;
225225
}
226226

227227
$existing = $this->identityMap[$coll->name][$idValue] ?? null;
228228
if ($existing === null || $existing === $entity) {
229-
return false;
229+
return null;
230230
}
231231

232232
if ($entityId === null) {
233233
$idName = $this->style->identifier($coll->name);
234234
$this->entityFactory->set($entity, $idName, $idValue);
235235
}
236236

237-
$this->tracked->offsetUnset($existing);
238-
$this->pending->offsetUnset($existing);
239-
$this->evictFromIdentityMap($existing, $coll);
240-
$this->markTracked($entity, $coll);
241-
$this->registerInIdentityMap($entity, $coll);
242-
$this->pending[$entity] = 'update';
237+
if ($this->entityFactory->isReadOnly($existing)) {
238+
$merged = $this->entityFactory->mergeEntities($existing, $entity);
243239

244-
return true;
240+
if ($merged !== $existing) {
241+
$this->tracked->offsetUnset($existing);
242+
$this->pending->offsetUnset($existing);
243+
$this->evictFromIdentityMap($existing, $coll);
244+
$this->markTracked($merged, $coll);
245+
$this->registerInIdentityMap($merged, $coll);
246+
}
247+
248+
$this->pending[$merged] = 'update';
249+
250+
return $merged;
251+
}
252+
253+
foreach ($this->entityFactory->extractProperties($entity) as $prop => $value) {
254+
$this->entityFactory->set($existing, $prop, $value);
255+
}
256+
257+
if (!$this->isTracked($existing)) {
258+
$this->markTracked($existing, $coll);
259+
}
260+
261+
$this->pending[$existing] = 'update';
262+
263+
return $existing;
245264
}
246265

247266
private function entityIdValue(object $entity, string $collName): int|string|null
248267
{
249-
$idValue = $this->entityFactory->get($entity, $this->style->identifier($collName));
268+
return $this->normalizeIdValue(
269+
$this->entityFactory->get($entity, $this->style->identifier($collName)),
270+
);
271+
}
272+
273+
private function normalizeIdValue(mixed $value): int|string|null
274+
{
275+
if (is_int($value)) {
276+
return $value;
277+
}
278+
279+
if (is_string($value)) {
280+
return ctype_digit($value) ? (int) $value : $value;
281+
}
250282

251-
return is_int($idValue) || is_string($idValue) ? $idValue : null;
283+
return null;
252284
}
253285

254286
public function __get(string $name): Collection

src/CollectionNotBound.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\Data;
6+
7+
use RuntimeException;
8+
9+
final class CollectionNotBound extends RuntimeException
10+
{
11+
public function __construct(string|null $collectionName)
12+
{
13+
parent::__construct(
14+
'Collection \'' . ($collectionName ?? '(unnamed)')
15+
. '\' must be attached to a mapper before fetching or persisting',
16+
);
17+
}
18+
}

src/Collections/Collection.php

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66

77
use ArrayAccess;
88
use Respect\Data\AbstractMapper;
9+
use Respect\Data\CollectionNotBound;
910
use Respect\Data\Hydrator;
10-
use RuntimeException;
1111

1212
/** @implements ArrayAccess<string, Collection> */
1313
class Collection implements ArrayAccess
@@ -65,9 +65,7 @@ public function persist(object $object, mixed ...$changes): object
6565
}
6666
}
6767

68-
$mapper->persist($object, $this);
69-
70-
return $object;
68+
return $mapper->persist($object, $this);
7169
}
7270

7371
public function remove(object $object): bool
@@ -150,7 +148,7 @@ private function findMapper(): AbstractMapper|null
150148

151149
private function resolveMapper(): AbstractMapper
152150
{
153-
return $this->findMapper() ?? throw new RuntimeException();
151+
return $this->findMapper() ?? throw new CollectionNotBound($this->name);
154152
}
155153

156154
private function setNext(Collection $collection): void

src/EntityFactory.php

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@
2626
/** Creates and manipulates entity objects using Style-based naming conventions */
2727
class EntityFactory
2828
{
29-
/** @var array<string, ReflectionClass<object>> */
29+
/** @var array<class-string, ReflectionClass<object>> */
3030
private array $classCache = [];
3131

32-
/** @var array<string, array<string, ReflectionProperty>> */
32+
/** @var array<class-string, array<string, ReflectionProperty>> */
3333
private array $propertyCache = [];
3434

3535
/** @var array<string, class-string> */
@@ -156,6 +156,46 @@ public function withChanges(object $entity, mixed ...$changes): object
156156
return $clone;
157157
}
158158

159+
public function mergeEntities(object $base, object $overlay): object
160+
{
161+
if ($base::class !== $overlay::class) {
162+
throw new DomainException(
163+
'Cannot merge entities of different classes: ' . $base::class . ' and ' . $overlay::class,
164+
);
165+
}
166+
167+
$overlayProps = $this->extractProperties($overlay);
168+
$hasDifference = false;
169+
170+
foreach ($overlayProps as $name => $value) {
171+
$mirror = $this->reflectProperties($base::class)[$name] ?? null;
172+
if ($mirror === null) {
173+
continue;
174+
}
175+
176+
if (!$mirror->isInitialized($base) || $mirror->getValue($base) !== $value) {
177+
$hasDifference = true;
178+
break;
179+
}
180+
}
181+
182+
if (!$hasDifference) {
183+
return $base;
184+
}
185+
186+
$clone = $this->reflectClass($base::class)->newInstanceWithoutConstructor();
187+
188+
foreach ($this->reflectProperties($base::class) as $name => $prop) {
189+
if (array_key_exists($name, $overlayProps)) {
190+
$prop->setValue($clone, $overlayProps[$name]);
191+
} elseif ($prop->isInitialized($base)) {
192+
$prop->setValue($clone, $prop->getValue($base));
193+
}
194+
}
195+
196+
return $clone;
197+
}
198+
159199
/**
160200
* Extract persistable columns, resolving entity objects to their reference representations.
161201
*
@@ -199,7 +239,11 @@ public function extractProperties(object $entity): array
199239
return $props;
200240
}
201241

202-
/** @return array<string, true> */
242+
/**
243+
* @param class-string $class
244+
*
245+
* @return array<string, true>
246+
*/
203247
private function detectRelationProperties(string $class): array
204248
{
205249
if (isset($this->relationCache[$class])) {
@@ -222,13 +266,21 @@ private function detectRelationProperties(string $class): array
222266
return $this->relationCache[$class] = $relations;
223267
}
224268

225-
/** @return ReflectionClass<object> */
269+
/**
270+
* @param class-string $class
271+
*
272+
* @return ReflectionClass<object>
273+
*/
226274
private function reflectClass(string $class): ReflectionClass
227275
{
228-
return $this->classCache[$class] ??= new ReflectionClass($class); // @phpstan-ignore argument.type
276+
return $this->classCache[$class] ??= new ReflectionClass($class);
229277
}
230278

231-
/** @return array<string, ReflectionProperty> */
279+
/**
280+
* @param class-string $class
281+
*
282+
* @return array<string, ReflectionProperty>
283+
*/
232284
private function reflectProperties(string $class): array
233285
{
234286
if (!isset($this->propertyCache[$class])) {

src/Hydrators/Flat.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public function hydrate(
5050
);
5151

5252
$entityFactory->set(
53-
/** @phpstan-ignore argument.type */
53+
/** @phpstan-ignore argument.type (array_pop returns object|null but SplObjectStorage guarantees object key) */
5454
$entityInstance,
5555
$columnName,
5656
$value,

tests/AbstractMapperTest.php

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -758,9 +758,10 @@ public function persistUntrackedEntityWithMatchingPkUpdates(): void
758758
/** @var SplObjectStorage<object, string> $pending */
759759
$pending = $pendingProp->getValue($mapper);
760760

761-
$this->assertSame('update', $pending[$replacement]);
762-
$this->assertFalse($mapper->isTracked($fetched));
763-
$this->assertTrue($mapper->isTracked($replacement));
761+
$this->assertSame('update', $pending[$fetched]);
762+
$this->assertTrue($mapper->isTracked($fetched));
763+
$this->assertFalse($mapper->isTracked($replacement));
764+
$this->assertSame('Updated', $fetched->title);
764765
}
765766

766767
#[Test]
@@ -791,20 +792,23 @@ public function persistReadOnlyViaCollectionPkUpdates(): void
791792

792793
// Create new readonly entity (no PK) and persist via collection[pk]
793794
$updated = $mapper->entityFactory->create(Stubs\ReadOnlyAuthor::class, name: 'Updated', bio: 'new bio');
794-
$mapper->read_only_author[1]->persist($updated);
795+
$merged = $mapper->read_only_author[1]->persist($updated);
795796

796-
// PK should have been set from collection condition
797-
$this->assertSame(1, $updated->id);
797+
// Merged entity should combine both: PK from fetched, changes from updated
798+
$this->assertSame(1, $merged->id);
799+
$this->assertSame('Updated', $merged->name);
800+
$this->assertSame('new bio', $merged->bio);
798801

799-
// Old entity should be evicted
802+
// Merged entity should be tracked, old fetched evicted
800803
$this->assertFalse($mapper->isTracked($fetched));
801-
$this->assertTrue($mapper->isTracked($updated));
804+
$this->assertFalse($mapper->isTracked($updated));
805+
$this->assertTrue($mapper->isTracked($merged));
802806

803807
$ref = new ReflectionObject($mapper);
804808
$pendingProp = $ref->getProperty('pending');
805809
/** @var SplObjectStorage<object, string> $pending */
806810
$pending = $pendingProp->getValue($mapper);
807-
$this->assertSame('update', $pending[$updated]);
811+
$this->assertSame('update', $pending[$merged]);
808812
}
809813

810814
#[Test]
@@ -1109,12 +1113,13 @@ public function readOnlyMultipleEntitiesFetchAllTracksAll(): void
11091113

11101114
// Replace one by identity map lookup
11111115
$updated = $mapper->entityFactory->create(Stubs\Immutable\Author::class, name: 'Alice Updated');
1112-
$mapper->author[1]->persist($updated);
1116+
$merged = $mapper->author[1]->persist($updated);
11131117

1114-
// Original Alice should be evicted, updated Alice takes its place
1118+
// Original Alice should be evicted, merged entity takes its place
11151119
$this->assertSame(3, $mapper->trackedCount());
1116-
$this->assertTrue($mapper->isTracked($updated));
1120+
$this->assertTrue($mapper->isTracked($merged));
11171121
$this->assertFalse($mapper->isTracked($authors[0]));
1122+
$this->assertSame('Alice Updated', $merged->name);
11181123
}
11191124

11201125
#[Test]
@@ -1129,11 +1134,12 @@ public function identityMapReplaceSkipsSetWhenPkAlreadyInitialized(): void
11291134

11301135
$updated = new Stubs\Immutable\Author(id: 1, name: 'Bob');
11311136

1132-
// persist via collection[1] — PK already set, should NOT try set() again
1133-
$mapper->author[1]->persist($updated);
1137+
// persist via collection[1] — PK already set, merge produces new entity
1138+
$merged = $mapper->author[1]->persist($updated);
11341139

1135-
$this->assertSame(1, $updated->id);
1136-
$this->assertTrue($mapper->isTracked($updated));
1140+
$this->assertSame(1, $merged->id);
1141+
$this->assertSame('Bob', $merged->name);
1142+
$this->assertTrue($mapper->isTracked($merged));
11371143
}
11381144

11391145
#[Test]

0 commit comments

Comments
 (0)