diff --git a/src/Eloquent/Model.php b/src/Eloquent/Model.php index 0231cfe..2bb8852 100644 --- a/src/Eloquent/Model.php +++ b/src/Eloquent/Model.php @@ -119,8 +119,8 @@ * * EXECUTE / FETCH * @method static Collection get() - * @method static object|false first() - * @method static object|false find(int|string $id) + * @method static static|null first() + * @method static static|null find(int|string $id) * @method static mixed value(string $column) * @method static array pluck(string $column, string|null $keyColumn = null) * @method static void chunk(int $size, callable $callback) @@ -153,7 +153,7 @@ * @method static string toSql() * @method static array getBindings() */ -abstract class Model +abstract class Model implements \JsonSerializable { use HasTimestamps; use HasCasts; @@ -609,13 +609,14 @@ public function refresh(): static // ----------------------------------------------------------------------- /** - * Get a new Builder for this model's table, with the soft-delete scope applied. + * Get a new ModelBuilder for this model's table, with the soft-delete scope applied. + * ModelBuilder wraps the raw Builder and exposes correct return types for IDE support. * - * @return Builder + * @return ModelBuilder */ - public static function query(): Builder + public static function query(): ModelBuilder { - return (new static())->newQuery(); + return new ModelBuilder((new static())->newQuery(), static::class); } /** @@ -646,7 +647,7 @@ public static function with(string|array ...$relations): \Foxdb\Eloquent\EagerBu } } - return new \Foxdb\Eloquent\EagerBuilder(static::query(), static::class, $withs); + return new \Foxdb\Eloquent\EagerBuilder((new static())->newQuery(), static::class, $withs); } /** @@ -715,13 +716,13 @@ public static function create(array $attributes): static * @param string|callable $column * @param mixed $operatorOrValue * @param mixed $value - * @return Builder + * @return ModelBuilder */ public static function where( string|callable $column, mixed $operatorOrValue = null, mixed $value = null, - ): Builder { + ): ModelBuilder { return static::query()->where($column, $operatorOrValue, $value); } @@ -922,6 +923,20 @@ public function toJson(int $flags = JSON_UNESCAPED_UNICODE): string return (string) json_encode($this->toArray(), $flags); } + /** + * Implement JsonSerializable so json_encode($model) works correctly. + * + * Without this, json_encode() on a Model produces {} because PHP + * serializes only public properties — and Model has none. + * With this, json_encode($model) is identical to $model->toJson(). + * + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + // ----------------------------------------------------------------------- // Magic property access // ----------------------------------------------------------------------- diff --git a/src/Eloquent/ModelBuilder.php b/src/Eloquent/ModelBuilder.php new file mode 100644 index 0000000..25581d3 --- /dev/null +++ b/src/Eloquent/ModelBuilder.php @@ -0,0 +1,224 @@ +first() returns `object|false` + * (the Builder return type) and the IDE has no way to know the result + * is a User instance. + * + * All Builder methods are forwarded transparently via __call so that + * every chainable method (where, orderBy, limit, etc.) still works and + * continues to return $this (a ModelBuilder) rather than the raw Builder. + * + * @template TModel of Model + */ +class ModelBuilder +{ + /** + * @param Builder $builder The underlying query builder + * @param class-string $modelClass The model class being queried + */ + public function __construct( + protected Builder $builder, + protected string $modelClass, + ) {} + + // ----------------------------------------------------------------------- + // Terminal methods — declared explicitly so IDEs see the right types + // ----------------------------------------------------------------------- + + /** + * Execute the query and return all matching model instances. + * + * @return Collection + */ + public function get(): Collection + { + return $this->builder->get(); + } + + /** + * Execute the query and return the first matching model instance, + * or null if no row matches. + * + * @return TModel|null + */ + public function first(): ?Model + { + $result = $this->builder->first(); + return $result instanceof Model ? $result : null; + } + + /** + * Find a model by its primary key, or return null. + * + * @param int|string $id + * @return TModel|null + */ + public function find(int|string $id): ?Model + { + $result = $this->builder->find($id); + return $result instanceof Model ? $result : null; + } + + /** + * Paginate the results. + * + * @param int $perPage + * @param int $page + * @return object{total:int,per_page:int,current_page:int,last_page:int,from:int,to:int,data:Collection} + */ + public function paginate(int $perPage = 15, int $page = 1): object + { + return $this->builder->paginate($perPage, $page); + } + + /** + * Return the value of a single column from the first matching row. + * + * @param string $column + * @return mixed + */ + public function value(string $column): mixed + { + return $this->builder->value($column); + } + + /** + * Return a flat array of values for a single column. + * + * @param string $column + * @param string|null $keyColumn + * @return array + */ + public function pluck(string $column, ?string $keyColumn = null): array + { + return $this->builder->pluck($column, $keyColumn); + } + + /** + * Return the count of matching rows. + * + * @param string $column + * @return int + */ + public function count(string $column = '*'): int + { + return $this->builder->count($column); + } + + /** + * Return the sum of a column. + */ + public function sum(string $column): float|int + { + return $this->builder->sum($column); + } + + /** + * Return the average of a column. + */ + public function avg(string $column): float|int + { + return $this->builder->avg($column); + } + + /** + * Return the minimum value of a column. + */ + public function min(string $column): float|int|null + { + return $this->builder->min($column); + } + + /** + * Return the maximum value of a column. + */ + public function max(string $column): float|int|null + { + return $this->builder->max($column); + } + + /** + * Check whether any matching row exists. + */ + public function exists(): bool + { + return $this->builder->exists(); + } + + /** + * Update matching rows. + * + * @param array $values + * @return int Number of affected rows + */ + public function update(array $values): int + { + return $this->builder->update($values); + } + + /** + * Delete matching rows. + * + * @return int Number of affected rows + */ + public function delete(): int + { + return $this->builder->delete(); + } + + /** + * Return the compiled SQL string. + */ + public function toSql(): string + { + return $this->builder->toSql(); + } + + /** + * Return the current bindings array. + * + * @return array + */ + public function getBindings(): array + { + return $this->builder->getBindings(); + } + + // ----------------------------------------------------------------------- + // Forward all other Builder methods transparently + // ----------------------------------------------------------------------- + + /** + * Forward any Builder method not declared above (where, orderBy, limit, + * join, groupBy, etc.). If the Builder returns itself, we return $this + * (the ModelBuilder) so the chain stays intact and the IDE continues + * to see the correct type. + * + * @param string $name + * @param array $arguments + * @return static|mixed + */ + public function __call(string $name, array $arguments): mixed + { + $result = $this->builder->$name(...$arguments); + + // If Builder returned itself, keep the chain on ModelBuilder + if ($result === $this->builder) { + return $this; + } + + return $result; + } +} diff --git a/tests/Integration/ModelCrudTest.php b/tests/Integration/ModelCrudTest.php index e26247d..af1dd88 100644 --- a/tests/Integration/ModelCrudTest.php +++ b/tests/Integration/ModelCrudTest.php @@ -307,6 +307,92 @@ public function test_collection_json_encode_works_correctly(): void $this->assertSame('Alice', $decoded['users'][0]['name']); } + // ----------------------------------------------------------------------- + // json_encode / return model directly — regression tests + // + // Before the fix: + // $user = User::where(...)->first() returned object|false (Builder type) + // json_encode($user) produced {} because Model did not implement + // JsonSerializable and PHP only serializes public properties. + // ----------------------------------------------------------------------- + + public function test_json_encode_on_model_from_where_first_is_not_empty(): void + { + TestUser::create(['name' => 'Alice', 'email' => 'a@test.com', 'age' => 25]); + + $user = TestUser::where('name', 'Alice')->first(); + + // Must be a model instance, not false or null + $this->assertInstanceOf(TestUser::class, $user); + + // json_encode($model) must produce valid, non-empty JSON + $json = json_encode($user); + $this->assertJson($json); + + $decoded = json_decode($json, true); + $this->assertNotEmpty($decoded, 'json_encode($user) returned {} — JsonSerializable not implemented'); + $this->assertSame('Alice', $decoded['name']); + $this->assertSame(25, $decoded['age']); + } + + public function test_return_model_from_where_first_in_api_response(): void + { + TestUser::create(['name' => 'Alice', 'email' => 'a@test.com', 'age' => 25]); + + $user = TestUser::where('name', 'Alice')->first(); + + // Simulate what a controller does: return the model inside an array + $response = ['ok' => true, 'user' => $user]; + $json = json_encode($response); + + $this->assertJson($json); + $decoded = json_decode($json, true); + + $this->assertTrue($decoded['ok']); + $this->assertIsArray($decoded['user']); + $this->assertSame('Alice', $decoded['user']['name']); + $this->assertArrayNotHasKey('settings', $decoded['user']); // hidden field excluded + } + + public function test_json_encode_respects_hidden_fields(): void + { + TestUser::create(['name' => 'Alice', 'email' => 'a@test.com', 'age' => 25, 'settings' => ['x' => 1]]); + + $user = TestUser::where('name', 'Alice')->first(); + $json = json_encode($user); + $decoded = json_decode($json, true); + + // 'settings' is in $hidden — must not appear in json_encode output + $this->assertArrayNotHasKey('settings', $decoded); + // 'name' is not hidden — must appear + $this->assertArrayHasKey('name', $decoded); + } + + public function test_json_encode_on_model_from_find(): void + { + $created = TestUser::create(['name' => 'Alice', 'email' => 'a@test.com', 'age' => 25]); + + $user = TestUser::find($created->getKey()); + $json = json_encode($user); + $decoded = json_decode($json, true); + + $this->assertNotEmpty($decoded); + $this->assertSame('Alice', $decoded['name']); + } + + public function test_where_first_returns_model_instance_not_false(): void + { + TestUser::create(['name' => 'Alice', 'email' => 'a@test.com', 'age' => 25]); + + // Must return the model, not the Builder's false + $user = TestUser::where('name', 'Alice')->first(); + $this->assertInstanceOf(TestUser::class, $user); + + // Must return null (not false) when nothing matches + $nobody = TestUser::where('name', 'Nobody')->first(); + $this->assertNull($nobody); + } + // ----------------------------------------------------------------------- // Local scope // -----------------------------------------------------------------------