Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 131 additions & 10 deletions src/EntityFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,19 @@

use DomainException;
use ReflectionClass;
use ReflectionNamedType;
use ReflectionProperty;
use ReflectionUnionType;

use function class_exists;
use function is_array;
use function is_bool;
use function is_float;
use function is_int;
use function is_numeric;
use function is_object;
use function is_scalar;
use function is_string;

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

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

$mirror?->setValue($entity, $value);
if ($mirror === null) {
return;
}

$coerced = $this->coerce($mirror, $value);

if ($coerced === null && !($mirror->getType()?->allowsNull() ?? false)) {
return;
}

$mirror->setValue($entity, $coerced);
}

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

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

foreach ($cols as $key => $value) {
if (!is_object($value)) {
if (!isset($relations[$key])) {
continue;
}

if ($this->style->isRelationProperty($key)) {
$fk = $this->style->remoteIdentifier($key);
$fk = $this->style->remoteIdentifier($key);

if (is_object($value)) {
$cols[$fk] = $this->get($value, $this->style->identifier($key));
unset($cols[$key]);
} else {
$table = $this->style->remoteFromIdentifier($key) ?? $key;
$cols[$key] = $this->get($value, $this->style->identifier($table));
}

unset($cols[$key]);
}

return $cols;
Expand Down Expand Up @@ -121,6 +142,25 @@ public function hydrate(object $source, string $entityName): object
return $entity;
}

/** @return array<string, true> */
private function detectRelationProperties(string $class): array
{
$relations = [];

foreach ($this->reflectProperties($class) as $name => $prop) {
$type = $prop->getType();
$types = $type instanceof ReflectionUnionType ? $type->getTypes() : ($type !== null ? [$type] : []);
foreach ($types as $t) {
if ($t instanceof ReflectionNamedType && !$t->isBuiltin()) {
$relations[$name] = true;
break;
}
}
}

return $relations;
}

/** @return ReflectionClass<object> */
private function reflectClass(string $class): ReflectionClass
{
Expand All @@ -144,4 +184,85 @@ private function reflectProperties(string $class): array

return $this->propertyCache[$class];
}

private function coerce(ReflectionProperty $prop, mixed $value): mixed
{
$type = $prop->getType();

if ($type === null) {
throw new DomainException(
'Property ' . $prop->getDeclaringClass()->getName() . '::$' . $prop->getName()
. ' must have a type declaration',
);
}

if ($value === null) {
return $type->allowsNull() ? null : $value;
}

if ($type instanceof ReflectionNamedType) {
return $this->exactMatch($type, $value) ?? $this->coerceToNamedType($type, $value);
}

if ($type instanceof ReflectionUnionType) {
$members = [];
foreach ($type->getTypes() as $member) {
if (!($member instanceof ReflectionNamedType)) {
continue;
}

$members[] = $member;
}

// Pass 1: exact type match (no lossy casts)
foreach ($members as $member) {
$result = $this->exactMatch($member, $value);
if ($result !== null) {
return $result;
}
}

// Pass 2: lossy coercion (numeric string → int, scalar → string, etc.)
foreach ($members as $member) {
$result = $this->coerceToNamedType($member, $value);
if ($result !== null) {
return $result;
}
}
}

return null;
}

/** Accept value only if it already matches the type without any conversion */
private function exactMatch(ReflectionNamedType $type, mixed $value): mixed
{
$name = $type->getName();

return match (true) {
$name === 'mixed' => $value,
$name === 'int' && is_int($value) => $value,
$name === 'float' && is_float($value) => $value,
$name === 'string' && is_string($value) => $value,
$name === 'bool' && is_bool($value) => $value,
$name === 'array' && is_array($value) => $value,
is_object($value) && $value instanceof $name => $value,
default => null,
};
}

/** Accept value with lossy coercion (e.g. numeric string → int) */
private function coerceToNamedType(ReflectionNamedType $type, mixed $value): mixed
{
$name = $type->getName();

return match (true) {
$name === 'mixed' => $value,
$name === 'int' && is_string($value) && is_numeric($value) => (int) $value,
$name === 'float' && is_int($value) => (float) $value,
$name === 'float' && is_string($value) && is_numeric($value) => (float) $value,
$name === 'string' && is_scalar($value) => (string) $value,
default => null,
};
}
}
10 changes: 10 additions & 0 deletions src/Styles/NorthWind.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ public function styledName(string $name): string
return $name;
}

public function styledProperty(string $name): string
{
return $name;
}

public function realProperty(string $name): string
{
return $name;
}

