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
35 changes: 25 additions & 10 deletions src/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -153,7 +153,7 @@
* @method static string toSql()
* @method static array getBindings()
*/
abstract class Model
abstract class Model implements \JsonSerializable
{
use HasTimestamps;
use HasCasts;
Expand Down Expand Up @@ -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<static>
*/
public static function query(): Builder
public static function query(): ModelBuilder
{
return (new static())->newQuery();
return new ModelBuilder((new static())->newQuery(), static::class);
}

/**
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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<static>
*/
public static function where(
string|callable $column,
mixed $operatorOrValue = null,
mixed $value = null,
): Builder {
): ModelBuilder {
return static::query()->where($column, $operatorOrValue, $value);
}

Expand Down Expand Up @@ -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<string, mixed>
*/
public function jsonSerialize(): array
{
return $this->toArray();
}

// -----------------------------------------------------------------------
// Magic property access
// -----------------------------------------------------------------------
Expand Down
224 changes: 224 additions & 0 deletions src/Eloquent/ModelBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
<?php

declare(strict_types=1);

namespace Foxdb\Eloquent;

use Foxdb\Query\Builder;
use Foxdb\Support\Collection;

/**
* ModelBuilder — a thin decorator around Builder that carries the model
* class name so that terminal methods (first, find, get) can declare
* correct `static`-aware return types for IDE autocompletion.
*
* Without this wrapper, User::where(...)->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<TModel> $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<int, TModel>
*/
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<mixed>
*/
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<string, mixed> $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<mixed>
*/
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<mixed> $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;
}
}
Loading
Loading