Skip to content

Commit 7fe67b7

Browse files
committed
Support immutable entities
- Add EntityFactory::create(class-string, ...props) for constructing entities without calling the constructor (readonly-safe) - Add EntityFactory::resolveClass(name) replacing createByName - Add EntityFactory::withChanges(entity, ...changes) for immutable copies - Add EntityFactory::isReadOnly(entity) for readonly class detection - Add ReadOnlyViolation exception for initialized readonly property guard - Remove EntityFactory::createByName, hydrate, and disableConstructor - Extend persist() to consult identity map for untracked entities, enabling update-by-replacement for immutable entities - Add Collection::persist(...$changes) with inline withChanges support - Change persist() return type from bool to object (returns the entity) - Replace resolveEntityName with Typed::resolveEntityClass using FQN - Lift resolveEntityClass into Base hydrator, shared by Flat and Nested - Cache resolveClass and detectRelationProperties results - Use SplObjectStorage::offsetUnset instead of deprecated detach - Normalize terminology: PK/FK -> identity/reference throughout
1 parent 8f29aff commit 7fe67b7

22 files changed

Lines changed: 1231 additions & 160 deletions

src/AbstractMapper.php

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
use function array_flip;
1212
use function array_intersect_key;
13+
use function assert;
1314
use function count;
1415
use function is_int;
1516
use function is_scalar;
@@ -23,7 +24,7 @@ abstract class AbstractMapper
2324
/** @var SplObjectStorage<object, string> Maps entity → 'insert'|'update'|'delete' */
2425
protected SplObjectStorage $pending;
2526

26-
/** @var array<string, array<int|string, object>> PK-indexed identity map: [collectionName][pkValue] → entity */
27+
/** @var array<string, array<int|string, object>> Identity-indexed map: [collectionName][idValue] → entity */
2728
protected array $identityMap = [];
2829

2930
/** @var array<string, Collection> */
@@ -77,13 +78,13 @@ public function markTracked(object $entity, Collection $collection): bool
7778
return true;
7879
}
7980

80-
public function persist(object $object, Collection $onCollection): bool
81+
public function persist(object $object, Collection $onCollection): object
8182
{
8283
$next = $onCollection->next;
8384
if ($onCollection instanceof Filtered && $next !== null) {
8485
$this->persist($object, $next);
8586

86-
return true;
87+
return $object;
8788
}
8889

8990
if ($this->isTracked($object)) {
@@ -92,13 +93,17 @@ public function persist(object $object, Collection $onCollection): bool
9293
$this->pending[$object] = 'update';
9394
}
9495

95-
return true;
96+
return $object;
97+
}
98+
99+
if ($onCollection->name !== null && $this->tryReplaceFromIdentityMap($object, $onCollection)) {
100+
return $object;
96101
}
97102

98103
$this->pending[$object] = 'insert';
99104
$this->markTracked($object, $onCollection);
100105

101-
return true;
106+
return $object;
102107
}
103108

104109
public function remove(object $object, Collection $fromCollection): bool
@@ -117,6 +122,18 @@ public function isTracked(object $entity): bool
117122
return $this->tracked->offsetExists($entity);
118123
}
119124

125+
public function replaceTracked(object $old, object $new, Collection $onCollection): void
126+
{
127+
$op = $this->pending[$old] ?? 'update';
128+
$this->tracked->offsetUnset($old);
129+
$this->pending->offsetUnset($old);
130+
$this->evictFromIdentityMap($old, $onCollection);
131+
132+
$this->markTracked($new, $onCollection);
133+
$this->registerInIdentityMap($new, $onCollection);
134+
$this->pending[$new] = $op;
135+
}
136+
120137
public function registerCollection(string $alias, Collection $collection): void
121138
{
122139
$collection->mapper = $this;
@@ -141,9 +158,9 @@ protected function filterColumns(array $columns, Collection $collection): array
141158
return $columns;
142159
}
143160

144-
$pk = $this->style->identifier($collection->name);
161+
$id = $this->style->identifier($collection->name);
145162

146-
return array_intersect_key($columns, array_flip([...$collection->filters, $pk]));
163+
return array_intersect_key($columns, array_flip([...$collection->filters, $id]));
147164
}
148165

149166
protected function resolveHydrator(Collection $collection): Hydrator
@@ -157,12 +174,12 @@ protected function registerInIdentityMap(object $entity, Collection $coll): void
157174
return;
158175
}
159176

160-
$pkValue = $this->entityPkValue($entity, $coll->name);
161-
if ($pkValue === null) {
177+
$idValue = $this->entityIdValue($entity, $coll->name);
178+
if ($idValue === null) {
162179
return;
163180
}
164181

165-
$this->identityMap[$coll->name][$pkValue] = $entity;
182+
$this->identityMap[$coll->name][$idValue] = $entity;
166183
}
167184

