From 65d23dc0cdcc2e020ad943dff08e7b4f8d994f3f Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Mon, 19 Oct 2026 16:51:19 +0600 Subject: [PATCH 1/2] Honor overridden query() on instance calls to the model --- src/Phaseolies/Database/Entity/Model.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Phaseolies/Database/Entity/Model.php b/src/Phaseolies/Database/Entity/Model.php index f8eef04b..90d5a61b 100644 --- a/src/Phaseolies/Database/Entity/Model.php +++ b/src/Phaseolies/Database/Entity/Model.php @@ -1401,6 +1401,6 @@ public function __call($method, $parameters) return $this; } - return $this->newQuery()->$method(...$parameters); + return static::query($this->connection)->$method(...$parameters); } } From 34552e46a5f1434cf58a62de69a6f7de241e980b Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Mon, 19 Oct 2026 17:07:14 +0600 Subject: [PATCH 2/2] test: cover instance-call query() override and bind methods on models --- .../Model/ModelInstanceQueryOverrideTest.php | 147 ++++++++++++++++++ tests/Support/Model/MockScopedUser.php | 38 +++++ 2 files changed, 185 insertions(+) create mode 100644 tests/Model/ModelInstanceQueryOverrideTest.php create mode 100644 tests/Support/Model/MockScopedUser.php diff --git a/tests/Model/ModelInstanceQueryOverrideTest.php b/tests/Model/ModelInstanceQueryOverrideTest.php new file mode 100644 index 00000000..e3d51e01 --- /dev/null +++ b/tests/Model/ModelInstanceQueryOverrideTest.php @@ -0,0 +1,147 @@ +singleton('str', StringService::class); + Container::setInstance($container); + + $this->pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->pdo->exec('CREATE TABLE scoped_users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, role TEXT)'); + $this->pdo->exec("CREATE TABLE other_users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, role TEXT)"); + + foreach (['a' => 'admin', 'b' => 'user', 'c' => 'admin', 'd' => 'user'] as $name => $role) { + $this->pdo->exec("INSERT INTO scoped_users (name, role) VALUES ('$name', '$role')"); + } + $this->pdo->exec("INSERT INTO other_users (name, role) VALUES ('x', 'admin'), ('y', 'admin'), ('z', 'admin')"); + + $this->setStatic('connections', ['default' => $this->pdo, 'sqlite' => $this->pdo]); + $this->setStatic('transactions', []); + } + + protected function tearDown(): void + { + $this->setStatic('connections', []); + $this->setStatic('transactions', []); + $this->pdo = null; + } + + /** Baseline: the static path applies the override */ + public function testStaticCallsApplyQueryOverride(): void + { + $this->assertSame(['c', 'd'], MockScopedUser::all()->pluck('name')->toArray()); + $this->assertSame('c', MockScopedUser::first()->name); + } + + /** The reported bug: first() on an instance must honor the override */ + public function testInstanceFirstAppliesQueryOverride(): void + { + $this->assertSame('c', (new MockScopedUser())->first()->name); + } + + /** Instance chain methods start from the overridden query */ + public function testInstanceBuilderMethodsApplyQueryOverride(): void + { + $names = (new MockScopedUser())->orderBy('id')->get()->pluck('name')->toArray(); + + $this->assertSame(['c', 'd'], $names); + } + + /** Instance and static calls must return identical results */ + public function testInstanceAndStaticCallsAreEquivalent(): void + { + $this->assertSame( + MockScopedUser::where('role', 'user')->get()->pluck('name')->toArray(), + (new MockScopedUser())->where('role', 'user')->get()->pluck('name')->toArray() + ); + $this->assertSame(MockScopedUser::count(), (new MockScopedUser())->count()); + } + + /** Instance calls cannot reach rows the override hides */ + public function testInstanceCallCannotSeeHiddenRows(): void + { + $this->assertNull((new MockScopedUser())->where('name', 'a')->first()); + } + + /** Bind methods (__admin) still resolve on instance calls, combined with the override */ + public function testBindMethodResolvesOnInstanceAndKeepsOverride(): void + { + $names = (new MockScopedUser())->admin()->get()->pluck('name')->toArray(); + + $this->assertSame(['c'], $names); + $this->assertSame(['c'], MockScopedUser::admin()->get()->pluck('name')->toArray()); + } + + /** Bind methods work in a chain after an instance call */ + public function testBindMethodChainedAfterInstanceCall(): void + { + $result = (new MockScopedUser())->where('name', 'c')->admin('admin')->first(); + + $this->assertSame('c', $result->name); + } + + /** Internal writes use newQuery() and must NOT be scoped by the override */ + public function testInstanceWritesAreNotAffectedByOverride(): void + { + // newQuery() bypasses the override, so the hidden row can be loaded + $model = (new MockScopedUser())->newQuery()->where('id', 1)->first(); + $this->assertNotNull($model); + + $model->name = 'renamed'; + $model->save(); + + $this->assertSame( + 'renamed', + $this->pdo->query("SELECT name FROM scoped_users WHERE id = 1")->fetchColumn() + ); + } + + /** Unknown methods still throw instead of being swallowed */ + public function testUnknownMethodStillThrows(): void + { + $this->expectException(\BadMethodCallException::class); + + (new MockScopedUser())->definitelyNotAMethod(); + } + + /** withoutHook() is still intercepted and returns the instance */ + public function testWithoutHookReturnsInstance(): void + { + $model = new MockScopedUser(); + + $this->assertSame($model, $model->withoutHook()); + } + + /** + * Documented trade-off: a runtime setTable() on the instance is ignored by + * dynamic calls because query() builds a fresh model. Locks the behavior in. + */ + public function testRuntimeSetTableIsIgnoredByDynamicCalls(): void + { + $model = new MockScopedUser(); + $model->setTable('other_users'); + + // Queries scoped_users (ids 3, 4), not other_users (ids 1-3) + $this->assertSame(['c', 'd'], $model->orderBy('id')->get()->pluck('name')->toArray()); + } + + private function setStatic(string $property, mixed $value): void + { + (new \ReflectionClass(Database::class))->getProperty($property)->setValue(null, $value); + } +} diff --git a/tests/Support/Model/MockScopedUser.php b/tests/Support/Model/MockScopedUser.php new file mode 100644 index 00000000..85ccf46b --- /dev/null +++ b/tests/Support/Model/MockScopedUser.php @@ -0,0 +1,38 @@ +where('id', '>', 2); + } + + /** + * Bind method, resolved as admin() + * + * @param Builder $query + * @param string $role + * @return Builder + */ + public function __admin(Builder $query, string $role = 'admin'): Builder + { + return $query->where('role', $role); + } +}