Skip to content

Commit 25e47ed

Browse files
committed
Fix hydration for composite-PK joins and relation property persistence
FlatNum now detects table boundaries via column metadata, fixing hydration of many-to-many join tables without a standalone id column. Mapper's persist/flush cycle respects the new relation properties: extractColumns() skips relation properties (via isRelationProperty), and getRelatedEntity() resolves entities from either the relation property or the FK field for backwards compatibility.
1 parent 98f1f09 commit 25e47ed

5 files changed

Lines changed: 120 additions & 42 deletions

File tree

src/Hydrators/FlatNum.php

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,39 @@
77
use PDOStatement;
88
use Respect\Data\Hydrators\Flat;
99

10+
use function is_int;
11+
1012
/** Resolves column names from PDOStatement column metadata for numeric-indexed rows */
1113
final class FlatNum extends Flat
1214
{
15+
/** @var array<int, array<string, mixed>> */
16+
private array $metaCache = [];
17+
1318
public function __construct(
1419
private readonly PDOStatement $statement,
1520
) {
1621
}
1722

1823
protected function resolveColumnName(mixed $reference, mixed $raw): string
1924
{
20-
/** @phpstan-ignore offsetAccess.nonOffsetAccessible */
21-
return $this->statement->getColumnMeta($reference)['name'];
25+
return $this->columnMeta($reference)['name'];
26+
}
27+
28+
protected function isEntityBoundary(mixed $col, mixed $raw): bool
29+
{
30+
if (!is_int($col) || $col <= 0) {
31+
return false;
32+
}
33+
34+
$currentTable = $this->columnMeta($col)['table'] ?? '';
35+
$previousTable = $this->columnMeta($col - 1)['table'] ?? '';
36+
37+
return $currentTable !== '' && $previousTable !== '' && $currentTable !== $previousTable;
38+
}
39+
40+
/** @return array<string, mixed> */
41+
private function columnMeta(int $col): array
42+
{
43+
return $this->metaCache[$col] ??= $this->statement->getColumnMeta($col) ?: [];
2244
}
2345
}

src/Mapper.php

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use SplObjectStorage;
1919
use Throwable;
2020

21+
use function array_key_exists;
2122
use function array_keys;
2223
use function array_merge;
2324
use function array_push;
@@ -71,12 +72,20 @@ public function persist(object $object, Collection $onCollection): bool
7172

7273
if ($next) {
7374
$remote = $this->style->remoteIdentifier($next->name);
74-
$this->persist($this->entityFactory->get($object, $remote), $next);
75+
$related = $this->getRelatedEntity($object, $remote);
76+
if ($related !== null) {
77+
$this->persist($related, $next);
78+
}
7579
}
7680

7781
foreach ($onCollection->children as $child) {
7882
$remote = $this->style->remoteIdentifier($child->name);
79-
$this->persist($this->entityFactory->get($object, $remote), $child);
83+
$related = $this->getRelatedEntity($object, $remote);
84+
if ($related === null) {
85+
continue;
86+
}
87+
88+
$this->persist($related, $child);
8089
}
8190

8291
return parent::persist($object, $onCollection);
@@ -106,6 +115,22 @@ protected function defaultHydrator(Collection $collection): Hydrator
106115
return new FlatNum($this->lastStatement);
107116
}
108117