168185
protected function evictFromIdentityMap(object $entity, Collection $coll): void
@@ -171,12 +188,12 @@ protected function evictFromIdentityMap(object $entity, Collection $coll): void
171188
return;
172189
}
173190

174-
$pkValue = $this->entityPkValue($entity, $coll->name);
175-
if ($pkValue === null) {
191+
$idValue = $this->entityIdValue($entity, $coll->name);
192+
if ($idValue === null) {
176193
return;
177194
}
178195

179-
unset($this->identityMap[$coll->name][$pkValue]);
196+
unset($this->identityMap[$coll->name][$idValue]);
180197
}
181198

182199
protected function findInIdentityMap(Collection $collection): object|null
@@ -193,11 +210,45 @@ protected function findInIdentityMap(Collection $collection): object|null
193210
return $this->identityMap[$collection->name][$condition] ?? null;
194211
}
195212

196-
private function entityPkValue(object $entity, string $collName): int|string|null
213+
private function tryReplaceFromIdentityMap(object $entity, Collection $coll): bool
214+
{
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;
221+
}
222+
223+
if ($idValue === null || (!is_int($idValue) && !is_string($idValue))) {
224+
return false;
225+
}
226+
227+
$existing = $this->identityMap[$coll->name][$idValue] ?? null;
228+
if ($existing === null || $existing === $entity) {
229+
return false;
230+
}
231+
232+
if ($entityId === null) {
233+
$idName = $this->style->identifier($coll->name);
234+
$this->entityFactory->set($entity, $idName, $idValue);
235+
}
236+
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';
243+
244+
return true;
245+
}
246+
247+
private function entityIdValue(object $entity, string $collName): int|string|null
197248
{
198-
$pkValue = $this->entityFactory->get($entity, $this->style->identifier($collName));
249+
$idValue = $this->entityFactory->get($entity, $this->style->identifier($collName));
199250

200-
return is_int($pkValue) || is_string($pkValue) ? $pkValue : null;
251+
return is_int($idValue) || is_string($idValue) ? $idValue : null;
201252
}
202253

203254
public function __get(string $name): Collection

src/Collections/Collection.php

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
use ArrayAccess;
88
use Respect\Data\AbstractMapper;
9-
use Respect\Data\EntityFactory;
109
use Respect\Data\Hydrator;
1110
use RuntimeException;
1211

@@ -51,9 +50,24 @@ public function addChild(Collection $child): void
5150
$this->children[] = $clone;
5251
}
5352

54-
public function persist(object $object): bool
53+
public function persist(object $object, mixed ...$changes): object
5554
{
56-
return $this->resolveMapper()->persist($object, $this);
55+
$mapper = $this->resolveMapper();
56+
57+
if ($changes) {
58+
$original = $object;
59+
$object = $mapper->entityFactory->withChanges($original, ...$changes);
60+
61+
if ($mapper->isTracked($original)) {
62+
$mapper->replaceTracked($original, $object, $this);
63+
64+
return $object;
65+
}
66+
}
67+
68+
$mapper->persist($object, $this);
69+
70+
return $object;
5771
}
5872

5973
public function remove(object $object): bool
@@ -71,12 +85,6 @@ public function fetchAll(mixed $extra = null): mixed
7185
return $this->resolveMapper()->fetchAll($this, $extra);
7286
}
7387

74-
/** @param object|array<string, mixed> $row */
75-
public function resolveEntityName(EntityFactory $factory, object|array $row): string
76-
{
77-
return $this->name ?? '';
78-
}
79-
8088
public function offsetExists(mixed $offset): bool
8189
{
8290
return false;

src/Collections/Filtered.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
final class Filtered extends Collection
1010
{
11-
/** Fetch only the entity identifier (primary key, document ID, etc.) */
11+
/** Fetch only the entity identifier */
1212
public const string IDENTIFIER_ONLY = '*';
1313

1414
// phpcs:ignore PSR2.Classes.PropertyDeclaration

src/Collections/Typed.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,16 @@ public function __construct(
1818
parent::__construct($name);
1919
}
2020

21-
/** @param object|array<string, mixed> $row */
22-
public function resolveEntityName(EntityFactory $factory, object|array $row): string
21+
/**
22+
* @param object|array<string, mixed> $row
23+
*
24+
* @return class-string
25+
*/
26+
public function resolveEntityClass(EntityFactory $factory, object|array $row): string
2327
{
2428
$name = is_array($row) ? ($row[$this->type] ?? null) : $factory->get($row, $this->type);
2529

26-
return is_string($name) ? $name : ($this->name ?? '');
30+
return $factory->resolveClass(is_string($name) ? $name : (string) $this->name);
2731
}
2832

2933
/** @param array<int, string> $arguments */

0 commit comments

Comments
 (0)