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
5 changes: 3 additions & 2 deletions src/Db.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,10 @@ private function executeStatement(
int|string|array|callable|object $object = stdClass::class,
array|null $extra = null,
): PDOStatement {
$statement = $this->prepare((string) $this->currentSql, $object, $extra);
$statement->execute($this->currentSql->params);
$sql = $this->currentSql;
$this->currentSql = clone $this->protoSql;
$statement = $this->prepare((string) $sql, $object, $extra);
$statement->execute($sql->params);

return $statement;
}
Expand Down
98 changes: 81 additions & 17 deletions src/Mapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ public function flush(): void
}
} catch (Throwable $e) {
$conn->rollback();
$this->reset();

throw $e;
}
Expand Down Expand Up @@ -157,6 +158,14 @@ private function flushSingle(object $entity): void
}

/**
* Extract composed columns from parent, UPDATE child tables using FK relationship.
*
* For existing entities (UPDATE path), the parent PK is known and we can
* update the child table directly: UPDATE comment SET text=? WHERE post_id=?
*
* For new entities (INSERT path), child inserts happen after the parent
* via insertCompositionChildren().
*
* @param array<string, mixed> $cols
*
* @return array<string, mixed>
Expand All @@ -167,30 +176,75 @@ private function extractAndOperateCompositions(Collection $collection, array $co
return $cols;
}

$parentPk = $this->style->identifier($collection->name);
$parentPkValue = $cols[$parentPk] ?? null;
$fkToParent = $this->style->remoteIdentifier($collection->name);

foreach ($collection->compositions as $comp => $spec) {
$compCols = [];
foreach ($spec as $key) {
if (!isset($cols[$key])) {
$dbKey = $this->style->realProperty($key);
if (!isset($cols[$dbKey])) {
continue;
}

$compCols[$key] = $cols[$key];
unset($cols[$key]);
$compCols[$dbKey] = $cols[$dbKey];
unset($cols[$dbKey]);
}

if (isset($cols[$comp . '_id'])) {
$compCols['id'] = $cols[$comp . '_id'];
unset($cols[$comp . '_id']);
$this->rawUpdate($compCols, $this->__get($comp));
} else {
$compCols['id'] = null;
$this->rawInsert($compCols, $this->__get($comp));
if ($parentPkValue === null || empty($compCols)) {
continue;
}

$this->db
->update($comp)
->set($compCols)
->where([[$fkToParent, '=', $parentPkValue]])
->exec();
}

return $cols;
}

private function insertCompositionChildren(Collection $collection, object|null $entity): void
{
if (!$collection instanceof Composite || $entity === null) {
return;
}

$parentPk = $this->style->identifier($collection->name);
$parentPkValue = $this->entityFactory->get($entity, $parentPk);

if ($parentPkValue === null) {
return;
}

$fkToParent = $this->style->remoteIdentifier($collection->name);
$entityCols = $this->entityFactory->extractColumns($entity);

foreach ($collection->compositions as $comp => $spec) {
$compCols = [];
foreach ($spec as $key) {
$dbKey = $this->style->realProperty($key);
if (!isset($entityCols[$key])) {
continue;
}

$compCols[$dbKey] = $entityCols[$key];
}

if (empty($compCols)) {
continue;
}

$compCols[$fkToParent] = $parentPkValue;
$this->db
->insertInto($comp, array_keys($compCols))
->values(array_values($compCols))
->exec();
}
}

/**
* @param array<string, mixed> $columns
*
Expand Down Expand Up @@ -249,6 +303,8 @@ private function rawInsert(
$this->checkNewIdentity($entity, $collection);
}

$this->insertCompositionChildren($collection, $entity);

return $result;
}

Expand All @@ -264,7 +320,7 @@ private function checkNewIdentity(object $entity, Collection $collection): bool
return false;
}

$this->entityFactory->set($entity, $this->style->identifier($collection->name), $identity);
$this->entityFactory->set($entity, $this->style->identifier($collection->name), (int) $identity);

return true;
}
Expand All @@ -286,10 +342,17 @@ private function generateQuery(Collection $collection): Sql
/** @return array<string, mixed> */
private function extractColumns(object $entity, Collection $collection): array
{
return $this->filterColumns(
$cols = $this->filterColumns(
$this->entityFactory->extractColumns($entity),
$collection,
);

$dbCols = [];
foreach ($cols as $key => $value) {
$dbCols[$this->style->realProperty($key)] = $value;
}

return $dbCols;
}

/** @param array<string, Collection> $collections */
Expand All @@ -302,10 +365,6 @@ private function buildSelectStatement(Sql $sql, array $collections): Sql
foreach ($columns as $col) {
$selectTable[] = $tableSpecifier . '_comp' . $composition . '.' . $col;
}

$selectTable[] = $tableSpecifier . '_comp' . $composition . '.' .
$this->style->identifier($composition) .
' as ' . $composition . '_id';
}
}

