diff --git a/src/Eloquent/Model.php b/src/Eloquent/Model.php index bfa2a13..5539f36 100644 --- a/src/Eloquent/Model.php +++ b/src/Eloquent/Model.php @@ -863,8 +863,18 @@ public static function __callStatic(string $name, array $parameters): mixed $scope = 'scope' . ucfirst($name); if (method_exists($instance, $scope)) { - $query = $instance->newQuery(); - return $instance->$scope($query, ...$parameters); + $query = $instance->newQuery(); + $result = $instance->$scope($query, ...$parameters); + + // Keep the chain model-aware (wrap back into ModelBuilder) so + // further calls — another scope, with(), etc. — can still be + // chained after this one. Without this, a second scope call + // like Model::active()->adult() fails with "Call to undefined + // method Query\Builder::adult()", because the raw Builder + // returned here has no idea what a "scope" is. + return $result instanceof Builder + ? new ModelBuilder($result, static::class) + : $result; } // Forward to ModelBuilder (select, where, orderBy, limit, etc.) so diff --git a/src/Eloquent/ModelBuilder.php b/src/Eloquent/ModelBuilder.php index d8979c4..ecfd06a 100644 --- a/src/Eloquent/ModelBuilder.php +++ b/src/Eloquent/ModelBuilder.php @@ -229,12 +229,28 @@ public function with(string|array ...$relations): EagerBuilder * (the ModelBuilder) so the chain stays intact and the IDE continues * to see the correct type. * + * Local scopes (scopeXxx on the model) are resolved here too, so a + * scope stays chainable after another scope — e.g. + * Model::active()->adult()->get() — and not just after plain Builder + * methods. Without this, $name is forwarded straight to the raw + * Builder, which knows nothing about scopes and throws "Call to + * undefined method". + * * @param string $name * @param array $arguments * @return static|mixed */ public function __call(string $name, array $arguments): mixed { + $modelInstance = new $this->modelClass(); + $scope = 'scope' . ucfirst($name); + + if (method_exists($modelInstance, $scope)) { + $result = $modelInstance->$scope($this->builder, ...$arguments); + + return $result === $this->builder ? $this : $result; + } + $result = $this->builder->$name(...$arguments); // If Builder returned itself, keep the chain on ModelBuilder diff --git a/tests/Integration/ModelCrudTest.php b/tests/Integration/ModelCrudTest.php index af1dd88..e71b22b 100644 --- a/tests/Integration/ModelCrudTest.php +++ b/tests/Integration/ModelCrudTest.php @@ -407,6 +407,41 @@ public function test_local_scope(): void $this->assertSame('Alice', $active->first()->name); } + /** + * Regression: chaining a second local scope after the first one used + * to fail with "Call to undefined method Foxdb\Query\Builder::adult()" + * because the first scope call returned a raw Query\Builder instead of + * a model-aware ModelBuilder, so nothing could resolve scopeAdult() + * as a scope anymore. + */ + public function test_chained_local_scopes(): void + { + TestUser::create(['name' => 'Alice', 'email' => 'a@test.com', 'age' => 25, 'is_active' => 1]); // active adult + TestUser::create(['name' => 'Bob', 'email' => 'b@test.com', 'age' => 15, 'is_active' => 1]); // active minor + TestUser::create(['name' => 'Carol', 'email' => 'c@test.com', 'age' => 30, 'is_active' => 0]); // inactive adult + + $result = TestUser::active()->adult()->get(); + + $this->assertCount(1, $result); + $this->assertSame('Alice', $result->first()->name); + } + + /** + * Regression: a scope must also stay chainable with ordinary Builder + * methods that come after it (where, orderBy, ...), not just other + * scopes. + */ + public function test_local_scope_chained_with_builder_methods(): void + { + TestUser::create(['name' => 'Alice', 'email' => 'a@test.com', 'age' => 25, 'is_active' => 1]); + TestUser::create(['name' => 'Bob', 'email' => 'b@test.com', 'age' => 30, 'is_active' => 1]); + + $result = TestUser::active()->where('age', 30)->orderByDesc('age')->get(); + + $this->assertCount(1, $result); + $this->assertSame('Bob', $result->first()->name); + } + // ----------------------------------------------------------------------- // Pagination // ----------------------------------------------------------------------- @@ -488,4 +523,9 @@ public function scopeActive(Builder $q): Builder { return $q->where('is_active', 1); } + + public function scopeAdult(Builder $q): Builder + { + return $q->where('age', '>=', 18); + } } \ No newline at end of file