146 lines
4.8 KiB
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');
|
|
}
|
|
}
|