From 994794977751317d43ff42ad16b2fa34fe9a2e90 Mon Sep 17 00:00:00 2001 From: rtcoder Date: Wed, 17 Jun 2026 14:39:36 +0200 Subject: [PATCH] Add model query builder --- README.md | 54 ++++ REFACTORING.md | 1 + docs/index.html | 44 +++ src/Console/Commands/CreateModel.php | 1 + src/Console/Generator/NameFormatter.php | 7 + src/Console/Generator/stubs/model.stub | 5 +- src/Database/Attributes/Cast.php | 15 ++ src/Database/Attributes/Guarded.php | 10 + src/Database/Attributes/PrimaryKey.php | 10 + src/Database/Database.php | 10 + src/Database/Model.php | 341 ++++++++++++++++++++++++ src/Database/ModelQueryBuilder.php | 141 ++++++++++ src/Database/QueryBuilder.php | 247 +++++++++++++++++ tests/Feature/ConsoleCreateTest.php | 3 + tests/Feature/ModelQueryBuilderTest.php | 155 +++++++++++ 15 files changed, 1043 insertions(+), 1 deletion(-) create mode 100644 src/Database/Attributes/Cast.php create mode 100644 src/Database/Attributes/Guarded.php create mode 100644 src/Database/Attributes/PrimaryKey.php create mode 100644 src/Database/Model.php create mode 100644 src/Database/ModelQueryBuilder.php create mode 100644 src/Database/QueryBuilder.php create mode 100644 tests/Feature/ModelQueryBuilderTest.php diff --git a/README.md b/README.md index 18827d7..21bd71d 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,60 @@ class UserService Available helpers are `query($sql, $parameters)`, `execute($sql, $parameters)`, `pdo()`, and `transaction($callback)`. Query results expose `all()`, `first()`, `value()`, `affectedRows()`, and the raw `PDOStatement`. +### Query Builder and Models + +Use the table query builder for simple fluent queries: + +```php +$users = $db->table('users') + ->select('id', 'email') + ->where('active', true) + ->orderBy('id', 'desc') + ->limit(10) + ->get(); +``` + +Models extend `Shift\Database\Model`. Public properties represent database columns: + +```php +use Shift\Database\Attributes\Cast; +use Shift\Database\Attributes\Guarded; +use Shift\Database\Attributes\PrimaryKey; +use Shift\Database\Model; + +class User extends Model +{ + protected string $table = 'users'; + + #[PrimaryKey] + #[Cast('int')] + public ?int $id = null; + + public string $email = ''; + + #[Guarded] + public string $role = 'user'; + + #[Cast('array')] + public array $meta = []; + + #[Cast('datetime')] + public ?DateTimeImmutable $created_at = null; +} +``` + +Model queries return hydrated model instances: + +```php +$user = User::query($db)->where('email', 'dev@example.com')->first(); +$user = User::find(1, $db); +$user = User::create(['email' => 'dev@example.com'], $db); +$user->role = 'admin'; +$user->save($db); +``` + +`#[Guarded]` fields are ignored during mass assignment through `create()` and query `update()`, but can be set explicitly on a model instance before `save()`. Supported casts include `int`, `float`, `bool`, `string`, `array`, `date`, `datetime`, and class names. Class casts use `fromArray()` when available. + ## Service Container The container can resolve registered services and build classes with typed constructor dependencies: diff --git a/REFACTORING.md b/REFACTORING.md index 0642c4b..7ca3cd4 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -29,6 +29,7 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled - [x] CLI create generators for modules and module-owned classes with file-based stubs. - [x] `.env` loading for application configuration. - [x] Native PDO database configuration, lazy connection, and basic query API. +- [x] Fluent query builder and attribute-driven database models. - [x] Removal of view storage and example page assets from runtime. - [x] Removal of legacy `application/controllers` and `application/routes.php`. - [x] Domain-oriented framework namespaces: diff --git a/docs/index.html b/docs/index.html index 583ff63..63c29e5 100644 --- a/docs/index.html +++ b/docs/index.html @@ -423,6 +423,50 @@

