Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/Eloquent/ModelBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<mixed> $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
Expand Down
40 changes: 40 additions & 0 deletions tests/Integration/ModelCrudTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -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);
}
}
Loading