118+
/** Resolve related entity from relation property or FK field */
119+
private function getRelatedEntity(object $object, string $remoteField): object|null
120+
{
121+
$relation = $this->style->relationProperty($remoteField);
122+
if ($relation !== null) {
123+
$value = $this->entityFactory->get($object, $relation);
124+
if (is_object($value)) {
125+
return $value;
126+
}
127+
}
128+
129+
$value = $this->entityFactory->get($object, $remoteField);
130+
131+
return is_object($value) ? $value : null;
132+
}
133+
109134
private function flushSingle(object $entity): void
110135
{
111136
$coll = $this->tracked[$entity];
@@ -253,12 +278,27 @@ private function extractColumns(object $entity, Collection $collection): array
253278
$primaryName = $this->style->identifier($collection->name);
254279
$cols = $this->entityFactory->extractProperties($entity);
255280

256-
foreach ($cols as &$c) {
257-
if (!is_object($c)) {
281+
foreach ($cols as $key => $c) {
282+
if (is_object($c) && $this->style->isRelationProperty($key)) {
283+
unset($cols[$key]);
284+
285+
continue;
286+
}
287+
288+
if (is_object($c)) {
289+
$cols[$key] = $this->entityFactory->get($c, $primaryName);
290+
291+
continue;
292+
}
293+
294+
if (
295+
!$this->style->isRelationProperty($key)
296+
|| !array_key_exists($this->style->remoteIdentifier($key), $cols)
297+
) {
258298
continue;
259299
}
260300

261-
$c = $this->entityFactory->get($c, $primaryName);
301+
unset($cols[$key]);
262302
}
263303

264304
return $this->filterColumns($cols, $collection);

tests/MapperTest.php

Lines changed: 41 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,8 @@ public function testMultipleConditionsAcrossCollectionsProduceAndClause(): void
295295
$mapper = $this->mapper;
296296
$comment = $mapper->comment[7]->post[5]->fetch();
297297
$this->assertEquals(7, $comment->id);
298-
$this->assertEquals(5, $comment->post_id->id);
299-
$this->assertEquals('Post Title', $comment->post_id->title);
298+
$this->assertEquals(5, $comment->post->id);
299+
$this->assertEquals('Post Title', $comment->post->title);
300300
}
301301

302302
public function testNestedCollectionsShouldHydrateResults(): void
@@ -305,11 +305,11 @@ public function testNestedCollectionsShouldHydrateResults(): void
305305
$comment = $mapper->comment->post[5]->fetch();
306306
$this->assertEquals(7, $comment->id);
307307
$this->assertEquals('Comment Text', $comment->text);
308-
$this->assertEquals(4, count(get_object_vars($comment)));
309-
$this->assertEquals(5, $comment->post_id->id);
310-
$this->assertEquals('Post Title', $comment->post_id->title);
311-
$this->assertEquals('Post Text', $comment->post_id->text);
312-
$this->assertEquals(4, count(get_object_vars($comment->post_id)));
308+
$this->assertEquals(5, count(get_object_vars($comment)));
309+
$this->assertEquals(5, $comment->post->id);
310+
$this->assertEquals('Post Title', $comment->post->title);
311+
$this->assertEquals('Post Text', $comment->post->text);
312+
$this->assertEquals(4, count(get_object_vars($comment->post)));
313313
}
314314

315315
public function testOneToN(): void
@@ -320,14 +320,14 @@ public function testOneToN(): void
320320
$this->assertEquals(1, count($comments));
321321
$this->assertEquals(7, $comment->id);
322322
$this->assertEquals('Comment Text', $comment->text);
323-
$this->assertEquals(4, count(get_object_vars($comment)));
324-
$this->assertEquals(5, $comment->post_id->id);
325-
$this->assertEquals('Post Title', $comment->post_id->title);
326-
$this->assertEquals('Post Text', $comment->post_id->text);
327-
$this->assertEquals(4, count(get_object_vars($comment->post_id)));
328-
$this->assertEquals(1, $comment->post_id->author_id->id);
329-
$this->assertEquals('Author 1', $comment->post_id->author_id->name);
330-
$this->assertEquals(2, count(get_object_vars($comment->post_id->author_id)));
323+
$this->assertEquals(5, count(get_object_vars($comment)));
324+
$this->assertEquals(5, $comment->post->id);
325+
$this->assertEquals('Post Title', $comment->post->title);
326+
$this->assertEquals('Post Text', $comment->post->text);
327+
$this->assertEquals(5, count(get_object_vars($comment->post)));
328+
$this->assertEquals(1, $comment->post->author->id);
329+
$this->assertEquals('Author 1', $comment->post->author->name);
330+
$this->assertEquals(2, count(get_object_vars($comment->post->author)));
331331
}
332332

