Skip to content

Commit 8f29aff

Browse files
committed
Adopt pure entity trees with typed relations
EntityFactory now detects relation properties via type-hint reflection instead of naming conventions, and converts between camelCase entity properties and snake_case DB columns through the Style subsystem. - extractColumns() uses detectRelationProperties() to identify relations by their non-builtin type hints, replacing convention-based isRelationProperty() - set()/get() apply styledProperty() for DB-to-entity name conversion - Type coercion in set() handles numeric strings, union types, and nullability - Standard style styledProperty/realProperty now perform camelCase↔snake_case - NorthWind overrides both to preserve PascalCase as-is - All test stubs use typed properties: int $id (uninitialized), typed object relations (Author $author), no mixed or _id scalars
1 parent 48a4ff2 commit 8f29aff

37 files changed

Lines changed: 303 additions & 105 deletions

src/EntityFactory.php

Lines changed: 131 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,19 @@
66

77
use DomainException;
88
use ReflectionClass;
9+
use ReflectionNamedType;
910
use ReflectionProperty;
11+
use ReflectionUnionType;
1012

1113
use function class_exists;
14+
use function is_array;
15+
use function is_bool;
16+
use function is_float;
17+
use function is_int;
18+
use function is_numeric;
1219
use function is_object;
20+
use function is_scalar;
21+
use function is_string;
1322

1423
/** Creates and manipulates entity objects using Style-based naming conventions */
1524
class EntityFactory
@@ -47,14 +56,26 @@ public function createByName(string $name): object
4756

4857
public function set(object $entity, string $prop, mixed $value): void
4958
{
50-
$mirror = $this->reflectProperties($entity::class)[$prop] ?? null;
59+
$styledProp = $this->style->styledProperty($prop);
60+
$mirror = $this->reflectProperties($entity::class)[$styledProp] ?? null;
5161

52-
$mirror?->setValue($entity, $value);
62+
if ($mirror === null) {
63+
return;
64+
}
65+
66+
$coerced = $this->coerce($mirror, $value);
67+
68+
if ($coerced === null && !($mirror->getType()?->allowsNull() ?? false)) {
69+
return;
70+
}
71+
72+
$mirror->setValue($entity, $coerced);
5373
}
5474

