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 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'); } }