333333
public function testNtoN(): void
@@ -338,11 +338,11 @@ public function testNtoN(): void
338338
$this->assertEquals(1, count($comments));
339339
$this->assertEquals(7, $comment->id);
340340
$this->assertEquals('Comment Text', $comment->text);
341-
$this->assertEquals(4, count(get_object_vars($comment)));
342-
$this->assertEquals(5, $comment->post_id->id);
343-
$this->assertEquals('Post Title', $comment->post_id->title);
344-
$this->assertEquals('Post Text', $comment->post_id->text);
345-
$this->assertEquals(4, count(get_object_vars($comment->post_id)));
341+
$this->assertEquals(5, count(get_object_vars($comment)));
342+
$this->assertEquals(5, $comment->post->id);
343+
$this->assertEquals('Post Title', $comment->post->title);
344+
$this->assertEquals('Post Text', $comment->post->text);
345+
$this->assertEquals(4, count(get_object_vars($comment->post)));
346346
}
347347

348348
public function testManyToManyReverse(): void
@@ -557,7 +557,7 @@ public function testFetchingAllEntityTypedNested(): void
557557
$mapper = new Mapper($this->conn, new EntityFactory(entityNamespace: '\Respect\Relational\\'));
558558
$comment = $mapper->comment->post->fetchAll();
559559
$this->assertInstanceOf('\Respect\Relational\Comment', $comment[0]);
560-
$this->assertInstanceOf('\Respect\Relational\Post', $comment[0]->post_id);
560+
$this->assertInstanceOf('\Respect\Relational\Post', $comment[0]->post);
561561
}
562562

563563
public function testPersistingEntityTyped(): void
@@ -699,10 +699,10 @@ public function testFilteredCollectionsShouldHydrateNonFilteredPartsAsUsual(): v
699699
$mapper->postsFromAuthorsWithComments = Filtered::comment()->post()->author();
700700
$post = $mapper->postsFromAuthorsWithComments->fetch();
701701
$this->assertEquals(
702-
(object) (['author_id' => $post->author_id] + (array) $this->posts[0]),
702+
(object) (['author' => $post->author] + (array) $this->posts[0]),
703703
$post,
704704
);
705-
$this->assertEquals($this->authors[0], $post->author_id);
705+
$this->assertEquals($this->authors[0], $post->author);
706706
}
707707