Environment and Database

}

Use query() for prepared queries, execute() for write statements, pdo() for raw PDO access, and transaction() for transactional work.

+ +

Query Builder

+
$users = $db->table('users')
+    ->select('id', 'email')
+    ->where('active', true)
+    ->orderBy('id', 'desc')
+    ->limit(10)
+    ->get();
+ +

Models

+

Models extend Shift\Database\Model. Public properties are database columns, and model attributes describe primary keys, guarded fields, and casts.

+ +
use Shift\Database\Attributes\Cast;
+use Shift\Database\Attributes\Guarded;
+use Shift\Database\Attributes\PrimaryKey;
+use Shift\Database\Model;
+
+class User extends Model
+{
+    protected string $table = 'users';
+
+    #[PrimaryKey]
+    #[Cast('int')]
+    public ?int $id = null;
+
+    public string $email = '';
+
+    #[Guarded]
+    public string $role = 'user';
+
+    #[Cast('array')]
+    public array $meta = [];
+
+    #[Cast('datetime')]
+    public ?DateTimeImmutable $created_at = null;
+}
+ +
$user = User::query($db)->where('email', 'dev@example.com')->first();
+$user = User::find(1, $db);
+$user = User::create(['email' => 'dev@example.com'], $db);
+$user->role = 'admin';
+$user->save($db);
+ +

#[Guarded] fields are ignored during mass assignment through create() and query update(), but can be set explicitly before save(). Supported casts include int, float, bool, string, array, date, datetime, and class names. Class casts use fromArray() when available.

