Skip to content

Commit 88fd64d

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 88fd64d

22 files changed

Lines changed: 1141 additions & 160 deletions

src/AbstractMapper.php

Lines changed: 55 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
@@ -141,9 +146,9 @@ protected function filterColumns(array $columns, Collection $collection): array
141146
return $columns;
142147
}
143148

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

146-
return array_intersect_key($columns, array_flip([...$collection->filters, $pk]));
151+
return array_intersect_key($columns, array_flip([...$collection->filters, $id]));
147152
}
148153

149154
protected function resolveHydrator(Collection $collection): Hydrator
@@ -157,12 +162,12 @@ protected function registerInIdentityMap(object $entity, Collection $coll): void
157162
return;
158163
}
159164

160-
$pkValue = $this->entityPkValue($entity, $coll->name);
161-
if ($pkValue === null) {
165+
$idValue = $this->entityIdValue($entity, $coll->name);
166+
if ($idValue === null) {
162167
return;
163168
}
164169

165-
$this->identityMap[$coll->name][$pkValue] = $entity;
170+
$this->identityMap[$coll->name][$idValue] = $entity;
166171
}
167172

168173
protected function evictFromIdentityMap(object $entity, Collection $coll): void
@@ -171,12 +176,12 @@ protected function evictFromIdentityMap(object $entity, Collection $coll): void
171176
return;
172177
}
173178

174-
$pkValue = $this->entityPkValue($entity, $coll->name);
175-
if ($pkValue === null) {
179+
$idValue = $this->entityIdValue($entity, $coll->name);
180+
if ($idValue === null) {
176181
return;
177182
}
178183

179-
unset($this->identityMap[$coll->name][$pkValue]);
184+
unset($this->identityMap[$coll->name][$idValue]);
180185
}
181186

182187
protected function findInIdentityMap(Collection $collection): object|null
@@ -193,11 +198,45 @@ protected function findInIdentityMap(Collection $collection): object|null
193198
return $this->identityMap[$collection->name][$condition] ?? null;
194199
}
195200

196-
private function entityPkValue(object $entity, string $collName): int|string|null
201+
private function tryReplaceFromIdentityMap(object $entity, Collection $coll): bool
202+
{
203+
assert($coll->name !== null);
204+
$entityId = $this->entityIdValue($entity, $coll->name);
205+
$idValue = $entityId;
206+
207+
if ($idValue === null && is_scalar($coll->condition)) {
208+
$idValue = $coll->condition;
209+
}
210+
211+
if ($idValue === null || (!is_int($idValue) && !is_string($idValue))) {
212+
return false;
213+
}
214+
215+
$existing = $this->identityMap[$coll->name][$idValue] ?? null;
216+
if ($existing === null || $existing === $entity) {
217+
return false;
218+
}
219+
220+
if ($entityId === null) {
221+
$idName = $this->style->identifier($coll->name);
222+
$this->entityFactory->set($entity, $idName, $idValue);
223+
}
224+
225+
$this->tracked->offsetUnset($existing);
226+
$this->pending->offsetUnset($existing);
227+
$this->evictFromIdentityMap($existing, $coll);
228+
$this->markTracked($entity, $coll);
229+
$this->registerInIdentityMap($entity, $coll);
230+
$this->pending[$entity] = 'update';
231+
232+
return true;
233+
}
234+
235+
private function entityIdValue(object $entity, string $collName): int|string|null
197236
{
198-
$pkValue = $this->entityFactory->get($entity, $this->style->identifier($collName));
237+
$idValue = $this->entityFactory->get($entity, $this->style->identifier($collName));
199238

200-
return is_int($pkValue) || is_string($pkValue) ? $pkValue : null;
239+
return is_int($idValue) || is_string($idValue) ? $idValue : null;
201240
}
202241

203242
public function __get(string $name): Collection

src/Collections/Collection.php

Lines changed: 9 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,16 @@ 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+
if ($changes) {
57+
$object = $mapper->entityFactory->withChanges($object, ...$changes);
58+
}
59+
60+
$mapper->persist($object, $this);
61+
62+
return $object;
5763
}
5864

5965
public function remove(object $object): bool
@@ -71,12 +77,6 @@ public function fetchAll(mixed $extra = null): mixed
7177
return $this->resolveMapper()->fetchAll($this, $extra);
7278
}
7379

