diff --git a/src/EntityFactory.php b/src/EntityFactory.php index fe7fcc6..266aeeb 100644 --- a/src/EntityFactory.php +++ b/src/EntityFactory.php @@ -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 @@ -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; @@ -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; @@ -121,6 +142,25 @@ public function hydrate(object $source, string $entityName): object return $entity; } + /** @return array */ + 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 */ private function reflectClass(string $class): ReflectionClass { @@ -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, + }; + } } diff --git a/src/Styles/NorthWind.php b/src/Styles/NorthWind.php index da9c052..e7c3982 100644 --- a/src/Styles/NorthWind.php +++ b/src/Styles/NorthWind.php @@ -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; diff --git a/src/Styles/Standard.php b/src/Styles/Standard.php index d278721..4084614 100644 --- a/src/Styles/Standard.php +++ b/src/Styles/Standard.php @@ -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 @@ -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 diff --git a/tests/AbstractMapperTest.php b/tests/AbstractMapperTest.php index 344ccad..562aca7 100644 --- a/tests/AbstractMapperTest.php +++ b/tests/AbstractMapperTest.php @@ -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 { diff --git a/tests/EntityFactoryTest.php b/tests/EntityFactoryTest.php index a5ff5fb..6613711 100644 --- a/tests/EntityFactoryTest.php +++ b/tests/EntityFactoryTest.php @@ -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 @@ -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']); @@ -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); + } } diff --git a/tests/Stubs/Author.php b/tests/Stubs/Author.php index 13cee09..cfdd8e7 100644 --- a/tests/Stubs/Author.php +++ b/tests/Stubs/Author.php @@ -6,7 +6,7 @@ class Author { - public mixed $id = null; + public int $id; public string|null $name = null; diff --git a/tests/Stubs/Bug.php b/tests/Stubs/Bug.php index 8eb8350..41afbfa 100644 --- a/tests/Stubs/Bug.php +++ b/tests/Stubs/Bug.php @@ -6,7 +6,7 @@ class Bug { - public mixed $id = null; + public int $id; public string|null $title = null; diff --git a/tests/Stubs/Category.php b/tests/Stubs/Category.php index 6a85c3a..c5a9e68 100644 --- a/tests/Stubs/Category.php +++ b/tests/Stubs/Category.php @@ -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; } diff --git a/tests/Stubs/Comment.php b/tests/Stubs/Comment.php index 5c5ed20..43cbf3c 100644 --- a/tests/Stubs/Comment.php +++ b/tests/Stubs/Comment.php @@ -6,9 +6,9 @@ class Comment { - public mixed $id = null; + public int $id; - public mixed $post; + public Post $post; public string|null $text = null; } diff --git a/tests/Stubs/Foo.php b/tests/Stubs/Foo.php index 705c0de..c8099cf 100644 --- a/tests/Stubs/Foo.php +++ b/tests/Stubs/Foo.php @@ -6,11 +6,11 @@ class Foo { - public mixed $id = null; + public int $id; public string|null $name = null; public string|null $title = null; - public mixed $text = null; + public string|null $text = null; } diff --git a/tests/Stubs/Issue.php b/tests/Stubs/Issue.php index e006db6..df4ad1f 100644 --- a/tests/Stubs/Issue.php +++ b/tests/Stubs/Issue.php @@ -6,7 +6,7 @@ class Issue { - public mixed $id = null; + public int $id; public string|null $title = null; diff --git a/tests/Stubs/Post.php b/tests/Stubs/Post.php index a272695..84649bc 100644 --- a/tests/Stubs/Post.php +++ b/tests/Stubs/Post.php @@ -6,11 +6,11 @@ class Post { - public mixed $id = null; + public int $id; public string|null $title = null; public string|null $text = null; - public mixed $author; + public Author $author; } diff --git a/tests/Stubs/TypeCoercionEntity.php b/tests/Stubs/TypeCoercionEntity.php new file mode 100644 index 0000000..ddcae50 --- /dev/null +++ b/tests/Stubs/TypeCoercionEntity.php @@ -0,0 +1,19 @@ +comments->persist($comment); $mapper->flush(); - $this->assertNotNull($comment->id); + $this->assertGreaterThan(0, $comment->id); $allComments = $mapper->comments->fetchAll(); $this->assertCount(3, $allComments); } diff --git a/tests/Styles/CakePHP/Category.php b/tests/Styles/CakePHP/Category.php index 1b84156..fb8fcb1 100644 --- a/tests/Styles/CakePHP/Category.php +++ b/tests/Styles/CakePHP/Category.php @@ -6,9 +6,9 @@ class Category { - public mixed $id = null; + public int $id; public string|null $name = null; - public mixed $category_id = null; + public Category $category; } diff --git a/tests/Styles/CakePHP/Comment.php b/tests/Styles/CakePHP/Comment.php index 503d9f1..a0f723e 100644 --- a/tests/Styles/CakePHP/Comment.php +++ b/tests/Styles/CakePHP/Comment.php @@ -6,11 +6,9 @@ class Comment { - public mixed $id = null; + public int $id; - public mixed $post_id = null; - - public mixed $post = null; + public Post $post; public string|null $text = null; } diff --git a/tests/Styles/CakePHP/Post.php b/tests/Styles/CakePHP/Post.php index a454e41..a297af1 100644 --- a/tests/Styles/CakePHP/Post.php +++ b/tests/Styles/CakePHP/Post.php @@ -6,13 +6,11 @@ class Post { - public mixed $id = null; + public int $id; public string|null $title = null; public string|null $text = null; - public mixed $author_id = null; - - public mixed $author = null; + public Author $author; } diff --git a/tests/Styles/CakePHP/PostCategory.php b/tests/Styles/CakePHP/PostCategory.php index 6046004..cd93cd5 100644 --- a/tests/Styles/CakePHP/PostCategory.php +++ b/tests/Styles/CakePHP/PostCategory.php @@ -6,11 +6,9 @@ class PostCategory { - public mixed $id = null; + public int $id; - public mixed $post_id = null; + public Post $post; - public mixed $category_id = null; - - public mixed $category = null; + public Category $category; } diff --git a/tests/Styles/NorthWind/Authors.php b/tests/Styles/NorthWind/Authors.php index 24da07d..bbea1d9 100644 --- a/tests/Styles/NorthWind/Authors.php +++ b/tests/Styles/NorthWind/Authors.php @@ -6,7 +6,7 @@ class Authors { - public mixed $AuthorID = null; + public int $AuthorID; public string|null $Name = null; } diff --git a/tests/Styles/NorthWind/Categories.php b/tests/Styles/NorthWind/Categories.php index 90a7850..68102ef 100644 --- a/tests/Styles/NorthWind/Categories.php +++ b/tests/Styles/NorthWind/Categories.php @@ -6,7 +6,7 @@ class Categories { - public mixed $CategoryID = null; + public int $CategoryID; public string|null $Name = null; diff --git a/tests/Styles/NorthWind/Comments.php b/tests/Styles/NorthWind/Comments.php index 2f93800..855ed7a 100644 --- a/tests/Styles/NorthWind/Comments.php +++ b/tests/Styles/NorthWind/Comments.php @@ -6,11 +6,9 @@ class Comments { - public mixed $CommentID = null; + public int $CommentID; - public mixed $PostID = null; - - public mixed $Post = null; + public Posts $Post; public string|null $Text = null; } diff --git a/tests/Styles/NorthWind/NorthWindIntegrationTest.php b/tests/Styles/NorthWind/NorthWindIntegrationTest.php index dbed67e..bf440e7 100644 --- a/tests/Styles/NorthWind/NorthWindIntegrationTest.php +++ b/tests/Styles/NorthWind/NorthWindIntegrationTest.php @@ -93,7 +93,7 @@ public function testPersistingNewEntityTyped(): void $mapper->Comments->persist($comment); $mapper->flush(); - $this->assertNotNull($comment->CommentID); + $this->assertGreaterThan(0, $comment->CommentID); $allComments = $mapper->Comments->fetchAll(); $this->assertCount(3, $allComments); } diff --git a/tests/Styles/NorthWind/PostCategories.php b/tests/Styles/NorthWind/PostCategories.php index 27cd528..14f6b1b 100644 --- a/tests/Styles/NorthWind/PostCategories.php +++ b/tests/Styles/NorthWind/PostCategories.php @@ -6,11 +6,9 @@ class PostCategories { - public mixed $PostCategoryID = null; + public int $PostCategoryID; - public mixed $PostID = null; + public Posts $Post; - public mixed $CategoryID = null; - - public mixed $Category = null; + public Categories $Category; } diff --git a/tests/Styles/NorthWind/Posts.php b/tests/Styles/NorthWind/Posts.php index a933e38..68a2d9f 100644 --- a/tests/Styles/NorthWind/Posts.php +++ b/tests/Styles/NorthWind/Posts.php @@ -6,13 +6,11 @@ class Posts { - public mixed $PostID = null; + public int $PostID; public string|null $Title = null; public string|null $Text = null; - public mixed $AuthorID = null; - - public mixed $Author = null; + public Authors $Author; } diff --git a/tests/Styles/Plural/Author.php b/tests/Styles/Plural/Author.php index cd62e74..d16c7db 100644 --- a/tests/Styles/Plural/Author.php +++ b/tests/Styles/Plural/Author.php @@ -6,7 +6,7 @@ class Author { - public mixed $id = null; + public int $id; public string|null $name = null; } diff --git a/tests/Styles/Plural/Category.php b/tests/Styles/Plural/Category.php index 7701797..23db86e 100644 --- a/tests/Styles/Plural/Category.php +++ b/tests/Styles/Plural/Category.php @@ -6,7 +6,9 @@ class Category { - public mixed $id = null; + public int $id; public string|null $name = null; + + public Category $category; } diff --git a/tests/Styles/Plural/Comment.php b/tests/Styles/Plural/Comment.php index a70022d..a188031 100644 --- a/tests/Styles/Plural/Comment.php +++ b/tests/Styles/Plural/Comment.php @@ -6,11 +6,9 @@ class Comment { - public mixed $id = null; + public int $id; - public mixed $post_id = null; - - public mixed $post = null; + public Post $post; public string|null $text = null; } diff --git a/tests/Styles/Plural/PluralIntegrationTest.php b/tests/Styles/Plural/PluralIntegrationTest.php index 8e7b148..f60fab4 100644 --- a/tests/Styles/Plural/PluralIntegrationTest.php +++ b/tests/Styles/Plural/PluralIntegrationTest.php @@ -93,7 +93,7 @@ public function testPersistingNewEntityTyped(): void $mapper->comments->persist($comment); $mapper->flush(); - $this->assertNotNull($comment->id); + $this->assertGreaterThan(0, $comment->id); $allComments = $mapper->comments->fetchAll(); $this->assertCount(3, $allComments); } diff --git a/tests/Styles/Plural/Post.php b/tests/Styles/Plural/Post.php index a29b5e7..58bc1c7 100644 --- a/tests/Styles/Plural/Post.php +++ b/tests/Styles/Plural/Post.php @@ -6,13 +6,11 @@ class Post { - public mixed $id = null; + public int $id; public string|null $title = null; public string|null $text = null; - public mixed $author_id = null; - - public mixed $author = null; + public Author $author; } diff --git a/tests/Styles/Plural/PostCategory.php b/tests/Styles/Plural/PostCategory.php index 2e168de..3b7175b 100644 --- a/tests/Styles/Plural/PostCategory.php +++ b/tests/Styles/Plural/PostCategory.php @@ -6,11 +6,9 @@ class PostCategory { - public mixed $id = null; + public int $id; - public mixed $post_id = null; + public Post $post; - public mixed $category_id = null; - - public mixed $category = null; + public Category $category; } diff --git a/tests/Styles/Sakila/Author.php b/tests/Styles/Sakila/Author.php index c433467..992f833 100644 --- a/tests/Styles/Sakila/Author.php +++ b/tests/Styles/Sakila/Author.php @@ -6,7 +6,7 @@ class Author { - public mixed $author_id = null; + public int $authorId; public string|null $name = null; } diff --git a/tests/Styles/Sakila/Category.php b/tests/Styles/Sakila/Category.php index 194762d..36a06b2 100644 --- a/tests/Styles/Sakila/Category.php +++ b/tests/Styles/Sakila/Category.php @@ -6,7 +6,7 @@ class Category { - public mixed $category_id = null; + public int $categoryId; public string|null $name = null; diff --git a/tests/Styles/Sakila/Comment.php b/tests/Styles/Sakila/Comment.php index 0e4afc1..549c71c 100644 --- a/tests/Styles/Sakila/Comment.php +++ b/tests/Styles/Sakila/Comment.php @@ -6,11 +6,9 @@ class Comment { - public mixed $comment_id = null; + public int $commentId; - public mixed $post_id = null; - - public mixed $post = null; + public Post $post; public string|null $text = null; } diff --git a/tests/Styles/Sakila/Post.php b/tests/Styles/Sakila/Post.php index ffaf836..a968248 100644 --- a/tests/Styles/Sakila/Post.php +++ b/tests/Styles/Sakila/Post.php @@ -6,13 +6,11 @@ class Post { - public mixed $post_id = null; + public int $postId; public string|null $title = null; public string|null $text = null; - public mixed $author_id = null; - - public mixed $author = null; + public Author $author; } diff --git a/tests/Styles/Sakila/PostCategory.php b/tests/Styles/Sakila/PostCategory.php index 86a1d11..23ff9da 100644 --- a/tests/Styles/Sakila/PostCategory.php +++ b/tests/Styles/Sakila/PostCategory.php @@ -6,11 +6,9 @@ class PostCategory { - public mixed $post_category_id = null; + public int $postCategoryId; - public mixed $post_id = null; + public Post $post; - public mixed $category_id = null; - - public mixed $category = null; + public Category $category; } diff --git a/tests/Styles/Sakila/SakilaIntegrationTest.php b/tests/Styles/Sakila/SakilaIntegrationTest.php index 57a391e..5129a4d 100644 --- a/tests/Styles/Sakila/SakilaIntegrationTest.php +++ b/tests/Styles/Sakila/SakilaIntegrationTest.php @@ -93,7 +93,7 @@ public function testPersistingNewEntityTyped(): void $mapper->comment->persist($comment); $mapper->flush(); - $this->assertNotNull($comment->comment_id); + $this->assertGreaterThan(0, $comment->commentId); $allComments = $mapper->comment->fetchAll(); $this->assertCount(3, $allComments); }