Expand Down Expand Up @@ -393,8 +452,13 @@ private function parseCompositions(Sql $sql, Collection $collection, string $ent
}

foreach (array_keys($collection->compositions) as $comp) {
$alias = $entity . '_comp' . $comp;
$sql->innerJoin($comp);
$sql->as($entity . '_comp' . $comp);
$sql->as($alias);
$sql->on([
$alias . '.' . $this->style->remoteIdentifier($entity)
=> $entity . '.' . $this->style->identifier($entity),
]);
}
}

Expand Down
140 changes: 135 additions & 5 deletions tests/MapperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use PDOStatement;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
use Respect\Data\Collections\Composite;
use Respect\Data\Collections\Filtered;
use Respect\Data\Collections\Typed;
Expand Down Expand Up @@ -176,6 +177,31 @@ public function testRollingBackTransaction(): void
}
}

public function testFailedFlushResetsPending(): void
{
// Force a flush failure via a UNIQUE constraint violation
$this->conn->exec('CREATE UNIQUE INDEX author_name_unique ON author(name)');

$dupe = new Author();
$dupe->name = 'Author 1'; // already seeded
$this->mapper->author->persist($dupe);

try {
$this->mapper->flush();
$this->fail('Expected flush to throw on UNIQUE violation');
} catch (Throwable) {
// expected
}

// Second flush with a valid entity should succeed without replaying the failed one
$author = new Author();
$author->name = 'Fresh Author';
$this->mapper->author->persist($author);
$this->mapper->flush();

$this->assertGreaterThan(0, $author->id);
}

public function testIgnoringLastInsertIdErrors(): void
{
$conn = $this->createStub(PDO::class);
Expand All @@ -197,7 +223,7 @@ public function testIgnoringLastInsertIdErrors(): void
$obj->name = 'bar';
$mapper->author->persist($obj);
$mapper->flush();
$this->assertNull($obj->id);
$this->assertFalse((new ReflectionProperty($obj, 'id'))->isInitialized($obj));
$this->assertEquals('bar', $obj->name);
}

Expand Down Expand Up @@ -377,10 +403,12 @@ public function testNestedPersistCollectionWithChildrenShortcut(): void
public function testSubCategory(): void
{
$mapper = $this->mapper;
$parent = $mapper->category[2]->fetch();

$entity = new Category();
$entity->id = 8;
$entity->name = 'inserted';
$entity->category_id = 2;
$entity->category = $parent;
$mapper->category->persist($entity);
$mapper->flush();
$result = $this->query('select * from category where id=8')
Expand All @@ -395,10 +423,12 @@ public function testSubCategory(): void
public function testSubCategoryCondition(): void
{
$mapper = $this->mapper;
$parent = $mapper->category[2]->fetch();

$entity = new Category();
$entity->id = 8;
$entity->name = 'inserted';
$entity->category_id = 2;
$entity->category = $parent;
$mapper->category->persist($entity);
$mapper->flush();
$result = $this->query('select * from category where id=8')
Expand Down Expand Up @@ -866,6 +896,18 @@ public function testCompositesPersistDoesNotDropColumnsWithMatchingValues(): voi
$this->assertEquals('Same Value', $result->text);
}

public function testCompositeColumnOverridesParentOnNameCollision(): void
{
$mapper = $this->mapper;
$mapper->postComment = Composite::post(['comment' => ['text']])->author();
$post = $mapper->postComment->fetch();

// Both post and comment have a 'text' column.
// The composite column (comment.text) should take precedence.
$this->assertEquals('Comment Text', $post->text);
$this->assertNotEquals('Post Text', $post->text);
}

public function testTyped(): void
{
$mapper = new Mapper($this->conn, new EntityFactory(entityNamespace: '\Respect\Relational\\'));
Expand Down Expand Up @@ -1027,7 +1069,7 @@ public function testPersistNewEntityWithNoAutoIncrementId(): void
$obj->name = 'test';
$mapper->author->persist($obj);
$mapper->flush();
$this->assertNull($obj->id);
$this->assertFalse((new ReflectionProperty($obj, 'id'))->isInitialized($obj));
}

public function testFetchReturnsDbInstance(): void
Expand Down Expand Up @@ -1120,7 +1162,7 @@ public function testInsertedEntityIsRetrievableFromIdentityMap(): void
$this->mapper->flush();

// The entity should now have an auto-assigned id and be cached
$this->assertNotNull($entity->id);
$this->assertGreaterThan(0, $entity->id);

$fetched = $this->mapper->post($entity->id)->fetch();
$this->assertSame($entity, $fetched);
Expand Down Expand Up @@ -1233,6 +1275,94 @@ public function testPersistPureEntityTreeDerivesForeignKey(): void
$this->assertEquals(1, $row['author_id']);
}