74-
/** @param object|array<string, mixed> $row */
75-
public function resolveEntityName(EntityFactory $factory, object|array $row): string
76-
{
77-
return $this->name ?? '';
78-
}
79-
8080
public function offsetExists(mixed $offset): bool
8181
{
8282
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 */

src/EntityFactory.php

Lines changed: 80 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
use ReflectionProperty;
1111
use ReflectionUnionType;
1212

13+
use function array_key_exists;
14+
use function array_keys;
1315
use function class_exists;
16+
use function implode;
1417
use function is_array;
1518
use function is_bool;
1619
use function is_float;
@@ -29,29 +32,33 @@ class EntityFactory
2932
/** @var array<string, array<string, ReflectionProperty>> */
3033
private array $propertyCache = [];
3134

35+
/** @var array<string, class-string> */
36+
private array $resolveCache = [];
37+
38+
/** @var array<string, array<string, true>> */
39+
private array $relationCache = [];
40+
3241
public function __construct(
3342
public readonly Styles\Stylable $style = new Styles\Standard(),
3443
private readonly string $entityNamespace = '\\',
35-
private readonly bool $disableConstructor = false,
3644
) {
3745
}
3846

39-
public function createByName(string $name): object
47+
/** @return class-string */
48+
public function resolveClass(string $name): string
4049
{
50+
if (isset($this->resolveCache[$name])) {
51+
return $this->resolveCache[$name];
52+
}
53+
4154
$entityName = $this->style->styledName($name);
4255
$entityClass = $this->entityNamespace . $entityName;
4356

4457
if (!class_exists($entityClass)) {
4558
throw new DomainException('Entity class ' . $entityClass . ' not found for ' . $name);
4659
}
4760

48-
$ref = $this->reflectClass($entityClass);
49-
50-
if (!$this->disableConstructor) {
51-
return $ref->newInstanceArgs();
52-
}
53-
54-
return $ref->newInstanceWithoutConstructor();
61+
return $this->resolveCache[$name] = $entityClass;
5562
}
5663

5764
public function set(object $entity, string $prop, mixed $value): void
@@ -69,6 +76,12 @@ public function set(object $entity, string $prop, mixed $value): void
6976
return;
7077
}
7178

79+
if ($mirror->isReadOnly() && $mirror->isInitialized($entity)) {
80+
throw new ReadOnlyViolation(
81+
'Cannot modify readonly property ' . $entity::class . '::$' . $mirror->getName(),
82+
);
83+
}
84+
7285
$mirror->setValue($entity, $coerced);
7386
}
7487

@@ -84,8 +97,58 @@ public function get(object $entity, string $prop): mixed
8497
return $mirror->getValue($entity);
8598
}
8699

100+
public function isReadOnly(object $entity): bool
101+
{
102+
return $this->reflectClass($entity::class)->isReadOnly();
103+
}
104+
87105
/**
88-
* Extract persistable columns, resolving entity objects to their FK representations.
106+
* @param class-string<T> $class
107+
*
108+
* @return T
109+
*
110+
* @template T of object
111+
*/
112+
public function create(string $class, mixed ...$properties): object
113+
{
114+
/** @phpstan-var T $entity */
115+
$entity = $this->reflectClass($class)->newInstanceWithoutConstructor();
116+
117+
foreach ($properties as $prop => $value) {
118+
$this->set($entity, (string) $prop, $value);
119+
}
120+
121+
return $entity;
122+
}
123+
124+
public function withChanges(object $entity, mixed ...$changes): object
125+
{
126+
$clone = $this->reflectClass($entity::class)->newInstanceWithoutConstructor();
127+
$styledChanges = [];
128+
foreach ($changes as $prop => $value) {
129+
$styledChanges[$this->style->styledProperty((string) $prop)] = $value;
130+
}
131+
132+
foreach ($this->reflectProperties($entity::class) as $name => $prop) {
133+
if (array_key_exists($name, $styledChanges)) {
134+
$prop->setValue($clone, $styledChanges[$name]);
135+
unset($styledChanges[$name]);
136+
} elseif ($prop->isInitialized($entity)) {
137+
$prop->setValue($clone, $prop->getValue($entity));
138+
}
139+
}
140+
141+
if ($styledChanges) {
142+
throw new DomainException(
143+
'Unknown properties for ' . $entity::class . ': ' . implode(', ', array_keys($styledChanges)),
144+
);
145+
}
146+
147+
return $clone;
148+
}
149+
150+
/**
151+
* Extract persistable columns, resolving entity objects to their reference representations.
89152
*
90153
* @return array<string, mixed>
91154
*/
@@ -99,10 +162,10 @@ public function extractColumns(object $entity): array
99162
continue;
100163
}
101164

102-
$fk = $this->style->remoteIdentifier($key);
165+
$ref = $this->style->remoteIdentifier($key);
103166

104167
if (is_object($value)) {
105-
$cols[$fk] = $this->get($value, $this->style->identifier($key));
168+
$cols[$ref] = $this->get($value, $this->style->identifier($key));
106169
}
107170

108171
unset($cols[$key]);
@@ -127,24 +190,13 @@ public function extractProperties(object $entity): array
127190
return $props;
128191
}
129192

130-
public function hydrate(object $source, string $entityName): object
131-
{
132-
$entity = $this->createByName($entityName);
133-
134-
foreach ($this->reflectProperties($source::class) as $name => $prop) {
135-
if (!$prop->isInitialized($source)) {
136-
continue;
137-
}
138-
139-
$this->set($entity, $name, $prop->getValue($source));
140-
}
141-
142-
return $entity;
143-
}
144-
145193
/** @return array<string, true> */
146194
private function detectRelationProperties(string $class): array
147195
{
196+
if (isset($this->relationCache[$class])) {
197+
return $this->relationCache[$class];
198+
}
199+
148200
$relations = [];
149201

150202
foreach ($this->reflectProperties($class) as $name => $prop) {
@@ -158,7 +210,7 @@ private function detectRelationProperties(string $class): array
158210
}
159211
}
160212

161-
return $relations;
213+
return $this->relationCache[$class] = $relations;
162214
}
163215

164216
/** @return ReflectionClass<object> */

0 commit comments

Comments
 (0)