From 4e2acf5e3ce308184a004dbd1aa29749a5f3fd42 Mon Sep 17 00:00:00 2001 From: benkhalife Date: Tue, 30 Jun 2026 14:32:27 +0330 Subject: [PATCH 1/3] fix(eloquent): route __callStatic fallback through ModelBuilder Model::__callStatic() forwarded unmatched static calls (select, orderBy, limit, ...) directly to the raw Query\Builder instead of ModelBuilder. Only the hand-written where() went through static::query(). As a result, Model::select(...) returned a builder with no knowledge of relations, breaking with() when chained after it (or after any other forwarded method). --- src/Eloquent/Model.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Eloquent/Model.php b/src/Eloquent/Model.php index 1bcfd0d..c243805 100644 --- a/src/Eloquent/Model.php +++ b/src/Eloquent/Model.php @@ -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); } // ----------------------------------------------------------------------- From 7830a8c9113feed41f3b11046fe50a123cb22a03 Mon Sep 17 00:00:00 2001 From: benkhalife Date: Tue, 30 Jun 2026 14:32:44 +0330 Subject: [PATCH 2/3] feat(eloquent): add with() to ModelBuilder for order-independent eager loading Adds ModelBuilder::with(), returning an EagerBuilder seeded with the current underlying query (preserving any select/where/etc already applied). Combined with the __callStatic fix, this makes with() order-independent in the chain, matching the behaviour expected from Eloquent-style ORMs (e.g. Laravel): Page::select('id', 'slug')->with('translations')->where(...)->first(); Page::where(...)->with('translations')->select('id', 'slug')->first(); Previously with() only worked as the very first call via Model::with(). --- src/Eloquent/ModelBuilder.php | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Eloquent/ModelBuilder.php b/src/Eloquent/ModelBuilder.php index 25581d3..d8979c4 100644 --- a/src/Eloquent/ModelBuilder.php +++ b/src/Eloquent/ModelBuilder.php @@ -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 ...$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 // ----------------------------------------------------------------------- @@ -221,4 +244,4 @@ public function __call(string $name, array $arguments): mixed return $result; } -} +} \ No newline at end of file From 5a01ce91c679cc9f94c29c9939f88df2aaf89d11 Mon Sep 17 00:00:00 2001 From: benkhalife Date: Tue, 30 Jun 2026 14:33:03 +0330 Subject: [PATCH 3/3] test(relations): cover with() chained after select()/where() Adds regression coverage for Model::select(...)->with(...) throwing "Call to unknown method: Foxdb\Query\Builder::with()", plus several chain-order variants (where->with->select, with->select->where), a with() constraint-closure case, and a guard confirming plain select() without with() is unaffected. --- tests/Integration/RelationsTest.php | 89 ++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/tests/Integration/RelationsTest.php b/tests/Integration/RelationsTest.php index a37721a..dade02d 100644 --- a/tests/Integration/RelationsTest.php +++ b/tests/Integration/RelationsTest.php @@ -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); + } } // ----------------------------------------------------------------------- @@ -268,4 +355,4 @@ class RelComment extends Model protected string $table = 'rel_comments'; protected array $fillable = ['post_id', 'body']; protected bool $timestamps = false; -} +} \ No newline at end of file