feat(test): add multi-engine integration matrix
This commit is contained in:
145
tests/Integration/ModelJoinTest.php
Normal file
145
tests/Integration/ModelJoinTest.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user