diff --git a/tests/Model/Query/EntityModelComplexQueryTest.php b/tests/Model/Query/EntityModelComplexQueryTest.php new file mode 100644 index 0000000..1c8c11f --- /dev/null +++ b/tests/Model/Query/EntityModelComplexQueryTest.php @@ -0,0 +1,1082 @@ +bind('request', fn() => new Request()); + $container->bind('url', fn() => UrlGenerator::class); + $container->bind('db', fn() => new Database('default')); + + $this->pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $this->createTestTables(); + $this->seedData(); + $this->setupDatabaseConnections(); + } + + protected function tearDown(): void + { + $this->pdo = null; + $this->tearDownDatabaseConnections(); + } + + private function createTestTables(): void + { + $this->pdo->exec(" + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT, + age INTEGER, + status TEXT DEFAULT 'active', + score REAL DEFAULT 0, + bio TEXT, + created_at TEXT, + updated_at TEXT + ) + "); + + $this->pdo->exec(" + CREATE TABLE userss ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT UNIQUE, + age INTEGER, + status TEXT DEFAULT 'active', + created_at TEXT, + updated_at TEXT + ) + "); + + $this->pdo->exec(" + CREATE TABLE posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT, + status TEXT DEFAULT 'published', + views INTEGER DEFAULT 0, + created_at TEXT, + updated_at TEXT + ) + "); + + $this->pdo->exec(" + CREATE TABLE comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + body TEXT NOT NULL, + approved INTEGER DEFAULT 0, + created_at TEXT + ) + "); + + $this->pdo->exec(" + CREATE TABLE tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL + ) + "); + + $this->pdo->exec(" + CREATE TABLE post_tag ( + post_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + created_at TEXT + ) + "); + + $this->pdo->exec(" + CREATE TABLE product ( + price INTEGER + ) + "); + } + + private function seedData(): void + { + // 10 users: 7 active, 3 inactive; Dave (id=4) has null email; Carol/Grace have null bio + $this->pdo->exec(" + INSERT INTO users (name, email, age, status, score, bio, created_at, updated_at) VALUES + ('Alice Smith', 'alice@example.com', 30, 'active', 95.5, 'Developer', '2024-01-01 10:00:00', '2024-01-01 10:00:00'), + ('Bob Jones', 'bob@example.com', 25, 'active', 72.0, 'Designer', '2024-01-02 10:00:00', '2024-01-02 10:00:00'), + ('Carol White', 'carol@example.com', 35, 'inactive', 55.0, NULL, '2024-01-03 10:00:00', '2024-01-03 10:00:00'), + ('Dave Brown', NULL, 40, 'active', 88.0, 'Manager', '2024-01-04 10:00:00', '2024-01-04 10:00:00'), + ('Eve Davis', 'eve@example.com', 22, 'inactive', 40.0, 'Intern', '2024-01-05 10:00:00', '2024-01-05 10:00:00'), + ('Frank Miller', 'frank@example.com', 45, 'active', 91.0, 'Architect', '2024-01-06 10:00:00', '2024-01-06 10:00:00'), + ('Grace Lee', 'grace@example.com', 28, 'active', 63.0, NULL, '2024-01-07 10:00:00', '2024-01-07 10:00:00'), + ('Henry Wilson', 'henry@example.com', 33, 'inactive', 77.0, 'DevOps', '2024-01-08 10:00:00', '2024-01-08 10:00:00'), + ('Irene Clark', 'irene@example.com', 29, 'active', 82.0, 'QA', '2024-01-09 10:00:00', '2024-01-09 10:00:00'), + ('James Scott', 'james@example.com', 50, 'active', 99.0, 'CTO', '2024-01-10 10:00:00', '2024-01-10 10:00:00') + "); + + // 10 posts across users 1,2,3,4,6,9,10 + $this->pdo->exec(" + INSERT INTO posts (user_id, title, content, status, views, created_at, updated_at) VALUES + (1, 'Alice Post One', 'Content 1', 'published', 1000, '2024-02-01 10:00:00', '2024-02-01 10:00:00'), + (1, 'Alice Post Two', 'Content 2', 'draft', 500, '2024-02-02 10:00:00', '2024-02-02 10:00:00'), + (2, 'Bob Post One', 'Content 3', 'published', 200, '2024-02-03 10:00:00', '2024-02-03 10:00:00'), + (3, 'Carol Post One', 'Content 4', 'published', 750, '2024-02-04 10:00:00', '2024-02-04 10:00:00'), + (4, 'Dave Post One', 'Content 5', 'draft', 300, '2024-02-05 10:00:00', '2024-02-05 10:00:00'), + (6, 'Frank Post One', 'Content 6', 'published', 1500, '2024-02-06 10:00:00', '2024-02-06 10:00:00'), + (6, 'Frank Post Two', 'Content 7', 'published', 100, '2024-02-07 10:00:00', '2024-02-07 10:00:00'), + (9, 'Irene Post One', 'Content 8', 'draft', 600, '2024-02-08 10:00:00', '2024-02-08 10:00:00'), + (10, 'James Post One', 'Content 9', 'published', 2000, '2024-02-09 10:00:00', '2024-02-09 10:00:00'), + (10, 'James Post Two', 'Content 10', 'published', 900, '2024-02-10 10:00:00', '2024-02-10 10:00:00') + "); + + $this->pdo->exec(" + INSERT INTO comments (post_id, user_id, body, approved, created_at) VALUES + (1, 2, 'Great post!', 1, '2024-03-01 10:00:00'), + (1, 3, 'Very helpful.', 1, '2024-03-02 10:00:00'), + (1, 4, 'Thanks Alice!', 0, '2024-03-03 10:00:00'), + (6, 1, 'Love it', 1, '2024-03-04 10:00:00'), + (9, 6, 'Nice draft', 0, '2024-03-05 10:00:00'), + (9, 7, 'Looking forward', 1, '2024-03-06 10:00:00') + "); + + $this->pdo->exec(" + INSERT INTO tags (name) VALUES ('php'), ('orm'), ('database'), ('performance'), ('testing') + "); + + $this->pdo->exec(" + INSERT INTO post_tag (post_id, tag_id, created_at) VALUES + (1, 1, '2024-02-01'), (1, 2, '2024-02-01'), (1, 3, '2024-02-01'), + (6, 1, '2024-02-06'), (6, 4, '2024-02-06'), + (9, 5, '2024-02-08'), + (10,1, '2024-02-09'), (10,2, '2024-02-09'), (10,4, '2024-02-09') + "); + } + + private function setupDatabaseConnections(): void + { + $this->setStaticProperty(Database::class, 'connections', []); + $this->setStaticProperty(Database::class, 'transactions', []); + $this->setStaticProperty(Database::class, 'connections', [ + 'default' => $this->pdo, + 'sqlite' => $this->pdo, + ]); + } + + private function tearDownDatabaseConnections(): void + { + $this->setStaticProperty(Database::class, 'connections', []); + $this->setStaticProperty(Database::class, 'transactions', []); + } + + private function setStaticProperty(string $class, string $property, mixed $value): void + { + $ref = new \ReflectionClass($class); + $prop = $ref->getProperty($property); + $prop->setAccessible(true); + $prop->setValue(null, $value); + } + + public function testOrWhereNullOnLargeDataset(): void + { + $users = MockUser::where('status', 'active') + ->orWhere('email', null) + ->get(); + + // 7 active users, Dave (null email) already among them → 7 total + $this->assertCount(7, $users); + $this->assertInstanceOf(Collection::class, $users); + } + + // /** + // * orWhere(null) SQL must use IS NULL, never '= ?'. + // */ + public function testOrWhereNullSqlShape(): void + { + $sql = MockUser::where('status', 'active') + ->orWhere('email', null) + ->toSql(); + + $this->assertStringContainsString('IS NULL', strtoupper($sql)); + $this->assertStringNotContainsString('email = ?', $sql); + } + + /** + * whereNull + whereIn combined — Fix 3 binding order. + * Users in ids [1,2,3,4] AND email IS NULL → only Dave (id=4). + */ + public function testWhereNullCombinedWithWhereIn(): void + { + $users = MockUser::whereIn('id', [1, 2, 3, 4]) + ->whereNull('email') + ->get(); + + $this->assertCount(1, $users); + $this->assertEquals('Dave Brown', $users->first()->name); + } + + /** + * orWhereNull alongside regular condition. + * inactive OR bio IS NULL → Carol(inactive,null bio), Eve(inactive), Henry(inactive), + * Grace(active,null bio) = 4 + */ + public function testOrWhereNullAlongsideWhereCondition(): void + { + $users = MockUser::where('status', 'inactive') + ->orWhereNull('bio') + ->get(); + + // inactive: Carol,Eve,Henry + bio IS NULL: Grace(active) = 4 distinct + $this->assertCount(4, $users); + $names = $users->map->name->toArray(); + $this->assertContains('Grace Lee', $names); + $this->assertContains('Carol White', $names); + } + + /** + * whereNotNull filters correctly through ORM. + */ + public function testWhereNotNullFiltersNullRows(): void + { + $users = MockUser::whereNotNull('email')->get(); + + $this->assertCount(9, $users); // Dave has no email → 9 results + foreach ($users as $user) { + $this->assertNotNull($user->email); + } + } + + /** + * Nested OR inside AND — ORM Builder must bracket correctly. + * active users where (age >= 40 OR score > 90) + */ + public function testNestedOrInsideAndCondition(): void + { + $users = MockUser::where('status', 'active') + ->where(function ($q) { + $q->where('age', '>=', 40) + ->orWhere('score', '>', 90); + }) + ->orderBy('id') + ->get(); + + // age>=40 active: Dave(40),Frank(45),James(50) + // score>90 active: Alice(95.5),Frank(91),James(99) + // union: Alice,Dave,Frank,James = 4 + $this->assertCount(4, $users); + $names = $users->map->name->toArray(); + $this->assertContains('Alice Smith', $names); + $this->assertContains('Dave Brown', $names); + $this->assertContains('Frank Miller', $names); + $this->assertContains('James Scott', $names); + $this->assertNotContains('Bob Jones', $names); + } + + /** + * Triple nested conditions — deep nesting must not corrupt bindings. + * published posts OR (views BETWEEN 100–500 AND status = 'draft') + */ + public function testTripleNestedConditionsOnPosts(): void + { + $posts = MockPost::where('status', 'published') + ->orWhere(function ($q) { + $q->whereBetween('views', [100, 500]) + ->where('status', 'draft'); + }) + ->get(); + + // published: 7 posts | draft BETWEEN 100-500: Alice2(500),Dave(300) = 2 | total = 9 + $this->assertCount(9, $posts); + } + + /** + * whereIn + whereBetween combined — Fix 4 binding integrity. + */ + public function testWhereInAndWhereBetweenCombined(): void + { + $posts = MockPost::whereIn('user_id', [1, 2, 6, 10]) + ->whereBetween('views', [200, 1500]) + ->orderBy('views') + ->get(); + + foreach ($posts as $post) { + $this->assertContains((int)$post->user_id, [1, 2, 6, 10]); + $this->assertGreaterThanOrEqual(200, (int)$post->views); + $this->assertLessThanOrEqual(1500, (int)$post->views); + } + $this->assertGreaterThan(0, $posts->count()); + } + + /** + * Multiple whereIn on different columns — no binding list overlap. + */ + public function testMultipleWhereInOnDifferentColumns(): void + { + $users = MockUser::whereIn('status', ['active', 'inactive']) + ->whereIn('age', [25, 30, 35]) + ->orderBy('age') + ->get(); + + // Alice(30,active), Bob(25,active), Carol(35,inactive) + $this->assertCount(3, $users); + $names = $users->map->name->toArray(); + $this->assertContains('Alice Smith', $names); + $this->assertContains('Bob Jones', $names); + $this->assertContains('Carol White', $names); + } + + /** + * distinct() with whereIn — uses buildWhereClause() path. + */ + public function testDistinctWithWhereIn(): void + { + // distinct statuses from users with id in [1,3,5] → active + inactive + $statuses = MockUser::query() + ->whereIn('id', [1, 3, 5]) + ->distinct('status'); + + $this->assertInstanceOf(Collection::class, $statuses); + $this->assertCount(2, $statuses); + $this->assertContains('active', $statuses->toArray()); + $this->assertContains('inactive', $statuses->toArray()); + } + + /** + * distinct() with whereBetween. + */ + public function testDistinctWithWhereBetween(): void + { + // distinct user_ids from posts where views BETWEEN 100 AND 600 + $userIds = MockPost::query() + ->whereBetween('views', [100, 600]) + ->distinct('user_id'); + + $this->assertInstanceOf(Collection::class, $userIds); + $this->assertGreaterThan(0, $userIds->count()); + } + + /** + * distinct() with nested OR — Fix 3 regression. + */ + public function testDistinctWithNestedOrCondition(): void + { + // distinct user_ids from posts that are draft OR views > 900 + $userIds = MockPost::query() + ->where('status', 'draft') + ->orWhere('views', '>', 900) + ->distinct('user_id'); + + // draft: user 1,4,9 | views>900: user 1(1000),6(1500),10(2000) → distinct: 1,4,6,9,10 = 5 + $this->assertCount(5, $userIds); + } + + /** + * increment() with whereIn — binding order: amount then whereBindings. + */ + public function testIncrementWithWhereIn(): void + { + $affected = MockPost::query() + ->whereIn('id', [1, 3, 6]) + ->increment('views', 100); + + $this->assertEquals(3, $affected); + $this->assertEquals(1100, (int)MockPost::find(1)->views); // 1000+100 + $this->assertEquals(300, (int)MockPost::find(3)->views); // Bob 200+100 + $this->assertEquals(1600, (int)MockPost::find(6)->views); // Frank1 1500+100 + } + + /** + * decrement() with whereBetween — Fix 4 binding integrity. + */ + public function testDecrementWithWhereBetween(): void + { + $affected = MockPost::query() + ->whereBetween('views', [400, 1000]) + ->decrement('views', 50); + + $this->assertEquals(5, $affected); + $this->assertEquals(450, (int)MockPost::find(2)->views); // Alice2: 500–50 + $this->assertEquals(700, (int)MockPost::find(4)->views); // Carol: 750–50 + $this->assertEquals(850, (int)MockPost::find(10)->views); // James2: 900–50 + } + + /** + * increment() with deep nested WHERE — Fix 4 + Fix 3 combined. + */ + public function testIncrementWithNestedWhereCondition(): void + { + $affected = MockPost::query() + ->where(function ($q) { + $q->where('status', 'published') + ->whereIn('user_id', [6, 10]); + }) + ->increment('views', 500); + + // Frank1(1500),Frank2(100),James1(2000),James2(900) = 4 + $this->assertEquals(4, $affected); + $this->assertEquals(2000, (int)MockPost::find(6)->views); // 1500+500 + $this->assertEquals(600, (int)MockPost::find(7)->views); // 100+500 + $this->assertEquals(2500, (int)MockPost::find(9)->views); // 2000+500 + $this->assertEquals(1400, (int)MockPost::find(10)->views); // 900+500 + } + + /** + * increment() with extra column updates + whereIn. + */ + public function testIncrementWithExtraColumnsAndWhereIn(): void + { + $now = '2025-06-01 00:00:00'; + + MockPost::query() + ->whereIn('id', [1, 2]) + ->increment('views', 10, ['updated_at' => $now]); + + $post1 = MockPost::find(1); + $post2 = MockPost::find(2); + + $this->assertEquals(1010, (int)$post1->views); + $this->assertEquals($now, $post1->updated_at); + $this->assertEquals(510, (int)$post2->views); + $this->assertEquals($now, $post2->updated_at); + } + + /** + * saveMany() injects created_at and updated_at for all rows. + */ + public function testSaveManyInjectsTimestampsForAllRows(): void + { + MockAnotherUser::saveMany([ + ['name' => 'TS User A', 'email' => 'tsa@test.com', 'age' => 20, 'status' => 'active'], + ['name' => 'TS User B', 'email' => 'tsb@test.com', 'age' => 21, 'status' => 'active'], + ['name' => 'TS User C', 'email' => 'tsc@test.com', 'age' => 22, 'status' => 'active'], + ]); + + foreach (['tsa@test.com', 'tsb@test.com', 'tsc@test.com'] as $email) { + $user = MockAnotherUser::where('email', $email)->first(); + $this->assertNotNull($user->created_at, "created_at must be set for {$email}"); + $this->assertNotNull($user->updated_at, "updated_at must be set for {$email}"); + } + } + + /** + * saveMany() does not overwrite an explicit created_at. + */ + public function testSaveManyDoesNotOverwriteExistingCreatedAt(): void + { + $fixed = '2019-12-31 23:59:59'; + + MockAnotherUser::saveMany([ + ['name' => 'Legacy', 'email' => 'legacy@test.com', 'age' => 30, 'status' => 'active', 'created_at' => $fixed], + ]); + + $user = MockAnotherUser::where('email', 'legacy@test.com')->first(); + $this->assertEquals($fixed, $user->created_at); + } + + /** + * saveMany() in chunks still injects timestamps for every row. + */ + public function testSaveManyInChunksInjectsTimestamps(): void + { + $rows = []; + for ($i = 1; $i <= 6; $i++) { + $rows[] = ['name' => "Chunk User {$i}", 'email' => "chunk{$i}@test.com", 'age' => 20 + $i, 'status' => 'active']; + } + + MockAnotherUser::saveMany($rows, 2); // chunk of 2 → 3 batches + + $count = MockAnotherUser::whereNotNull('created_at') + ->whereLike('email', 'chunk') + ->count(); + + $this->assertEquals(6, $count); + } + + /** + * String DB value vs int PHP value — must not be falsely dirty. + */ + public function testDirtyNotFalselyDirtyOnDbStringVsPhpInt(): void + { + $post = MockPost::find(1); // views = 1000 comes back as string from SQLite + + $post->setAttribute('views', 1000); // same value as int + + $this->assertArrayNotHasKey('views', $post->getDirtyAttributes()); + } + + /** + * Actual value change IS detected as dirty. + */ + public function testDirtyDetectsRealChange(): void + { + $user = MockUser::find(1); + $user->name = 'Alice Johnson'; + + $dirty = $user->getDirtyAttributes(); + $this->assertArrayHasKey('name', $dirty); + $this->assertEquals('Alice Johnson', $dirty['name']); + } + + /** + * '' vs original string value IS dirty. + */ + public function testDirtyEmptyStringVsOriginalValueIsDirty(): void + { + $user = MockUser::find(1); // name = 'Alice Smith' + $user->setAttribute('name', ''); + + $this->assertArrayHasKey('name', $user->getDirtyAttributes()); + } + + /** + * null to '' IS dirty (null and empty string are distinct). + */ + public function testDirtyNullToEmptyStringIsDirty(): void + { + $user = MockUser::find(4); // Dave has null email + $user->setAttribute('email', ''); + + $this->assertArrayHasKey('email', $user->getDirtyAttributes()); + } + + /** + * '' to null IS dirty. + */ + public function testDirtyEmptyStringToNullIsDirty(): void + { + $user = MockUser::find(1); // email = 'alice@example.com' + $user->setAttribute('email', null); + + $this->assertArrayHasKey('email', $user->getDirtyAttributes()); + } + + /** + * Both null — not dirty. + */ + public function testDirtyBothNullNotDirty(): void + { + $user = MockUser::find(4); // Dave has null email + $user->setAttribute('email', null); // set null again + + $this->assertArrayNotHasKey('email', $user->getDirtyAttributes()); + } + + /** + * Only changed fields appear in dirty — untouched fields excluded. + */ + public function testDirtyOnlyChangedFieldsReturned(): void + { + $user = MockUser::find(1); + + $user->name = 'Alice Johnson'; // changed + $user->score = 96.0; // changed + // email, age, status untouched + + $dirty = $user->getDirtyAttributes(); + $this->assertArrayHasKey('name', $dirty); + $this->assertArrayHasKey('score', $dirty); + $this->assertArrayNotHasKey('email', $dirty); + $this->assertArrayNotHasKey('age', $dirty); + $this->assertArrayNotHasKey('status', $dirty); + } + + /** + * After save(), getDirtyAttributes() is empty — model re-syncs originalAttributes. + */ + public function testDirtyIsEmptyAfterSave(): void + { + $user = MockUser::find(1); + $user->name = 'Alice Changed'; + $user->save(); + + $this->assertEmpty($user->getDirtyAttributes()); + } + + /** + * Empty string assigned to model attribute is preserved as '', not null. + */ + public function testSanitizeEmptyStringPreservedOnModel(): void + { + $tag = new MockTag(); + $tag->name = ''; + + $this->assertSame('', $tag->name); + $this->assertNotNull($tag->name); + } + + /** + * Whitespace-only value trims to '' not null. + */ + public function testSanitizeWhitespaceTrimmedToEmptyNotNull(): void + { + $tag = new MockTag(); + $tag->name = ' '; + + $this->assertSame('', $tag->name); + } + + /** + * Leading/trailing whitespace trimmed from normal string. + */ + public function testSanitizeNormalStringTrimmed(): void + { + $tag = new MockTag(); + $tag->name = ' PHP '; + + $this->assertSame('PHP', $tag->name); + } + + /** + * null stays null — not converted to ''. + */ + public function testSanitizeNullRemainsNull(): void + { + $user = MockUser::find(4); // Dave has null email + $user->setAttribute('email', null); + + $this->assertNull($user->email); + } + + /** + * '' persists to DB as '', not NULL. + */ + public function testSanitizeEmptyStringPersistsAsEmptyNotNull(): void + { + $tag = MockTag::create(['name' => 'temp']); + $tag->name = ''; + $tag->save(); + + $reloaded = MockTag::find($tag->id); + $this->assertSame('', $reloaded->name); + } + + /** + * toArray() serializes pivot data under 'pivot' key, not 'pivot_data'. + */ + public function testToArrayIncludesPivotUnderCorrectKey(): void + { + $post = MockPost::query()->select('id', 'title', 'user_id')->embed('tags')->find(1); + $array = $post->toArray(); + + $this->assertArrayHasKey('tags', $array); + $this->assertNotEmpty($array['tags']); + + foreach ($array['tags'] as $tag) { + $this->assertArrayHasKey('pivot', $tag, 'pivot key must exist — not pivot_data'); + $this->assertIsObject($tag['pivot']); + $this->assertObjectHasProperty('post_id', $tag['pivot']); + } + } + + /** + * toArray() with a null relation explicitly includes key => null. + */ + public function testToArrayNullRelationExplicitlyNull(): void + { + $user = MockUser::find(1); + $user->setRelation('profile', null); + + $array = $user->toArray(); + + $this->assertArrayHasKey('profile', $array); + $this->assertNull($array['profile']); + } + + /** + * toArray() recursively converts nested Model relation. + */ + public function testToArrayRecursesIntoNestedModel(): void + { + $post = MockPost::embed('user')->find(1); + $array = $post->toArray(); + + $this->assertArrayHasKey('user', $array); + $this->assertIsArray($array['user']); + $this->assertEquals('Alice Smith', $array['user']['name']); + $this->assertArrayHasKey('email', $array['user']); + } + + /** + * toArray() on a collection with many-to-many — pivot preserved for all items. + */ + public function testToArrayOnCollectionWithPivot(): void + { + $posts = MockPost::query()->whereIn('id', [1, 6])->embed('tags')->get(); + $array = $posts->toArray(); + + $this->assertCount(2, $array); + foreach ($array as $post) { + $this->assertArrayHasKey('tags', $post); + foreach ($post['tags'] as $tag) { + $this->assertArrayHasKey('pivot', $tag); + } + } + } + + /** + * __get() returns attribute value correctly. + */ + public function testMagicGetReturnsAttribute(): void + { + $user = MockUser::find(1); + + $this->assertEquals('Alice Smith', $user->name); + $this->assertEquals('alice@example.com', $user->email); + $this->assertEquals(30, (int)$user->age); + } + + /** + * __get() returns null for non-existent property — does not throw. + */ + public function testMagicGetNonExistentPropertyReturnsNull(): void + { + $user = MockUser::find(1); + + $this->assertNull($user->nonExistentProperty); + $this->assertNull($user->definitelyMissing); + } + + /** + * __get() lazy-loads a relation and caches it. + */ + public function testMagicGetLazyLoadsRelation(): void + { + $user = MockUser::find(1); + $posts = $user->posts; // Alice has 2 posts + + $this->assertInstanceOf(Collection::class, $posts); + $this->assertCount(2, $posts); + + // Second access hits relation cache + $this->assertCount(2, $user->posts); + } + + /** + * __get() on a relation with no results returns empty Collection, not null. + */ + public function testMagicGetEmptyRelationReturnsEmptyCollection(): void + { + $user = MockUser::find(5); // Eve has no posts + $posts = $user->posts; + + $this->assertInstanceOf(Collection::class, $posts); + $this->assertCount(0, $posts); + } + + /** + * ORM update() with whereIn — only matching rows updated, others untouched. + */ + public function testOrmUpdateWithWhereIn(): void + { + MockUser::query() + ->whereIn('id', [1, 2, 3]) + ->update(['status' => 'vip']); + + $this->assertEquals('vip', MockUser::find(1)->status); + $this->assertEquals('vip', MockUser::find(2)->status); + $this->assertEquals('vip', MockUser::find(3)->status); + $this->assertEquals('active', MockUser::find(4)->status); // untouched + } + + /** + * ORM delete() with whereBetween — only rows in range deleted. + */ + public function testOrmDeleteWithWhereBetween(): void + { + $before = MockUser::count(); + + MockUser::query()->whereBetween('age', [40, 50])->delete(); + + $after = MockUser::count(); + + // Dave(40),Frank(45),James(50) = 3 deleted + $this->assertEquals(3, $before - $after); + $this->assertNull(MockUser::find(4)); + $this->assertNull(MockUser::find(6)); + $this->assertNull(MockUser::find(10)); + } + + /** + * ORM update() with orWhere + */ + public function testOrmUpdateWithOrWhereCondition(): void + { + MockPost::query() + ->where('status', 'draft') + ->orWhere('views', '>', 1500) + ->update(['content' => 'UPDATED']); + + // drafts: posts 2,5,8 | views>1500: post 9(2000) | total = 4 distinct + $updatedCount = MockPost::query()->where('content', 'UPDATED')->count(); + $this->assertEquals(4, $updatedCount); + + // Untouched: post 1 (published, 1000 views) + $this->assertNotEquals('UPDATED', MockPost::find(1)->content); + } + + /** + * ORM delete() with whereIn + whereNotNull — Fix 11 regression. + */ + public function testOrmDeleteWithWhereInAndWhereNotNull(): void + { + $before = MockUser::count(); + + // Delete users id 1 and 4 who have non-null emails + // Alice(1) has email → deleted; Dave(4) null email → kept + MockUser::query() + ->whereIn('id', [1, 4]) + ->whereNotNull('email') + ->delete(); + + $this->assertEquals($before - 1, MockUser::count()); + $this->assertNull(MockUser::find(1)); // Alice deleted + $this->assertNotNull(MockUser::find(4)); // Dave kept + } + + public function testSumWithWhereIn(): void + { + // Alice1(1000) + Frank1(1500) + Irene(600) = 3100 + $total = MockPost::query()->whereIn('id', [1, 6, 8])->sum('views'); + $this->assertEquals(3100, (int)$total); + } + + public function testMaxWithWhereCondition(): void + { + // James Post One = 2000 + $max = MockPost::query()->where('status', 'published')->max('views'); + $this->assertEquals(2000.0, $max); + } + + public function testMinPublishedViews(): void + { + // Frank Post Two has 100 views and is published → min = 100 + $min = MockPost::query()->where('status', 'published')->min('views'); + $this->assertEquals(100.0, $min); + } + + public function testCountWithNestedCondition(): void + { + // active users with (age < 30 OR score > 90) + $count = MockUser::where('status', 'active') + ->where(function ($q) { + $q->where('age', '<', 30) + ->orWhere('score', '>', 90); + }) + ->count(); + + // age<30 active: Bob(25),Grace(28),Irene(29) = 3 + // score>90 active: Alice(95.5),Frank(91),James(99) = 3 + // union: 6 + $this->assertEquals(6, $count); + } + + public function testDistinctUserIdsFromPublishedPosts(): void + { + // Users who have at least one published post + $userIds = MockPost::query() + ->where('status', 'published') + ->distinct('user_id'); + + // user_ids 1,2,3,6,10 have published posts = 5 distinct + $this->assertCount(5, $userIds); + } + + /** + * Eager load with constraint callback — callback runs exactly ONCE (Fix 16). + */ + public function testEagerLoadConstraintRunsOnce(): void + { + $callCount = 0; + + $post = MockPost::query() + ->embed([ + 'comments' => function ($q) use (&$callCount) { + $callCount++; + $q->where('approved', 1); + }, + ]) + ->find(1); + + $this->assertEquals(1, $callCount, 'Constraint callback must run exactly once'); + // Post 1 has 3 comments, 2 are approved + $this->assertCount(2, $post->comments); + } + + /** + * present() with nested condition through ORM. + */ + public function testPresentWithNestedCondition(): void + { + // Users who have published posts with views > 500 + $users = MockUser::query() + ->present('posts', function ($q) { + $q->where('status', 'published') + ->where('views', '>', 500); + }) + ->orderBy('id') + ->get(); + + $names = $users->map->name->toArray(); + $this->assertContains('Alice Smith', $names); // post 1: 1000 + $this->assertContains('Carol White', $names); // post 4: 750 + $this->assertContains('Frank Miller', $names); // post 6: 1500 + $this->assertContains('James Scott', $names); // post 9: 2000 + $this->assertNotContains('Bob Jones', $names); // Bob's post: 200 only + } + + /** + * embedCount() with whereIn — only selected posts counted. + */ + public function testEmbedCountWithWhereIn(): void + { + $posts = MockPost::query() + ->whereIn('id', [1, 6, 8]) + ->embedCount('comments') + ->get(); + + $this->assertCount(3, $posts); + + foreach ($posts->toArray() as $post) { + $this->assertArrayHasKey('comments_count', $post); + } + + $post1 = $posts->filter(fn($p) => (int)$p->id === 1)->first(); + $this->assertEquals(3, (int)$post1->comments_count); + } + + /** + * Many-to-many: all pivot data serialized correctly through toArray() + */ + public function testManyToManyPivotDataSerialization(): void + { + $post = MockPost::embed('tags')->find(1); + $array = $post->toArray(); + + // Post 1 has 3 tags + $this->assertCount(3, $array['tags']); + foreach ($array['tags'] as $tag) { + $this->assertArrayHasKey('pivot', $tag); + $this->assertIsObject($tag['pivot']); + $this->assertObjectHasProperty('post_id', $tag['pivot']); + $this->assertEquals(1, $tag['pivot']->post_id); + } + } + + /** + * whereIn with a single element — must not generate syntax error. + */ + public function testWhereInWithSingleElement(): void + { + $users = MockUser::whereIn('id', [1])->get(); + + $this->assertCount(1, $users); + $this->assertEquals('Alice Smith', $users->first()->name); + } + + /** + * Chained: where + whereNotNull + orderBy + limit — all clauses intact. + */ + public function testChainedConditionsWithOrderingAndLimit(): void + { + $users = MockUser::where('status', 'active') + ->whereNotNull('email') + ->orderBy('age', 'desc') + ->limit(3) + ->get(); + + $this->assertCount(3, $users); + foreach ($users as $user) { + $this->assertEquals('active', $user->status); + $this->assertNotNull($user->email); + } + + // Must be descending age + $ages = $users->map(fn($u) => (int)$u->age)->toArray(); + $this->assertGreaterThanOrEqual($ages[1], $ages[0]); + $this->assertGreaterThanOrEqual($ages[2], $ages[1]); + } + + /** + * select() + whereIn — selected columns only, no bleed into where clause. + */ + public function testSelectWithWhereInNoColumnBleed(): void + { + $users = MockUser::select('name', 'status', 'age') + ->whereIn('status', ['active']) + ->orderBy('age', 'asc') + ->get(); + + $this->assertCount(7, $users); + foreach ($users->toArray() as $user) { + $this->assertArrayHasKey('name', $user); + $this->assertArrayNotHasKey('email', $user); + $this->assertArrayNotHasKey('score', $user); + } + } + + /** + * find([]) with multiple IDs returns correct Collection. + */ + public function testFindWithMultipleIds(): void + { + $users = MockUser::find([1, 3, 5, 7, 9]); + + $this->assertInstanceOf(Collection::class, $users); + $this->assertCount(5, $users); + } + + /** + * count() with nested OR inside AND — complex condition count. + */ + public function testCountWithNestedOrInsideAnd(): void + { + $count = MockPost::where('status', 'published') + ->where(function ($q) { + $q->where('views', '>', 1000) + ->orWhereIn('user_id', [2, 3]); + }) + ->count(); + + // published AND (views>1000 OR user_id in [2,3]) + // views>1000 published: Frank1(1500),James1(2000) = 2 + // user_id 2,3 published: Bob(id=3),Carol(id=4) = 2 + // total: 4 + $this->assertEquals(4, $count); + } + + /** + * toSql() with complex conditions produces well-formed SQL string. + */ + public function testToSqlWithComplexConditions(): void + { + $sql = MockPost::where('status', 'published') + ->whereIn('user_id', [1, 6, 10]) + ->whereBetween('views', [100, 2000]) + ->toSql(); + + $this->assertIsString($sql); + $upperSql = strtoupper($sql); + $this->assertStringContainsString('WHERE', $upperSql); + $this->assertStringContainsString('IN', $upperSql); + $this->assertStringContainsString('BETWEEN', $upperSql); + } +} diff --git a/tests/Model/Query/EntityRelationshipTest.php b/tests/Model/Query/EntityRelationshipTest.php new file mode 100644 index 0000000..2842fb1 --- /dev/null +++ b/tests/Model/Query/EntityRelationshipTest.php @@ -0,0 +1,1249 @@ +bind('request', fn() => new Request()); + $container->bind('url', fn() => UrlGenerator::class); + $container->bind('db', fn() => new Database('default')); + + $this->pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $this->createSchema(); + $this->seedData(); + $this->setupDatabaseConnections(); + } + + protected function tearDown(): void + { + $this->pdo = null; + $this->tearDownDatabaseConnections(); + } + + // ========================================================================= + // SCHEMA & SEED + // ========================================================================= + + private function createSchema(): void + { + // users + $this->pdo->exec(" + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT UNIQUE, + age INTEGER, + status TEXT DEFAULT 'active', + created_at TEXT + ) + "); + + // posts + $this->pdo->exec(" + CREATE TABLE posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + title TEXT NOT NULL, + content TEXT, + status BOOLEAN DEFAULT 1, + views INTEGER DEFAULT 0, + created_at TEXT + ) + "); + + // comments + $this->pdo->exec(" + CREATE TABLE comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER, + user_id INTEGER, + body TEXT NOT NULL, + approved BOOLEAN DEFAULT 0, + created_at TEXT + ) + "); + + // tags + $this->pdo->exec(" + CREATE TABLE tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL + ) + "); + + // post_tag pivot + $this->pdo->exec(" + CREATE TABLE post_tag ( + post_id INTEGER, + tag_id INTEGER, + created_at TEXT + ) + "); + } + + private function seedData(): void + { + // Users: 3 rows + // id=1 John Doe active age=30 + // id=2 Jane Smith active age=25 + // id=3 Bob Wilson inactive age=35 + $this->pdo->exec(" + INSERT INTO users (name, email, age, status, created_at) VALUES + ('John Doe', 'john@example.com', 30, 'active', '2024-01-01 10:00:00'), + ('Jane Smith', 'jane@example.com', 25, 'active', '2024-01-02 10:00:00'), + ('Bob Wilson', 'bob@example.com', 35, 'inactive', '2024-01-03 10:00:00') + "); + + // Posts: 4 rows + // id=1 user_id=1 First Post status=1 views=100 + // id=2 user_id=1 Second Post status=0 views=50 + // id=3 user_id=2 Jane Post status=1 views=200 + // id=4 user_id=1 Third Post status=1 views=150 + $this->pdo->exec(" + INSERT INTO posts (user_id, title, content, status, views, created_at) VALUES + (1, 'First Post', 'Content 1', 1, 100, '2024-01-01 11:00:00'), + (1, 'Second Post', 'Content 2', 0, 50, '2024-01-02 11:00:00'), + (2, 'Jane Post', 'Content 3', 1, 200, '2024-01-03 11:00:00'), + (1, 'Third Post', 'Content 4', 1, 150, '2024-01-04 11:00:00') + "); + + // Comments: 5 rows + // id=1 post_id=1 user_id=1 Great post! approved=1 + // id=2 post_id=1 user_id=2 Nice work approved=0 + // id=3 post_id=2 user_id=1 Interesting approved=1 + // id=4 post_id=3 user_id=2 Amazing approved=1 + // id=5 post_id=1 user_id=3 Awesome approved=1 + $this->pdo->exec(" + INSERT INTO comments (post_id, user_id, body, approved, created_at) VALUES + (1, 1, 'Great post!', 1, '2024-01-01 12:00:00'), + (1, 2, 'Nice work', 0, '2024-01-01 13:00:00'), + (2, 1, 'Interesting', 1, '2024-01-02 12:00:00'), + (3, 2, 'Amazing', 1, '2024-01-03 12:00:00'), + (1, 3, 'Awesome', 1, '2024-01-01 14:00:00') + "); + + // Tags: 4 rows + $this->pdo->exec(" + INSERT INTO tags (name) VALUES ('PHP'), ('Doppar'), ('Testing'), ('Database') + "); + + // post_tag pivot + // post 1 → tags 1,2 + // post 2 → tag 1 + // post 3 → tag 3 + // post 4 → tag 4 + $this->pdo->exec(" + INSERT INTO post_tag (post_id, tag_id, created_at) VALUES + (1, 1, '2024-01-01 11:00:00'), + (1, 2, '2024-01-01 11:00:00'), + (2, 1, '2024-01-02 11:00:00'), + (3, 3, '2024-01-03 11:00:00'), + (4, 4, '2024-01-04 11:00:00') + "); + } + + private function setupDatabaseConnections(): void + { + $this->setStaticProperty(Database::class, 'connections', []); + $this->setStaticProperty(Database::class, 'transactions', []); + $this->setStaticProperty(Database::class, 'connections', [ + 'default' => $this->pdo, + 'sqlite' => $this->pdo, + ]); + } + + private function tearDownDatabaseConnections(): void + { + $this->setStaticProperty(Database::class, 'connections', []); + $this->setStaticProperty(Database::class, 'transactions', []); + } + + private function setStaticProperty(string $class, string $prop, $value): void + { + $r = new \ReflectionClass($class); + $p = $r->getProperty($prop); + $p->setAccessible(true); + $p->setValue(null, $value); + $p->setAccessible(false); + } + + // ========================================================================= + // 1. LAZY LOADING — linkMany + // ========================================================================= + + /** + * A User has many Posts; accessing $user->posts triggers a lazy load + * and returns a Collection of all matching posts. + */ + public function testLinkManyLazyLoad(): void + { + $user = MockUser::find(1); + $posts = $user->posts; + + $this->assertInstanceOf(Collection::class, $posts); + $this->assertCount(3, $posts); + + // Verify IDs belong to user 1 + foreach ($posts as $post) { + $this->assertEquals(1, $post->user_id); + } + } + + /** + * A User with no posts (Bob Wilson, id=3) returns an empty Collection, + * not null. + */ + public function testLinkManyLazyLoadReturnsEmptyCollectionWhenNoChildren(): void + { + $user = MockUser::find(3); // Bob Wilson – no posts + $posts = $user->posts; + + $this->assertInstanceOf(Collection::class, $posts); + $this->assertCount(0, $posts); + } + + /** + * Lazy-loaded relation can be further constrained via the method call syntax + * $user->posts()->where(...)->get(). + */ + public function testLinkManyLazyLoadWithConstraint(): void + { + $user = MockUser::find(1); + + $active = $user->posts()->where('status', true)->get(); + $inactive = $user->posts()->where('status', false)->get(); + + $this->assertCount(2, $active); + $this->assertCount(1, $inactive); + } + + // ========================================================================= + // 2. LAZY LOADING — bindTo + // ========================================================================= + + /** + * A Post belongs to a User; accessing $post->user triggers a lazy load + * and returns the parent model. + */ + public function testBindToLazyLoad(): void + { + $post = MockPost::find(1); + $user = $post->user; + + $this->assertNotNull($user); + $this->assertEquals(1, $user->id); + $this->assertEquals('John Doe', $user->name); + } + + /** + * bindTo for a different post owner (Jane Smith, user_id=2). + */ + public function testBindToLazyLoadDifferentOwner(): void + { + $post = MockPost::find(3); // Jane Post + $user = $post->user; + + $this->assertEquals(2, $user->id); + $this->assertEquals('Jane Smith', $user->name); + } + + // ========================================================================= + // 3. LAZY LOADING — bindToMany (many-to-many) + // ========================================================================= + + /** + * Post 1 has 2 tags via post_tag pivot. + * Each tag entity must include a `pivot` object. + */ + public function testBindToManyLazyLoad(): void + { + $post = MockPost::find(1); + $tags = $post->tags; + + $this->assertInstanceOf(Collection::class, $tags); + $this->assertCount(2, $tags); + + foreach ($tags as $tag) { + $this->assertEquals(1, $tag->pivot->post_id); + } + } + + /** + * Post 4 has exactly 1 tag (Database). + */ + public function testBindToManyLazyLoadSingleTag(): void + { + $post = MockPost::find(4); + $tags = $post->tags; + + $this->assertCount(1, $tags); + $this->assertEquals('Database', $tags[0]->name); + } + + /** + * Inverse: Tag 1 (PHP) belongs to many Posts. + */ + public function testBindToManyInverseLazyLoad(): void + { + $tag = MockTag::find(1); + $posts = $tag->posts; + + $this->assertCount(2, $posts); + + $postIds = array_map(fn($p) => $p['id'], $posts->toArray()); + + sort($postIds); + + $this->assertEquals([1, 2], $postIds); + } + + /** + * Pivot data must carry all pivot-table columns, including created_at. + */ + public function testBindToManyPivotContainsTimestamp(): void + { + $post = MockPost::find(1); + $tag = $post->tags->first(); + + $this->assertEquals('2024-01-01 11:00:00', $tag->pivot->created_at); + } + + // ========================================================================= + // 4. EAGER LOADING (embed) — basic + // ========================================================================= + + /** + * embed('posts') on a single find returns user with nested posts array. + */ + public function testEagerLoadLinkManyOnFind(): void + { + $user = MockUser::embed('posts')->find(1); + + $array = $user->toArray(); + $this->assertArrayHasKey('posts', $array); + $this->assertCount(3, $array['posts']); + $this->assertEquals('First Post', $array['posts'][0]['title']); + } + + /** + * embed('posts') on get() returns every user with their posts nested. + */ + public function testEagerLoadLinkManyOnCollection(): void + { + $users = MockUser::embed('posts')->get(); + + $this->assertCount(3, $users); + + // John (id=1) has 3 posts + $this->assertCount(3, $users[0]->posts); + // Jane (id=2) has 1 post + $this->assertCount(1, $users[1]->posts); + // Bob (id=3) has 0 posts + $this->assertCount(0, $users[2]->posts); + } + + /** + * embed('user') on posts loads the bindTo parent. + */ + public function testEagerLoadBindTo(): void + { + $post = MockPost::embed('user')->find(1); + + $array = $post->toArray(); + $this->assertArrayHasKey('user', $array); + $this->assertEquals('John Doe', $array['user']['name']); + } + + /** + * embed('tags') loads the many-to-many relation with pivot data. + */ + public function testEagerLoadBindToMany(): void + { + $post = MockPost::embed('tags')->find(1); + + $array = $post->toArray(); + $this->assertArrayHasKey('tags', $array); + $this->assertCount(2, $array['tags']); + + // Each tag must have a pivot key that is an object + foreach ($array['tags'] as $tag) { + $this->assertArrayHasKey('pivot', $tag); + $this->assertIsObject($tag['pivot']); + } + } + + // ========================================================================= + // 5. COLUMN-SCOPED EAGER LOADING (embed with :col syntax) + // ========================================================================= + + /** + * embed('posts:title') should auto-include id and foreign key alongside + * the requested column. + */ + public function testEagerLoadColumnScoped(): void + { + $user = MockUser::embed('posts:title')->find(1); + $posts = $user->toArray()['posts']; + + foreach ($posts as $post) { + $this->assertArrayHasKey('id', $post); + $this->assertArrayHasKey('user_id', $post); + $this->assertArrayHasKey('title', $post); + // Non-selected columns must be absent + $this->assertArrayNotHasKey('content', $post); + $this->assertArrayNotHasKey('views', $post); + $this->assertArrayNotHasKey('created_at', $post); + } + } + + /** + * embed('comments:body') inside a nested relation also respects column scope. + */ + public function testEagerLoadNestedColumnScoped(): void + { + $post = MockPost::embed('user.comments:body')->find(1); + $comments = $post->toArray()['user']['comments']; + + foreach ($comments as $comment) { + $this->assertArrayHasKey('id', $comment); + $this->assertArrayHasKey('user_id', $comment); + $this->assertArrayHasKey('body', $comment); + $this->assertArrayNotHasKey('approved', $comment); + $this->assertArrayNotHasKey('created_at', $comment); + } + } + + // ========================================================================= + // 6. CONSTRAINED EAGER LOADING (embed with closure) + // ========================================================================= + + /** + * A closure passed to embed() filters the related records. + */ + public function testConstrainedEagerLoad(): void + { + $post = MockPost::query() + ->where('id', 1) + ->embed([ + 'comments' => fn($q) => $q->where('approved', true), + ]) + ->first(); + + // Post 1 has 3 comments total but 2 are approved (id=1 and id=5), id=2 is not + $comments = $post->toArray()['comments']; + foreach ($comments as $c) { + $this->assertEquals(1, $c['approved']); + } + } + + /** + * Closure can also limit the number of related records loaded. + */ + public function testConstrainedEagerLoadWithLimit(): void + { + $post = MockPost::query() + ->where('id', 1) + ->embed([ + 'comments:id,body,created_at' => fn($q) => $q + ->where('approved', true) + ->limit(1) + ->oldest('created_at'), + ]) + ->first(); + + $comments = $post->toArray()['comments']; + $this->assertCount(1, $comments); + $this->assertEquals('Great post!', $comments[0]['body']); + } + + /** + * Closure on many-to-many embed (tags) applies WHERE to the tag query. + */ + public function testConstrainedEagerLoadBindToMany(): void + { + $post = MockPost::query() + ->where('id', 1) + ->embed([ + 'tags' => fn($q) => $q->where('tags.id', 1), + ]) + ->first(); + + $tags = $post->toArray()['tags']; + $this->assertCount(1, $tags); + $this->assertEquals('PHP', $tags[0]['name']); + } + + // ========================================================================= + // 7. NESTED EAGER LOADING (dot notation) + // ========================================================================= + + /** + * embed('user.comments') loads post → user → comments (two levels deep). + */ + public function testTwoLevelNestedEagerLoad(): void + { + $post = MockPost::embed('user.comments')->find(1); + $array = $post->toArray(); + + $this->assertArrayHasKey('user', $array); + $this->assertArrayHasKey('comments', $array['user']); + $this->assertCount(2, $array['user']['comments']); // John's comments + } + + /** + * embed(['posts.comments']) on a User loads posts each with their comments. + */ + public function testTwoLevelNestedEagerLoadFromUser(): void + { + $user = MockUser::embed('posts.comments')->find(1); + $array = $user->toArray(); + + $this->assertArrayHasKey('posts', $array); + + // Post 1 has 3 comments + $post1Comments = collect($array['posts'])->first(fn($p) => $p['id'] === 1)['comments']; + $this->assertCount(3, $post1Comments); + } + + /** + * Multiple simultaneous relations and nested relations via array syntax. + */ + public function testMultipleEmbedRelationsAtOnce(): void + { + $user = MockUser::omit('created_at') + ->embed(['comments:body', 'posts.comments:body']) + ->find(1); + $array = $user->toArray(); + + // Top-level comments (user_id=1): ids 1 and 3 + $this->assertCount(2, $array['comments']); + $this->assertArrayNotHasKey('created_at', $array); + + // Nested posts → comments + $this->assertArrayHasKey('posts', $array); + foreach ($array['posts'] as $post) { + $this->assertArrayHasKey('comments', $post); + } + } + + /** + * Three-level: Tag → posts → comments (embedCount on the deepest level). + */ + public function testThreeLevelNestedViaEmbedCount(): void + { + $tag = MockTag::embedCount('posts.comments')->find(1); + $array = $tag->toArray(); + + $this->assertArrayHasKey('posts', $array); + + foreach ($array['posts'] as $post) { + $this->assertArrayHasKey('comments_count', $post); + } + + // Post 1 (First Post) has 3 comments + $post1 = collect($array['posts'])->first(fn($p) => $p['id'] === 1); + $this->assertEquals(3, $post1['comments_count']); + } + + // ========================================================================= + // 8. embedCount + // ========================================================================= + + /** + * embedCount('comments') appends comments_count without loading records. + */ + public function testEmbedCountBasic(): void + { + $posts = MockPost::omit('created_at')->embedCount('comments')->get(); + + $counts = collect($posts->toArray())->mapWithKeys(fn($p) => [$p['id'] => $p['comments_count']]); + + $this->assertEquals(3, $counts[1]); // post 1 + $this->assertEquals(1, $counts[2]); // post 2 + $this->assertEquals(1, $counts[3]); // post 3 + $this->assertEquals(0, $counts[4]); // post 4 + } + + /** + * embedCount with a closure constrains what is counted. + */ + public function testEmbedCountWithConstraint(): void + { + $posts = MockPost::embedCount([ + 'comments' => fn($q) => $q->where('approved', true), + ])->get(); + + // Post 1: comments 1,5 are approved (id=2 is NOT approved) → 2 + $post1 = collect($posts->toArray())->first(fn($p) => $p['id'] === 1); + $this->assertEquals(2, $post1['comments_count']); + } + + /** + * embedCount('tags') on posts counts many-to-many related records. + */ + public function testEmbedCountBindToMany(): void + { + $posts = MockPost::embedCount('tags')->get(); + + $counts = collect($posts->toArray())->mapWithKeys(fn($p) => [$p['id'] => $p['tags_count']]); + + $this->assertEquals(2, $counts[1]); // post 1 has 2 tags + $this->assertEquals(1, $counts[2]); + $this->assertEquals(1, $counts[3]); + $this->assertEquals(1, $counts[4]); + } + + /** + * embedCount mixed: count comments (filtered) AND tags simultaneously. + */ + public function testEmbedCountMultipleRelationsAtOnce(): void + { + $posts = MockPost::omit('views', 'created_at', 'status') + ->embedCount([ + 'comments' => fn($q) => $q->where('approved', true), + 'tags', + ])->get(); + + $p1 = collect($posts->toArray())->first(fn($p) => $p['id'] === 1); + + $this->assertEquals(2, $p1['comments_count']); // 2 approved + $this->assertEquals(2, $p1['tags_count']); // PHP + Doppar + } + + /** + * embedCount on user counts posts_count (linkMany). + */ + public function testEmbedCountLinkManyOnUser(): void + { + $user = MockUser::embedCount('posts')->find(1); + + $this->assertEquals(3, $user->toArray()['posts_count']); + } + + /** + * embedCount combined with embed on same request (count + data together). + */ + public function testEmbedCountCombinedWithEmbed(): void + { + $user = MockUser::omit('created_at') + ->embedCount('comments') + ->embed(['comments:body', 'posts.comments:body']) + ->find(1) + ->toArray(); + + $this->assertEquals(2, $user['comments_count']); + $this->assertCount(2, $user['comments']); + $this->assertArrayHasKey('posts', $user); + } + + // ========================================================================= + // 9. present / absent + // ========================================================================= + + /** + * present('comments') returns only posts that have at least one comment. + */ + public function testPresentFiltersPostsWithComments(): void + { + $posts = MockPost::query()->present('comments')->get(); + + // Post 4 has no comments, so only 3 posts + $this->assertCount(3, $posts); + $ids = $posts->map->id->toArray(); + $this->assertNotContains(4, $ids); + } + + /** + * absent('comments') returns only posts without any comments. + */ + public function testAbsentFiltersPostsWithoutComments(): void + { + $posts = MockPost::query()->absent('comments')->get(); + + $this->assertCount(1, $posts); + $this->assertEquals(4, $posts->first()->id); + } + + /** + * present() with a closure further filters the related model. + */ + public function testPresentWithConstraintClosure(): void + { + $posts = MockPost::query() + ->present('comments', fn($q) => $q->where('body', 'Great post!')) + ->get(); + + // Only post 1 has a comment with that exact body + $this->assertCount(1, $posts); + $this->assertEquals(1, $posts->first()->id); + } + + /** + * present() on a many-to-many relation (tags). + */ + public function testPresentOnBindToMany(): void + { + // All 4 posts have at least 1 tag + $posts = MockPost::query()->present('tags')->get(); + $this->assertCount(4, $posts); + } + + // ========================================================================= + // 10. ifExists / ifNotExists + // ========================================================================= + + /** + * ifExists('comments') is equivalent to present() — posts with ≥1 comment. + */ + public function testIfExistsEquivalentToPresent(): void + { + $postsPresent = MockPost::query()->present('comments')->get(); + $postsIfExists = MockPost::query()->ifExists('comments')->get(); + + $this->assertEquals( + $postsPresent->map->id->toArray(), + $postsIfExists->map->id->toArray() + ); + } + + /** + * ifNotExists('comments') is equivalent to absent(). + */ + public function testIfNotExistsEquivalentToAbsent(): void + { + $postsAbsent = MockPost::query()->absent('comments')->get(); + $postsIfNotExists = MockPost::query()->ifNotExists('comments')->get(); + + $this->assertEquals( + $postsAbsent->map->id->toArray(), + $postsIfNotExists->map->id->toArray() + ); + } + + /** + * ifExists() with a closure constraint. + */ + public function testIfExistsWithConstraint(): void + { + $posts = MockPost::query() + ->ifExists('comments', fn($q) => $q->where('body', 'Great post!')) + ->get(); + + $this->assertCount(1, $posts); + $this->assertEquals(1, $posts->first()->id); + } + + // ========================================================================= + // 11. NESTED ifExists (dot notation) + // ========================================================================= + + /** + * ifExists('posts.comments') on Users returns users whose posts have + * at least one comment matching the closure. + */ + public function testNestedIfExistsWithDotNotation(): void + { + $users = MockUser::query() + ->ifExists('posts.comments', fn($q) => $q->where('body', 'Great post!')) + ->get(); + + // Only John Doe (user_id=1) authored the post that has that comment + $this->assertCount(1, $users); + $this->assertEquals('John Doe', $users->first()->name); + } + + /** + * ifExists('posts.comments') without a closure returns all users who + * have at least one post with at least one comment. + */ + public function testNestedIfExistsNoConstraint(): void + { + $users = MockUser::query() + ->ifExists('posts.comments') + ->get(); + + // John (posts 1,2,4 – posts 1 & 2 have comments) ✓ + // Jane (post 3 has comment) ✓ + // Bob (no posts) ✗ + $this->assertCount(2, $users); + } + + // ========================================================================= + // 12. whereLinked + // ========================================================================= + + /** + * whereLinked('posts', 'status', true) returns users who have at least + * one active post. + */ + public function testWhereLinkedActivePost(): void + { + $users = MockUser::query() + ->whereLinked('posts', 'status', true) + ->orderBy('id', 'asc') + ->get(); + + $this->assertCount(2, $users); + $ids = $users->map->id->toArray(); + $this->assertEquals([1, 2], $ids); + } + + /** + * whereLinked('posts', 'status', false) returns users who have at least + * one inactive post. + */ + public function testWhereLinkedInactivePost(): void + { + $users = MockUser::query() + ->whereLinked('posts', 'status', false) + ->get(); + + // Only John Doe has an inactive post (id=2) + $this->assertCount(1, $users); + $this->assertEquals('John Doe', $users->first()->name); + } + + // ========================================================================= + // 13. link (attach) — many-to-many + // ========================================================================= + + /** + * link() attaches new pivot rows without removing existing ones. + */ + public function testLinkAttachesNewPivotRows(): void + { + $post = MockPost::find(1); // already has tags 1,2 + $post->tags()->link([3]); // attach tag 3 + + $tagIds = MockPost::find(1)->tags->pluck('id')->sort()->values()->toArray(); + $this->assertContains(3, $tagIds); + $this->assertCount(3, $tagIds); // 1,2,3 + } + + /** + * link() is additive — calling it twice does NOT replace previous links. + */ + public function testLinkIsAdditive(): void + { + $post = MockPost::find(2); // has tag 1 + $post->tags()->link([3]); + $post->tags()->link([4]); + + $tagIds = MockPost::find(2)->tags->pluck('id')->sort()->values()->toArray(); + // Should have 1, 3, 4 (tag 1 was already there, 3 and 4 added) + $this->assertContains(1, $tagIds); + $this->assertContains(3, $tagIds); + $this->assertContains(4, $tagIds); + } + + // ========================================================================= + // 14. unlink (detach) — many-to-many + // ========================================================================= + + /** + * unlink() removes specific pivot rows. + */ + public function testUnlinkRemovesPivotRows(): void + { + $post = MockPost::find(1); // tags: 1, 2 + $post->tags()->unlink([1]); + + $tagIds = MockPost::find(1)->tags->pluck('id')->toArray(); + $this->assertNotContains(1, $tagIds); + $this->assertContains(2, $tagIds); + } + + /** + * unlink() all tags leaves an empty collection. + */ + public function testUnlinkAllLeavesEmpty(): void + { + $post = MockPost::find(1); + $post->tags()->unlink([1, 2]); + + $tags = MockPost::find(1)->tags; + $this->assertCount(0, $tags); + } + + /** + * After unlink, we can link again cleanly. + */ + public function testUnlinkThenRelink(): void + { + $post = MockPost::find(1); + $post->tags()->unlink([1, 2]); + $post->tags()->link([3, 4]); + + $tagIds = MockPost::find(1)->tags->pluck('id')->sort()->values()->toArray(); + $this->assertEquals([3, 4], $tagIds); + } + + // ========================================================================= + // 15. relate (sync) — many-to-many + // ========================================================================= + + /** + * relate() replaces all pivot rows to exactly match the given IDs. + */ + public function testRelateSyncsToExactSet(): void + { + $post = MockPost::find(1); // currently tags: 1, 2 + $post->tags()->relate([1, 3]); + + $tagIds = MockPost::find(1)->tags->pluck('id')->sort()->values()->toArray(); + $this->assertEquals([1, 3], $tagIds); + } + + /** + * relate() returns a diff array with attached / detached / updated keys. + */ + public function testRelateDiffReport(): void + { + $post = MockPost::find(1); + + // Sync to [1, 3, 4] from [1, 2] + $changes = $post->tags()->relate([1, 3, 4]); + + $this->assertArrayHasKey('attached', $changes); + $this->assertArrayHasKey('detached', $changes); + $this->assertArrayHasKey('updated', $changes); + + // Tag 3 and 4 should be attached; tag 2 should be detached + $this->assertContains(3, array_keys($changes['attached'])); + $this->assertContains(4, array_keys($changes['attached'])); + + // detached stores IDs as values (not keys) + $this->assertContains(2, array_values($changes['detached'])); + } + + /** + * Calling relate() twice is idempotent for the same set. + */ + public function testRelateIsIdempotentForSameSet(): void + { + $post = MockPost::find(1); + $post->tags()->relate([1, 2]); + $changes = $post->tags()->relate([1, 2]); + + $this->assertEquals([], $changes['attached']); + $this->assertEquals([], $changes['detached']); + } + + // ========================================================================= + // 16. MIXED omit + embed + // ========================================================================= + + /** + * omit() on the parent model does not strip columns from eager-loaded children. + */ + public function testOmitDoesNotAffectEagerLoadedChildren(): void + { + $user = MockUser::omit('created_at')->embed('posts')->find(1); + $array = $user->toArray(); + + // Parent must NOT have created_at + $this->assertArrayNotHasKey('created_at', $array); + + // Children (posts) SHOULD still have created_at + $this->assertArrayHasKey('created_at', $array['posts'][0]); + } + + /** + * omit() + embed + embedCount all cooperate on the same query. + */ + public function testOmitPlusEmbedPlusEmbedCount(): void + { + $user = MockUser::omit('created_at') + ->embedCount('comments') + ->embed('posts') + ->find(1) + ->toArray(); + + $this->assertArrayNotHasKey('created_at', $user); + $this->assertArrayHasKey('comments_count', $user); + $this->assertArrayHasKey('posts', $user); + $this->assertEquals(2, $user['comments_count']); + } + + // ========================================================================= + // 17. toArray serialisation — pivot, null relation, nested recursion + // ========================================================================= + + /** + * toArray() must include pivot as a plain object (not array) for pivot data. + */ + public function testToArrayIncludesPivotAsObject(): void + { + $post = MockPost::embed('tags')->find(1); + $array = $post->toArray(); + + $firstTag = $array['tags'][0]; + $this->assertArrayHasKey('pivot', $firstTag); + $this->assertIsObject($firstTag['pivot']); + $this->assertEquals(1, $firstTag['pivot']->post_id); + $this->assertEquals(1, $firstTag['pivot']->tag_id); + } + + /** + * toArray() includes null relations as null (not missing). + */ + public function testToArrayNullRelationIsPreserved(): void + { + $user = MockUser::find(1); + $user->setRelation('profile', null); + + $array = $user->toArray(); + $this->assertArrayHasKey('profile', $array); + $this->assertNull($array['profile']); + } + + /** + * toArray() recursively converts nested relations. + */ + public function testToArrayRecursesIntoNestedRelation(): void + { + $post = MockPost::embed('user')->find(1); + $array = $post->toArray(); + + $this->assertIsArray($array['user']); + $this->assertEquals('John Doe', $array['user']['name']); + } + + /** + * toArray() on a collection correctly serialises many-to-many with pivot. + */ + public function testToArrayOnCollectionWithPivot(): void + { + $posts = MockPost::query() + ->select('id', 'title', 'user_id') + ->embed('tags') + ->get(); + + $array = $posts->toArray(); + + foreach ($array as $post) { + foreach ($post['tags'] as $tag) { + $this->assertArrayHasKey('pivot', $tag); + $this->assertIsObject($tag['pivot']); + } + } + } + + // ========================================================================= + // 18. COMPLEX COMBINED QUERIES + // ========================================================================= + + /** + * Full complex query: select, embed (with column scope + closure + m2m), + * embedCount, where, first. + */ + public function testFullComplexEagerLoad(): void + { + $post = MockPost::query() + ->where('id', 1) + ->select('id', 'title', 'user_id') + ->embed([ + 'comments:id,body,created_at' => fn($q) => $q + ->where('approved', true) + ->limit(1) + ->oldest('created_at'), + 'tags', + 'user:id,name', + ]) + ->embedCount('comments') + ->where('status', true) + ->first(); + + $array = $post->toArray(); + + // Selected columns only + $this->assertArrayHasKey('id', $array); + $this->assertArrayHasKey('title', $array); + $this->assertArrayHasKey('user_id', $array); + + // embedCount + $this->assertEquals(3, $array['comments_count']); + + // Constrained comments (1 oldest approved comment) + $this->assertCount(1, $array['comments']); + $this->assertEquals('Great post!', $array['comments'][0]['body']); + + // Many-to-many tags with pivot + $this->assertCount(2, $array['tags']); + $this->assertIsObject($array['tags'][0]['pivot']); + + // Column-scoped user (only id + name) + $this->assertArrayHasKey('id', $array['user']); + $this->assertArrayHasKey('name', $array['user']); + $this->assertArrayNotHasKey('email', $array['user']); + $this->assertArrayNotHasKey('created_at', $array['user']); + } + + /** + * Collect all posts, each with tags and comment counts, then filter + * by present() — a realistic "list posts with engagement" query. + */ + public function testListPostsWithTagsAndCommentCounts(): void + { + $posts = MockPost::query() + ->select('id', 'title', 'user_id') + ->embed('tags') + ->embedCount([ + 'comments' => fn($q) => $q->where('approved', true), + ]) + ->present('comments') + ->orderBy('id') + ->get(); + + // Post 4 has no comments so should be excluded + $this->assertCount(3, $posts); + + $ids = $posts->map->id->toArray(); + $this->assertNotContains(4, $ids); + + foreach ($posts->toArray() as $post) { + $this->assertArrayHasKey('tags', $post); + $this->assertArrayHasKey('comments_count', $post); + } + } + + /** + * Users → posts → tags: eager-load two levels, combine with whereLinked + * so only users who have at least one published post are included. + */ + public function testUsersWithPublishedPostsAndTags(): void + { + $users = MockUser::query() + ->whereLinked('posts', 'status', true) + ->embed('posts.tags') + ->orderBy('id') + ->get(); + + $this->assertCount(2, $users); + + foreach ($users->toArray() as $user) { + $this->assertArrayHasKey('posts', $user); + foreach ($user['posts'] as $post) { + $this->assertArrayHasKey('tags', $post); + } + } + } + + /** + * Tag → posts (inverse many-to-many) with embedCount on nested comments. + */ + public function testTagWithPostsAndNestedCommentCounts(): void + { + $tag = MockTag::embedCount('posts.comments')->find(1); + $array = $tag->toArray(); + + $this->assertArrayHasKey('posts', $array); + + $post1 = collect($array['posts'])->first(fn($p) => $p['id'] === 1); + $this->assertEquals(3, $post1['comments_count']); + } + + /** + * Tag with embedCount on posts (top-level many-to-many count). + */ + public function testTagEmbedCountPosts(): void + { + $tag = MockTag::embedCount('posts')->find(1); + $array = $tag->toArray(); + + $this->assertArrayNotHasKey('posts', $array); // count only, no records + $this->assertEquals(2, $array['posts_count']); + } + + /** + * embed() collection result: all posts with their user and the user's + * comments. Tests N+1 prevention at the collection level. + */ + public function testCollectionEmbedTwoLevels(): void + { + $posts = MockPost::embed('user.comments:body')->get(); + + $this->assertCount(4, $posts); + + foreach ($posts->toArray() as $post) { + $this->assertArrayHasKey('user', $post); + $this->assertArrayHasKey('comments', $post['user']); + } + + // Jane's post (id=3) → user=Jane (id=2) → 2 comments (ids 2, 4) + $janePost = collect($posts->toArray())->first(fn($p) => $p['id'] === 3); + $this->assertCount(2, $janePost['user']['comments']); + } + + /** + * When embed is applied to a collection and some parents share the same + * child (Jane's post and John's posts share different users), results + * must be correctly partitioned. + */ + public function testEagerLoadDoesNotCrossContaminateUsers(): void + { + $posts = MockPost::embed('user')->get(); + + foreach ($posts->toArray() as $post) { + if ($post['id'] === 3) { + // Jane Post must point to Jane + $this->assertEquals('Jane Smith', $post['user']['name']); + } else { + // All other posts belong to John Doe + $this->assertEquals('John Doe', $post['user']['name']); + } + } + } +}