Skip to content

Commit f6e9147

Browse files
authored
Merge pull request #64 from webrium/fix/chained-local-scopes
Fix/chained local scopes
2 parents 7fb5aa6 + d0a71d8 commit f6e9147

3 files changed

Lines changed: 68 additions & 2 deletions

File tree

src/Eloquent/Model.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -863,8 +863,18 @@ public static function __callStatic(string $name, array $parameters): mixed
863863
$scope = 'scope' . ucfirst($name);
864864

865865
if (method_exists($instance, $scope)) {
866-
$query = $instance->newQuery();
867-
return $instance->$scope($query, ...$parameters);
866+
$query = $instance->newQuery();
867+
$result = $instance->$scope($query, ...$parameters);
868+
869+
// Keep the chain model-aware (wrap back into ModelBuilder) so
870+
// further calls — another scope, with(), etc. — can still be
871+
// chained after this one. Without this, a second scope call
872+
// like Model::active()->adult() fails with "Call to undefined
873+
// method Query\Builder::adult()", because the raw Builder
874+
// returned here has no idea what a "scope" is.
875+
return $result instanceof Builder
876+
? new ModelBuilder($result, static::class)
877+
: $result;
868878
}
869879

870880
// Forward to ModelBuilder (select, where, orderBy, limit, etc.) so

src/Eloquent/ModelBuilder.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,12 +229,28 @@ public function with(string|array ...$relations): EagerBuilder
229229
* (the ModelBuilder) so the chain stays intact and the IDE continues
230230
* to see the correct type.
231231
*
232+
* Local scopes (scopeXxx on the model) are resolved here too, so a
233+
* scope stays chainable after another scope — e.g.
234+
* Model::active()->adult()->get() — and not just after plain Builder
235+
* methods. Without this, $name is forwarded straight to the raw
236+
* Builder, which knows nothing about scopes and throws "Call to
237+
* undefined method".
238+
*
232239
* @param string $name
233240
* @param array<mixed> $arguments
234241
* @return static|mixed
235242
*/
236243
public function __call(string $name, array $arguments): mixed
237244
{
245+
$modelInstance = new $this->modelClass();
246+
$scope = 'scope' . ucfirst($name);
247+
248+
if (method_exists($modelInstance, $scope)) {
249+
$result = $modelInstance->$scope($this->builder, ...$arguments);
250+
251+
return $result === $this->builder ? $this : $result;
252+
}
253+
238254
$result = $this->builder->$name(...$arguments);
239255

240256
// If Builder returned itself, keep the chain on ModelBuilder

tests/Integration/ModelCrudTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,41 @@ public function test_local_scope(): void
407407
$this->assertSame('Alice', $active->first()->name);
408408
}
409409

410+
/**
411+
* Regression: chaining a second local scope after the first one used
412+
* to fail with "Call to undefined method Foxdb\Query\Builder::adult()"
413+
* because the first scope call returned a raw Query\Builder instead of
414+
* a model-aware ModelBuilder, so nothing could resolve scopeAdult()
415+
* as a scope anymore.
416+
*/
417+
public function test_chained_local_scopes(): void
418+
{
419+
TestUser::create(['name' => 'Alice', 'email' => 'a@test.com', 'age' => 25, 'is_active' => 1]); // active adult
420+
TestUser::create(['name' => 'Bob', 'email' => 'b@test.com', 'age' => 15, 'is_active' => 1]); // active minor
421+
TestUser::create(['name' => 'Carol', 'email' => 'c@test.com', 'age' => 30, 'is_active' => 0]); // inactive adult
422+
423+
$result = TestUser::active()->adult()->get();
424+
425+
$this->assertCount(1, $result);
426+
$this->assertSame('Alice', $result->first()->name);
427+
}
428+
429+
/**
430+
* Regression: a scope must also stay chainable with ordinary Builder
431+
* methods that come after it (where, orderBy, ...), not just other
432+
* scopes.
433+
*/
434+
public function test_local_scope_chained_with_builder_methods(): void
435+
{
436+
TestUser::create(['name' => 'Alice', 'email' => 'a@test.com', 'age' => 25, 'is_active' => 1]);
437+
TestUser::create(['name' => 'Bob', 'email' => 'b@test.com', 'age' => 30, 'is_active' => 1]);
438+
439+
$result = TestUser::active()->where('age', 30)->orderByDesc('age')->get();
440+
441+
$this->assertCount(1, $result);
442+
$this->assertSame('Bob', $result->first()->name);
443+
}
444+
410445
// -----------------------------------------------------------------------
411446
// Pagination
412447
// -----------------------------------------------------------------------
@@ -488,4 +523,9 @@ public function scopeActive(Builder $q): Builder
488523
{
489524
return $q->where('is_active', 1);
490525
}
526+
527+
public function scopeAdult(Builder $q): Builder
528+
{
529+
return $q->where('age', '>=', 18);
530+
}
491531
}

0 commit comments

Comments
 (0)