708708
public function testFilteredCollectionsShouldPersistHydratedNonFilteredPartsAsUsual(): void
@@ -711,12 +711,12 @@ public function testFilteredCollectionsShouldPersistHydratedNonFilteredPartsAsUs
711711
$mapper->postsFromAuthorsWithComments = Filtered::comment()->post()->author();
712712
$post = $mapper->postsFromAuthorsWithComments->fetch();
713713
$this->assertEquals(
714-
(object) (['author_id' => $post->author_id] + (array) $this->posts[0]),
714+
(object) (['author' => $post->author] + (array) $this->posts[0]),
715715
$post,
716716
);
717-
$this->assertEquals($this->authors[0], $post->author_id);
717+
$this->assertEquals($this->authors[0], $post->author);
718718
$post->title = 'Title Changed';
719-
$post->author_id->name = 'John';
719+
$post->author->name = 'John';
720720
$mapper->postsFromAuthorsWithComments->persist($post);
721721
$mapper->flush();
722722
$result = $this->query('select title from post where id=5')
@@ -904,14 +904,15 @@ public function testFetchingRegisteredFilteredCollectionsAlongsideNormal(): void
904904
$post = $mapper->post->fetchAll();
905905
$post = $post[0];
906906
$this->assertEquals(
907-
(object) ['id' => '5', 'author_id' => $post->author_id],
907+
(object) ['id' => '5', 'author_id' => 1, 'author' => $post->author],
908908
$post,
909909
);
910910
$this->assertEquals(
911911
(object) ['name' => 'Author 1', 'id' => 1],
912-
$post->author_id,
912+
$post->author,
913913
);
914914
$post->title = 'Title Changed';
915+
915916
$mapper->postsFromAuthorsWithComments->persist($post);
916917
$mapper->flush();
917918
$result = $this->query('select title from post where id=5')
@@ -926,12 +927,13 @@ public function testCompositesBringResultsFromTwoTables(): void
926927
$post = $mapper->postComment->fetch();
927928
$this->assertEquals(
928929
(object) ['name' => 'Author 1', 'id' => 1],
929-
$post->author_id,
930+
$post->author,
930931
);
931932
$this->assertEquals(
932933
(object) [
933934
'id' => '5',
934-
'author_id' => $post->author_id,
935+
'author_id' => 1,
936+
'author' => $post->author,
935937
'text' => 'Comment Text',
936938
'title' => 'Post Title',
937939
'comment_id' => 7,
@@ -947,12 +949,13 @@ public function testCompositesPersistsResultsOnTwoTables(): void
947949
$post = $mapper->postComment->fetch();
948950
$this->assertEquals(
949951
(object) ['name' => 'Author 1', 'id' => 1],
950-
$post->author_id,
952+
$post->author,
951953
);
952954
$this->assertEquals(
953955
(object) [
954956
'id' => '5',
955-
'author_id' => $post->author_id,
957+
'author_id' => 1,
958+
'author' => $post->author,
956959
'text' => 'Comment Text',
957960
'title' => 'Post Title',
958961
'comment_id' => 7,
@@ -961,6 +964,7 @@ public function testCompositesPersistsResultsOnTwoTables(): void
961964
);
962965
$post->title = 'Title Changed';
963966
$post->text = 'Comment Changed';
967+
964968
$mapper->postsFromAuthorsWithComments->persist($post);
965969
$mapper->flush();
966970
$result = $this->query('select title from post where id=5')
@@ -997,6 +1001,7 @@ public function testCompositesPersistDoesNotDropColumnsWithMatchingValues(): voi
9971001
$post = $mapper->postComment->fetch();
9981002
$post->title = 'Same Value';
9991003
$post->text = 'Same Value';
1004+
10001005
$mapper->postComment->persist($post);
10011006
$mapper->flush();
10021007
$result = $this->query('select title from post where id=5')
@@ -1015,12 +1020,13 @@ public function testCompositesAll(): void
10151020
$post = $post[0];
10161021
$this->assertEquals(
10171022
(object) ['name' => 'Author 1', 'id' => 1],
1018-
$post->author_id,
1023+
$post->author,
10191024
);
10201025
$this->assertEquals(
10211026
(object) [
10221027
'id' => '5',
1023-
'author_id' => $post->author_id,
1028+
'author_id' => 1,
1029+
'author' => $post->author,
10241030
'text' => 'Comment Text',
10251031
'title' => 'Post Title',
10261032
'comment_id' => 7,
@@ -1029,6 +1035,7 @@ public function testCompositesAll(): void
10291035
);
10301036
$post->title = 'Title Changed';
10311037
$post->text = 'Comment Changed';
1038+
10321039
$mapper->postsFromAuthorsWithComments->persist($post);
10331040
$mapper->flush();
10341041
$result = $this->query('select title from post where id=5')

tests/Stubs/Comment.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ class Comment
1212

1313
public mixed $post_id = null;
1414

15+
public mixed $post = null;
16+
1517
public string|null $text = null;
1618

1719
private string|null $datetime = null;

tests/Stubs/OtherEntity/Post.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ class Post
1010

1111
private mixed $author_id = null;
1212

13+
private mixed $author = null;
14+
1315
private mixed $title = null;
1416

1517
private mixed $text = null;
@@ -29,11 +31,16 @@ public function getId(): mixed
2931
return $this->id;
3032
}
3133

32-
public function getAuthor(): mixed
34+
public function getAuthorId(): mixed
3335
{
3436
return $this->author_id;
3537
}
3638

39+
public function getAuthor(): mixed
40+
{
41+
return $this->author;
42+
}
43+
3744
public function getText(): mixed
3845
{
3946
return $this->text;

0 commit comments

Comments
 (0)