diff --git a/src/Hydrators/Base.php b/src/Hydrators/Base.php index 1bd6d4d..7be44c6 100644 --- a/src/Hydrators/Base.php +++ b/src/Hydrators/Base.php @@ -9,6 +9,8 @@ use Respect\Data\Hydrator; use SplObjectStorage; +use function is_object; + /** Base hydrator providing FK-to-entity wiring shared by all strategies */ abstract class Base implements Hydrator { @@ -25,6 +27,10 @@ protected function wireRelationships(SplObjectStorage $entities, EntityFactory $ } foreach ($entitiesClone as $sub) { + if ($sub === $instance) { + continue; + } + $tableName = (string) $entities[$sub]->name; $primaryName = $style->identifier($tableName); @@ -38,7 +44,16 @@ protected function wireRelationships(SplObjectStorage $entities, EntityFactory $ $v = $sub; } - $entityFactory->set($instance, $field, $v); + if (!is_object($v)) { + continue; + } + + $relationName = $style->relationProperty($field); + if ($relationName === null) { + continue; + } + + $entityFactory->set($instance, $relationName, $v); } } } diff --git a/src/Hydrators/Flat.php b/src/Hydrators/Flat.php index 4b9e218..e2877cc 100644 --- a/src/Hydrators/Flat.php +++ b/src/Hydrators/Flat.php @@ -56,7 +56,7 @@ public function hydrate( $value, ); - if ($primaryName != $columnName) { + if ($primaryName != $columnName && !$this->isEntityBoundary($col, $raw)) { continue; } @@ -75,6 +75,12 @@ public function hydrate( /** Resolve the column name for a given reference (numeric index, namespaced key, etc.) */ abstract protected function resolveColumnName(mixed $reference, mixed $raw): string; + /** Check if this column is the last one for the current entity (table boundary without PK) */ + protected function isEntityBoundary(mixed $col, mixed $raw): bool + { + return false; + } + /** * @param SplObjectStorage $entities * diff --git a/src/Styles/NorthWind.php b/src/Styles/NorthWind.php index 562f376..da9c052 100644 --- a/src/Styles/NorthWind.php +++ b/src/Styles/NorthWind.php @@ -44,4 +44,14 @@ public function remoteFromIdentifier(string $name): string|null { return $this->isRemoteIdentifier($name) ? $this->singularToPlural(substr($name, 0, -2)) : null; } + + public function relationProperty(string $field): string|null + { + return $this->isRemoteIdentifier($field) ? substr($field, 0, -2) : null; + } + + public function isRelationProperty(string $name): bool + { + return !$this->isRemoteIdentifier($name) && $this->isRemoteIdentifier($name . 'ID'); + } } diff --git a/src/Styles/Standard.php b/src/Styles/Standard.php index 87bb7fe..d278721 100644 --- a/src/Styles/Standard.php +++ b/src/Styles/Standard.php @@ -56,4 +56,14 @@ public function remoteFromIdentifier(string $name): string|null { return $this->isRemoteIdentifier($name) ? substr($name, 0, -3) : null; } + + public function relationProperty(string $field): string|null + { + return $this->isRemoteIdentifier($field) ? substr($field, 0, -3) : null; + } + + public function isRelationProperty(string $name): bool + { + return !$this->isRemoteIdentifier($name) && $this->isRemoteIdentifier($name . '_id'); + } } diff --git a/src/Styles/Stylable.php b/src/Styles/Stylable.php index 9ef73bf..e135a03 100644 --- a/src/Styles/Stylable.php +++ b/src/Styles/Stylable.php @@ -22,5 +22,9 @@ public function remoteFromIdentifier(string $name): string|null; public function isRemoteIdentifier(string $name): bool; + public function relationProperty(string $remoteIdentifierField): string|null; + + public function isRelationProperty(string $name): bool; + public function composed(string $left, string $right): string; } diff --git a/tests/AbstractMapperTest.php b/tests/AbstractMapperTest.php index bca34d4..827dc43 100644 --- a/tests/AbstractMapperTest.php +++ b/tests/AbstractMapperTest.php @@ -243,12 +243,48 @@ public function hydrationWiresFkWithMatchingEntity(): void $comment = $mapper->comment->post->fetch(); $this->assertIsObject($comment); - $post = $mapper->entityFactory->get($comment, 'post_id'); + // FK stays as its original scalar value, never overwritten with an object + $fk = $mapper->entityFactory->get($comment, 'post_id'); + $this->assertIsNotObject($fk); + $this->assertEquals(5, $fk); + + // Related entity goes to the derived relation property + $post = $mapper->entityFactory->get($comment, 'post'); $this->assertIsObject($post); $this->assertEquals(5, $mapper->entityFactory->get($post, 'id')); $this->assertEquals('Post', $mapper->entityFactory->get($post, 'title')); } + #[Test] + public function persistAfterHydrationPreservesFkAndIgnoresRelation(): void + { + $mapper = new InMemoryMapper(); + $mapper->seed('comment', [ + ['id' => 1, 'text' => 'Hello', 'post_id' => 5], + ]); + $mapper->seed('post', [ + ['id' => 5, 'title' => 'Post'], + ]); + + // Fetch with relationship — hydrates $comment->post + $comment = $mapper->comment->post->fetch(); + $this->assertIsObject($mapper->entityFactory->get($comment, 'post')); + + // Modify and persist + $mapper->entityFactory->set($comment, 'text', 'Updated'); + $mapper->comment->persist($comment); + $mapper->flush(); + + // Re-fetch without relationship + $updated = $mapper->comment[1]->fetch(); + $this->assertEquals('Updated', $mapper->entityFactory->get($updated, 'text')); + + // FK stayed as scalar + $fk = $mapper->entityFactory->get($updated, 'post_id'); + $this->assertIsNotObject($fk); + $this->assertEquals(5, $fk); + } + #[Test] public function hydrationLeavesFkUnchangedWhenNoMatch(): void { @@ -278,7 +314,9 @@ public function hydrationMatchesIntFkToStringPk(): void $comment = $mapper->comment->post->fetch(); $this->assertIsObject($comment); - $post = $mapper->entityFactory->get($comment, 'post_id'); + // FK stays as int, relation goes to derived property + $this->assertEquals(5, $mapper->entityFactory->get($comment, 'post_id')); + $post = $mapper->entityFactory->get($comment, 'post'); $this->assertIsObject($post); $this->assertEquals('5', $mapper->entityFactory->get($post, 'id')); } diff --git a/tests/Styles/AbstractStyleTest.php b/tests/Styles/AbstractStyleTest.php index e962f96..a589a97 100644 --- a/tests/Styles/AbstractStyleTest.php +++ b/tests/Styles/AbstractStyleTest.php @@ -61,6 +61,16 @@ public function remoteFromIdentifier(string $name): string|null { return null; } + + public function relationProperty(string $field): string|null + { + return null; + } + + public function isRelationProperty(string $name): bool + { + return false; + } }; } diff --git a/tests/Styles/CakePHP/CakePHPIntegrationTest.php b/tests/Styles/CakePHP/CakePHPIntegrationTest.php index f0591bd..22de5b5 100644 --- a/tests/Styles/CakePHP/CakePHPIntegrationTest.php +++ b/tests/Styles/CakePHP/CakePHPIntegrationTest.php @@ -59,7 +59,7 @@ public function testFetchingAllEntityTyped(): void $categories = $mapper->post_categories->categories->fetch(); $this->assertInstanceOf(PostCategory::class, $categories); - $this->assertInstanceOf(Category::class, $categories->category_id); + $this->assertInstanceOf(Category::class, $categories->category); } public function testFetchingAllEntityTypedNested(): void @@ -67,8 +67,8 @@ public function testFetchingAllEntityTypedNested(): void $mapper = $this->mapper; $comment = $mapper->comments->posts->authors->fetchAll(); $this->assertInstanceOf(Comment::class, $comment[0]); - $this->assertInstanceOf(Post::class, $comment[0]->post_id); - $this->assertInstanceOf(Author::class, $comment[0]->post_id->author_id); + $this->assertInstanceOf(Post::class, $comment[0]->post); + $this->assertInstanceOf(Author::class, $comment[0]->post->author); } public function testPersistingEntityTyped(): void diff --git a/tests/Styles/CakePHP/Comment.php b/tests/Styles/CakePHP/Comment.php index 2833d77..503d9f1 100644 --- a/tests/Styles/CakePHP/Comment.php +++ b/tests/Styles/CakePHP/Comment.php @@ -10,5 +10,7 @@ class Comment public mixed $post_id = null; + public mixed $post = null; + public string|null $text = null; } diff --git a/tests/Styles/CakePHP/Post.php b/tests/Styles/CakePHP/Post.php index c5a76cb..a454e41 100644 --- a/tests/Styles/CakePHP/Post.php +++ b/tests/Styles/CakePHP/Post.php @@ -13,4 +13,6 @@ class Post public string|null $text = null; public mixed $author_id = null; + + public mixed $author = null; } diff --git a/tests/Styles/CakePHP/PostCategory.php b/tests/Styles/CakePHP/PostCategory.php index 825dc62..6046004 100644 --- a/tests/Styles/CakePHP/PostCategory.php +++ b/tests/Styles/CakePHP/PostCategory.php @@ -11,4 +11,6 @@ class PostCategory public mixed $post_id = null; public mixed $category_id = null; + + public mixed $category = null; } diff --git a/tests/Styles/NorthWind/Comments.php b/tests/Styles/NorthWind/Comments.php index 0d3fa47..2f93800 100644 --- a/tests/Styles/NorthWind/Comments.php +++ b/tests/Styles/NorthWind/Comments.php @@ -10,5 +10,7 @@ class Comments public mixed $PostID = null; + public mixed $Post = null; + public string|null $Text = null; } diff --git a/tests/Styles/NorthWind/NorthWindIntegrationTest.php b/tests/Styles/NorthWind/NorthWindIntegrationTest.php index 19ff9d8..dbed67e 100644 --- a/tests/Styles/NorthWind/NorthWindIntegrationTest.php +++ b/tests/Styles/NorthWind/NorthWindIntegrationTest.php @@ -59,7 +59,7 @@ public function testFetchingAllEntityTyped(): void $categories = $mapper->PostCategories->Categories->fetch(); $this->assertInstanceOf(PostCategories::class, $categories); - $this->assertInstanceOf(Categories::class, $categories->CategoryID); + $this->assertInstanceOf(Categories::class, $categories->Category); } public function testFetchingAllEntityTypedNested(): void @@ -67,8 +67,8 @@ public function testFetchingAllEntityTypedNested(): void $mapper = $this->mapper; $comment = $mapper->Comments->Posts->Authors->fetchAll(); $this->assertInstanceOf(Comments::class, $comment[0]); - $this->assertInstanceOf(Posts::class, $comment[0]->PostID); - $this->assertInstanceOf(Authors::class, $comment[0]->PostID->AuthorID); + $this->assertInstanceOf(Posts::class, $comment[0]->Post); + $this->assertInstanceOf(Authors::class, $comment[0]->Post->Author); } public function testPersistingEntityTyped(): void diff --git a/tests/Styles/NorthWind/PostCategories.php b/tests/Styles/NorthWind/PostCategories.php index 87421df..27cd528 100644 --- a/tests/Styles/NorthWind/PostCategories.php +++ b/tests/Styles/NorthWind/PostCategories.php @@ -11,4 +11,6 @@ class PostCategories public mixed $PostID = null; public mixed $CategoryID = null; + + public mixed $Category = null; } diff --git a/tests/Styles/NorthWind/Posts.php b/tests/Styles/NorthWind/Posts.php index 2153ca6..a933e38 100644 --- a/tests/Styles/NorthWind/Posts.php +++ b/tests/Styles/NorthWind/Posts.php @@ -13,4 +13,6 @@ class Posts public string|null $Text = null; public mixed $AuthorID = null; + + public mixed $Author = null; } diff --git a/tests/Styles/NorthWindTest.php b/tests/Styles/NorthWindTest.php index cbc448c..de3d2ec 100644 --- a/tests/Styles/NorthWindTest.php +++ b/tests/Styles/NorthWindTest.php @@ -93,4 +93,35 @@ public function testKeys(string $table, string $foreign): void $this->assertEquals($foreign, $this->style->identifier($table)); $this->assertEquals($foreign, $this->style->remoteIdentifier($table)); } + + /** @return array> */ + public static function relationProvider(): array + { + return [ + ['Post', 'PostID'], + ['Author', 'AuthorID'], + ['Tag', 'TagID'], + ['User', 'UserID'], + ]; + } + + #[DataProvider('relationProvider')] + public function testRelationProperty(string $relation, string $foreign): void + { + $this->assertEquals($relation, $this->style->relationProperty($foreign)); + $this->assertNull($this->style->relationProperty($relation)); + } + + #[DataProvider('relationProvider')] + public function testIsRelationProperty(string $relation, string $foreign): void + { + $this->assertTrue($this->style->isRelationProperty($relation)); + $this->assertFalse($this->style->isRelationProperty($foreign)); + } + + #[DataProvider('relationProvider')] + public function testForeignKeyIsNotRelationProperty(string $relation, string $foreign): void + { + $this->assertFalse($this->style->isRelationProperty($foreign)); + } } diff --git a/tests/Styles/Plural/Comment.php b/tests/Styles/Plural/Comment.php index 1f70592..a70022d 100644 --- a/tests/Styles/Plural/Comment.php +++ b/tests/Styles/Plural/Comment.php @@ -10,5 +10,7 @@ class Comment public mixed $post_id = null; + public mixed $post = null; + public string|null $text = null; } diff --git a/tests/Styles/Plural/PluralIntegrationTest.php b/tests/Styles/Plural/PluralIntegrationTest.php index 8e2bf39..8e7b148 100644 --- a/tests/Styles/Plural/PluralIntegrationTest.php +++ b/tests/Styles/Plural/PluralIntegrationTest.php @@ -59,7 +59,7 @@ public function testFetchingAllEntityTyped(): void $categories = $mapper->posts_categories->categories->fetch(); $this->assertInstanceOf(PostCategory::class, $categories); - $this->assertInstanceOf(Category::class, $categories->category_id); + $this->assertInstanceOf(Category::class, $categories->category); } public function testFetchingAllEntityTypedNested(): void @@ -67,8 +67,8 @@ public function testFetchingAllEntityTypedNested(): void $mapper = $this->mapper; $comment = $mapper->comments->posts->authors->fetchAll(); $this->assertInstanceOf(Comment::class, $comment[0]); - $this->assertInstanceOf(Post::class, $comment[0]->post_id); - $this->assertInstanceOf(Author::class, $comment[0]->post_id->author_id); + $this->assertInstanceOf(Post::class, $comment[0]->post); + $this->assertInstanceOf(Author::class, $comment[0]->post->author); } public function testPersistingEntityTyped(): void diff --git a/tests/Styles/Plural/Post.php b/tests/Styles/Plural/Post.php index d8789ab..a29b5e7 100644 --- a/tests/Styles/Plural/Post.php +++ b/tests/Styles/Plural/Post.php @@ -13,4 +13,6 @@ class Post public string|null $text = null; public mixed $author_id = null; + + public mixed $author = null; } diff --git a/tests/Styles/Plural/PostCategory.php b/tests/Styles/Plural/PostCategory.php index ea5b4fa..2e168de 100644 --- a/tests/Styles/Plural/PostCategory.php +++ b/tests/Styles/Plural/PostCategory.php @@ -11,4 +11,6 @@ class PostCategory public mixed $post_id = null; public mixed $category_id = null; + + public mixed $category = null; } diff --git a/tests/Styles/Sakila/Comment.php b/tests/Styles/Sakila/Comment.php index 337e666..0e4afc1 100644 --- a/tests/Styles/Sakila/Comment.php +++ b/tests/Styles/Sakila/Comment.php @@ -10,5 +10,7 @@ class Comment public mixed $post_id = null; + public mixed $post = null; + public string|null $text = null; } diff --git a/tests/Styles/Sakila/Post.php b/tests/Styles/Sakila/Post.php index 65e1473..ffaf836 100644 --- a/tests/Styles/Sakila/Post.php +++ b/tests/Styles/Sakila/Post.php @@ -13,4 +13,6 @@ class Post public string|null $text = null; public mixed $author_id = null; + + public mixed $author = null; } diff --git a/tests/Styles/Sakila/PostCategory.php b/tests/Styles/Sakila/PostCategory.php index 70b6e80..86a1d11 100644 --- a/tests/Styles/Sakila/PostCategory.php +++ b/tests/Styles/Sakila/PostCategory.php @@ -11,4 +11,6 @@ class PostCategory public mixed $post_id = null; public mixed $category_id = null; + + public mixed $category = null; } diff --git a/tests/Styles/Sakila/SakilaIntegrationTest.php b/tests/Styles/Sakila/SakilaIntegrationTest.php index 04645cd..57a391e 100644 --- a/tests/Styles/Sakila/SakilaIntegrationTest.php +++ b/tests/Styles/Sakila/SakilaIntegrationTest.php @@ -59,7 +59,7 @@ public function testFetchingAllEntityTyped(): void $categories = $mapper->post_category->category->fetch(); $this->assertInstanceOf(PostCategory::class, $categories); - $this->assertInstanceOf(Category::class, $categories->category_id); + $this->assertInstanceOf(Category::class, $categories->category); } public function testFetchingAllEntityTypedNested(): void @@ -67,8 +67,8 @@ public function testFetchingAllEntityTypedNested(): void $mapper = $this->mapper; $comment = $mapper->comment->post->author->fetchAll(); $this->assertInstanceOf(Comment::class, $comment[0]); - $this->assertInstanceOf(Post::class, $comment[0]->post_id); - $this->assertInstanceOf(Author::class, $comment[0]->post_id->author_id); + $this->assertInstanceOf(Post::class, $comment[0]->post); + $this->assertInstanceOf(Author::class, $comment[0]->post->author); } public function testPersistingEntityTyped(): void diff --git a/tests/Styles/StandardTest.php b/tests/Styles/StandardTest.php index 2ac49b2..369dc26 100644 --- a/tests/Styles/StandardTest.php +++ b/tests/Styles/StandardTest.php @@ -93,4 +93,24 @@ public function testForeign(string $table, string $foreign): void $this->assertEquals($table, $this->style->remoteFromIdentifier($foreign)); $this->assertEquals($foreign, $this->style->remoteIdentifier($table)); } + + #[DataProvider('foreignProvider')] + public function testRelationProperty(string $table, string $foreign): void + { + $this->assertEquals($table, $this->style->relationProperty($foreign)); + $this->assertNull($this->style->relationProperty($table)); + } + + #[DataProvider('foreignProvider')] + public function testIsRelationProperty(string $table, string $foreign): void + { + $this->assertTrue($this->style->isRelationProperty($table)); + $this->assertFalse($this->style->isRelationProperty($foreign)); + } + + #[DataProvider('foreignProvider')] + public function testForeignKeyIsNotRelationProperty(string $table, string $foreign): void + { + $this->assertFalse($this->style->isRelationProperty($foreign)); + } }