public function composed(string $left, string $right): string
{
return $this->pluralToSingular($left) . $right;
Expand Down
4 changes: 2 additions & 2 deletions src/Styles/Standard.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class Standard extends AbstractStyle
{
public function styledProperty(string $name): string
{
return $name;
return $this->separatorToCamelCase($name, '_');
}

public function realName(string $name): string
Expand All @@ -24,7 +24,7 @@ public function realName(string $name): string

public function realProperty(string $name): string
{
return $name;
return strtolower($this->camelCaseToSeparator($name, '_'));
}

public function styledName(string $name): string
Expand Down
16 changes: 0 additions & 16 deletions tests/AbstractMapperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -711,22 +711,6 @@ public function findInIdentityMapSkipsNonScalarCondition(): void
$this->assertNotEmpty($all);
}

#[Test]
public function registerSkipsEntityWithNonScalarPk(): void
{
$mapper = new InMemoryMapper(new EntityFactory(entityNamespace: 'Respect\\Data\\Stubs\\'));
$mapper->seed('post', []);

$entity = new Stubs\Post();
$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
{
Expand Down
88 changes: 87 additions & 1 deletion tests/EntityFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
use stdClass;

#[CoversClass(EntityFactory::class)]
class EntityFactoryTest extends TestCase
Expand Down Expand Up @@ -190,7 +192,7 @@ public function extractColumnsResolvesFkObjectInPlace(): void
$child = new Stubs\Category();
$child->id = 8;
$child->name = 'Child';
$child->category_id = $parent;
$child->category = $parent;

$cols = $factory->extractColumns($child);
$this->assertEquals(3, $cols['category_id']);
Expand All @@ -209,4 +211,88 @@ public function extractColumnsPassesScalarsThrough(): void
$cols = $factory->extractColumns($author);
$this->assertEquals(['id' => 5, 'name' => 'Bob', 'bio' => null], $cols);
}

#[Test]
public function extractColumnsExcludesUninitializedRelation(): void
{
$factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\');
$post = new Stubs\Post();
$post->id = 10;
$post->title = 'Test';

$cols = $factory->extractColumns($post);
$this->assertArrayNotHasKey('author', $cols);
$this->assertArrayNotHasKey('author_id', $cols);
$this->assertEquals(10, $cols['id']);
$this->assertEquals('Test', $cols['title']);
}

#[Test]
public function setSkipsIncompatibleType(): void
{
$factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\');
$entity = new Stubs\TypeCoercionEntity();
$entity->id = 1;

// Non-coercible value leaves the non-nullable property uninitialized
$factory->set($entity, 'strict', 'not-a-number');
$ref = new ReflectionProperty($entity, 'strict');
$this->assertFalse($ref->isInitialized($entity));
}

#[Test]
public function setCoercesNumericStringToInt(): void
{
$factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\');
$entity = new Stubs\TypeCoercionEntity();

$factory->set($entity, 'id', '42');
$this->assertSame(42, $entity->id);
}

#[Test]
public function setHandlesUnionType(): void
{
$factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\');
$entity = new Stubs\TypeCoercionEntity();

// Union type int|string|null — exact match takes priority over lossy coercion
$factory->set($entity, 'flexible', '99');
$this->assertSame('99', $entity->flexible);

// Int stays int (exact match on int branch, not lossy-cast to string)
$factory->set($entity, 'flexible', 42);
$this->assertSame(42, $entity->flexible);

// Null should work (nullable union)
$factory->set($entity, 'flexible', null);
$this->assertNull($entity->flexible);
}

#[Test]
public function coercionFailureFallsThrough(): void
{
$factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\');
$entity = new Stubs\TypeCoercionEntity();
$entity->id = 1;

// Setting an object on an int|string|null union fails all branches —
// property stays unchanged since the union includes null (nullable)
$entity->flexible = 'original';
$factory->set($entity, 'flexible', new stdClass());
$this->assertNull($entity->flexible);
}

#[Test]
public function unionLossyCoercionKicksInWhenExactMatchFails(): void
{
$factory = new EntityFactory(entityNamespace: __NAMESPACE__ . '\\Stubs\\');
$entity = new Stubs\TypeCoercionEntity();
$entity->id = 1;

// int|float with a numeric string — exact match fails (not int, not float),
// lossy pass coerces '42' → 42 (int branch wins)
$factory->set($entity, 'narrow_union', '42');
$this->assertSame(42, $entity->narrowUnion);
}
}
2 changes: 1 addition & 1 deletion tests/Stubs/Author.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

class Author
{
public mixed $id = null;
public int $id;

public string|null $name = null;

Expand Down
2 changes: 1 addition & 1 deletion tests/Stubs/Bug.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

class Bug
{
public mixed $id = null;
public int $id;

public string|null $title = null;

Expand Down
4 changes: 2 additions & 2 deletions tests/Stubs/Category.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@

class Category
{
public mixed $id = null;
public int $id;

public string|null $name = null;

public string|null $label = null;

public mixed $category_id = null;
public Category $category;
}
4 changes: 2 additions & 2 deletions tests/Stubs/Comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@

class Comment
{
public mixed $id = null;
public int $id;

public mixed $post;
public Post $post;

public string|null $text = null;
}
Loading
Loading