public function testPersistWithUninitializedRelationSkipsCascade(): void
{
// Post has `Author $author` (uninitialized). Persist should not
// crash — it should skip the cascade for the missing relation.
$mapper = $this->mapper;
$post = new Post();
$post->title = 'No Author';
$post->text = 'Body';

$mapper->post->persist($post);
$mapper->flush();

$this->assertGreaterThan(0, $post->id);
$result = $this->query('select title from post where id=' . $post->id)
->fetch(PDO::FETCH_OBJ);
$this->assertEquals('No Author', $result->title);
}

public function testCompositeUpdateSkipsMissingSpecColumn(): void
{
// Composite spec asks for 'text' from comment, but we only change
// 'title' (a post column). The composite should not crash on the
// missing spec column — it should just skip it.
$mapper = $this->mapper;
$mapper->postComment = Composite::post(['comment' => ['text']])->author();
$post = $mapper->postComment->fetch();

// Only change a parent column, leave composite column unchanged
$post->title = 'Only Title Changed';

$mapper->postComment->persist($post);
$mapper->flush();

$result = $this->query('select title from post where id=5')
->fetch(PDO::FETCH_OBJ);
$this->assertEquals('Only Title Changed', $result->title);
// Comment text should remain untouched
$result = $this->query('select text from comment where id=7')
->fetch(PDO::FETCH_OBJ);
$this->assertEquals('Comment Text', $result->text);
}

public function testCompositeInsertWithNoMatchingColumnsSkipsChild(): void
{
// New entity where the composite spec columns are NOT set — the
// child INSERT should be skipped entirely (no empty INSERT).
$mapper = $this->mapper;
$mapper->postComment = Composite::post(['comment' => ['text']])->author();

$post = new Postcomment();
$post->title = 'Post Without Comment';
$author = new Author();
$author->name = 'Author X';
$post->author = $author;
// Note: $post->text is NOT set (uninitialized)

$mapper->postComment->persist($post);
$mapper->flush();

$result = $this->query('select title from post order by id desc')
->fetch(PDO::FETCH_OBJ);
$this->assertEquals('Post Without Comment', $result->title);
}

public function testFetchWithArrayConditions(): void
{
// Test multiple array conditions (hits the AND branch in parseConditions)
$result = $this->mapper->post[['title' => 'Post Title', 'author_id' => 1]]->fetchAll();
$this->assertCount(1, $result);
$this->assertEquals('Post Title', $result[0]->title);
}

public function testPersistCascadeSkipsNullChildRelation(): void
{
// Register a collection with children: post → author (child).
// Persist a post where $author is uninitialized.
// The cascade should skip the null child without crashing (L87-91).
$mapper = $this->mapper;
$this->expectNotToPerformAssertions();
$mapper->postsFromAuthorsWithComments->persist(new class {
public int $id;

public string $title = 'Orphan Post';

public string $text = '';
});
}

private function query(string $sql): PDOStatement
{
$stmt = $this->conn->query($sql);
Expand Down
Loading
Loading