Files
duckbrain/tests/Integration/ModelJoinTest.php

146 lines
4.8 KiB
PHP

<?php
namespace Tests\Integration;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Tests\Models\Post;
use Tests\Models\User;
use Tests\TestCase;
/**
* ModelJoinTest - DuckBrain integration matrix
*
* Family: innerJoin, leftJoin, rightJoin, crossJoin, groupBy.
*
* Data has orphans on both sides (carol/dave have no posts; no post lacks an
* owner) so join cardinality actually discriminates. Projections are always
* qualified and limited to driver-model columns: a bare SELECT * on
* rightJoin differs between engines BY DESIGN (the sqlite branch rewrites
* into a swapped LEFT JOIN, see readme "fixed"); asserting on ambiguous
* duplicate columns would test that rewrite detail, not the join promise.
*/
#[Group('integration')]
final class ModelJoinTest extends TestCase
{
/** @var array<string,int> username => id */
private static array $ids = [];
public static function setUpBeforeClass(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
self::createTable('posts', [
'id' => 'pk',
'user_id' => 'number',
'title' => 'string',
'content' => 'text',
'rating' => 'number',
]);
foreach ([['alice', true], ['bob', true], ['carol', false], ['dave', true]] as $row) {
$user = new User();
$user->username = $row[0];
$user->email = $row[0] . '@duckbrain.dev';
$user->age = 30;
$user->isActive = $row[1];
$user->save();
self::$ids[$row[0]] = (int) $user->id;
}
foreach ([['p1', 'alice'], ['p2', 'alice'], ['p3', 'bob']] as [$title, $owner]) {
$post = new Post();
$post->title = $title;
$post->userId = self::$ids[$owner];
$post->save();
}
}
/** @return list<string> sorted usernames of the result rows */
private static function names(array $rows): array
{
$names = array_map(static fn (User $u): string => (string) $u->username, $rows);
sort($names);
return $names;
}
#[Test]
public function leftJoinKeepsUsersWithoutPosts(): void
{
$rows = User::select('users.id', 'users.username')
->leftJoin('posts', 'users.id', '=', 'posts.user_id')
->get();
$this->assertSame(
['alice', 'alice', 'bob', 'carol', 'dave'],
self::names($rows),
'LEFT JOIN must keep orphan users exactly once each'
);
}
#[Test]
public function innerJoinDropsBothOrphans(): void
{
$rows = User::select('users.id', 'users.username')
->innerJoin('posts', 'users.id', '=', 'posts.user_id')
->get();
$this->assertSame(['alice', 'alice', 'bob'], self::names($rows));
}
#[Test]
public function rightJoinKeepsEveryRowOfTheJoinedTable(): void
{
// sqlite takes the swapped-LEFT-JOIN rewrite; mysql/pgsql take the
// native RIGHT JOIN branch. Both must produce the same observable set.
$rows = User::select('users.id', 'users.username')
->rightJoin('posts', 'users.id', '=', 'posts.user_id')
->get();
$names = self::names($rows);
$this->assertCount(3, $rows, 'posts are the preserved side: 3 rows');
$this->assertSame(['alice', 'alice', 'bob'], $names);
$this->assertNotContains('carol', $names, 'users without posts vanish from a RIGHT JOIN over posts');
}
#[Test]
public function crossJoinProducesTheCartesianProduct(): void
{
$rows = User::select('users.id', 'posts.id')
->crossJoin('posts')
->get();
$this->assertCount(12, $rows, '4 users x 3 posts');
// Qualified both-id columns: the last one wins on every engine
// (users.* comes first), so the surviving id set is 1..3.
$surviving = array_unique(array_map(static fn (User $u): int => (int) $u->id, $rows));
sort($surviving);
$this->assertSame([1, 2, 3], $surviving);
}
#[Test]
public function groupByCollapsesRowsPerGroup(): void
{
$statuses = User::select('users.is_active')->groupBy('users.is_active')->get();
$this->assertCount(2, $statuses, 'active and inactive groups');
$owners = User::select('users.id', 'users.username')
->innerJoin('posts', 'users.id', '=', 'posts.user_id')
->groupBy('users.id')
->get();
$names = self::names($owners);
sort($names);
$this->assertSame(['alice', 'bob'], $names, 'one row per user that owns posts');
}
}