5575
public function get(object $entity, string $prop): mixed
5676
{
57-
$mirror = $this->reflectProperties($entity::class)[$prop] ?? null;
77+
$styledProp = $this->style->styledProperty($prop);
78+
$mirror = $this->reflectProperties($entity::class)[$styledProp] ?? null;
5879

5980
if ($mirror === null || !$mirror->isInitialized($entity)) {
6081
return null;
@@ -71,20 +92,20 @@ public function get(object $entity, string $prop): mixed
7192
public function extractColumns(object $entity): array
7293
{
7394
$cols = $this->extractProperties($entity);
95+
$relations = $this->detectRelationProperties($entity::class);
7496

7597
foreach ($cols as $key => $value) {
76-
if (!is_object($value)) {
98+
if (!isset($relations[$key])) {
7799
continue;
78100
}
79101

80-
if ($this->style->isRelationProperty($key)) {
81-
$fk = $this->style->remoteIdentifier($key);
102+
$fk = $this->style->remoteIdentifier($key);
103+
104+
if (is_object($value)) {
82105
$cols[$fk] = $this->get($value, $this->style->identifier($key));
83-
unset($cols[$key]);
84-
} else {
85-
$table = $this->style->remoteFromIdentifier($key) ?? $key;
86-
$cols[$key] = $this->get($value, $this->style->identifier($table));
87106
}
107+
108+
unset($cols[$key]);
88109
}
89110

90111
return $cols;
@@ -121,6 +142,25 @@ public function hydrate(object $source, string $entityName): object
121142
return $entity;
122143
}
123144

145+
/** @return array<string, true> */
146+
private function detectRelationProperties(string $class): array
147+
{
148+
$relations = [];
149+
150+
foreach ($this->reflectProperties($class) as $name => $prop) {
151+
$type = $prop->getType();
152+
$types = $type instanceof ReflectionUnionType ? $type->getTypes() : ($type !== null ? [$type] : []);
153+
foreach ($types as $t) {
154+
if ($t instanceof ReflectionNamedType && !$t->isBuiltin()) {
155+
$relations[$name] = true;
156+
break;
157+
}
158+
}
159+
}
160+
161+
return $relations;
162+
}
163+
124164
/** @return ReflectionClass<object> */
125165
private function reflectClass(string $class): ReflectionClass
126166
{
@@ -144,4 +184,85 @@ private function reflectProperties(string $class): array
144184

145185
return $this->propertyCache[$class];
146186
}
187+
188+
private function coerce(ReflectionProperty $prop, mixed $value): mixed
189+
{
190+
$type = $prop->getType();
191+
192+
if ($type === null) {
193+
throw new DomainException(
194+
'Property ' . $prop->getDeclaringClass()->getName() . '::$' . $prop->getName()
195+
. ' must have a type declaration',
196+
);
197+
}
198+
199+
if ($value === null) {
200+
return $type->allowsNull() ? null : $value;
201+
}
202+
203+
if ($type instanceof ReflectionNamedType) {
204+
return $this->exactMatch($type, $value) ?? $this->coerceToNamedType($type, $value);
205+
}
206+
207+
if ($type instanceof ReflectionUnionType) {
208+
$members = [];
209+
foreach ($type->getTypes() as $member) {
210+
if (!($member instanceof ReflectionNamedType)) {
211+
continue;
212+
}
213+
214+
$members[] = $member;
215+
}
216+
217+
// Pass 1: exact type match (no lossy casts)
218+
foreach ($members as $member) {
219+
$result = $this->exactMatch($member, $value);
220+
if ($result !== null) {
221+
return $result;
222+
}
223+
}
224+
225+
// Pass 2: lossy coercion (numeric string → int, scalar → string, etc.)
226+
foreach ($members as $member) {
227+
$result = $this->coerceToNamedType($member, $value);
228+
if ($result !== null) {
229+
return $result;
230+
}
231+
}
232+
}
233+
234+
return null;
235+
}
236+
237+
/** Accept value only if it already matches the type without any conversion */
238+
private function exactMatch(ReflectionNamedType $type, mixed $value): mixed
239+
{
240+
$name = $type->getName();
241+
242+
return match (true) {
243+
$name === 'mixed' => $value,
244+
$name === 'int' && is_int($value) => $value,
245+
$name === 'float' && is_float($value) => $value,
246+
$name === 'string' && is_string($value) => $value,
247+
$name === 'bool' && is_bool($value) => $value,
248+
$name === 'array' && is_array($value) => $value,
249+
is_object($value) && $value instanceof $name => $value,
250+
default => null,
251+
};
252+
}
253+
254+
/** Accept value with lossy coercion (e.g. numeric string → int) */
255+
private function coerceToNamedType(ReflectionNamedType $type, mixed $value): mixed
256+
{
257+
$name = $type->getName();
258+
259+
return match (true) {
260+
$name === 'mixed' => $value,
261+
$name === 'int' && is_string($value) && is_numeric($value) => (int) $value,
262+
$name === 'float' && is_int($value) => (float) $value,
263+
$name === 'float' && is_string($value) && is_numeric($value) => (float) $value,
264+
$name === 'string' && is_scalar($value) => (string) $value,
265+
default => null,
266+
};
267+
}
147268
}

src/Styles/NorthWind.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ public function styledName(string $name): string
2020
return $name;
2121
}
2222

23+
public function styledProperty(string $name): string
24+
{
25+
return $name;
26+
}
27+
28+
public function realProperty(string $name): string
29+
{
30+
return $name;
31+
}
32+
2333
public function composed(string $left, string $right): string
2434
{
2535
return $this->pluralToSingular($left) . $right;

src/Styles/Standard.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class Standard extends AbstractStyle
1414
{
1515
public function styledProperty(string $name): string
1616
{
17-
return $name;
17+
return $this->separatorToCamelCase($name, '_');
1818
}
1919

2020
public function realName(string $name): string
@@ -24,7 +24,7 @@ public function realName(string $name): string
2424

2525
public function realProperty(string $name): string
2626
{
27-
return $name;
27+
return strtolower($this->camelCaseToSeparator($name, '_'));
2828
}
2929

3030
public function styledName(string $name): string

tests/AbstractMapperTest.php

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -711,22 +711,6 @@ public function findInIdentityMapSkipsNonScalarCondition(): void
711711
$this->assertNotEmpty($all);
712712
}
713713

714-
#[Test]
715-
public function registerSkipsEntityWithNonScalarPk(): void
716-
{
717-
$mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\'));
718-
$mapper->seed('post', []);
719-
720-
$entity = new Stubs\Post();
721-
$entity->id = ['not', 'scalar'];
722-
$entity->title = 'Bad PK';
723-
$mapper->post->persist($entity);
724-
$mapper->flush();
725-
726-
// Entity with non-scalar PK should not enter identity map
727-
$this->assertSame(0, $mapper->identityMapCount());
728-
}
729-
730714
#[Test]
731715
public function findInIdentityMapSkipsCollectionWithChildren(): void
732716
{

tests/EntityFactoryTest.php

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
use PHPUnit\Framework\Attributes\CoversClass;
99
use PHPUnit\Framework\Attributes\Test;
1010
use PHPUnit\Framework\TestCase;
11+
use ReflectionProperty;
12+
use stdClass;
1113

1214
#[CoversClass(EntityFactory::class)]
1315
class EntityFactoryTest extends TestCase
@@ -190,7 +192,7 @@ public function extractColumnsResolvesFkObjectInPlace(): void
190192
$child = new Stubs\Category();
191193
$child->id = 8;
192194
$child->name = 'Child';
193-
$child->category_id = $parent;
195+
$child->category = $parent;
194196

195197
$cols = $factory->extractColumns($child);
196198
$this->assertEquals(3, $cols['category_id']);
@@ -209,4 +211,88 @@ public function extractColumnsPassesScalarsThrough(): void
209211
$cols = $factory->extractColumns($author);
210212
$this->assertEquals(['id' => 5, 'name' => 'Bob', 'bio' => null], $cols);
211213
}
214+
215+
#[Test]
216+
public function extractColumnsExcludesUninitializedRelation(): void
217+
{
218+
$factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\');
219+
$post = new Stubs\Post();
220+
$post->id = 10;
221+
$post->title = 'Test';
222+
223+
$cols = $factory->extractColumns($post);
224+
$this->assertArrayNotHasKey('author', $cols);
225+
$this->assertArrayNotHasKey('author_id', $cols);
226+
$this->assertEquals(10, $cols['id']);
227+
$this->assertEquals('Test', $cols['title']);
228+
}
229+
230+
#[Test]
231+
public function setSkipsIncompatibleType(): void
232+
{
233+
$factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\');
234+
$entity = new Stubs\TypeCoercionEntity();
235+
$entity->id = 1;
236+
237+
// Non-coercible value leaves the non-nullable property uninitialized
238+
$factory->set($entity, 'strict', 'not-a-number');
239+
$ref = new ReflectionProperty($entity, 'strict');
240+
$this->assertFalse($ref->isInitialized($entity));
241+
}
242+
243+
#[Test]
244+
public function setCoercesNumericStringToInt(): void
245+
{
246+
$factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\');
247+
$entity = new Stubs\TypeCoercionEntity();
248+
249+
$factory->set($entity, 'id', '42');
250+
$this->assertSame(42, $entity->id);
251+
}
252+
253+
#[Test]
254+
public function setHandlesUnionType(): void
255+
{
256+
$factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\');
257+
$entity = new Stubs\TypeCoercionEntity();
258+
259+
// Union type int|string|null — exact match takes priority over lossy coercion
260+
$factory->set($entity, 'flexible', '99');
261+
$this->assertSame('99', $entity->flexible);
262+
263+
// Int stays int (exact match on int branch, not lossy-cast to string)
264+
$factory->set($entity, 'flexible', 42);
265+
$this->assertSame(42, $entity->flexible);
266+
267+
// Null should work (nullable union)
268+
$factory->set($entity, 'flexible', null);
269+
$this->assertNull($entity->flexible);
270+
}
271+
272+
#[Test]
273+
public function coercionFailureFallsThrough(): void
274+
{
275+
$factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\');
276+
$entity = new Stubs\TypeCoercionEntity();
277+
$entity->id = 1;
278+
279+
// Setting an object on an int|string|null union fails all branches —
280+
// property stays unchanged since the union includes null (nullable)
281+
$entity->flexible = 'original';
282+
$factory->set($entity, 'flexible', new stdClass());
283+
$this->assertNull($entity->flexible);
284+
}
285+
286+
#[Test]
287+
public function unionLossyCoercionKicksInWhenExactMatchFails(): void
288+
{
289+
$factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\');
290+
$entity = new Stubs\TypeCoercionEntity();
291+
$entity->id = 1;
292+
293+
// int|float with a numeric string — exact match fails (not int, not float),
294+
// lossy pass coerces '42' → 42 (int branch wins)
295+
$factory->set($entity, 'narrow_union', '42');
296+
$this->assertSame(42, $entity->narrowUnion);
297+
}
212298
}

tests/Stubs/Author.php

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

77
class Author
88
{
9-
public mixed $id = null;
9+
public int $id;
1010

1111
public string|null $name = null;
1212

tests/Stubs/Bug.php

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

77
class Bug
88
{
9-
public mixed $id = null;
9+
public int $id;
1010

1111
public string|null $title = null;
1212

tests/Stubs/Category.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66

77
class Category
88
{
9-
public mixed $id = null;
9+
public int $id;
1010

1111
public string|null $name = null;
1212

1313
public string|null $label = null;
1414

15-
public mixed $category_id = null;
15+
public Category $category;
1616
}

tests/Stubs/Comment.php

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

77
class Comment
88
{
9-
public mixed $id = null;
9+
public int $id;
1010

11-
public mixed $post;
11+
public Post $post;
1212

1313
public string|null $text = null;
1414
}

0 commit comments

Comments
 (0)