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
8 changes: 6 additions & 2 deletions src/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -863,8 +863,12 @@ public static function __callStatic(string $name, array $parameters): mixed
return $instance->$scope($query, ...$parameters);
}

// Forward to Builder (where, orderBy, limit, etc.)
return $instance->newQuery()->$name(...$parameters);
// Forward to ModelBuilder (select, where, orderBy, limit, etc.) so
// every static entry point — not just the hand-written where() —
// returns a model-aware builder. Without this, calls like
// Model::select(...) leaked the raw Query\Builder, which knows
// nothing about relations and breaks with() being chained after it.
return static::query()->$name(...$parameters);
}

// -----------------------------------------------------------------------
Expand Down
25 changes: 24 additions & 1 deletion src/Eloquent/ModelBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,29 @@ public function getBindings(): array
return $this->builder->getBindings();
}

/**
* Eager-load the given relations on this query.
*
* Unlike Model::with(), which must be the first call in a chain,
* this allows with() to be added at any point after select(),
* where(), or any other Builder method — matching the behaviour
* Eloquent users expect from frameworks like Laravel, where with()
* is order-independent:
*
* Page::select('id', 'slug')->with('translations')->where(...)->first();
* Page::where(...)->with('translations')->select('id', 'slug')->first();
*
* Returns an EagerBuilder seeded with the current underlying Builder,
* so all constraints already applied (select, where, etc.) are kept.
*
* @param string|array<string|int, string|callable> ...$relations
* @return EagerBuilder
*/
public function with(string|array ...$relations): EagerBuilder
{
return (new EagerBuilder($this->builder, $this->modelClass, []))->with(...$relations);
}

// -----------------------------------------------------------------------
// Forward all other Builder methods transparently
// -----------------------------------------------------------------------
Expand All @@ -221,4 +244,4 @@ public function __call(string $name, array $arguments): mixed

return $result;
}
}
}
89 changes: 88 additions & 1 deletion tests/Integration/RelationsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,93 @@ public function test_to_array_includes_eager_loaded_relations(): void
$this->assertIsArray($alice['posts']);
$this->assertCount(2, $alice['posts']);
}

// -----------------------------------------------------------------------
// with() is order-independent when chained after select() / where()
//
// Regression coverage for: Model::select(...)->with(...) throwing
// "Call to unknown method: Foxdb\Query\Builder::with()".
//
// Root cause: Model::__callStatic() forwarded unmatched static calls
// (select, orderBy, limit, ...) straight to the raw Query\Builder
// instead of through ModelBuilder, so the result of select() had no
// knowledge of with() at all — only Model::with() (the entry point)
// returned something that understood eager loading. These tests pin
// down that with() now works no matter where it appears in the chain,
// matching the order-independent behaviour Eloquent users expect from
// frameworks like Laravel.
// -----------------------------------------------------------------------

public function test_with_after_select_eager_loads_relation(): void
{
$this->seedAll();

// This exact chain — select() first, with() second — is what
// originally raised "Call to unknown method: Builder::with()".
$users = RelUser::select('id', 'name')->with('posts')->get();

$alice = $users->first(fn($u) => $u->name === 'Alice');
$this->assertInstanceOf(Collection::class, $alice->posts);
$this->assertCount(2, $alice->posts);
}

public function test_with_after_select_and_where_returns_single_model(): void
{
$this->seedAll();

// Mirrors the real-world case: select(...)->with([...])->where(...)->first()
$user = RelUser::select('id', 'name')
->with('posts')
->where('name', 'Alice')
->first();

$this->assertInstanceOf(RelUser::class, $user);
$this->assertSame('Alice', $user->name);
$this->assertInstanceOf(Collection::class, $user->posts);
$this->assertCount(2, $user->posts);
}

public function test_with_after_where_before_select_still_works(): void
{
$this->seedAll();

$user = RelUser::where('name', 'Alice')
->with('posts')
->select('id', 'name')
->first();

$this->assertInstanceOf(RelUser::class, $user);
$this->assertCount(2, $user->posts);
}

public function test_with_constraint_closure_applies_after_select(): void
{
$this->seedAll();

// The reported scenario also relied on a constraint closure
// (filtering the eager-loaded relation), not just a bare relation
// name — make sure that keeps working through select() too.
$alice = RelUser::select('id', 'name')
->with(['posts' => fn($q) => $q->where('title', 'Post 1')])
->where('name', 'Alice')
->first();

$this->assertCount(1, $alice->posts);
$this->assertSame('Post 1', $alice->posts->first()->title);
}

public function test_select_without_with_is_unaffected(): void
{
// Regression guard: plain select() with no with() in the chain
// must keep returning normal model instances, not break or
// silently turn into an EagerBuilder/Collection of relations.
$this->seedAll();

$user = RelUser::select('id', 'name')->where('name', 'Alice')->first();

$this->assertInstanceOf(RelUser::class, $user);
$this->assertSame('Alice', $user->name);
}
}

// -----------------------------------------------------------------------
Expand Down Expand Up @@ -268,4 +355,4 @@ class RelComment extends Model
protected string $table = 'rel_comments';
protected array $fillable = ['post_id', 'body'];
protected bool $timestamps = false;
}
}
Loading