diff --git a/src/Console/Commands/CreateModel.php b/src/Console/Commands/CreateModel.php index af07c53..591ae5e 100644 --- a/src/Console/Commands/CreateModel.php +++ b/src/Console/Commands/CreateModel.php @@ -25,6 +25,7 @@ public function execute(mixed ...$args): void $this->writeAndReport($path, $this->renderStub('model', [ 'module' => $module, 'class' => $class, + 'table' => NameFormatter::tableName($class), ])); } diff --git a/src/Console/Generator/NameFormatter.php b/src/Console/Generator/NameFormatter.php index aa3f33e..e57f9e8 100644 --- a/src/Console/Generator/NameFormatter.php +++ b/src/Console/Generator/NameFormatter.php @@ -30,6 +30,13 @@ public static function commandName(string $className): string return implode(':', $parts); } + public static function tableName(string $className): string + { + $snake = strtolower((string) preg_replace('/(?query($sql, $parameters)->affectedRows(); @@ -71,6 +76,11 @@ public function transaction(callable $callback): mixed } } + public function lastInsertId(?string $name = null): string + { + return $this->pdo()->lastInsertId($name); + } + private function options(): array { return $this->config->options + [ diff --git a/src/Database/Model.php b/src/Database/Model.php new file mode 100644 index 0000000..6f18ce1 --- /dev/null +++ b/src/Database/Model.php @@ -0,0 +1,341 @@ +find($id); + } + + public static function create(array $attributes, ?Database $database = null): static + { + return static::query($database)->create($attributes); + } + + public static function hydrate(array $attributes): static + { + $model = new static(); + $model->fill($attributes, includeGuarded: true); + + return $model; + } + + public function fill(array $attributes, bool $includeGuarded = false): static + { + foreach ($attributes as $column => $value) { + if (!$includeGuarded && $this->isGuarded($column)) { + continue; + } + + if (!$this->hasColumn($column)) { + continue; + } + + $this->{$column} = $this->castFromDatabase($column, $value); + } + + return $this; + } + + public function forceFill(array $attributes): static + { + return $this->fill($attributes, includeGuarded: true); + } + + public function save(?Database $database = null): bool + { + $database ??= self::database(); + $primaryKey = static::primaryKey(); + $attributes = $this->storableAttributes(); + $id = $this->{$primaryKey} ?? null; + + if ($id !== null) { + unset($attributes[$primaryKey]); + + if ($attributes === []) { + return true; + } + + return $database->table(static::table()) + ->where($primaryKey, $id) + ->update($attributes) > 0; + } + + if (($attributes[$primaryKey] ?? null) === null) { + unset($attributes[$primaryKey]); + } + + $id = $database->table(static::table())->insertGetId($attributes); + + if ($this->hasColumn($primaryKey)) { + $this->{$primaryKey} = $this->castFromDatabase($primaryKey, $id); + } + + return true; + } + + public function delete(?Database $database = null): int + { + $primaryKey = static::primaryKey(); + $id = $this->{$primaryKey} ?? null; + + if ($id === null) { + return 0; + } + + return static::query($database)->where($primaryKey, $id)->delete(); + } + + public function toArray(): array + { + $values = []; + + foreach (static::columns() as $column => $property) { + if (!$property->isInitialized($this)) { + continue; + } + + $values[$column] = $this->{$column}; + } + + return $values; + } + + public static function table(): string + { + $defaults = (new ReflectionClass(static::class))->getDefaultProperties(); + $table = $defaults['table'] ?? ''; + + if (is_string($table) && $table !== '') { + return $table; + } + + return static::defaultTableName(); + } + + public static function primaryKey(): string + { + foreach (static::columns() as $column => $property) { + if ($property->getAttributes(PrimaryKey::class) !== []) { + return $column; + } + } + + return array_key_exists('id', static::columns()) ? 'id' : 'id'; + } + + public function storableAttributes(): array + { + $values = []; + + foreach (static::columns() as $column => $property) { + if (!$property->isInitialized($this)) { + continue; + } + + $values[$column] = $this->castForDatabase($column, $this->{$column}); + } + + return $values; + } + + public function storableInput(array $attributes, bool $includeGuarded = false): array + { + $values = []; + + foreach ($attributes as $column => $value) { + if (!$includeGuarded && $this->isGuarded($column)) { + continue; + } + + if (!$this->hasColumn($column)) { + continue; + } + + $values[$column] = $this->castForDatabase($column, $value); + } + + return $values; + } + + public static function guardedColumns(): array + { + $guarded = []; + + foreach (static::columns() as $column => $property) { + if ($property->getAttributes(Guarded::class) !== []) { + $guarded[] = $column; + } + } + + return $guarded; + } + + /** + * @return array + */ + public static function columns(): array + { + $reflection = new ReflectionClass(static::class); + $columns = []; + + foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->isStatic()) { + continue; + } + + $columns[$property->getName()] = $property; + } + + return $columns; + } + + private static function database(): Database + { + if (!self::$database instanceof Database) { + throw new DatabaseException('No database configured for model queries.'); + } + + return self::$database; + } + + private function hasColumn(string $column): bool + { + return array_key_exists($column, static::columns()); + } + + private function isGuarded(string $column): bool + { + return in_array($column, static::guardedColumns(), true); + } + + private function castFromDatabase(string $column, mixed $value): mixed + { + $cast = $this->castForColumn($column); + + if ($cast === null || $value === null) { + return $value; + } + + return match (strtolower($cast->type)) { + 'int', 'integer' => (int) $value, + 'float', 'double' => (float) $value, + 'bool', 'boolean' => filter_var($value, FILTER_VALIDATE_BOOLEAN), + 'string' => (string) $value, + 'array' => is_array($value) ? $value : (json_decode((string) $value, true) ?: []), + 'datetime', 'date' => $value instanceof DateTimeInterface + ? DateTimeImmutable::createFromInterface($value) + : new DateTimeImmutable((string) $value), + default => $this->castClassFromDatabase($cast->type, $value), + }; + } + + private function castForDatabase(string $column, mixed $value): mixed + { + $cast = $this->castForColumn($column); + + if ($cast === null || $value === null) { + return $value; + } + + return match (strtolower($cast->type)) { + 'int', 'integer' => (int) $value, + 'float', 'double' => (float) $value, + 'bool', 'boolean' => filter_var($value, FILTER_VALIDATE_BOOLEAN), + 'string' => (string) $value, + 'array' => json_encode($value, JSON_THROW_ON_ERROR), + 'datetime' => $value instanceof DateTimeInterface + ? $value->format($cast->format ?? DateTimeInterface::ATOM) + : $value, + 'date' => $value instanceof DateTimeInterface + ? $value->format($cast->format ?? 'Y-m-d') + : $value, + default => $this->castClassForDatabase($value), + }; + } + + private function castForColumn(string $column): ?Cast + { + $property = static::columns()[$column] ?? null; + + if (!$property instanceof ReflectionProperty) { + return null; + } + + $attributes = $property->getAttributes(Cast::class); + + if ($attributes === []) { + return null; + } + + return $attributes[0]->newInstance(); + } + + private function castClassFromDatabase(string $class, mixed $value): mixed + { + if (!class_exists($class)) { + return $value; + } + + if (is_a($value, $class)) { + return $value; + } + + if (is_string($value)) { + $decoded = json_decode($value, true); + $value = json_last_error() === JSON_ERROR_NONE ? $decoded : $value; + } + + if (method_exists($class, 'fromArray') && is_array($value)) { + return $class::fromArray($value); + } + + return new $class($value); + } + + private function castClassForDatabase(mixed $value): mixed + { + if ($value instanceof JsonSerializable) { + return json_encode($value, JSON_THROW_ON_ERROR); + } + + if (is_object($value) && method_exists($value, 'toArray')) { + return json_encode($value->toArray(), JSON_THROW_ON_ERROR); + } + + return $value; + } + + private static function defaultTableName(): string + { + $shortName = (new ReflectionClass(static::class))->getShortName(); + $snake = strtolower((string) preg_replace('/(? $modelClass + */ + public function __construct( + private readonly string $modelClass, + private readonly Database $database + ) { + $this->query = $database->table($modelClass::table()); + } + + public function select(string ...$columns): self + { + $this->query->select(...$columns); + + return $this; + } + + public function where(string $column, mixed $operatorOrValue, mixed $value = null): self + { + func_num_args() === 2 + ? $this->query->where($column, $operatorOrValue) + : $this->query->where($column, $operatorOrValue, $value); + + return $this; + } + + public function orWhere(string $column, mixed $operatorOrValue, mixed $value = null): self + { + func_num_args() === 2 + ? $this->query->orWhere($column, $operatorOrValue) + : $this->query->orWhere($column, $operatorOrValue, $value); + + return $this; + } + + public function orderBy(string $column, string $direction = 'asc'): self + { + $this->query->orderBy($column, $direction); + + return $this; + } + + public function limit(int $limit, ?int $offset = null): self + { + $this->query->limit($limit, $offset); + + return $this; + } + + /** + * @return list + */ + public function get(): array + { + $modelClass = $this->modelClass; + + return array_map( + fn (array $row): Model => $modelClass::hydrate($row), + $this->query->get() + ); + } + + /** + * @return TModel|null + */ + public function first(): ?Model + { + $modelClass = $this->modelClass; + $row = $this->query->first(); + + return $row === null ? null : $modelClass::hydrate($row); + } + + /** + * @return TModel|null + */ + public function find(mixed $id): ?Model + { + $modelClass = $this->modelClass; + + return $this->where($modelClass::primaryKey(), $id)->first(); + } + + /** + * @return TModel + */ + public function create(array $attributes): Model + { + $modelClass = $this->modelClass; + $model = new $modelClass(); + $model->fill($attributes); + $values = $model->storableAttributes(); + $primaryKey = $modelClass::primaryKey(); + + if (($values[$primaryKey] ?? null) === null) { + unset($values[$primaryKey]); + } + + $id = $this->database->table($modelClass::table())->insertGetId($values); + + if (array_key_exists($primaryKey, $modelClass::columns())) { + $model->{$primaryKey} = $model::hydrate([$primaryKey => $id])->{$primaryKey}; + } + + return $model; + } + + public function update(array $attributes): int + { + $modelClass = $this->modelClass; + $model = new $modelClass(); + $values = $model->storableInput($attributes); + + if ($values === []) { + return 0; + } + + return $this->query->update($values); + } + + public function delete(): int + { + return $this->query->delete(); + } + + public function toSql(): string + { + return $this->query->toSql(); + } +} diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php new file mode 100644 index 0000000..59906bd --- /dev/null +++ b/src/Database/QueryBuilder.php @@ -0,0 +1,247 @@ + */ + private array $columns = ['*']; + + /** @var list */ + private array $wheres = []; + + /** @var list */ + private array $orders = []; + + private ?int $limit = null; + + private ?int $offset = null; + + public function __construct( + private readonly Database $database, + private readonly string $table + ) { + } + + public function select(string ...$columns): self + { + $this->columns = $columns === [] ? ['*'] : $columns; + + return $this; + } + + public function where(string $column, mixed $operatorOrValue, mixed $value = null): self + { + if (func_num_args() === 2) { + $operator = '='; + $value = $operatorOrValue; + } else { + $operator = strtolower((string) $operatorOrValue); + } + + $this->wheres[] = [ + 'boolean' => 'and', + 'column' => $column, + 'operator' => $this->normalizeOperator($operator), + 'value' => $value, + ]; + + return $this; + } + + public function orWhere(string $column, mixed $operatorOrValue, mixed $value = null): self + { + if (func_num_args() === 2) { + $operator = '='; + $value = $operatorOrValue; + } else { + $operator = strtolower((string) $operatorOrValue); + } + + $this->wheres[] = [ + 'boolean' => 'or', + 'column' => $column, + 'operator' => $this->normalizeOperator($operator), + 'value' => $value, + ]; + + return $this; + } + + public function orderBy(string $column, string $direction = 'asc'): self + { + $direction = strtolower($direction) === 'desc' ? 'desc' : 'asc'; + $this->orders[] = [ + 'column' => $column, + 'direction' => $direction, + ]; + + return $this; + } + + public function limit(int $limit, ?int $offset = null): self + { + $this->limit = max(0, $limit); + $this->offset = $offset === null ? null : max(0, $offset); + + return $this; + } + + public function get(): array + { + return $this->database->query($this->toSql(), $this->bindings())->all(); + } + + public function first(): ?array + { + $this->limit(1); + + return $this->database->query($this->toSql(), $this->bindings())->first(); + } + + public function value(string|int $column = 0): mixed + { + $this->limit(1); + + return $this->database->query($this->toSql(), $this->bindings())->value($column); + } + + public function insert(array $values): int + { + if ($values === []) { + throw new DatabaseException('Insert values cannot be empty.'); + } + + $columns = array_keys($values); + $placeholders = array_fill(0, count($columns), '?'); + $sql = sprintf( + 'insert into %s (%s) values (%s)', + $this->identifier($this->table), + implode(', ', array_map(fn (string $column): string => $this->identifier($column), $columns)), + implode(', ', $placeholders) + ); + + return $this->database->execute($sql, array_values($values)); + } + + public function insertGetId(array $values): string + { + $this->insert($values); + + return $this->database->lastInsertId(); + } + + public function update(array $values): int + { + if ($values === []) { + return 0; + } + + $assignments = array_map( + fn (string $column): string => $this->identifier($column) . ' = ?', + array_keys($values) + ); + + $sql = sprintf( + 'update %s set %s%s', + $this->identifier($this->table), + implode(', ', $assignments), + $this->whereSql() + ); + + return $this->database->execute($sql, array_merge(array_values($values), $this->bindings())); + } + + public function delete(): int + { + $sql = sprintf( + 'delete from %s%s', + $this->identifier($this->table), + $this->whereSql() + ); + + return $this->database->execute($sql, $this->bindings()); + } + + public function toSql(): string + { + $sql = sprintf( + 'select %s from %s%s%s', + implode(', ', array_map(fn (string $column): string => $this->identifier($column), $this->columns)), + $this->identifier($this->table), + $this->whereSql(), + $this->orderSql() + ); + + if ($this->limit !== null) { + $sql .= ' limit ' . $this->limit; + } + + if ($this->offset !== null) { + $sql .= ' offset ' . $this->offset; + } + + return $sql; + } + + private function whereSql(): string + { + if ($this->wheres === []) { + return ''; + } + + $parts = []; + foreach ($this->wheres as $index => $where) { + $prefix = $index === 0 ? ' where ' : ' ' . $where['boolean'] . ' '; + $parts[] = $prefix . $this->identifier($where['column']) . ' ' . $where['operator'] . ' ?'; + } + + return implode('', $parts); + } + + private function orderSql(): string + { + if ($this->orders === []) { + return ''; + } + + $parts = array_map( + fn (array $order): string => $this->identifier($order['column']) . ' ' . $order['direction'], + $this->orders + ); + + return ' order by ' . implode(', ', $parts); + } + + private function bindings(): array + { + return array_map( + static fn (array $where): mixed => $where['value'], + $this->wheres + ); + } + + private function identifier(string $identifier): string + { + if ($identifier === '*') { + return $identifier; + } + + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/', $identifier)) { + throw new DatabaseException("Invalid database identifier '{$identifier}'."); + } + + return $identifier; + } + + private function normalizeOperator(string $operator): string + { + $allowed = ['=', '!=', '<>', '>', '>=', '<', '<=', 'like', 'not like']; + + if (!in_array($operator, $allowed, true)) { + throw new DatabaseException("Invalid query operator '{$operator}'."); + } + + return $operator; + } +} diff --git a/tests/Feature/ConsoleCreateTest.php b/tests/Feature/ConsoleCreateTest.php index cbd9525..206c3ad 100644 --- a/tests/Feature/ConsoleCreateTest.php +++ b/tests/Feature/ConsoleCreateTest.php @@ -63,7 +63,10 @@ assertFileExists($modulesPath . '/Billing/Middleware/AuditMiddleware.php', 'Middleware suffix should be added.'); assertFileExists($modulesPath . '/Billing/Dto/CreateInvoiceDto.php', 'DTO suffix should be added.'); + $model = file_get_contents($modulesPath . '/Billing/Models/Invoice.php'); $command = file_get_contents($modulesPath . '/Billing/Commands/SyncInvoices.php'); + assertStringContains('class Invoice extends Model', $model, 'Generated models should extend the base model.'); + assertStringContains("protected string \$table = 'invoices';", $model, 'Generated models should define a table name.'); assertStringContains('return \'Usage: php shift.php sync:invoices\';', $command, 'Generated command help should use CLI command syntax.'); } finally { removeDirectory(dirname($modulesPath)); diff --git a/tests/Feature/ModelQueryBuilderTest.php b/tests/Feature/ModelQueryBuilderTest.php new file mode 100644 index 0000000..f99d5f4 --- /dev/null +++ b/tests/Feature/ModelQueryBuilderTest.php @@ -0,0 +1,155 @@ +data; + } +} + +final class TestUserRecord extends Model +{ + protected string $table = 'test_users'; + + #[PrimaryKey] + #[Cast('int')] + public ?int $id = null; + + public string $email = ''; + + #[Guarded] + public string $role = 'user'; + + #[Cast('array')] + public array $meta = []; + + #[Cast('datetime')] + public ?DateTimeImmutable $created_at = null; + + #[Cast(TestProfileCast::class)] + public ?TestProfileCast $profile = null; +} + +return [ + 'query builder selects rows with fluent clauses' => function (): void { + $db = makeModelDatabase(); + seedModelRows($db); + + $rows = $db->table('test_users') + ->select('id', 'email') + ->where('role', 'admin') + ->orderBy('id', 'desc') + ->limit(1) + ->get(); + + assertSameValue(1, count($rows), 'Query builder should respect where and limit clauses.'); + assertSameValue('second@example.com', $rows[0]['email'] ?? null, 'Query builder should order selected rows.'); + }, + 'model query hydrates casts from database rows' => function (): void { + $db = makeModelDatabase(); + seedModelRows($db); + + $user = TestUserRecord::query($db)->where('email', 'first@example.com')->first(); + + assertSameValue(true, $user instanceof TestUserRecord, 'Model query should hydrate model instances.'); + assertSameValue(1, $user->id, 'Primary key should be cast to int.'); + assertSameValue(['tags' => ['api', 'db']], $user->meta, 'Array cast should decode JSON.'); + assertSameValue(true, $user->created_at instanceof DateTimeImmutable, 'Datetime cast should return DateTimeImmutable.'); + assertSameValue(true, $user->profile instanceof TestProfileCast, 'Class cast should hydrate value objects.'); + assertSameValue('Ada', $user->profile->data['name'] ?? null, 'Class cast should receive decoded data.'); + }, + 'model create skips guarded input and explicit save can persist guarded fields' => function (): void { + $db = makeModelDatabase(); + + $user = TestUserRecord::create([ + 'email' => 'guarded@example.com', + 'role' => 'admin', + 'meta' => ['source' => 'test'], + 'created_at' => new DateTimeImmutable('2026-06-17 10:00:00'), + 'profile' => new TestProfileCast(['name' => 'Grace']), + ], $db); + + $stored = TestUserRecord::find($user->id, $db); + assertSameValue('user', $stored->role, 'Guarded fields should not be mass assigned.'); + + $updated = TestUserRecord::query($db) + ->where('id', $user->id) + ->update(['role' => 'admin']); + + assertSameValue(0, $updated, 'Guarded fields should not be updated through mass assignment.'); + + $stored->role = 'admin'; + $stored->save($db); + $reloaded = TestUserRecord::find($user->id, $db); + + assertSameValue('admin', $reloaded->role, 'Guarded fields can be persisted when set explicitly on the model.'); + }, + 'model query supports find and delete' => function (): void { + $db = makeModelDatabase(); + seedModelRows($db); + + $user = TestUserRecord::find(1, $db); + $deleted = $user->delete($db); + $missing = TestUserRecord::find(1, $db); + + assertSameValue(1, $deleted, 'Model delete should remove the current record.'); + assertSameValue(null, $missing, 'Deleted models should not be found.'); + }, +]; + +function makeModelDatabase(): Database +{ + $db = new Database(new DatabaseConfig( + driver: 'sqlite', + database: ':memory:' + )); + + $db->execute( + 'create table test_users ( + id integer primary key autoincrement, + email text not null, + role text not null, + meta text, + created_at text, + profile text + )' + ); + + return $db; +} + +function seedModelRows(Database $db): void +{ + $db->table('test_users')->insert([ + 'email' => 'first@example.com', + 'role' => 'user', + 'meta' => json_encode(['tags' => ['api', 'db']], JSON_THROW_ON_ERROR), + 'created_at' => '2026-06-17T10:00:00+00:00', + 'profile' => json_encode(['name' => 'Ada'], JSON_THROW_ON_ERROR), + ]); + + $db->table('test_users')->insert([ + 'email' => 'second@example.com', + 'role' => 'admin', + 'meta' => json_encode(['tags' => ['ops']], JSON_THROW_ON_ERROR), + 'created_at' => '2026-06-17T11:00:00+00:00', + 'profile' => json_encode(['name' => 'Linus'], JSON_THROW_ON_ERROR), + ]); +}