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); } // ----------------------------------------------------------------------- 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 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