From 6074d8aa566811b869f3e48f2ca42a7879a178f6 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 3 Aug 2025 00:25:28 +0200 Subject: [PATCH 01/51] feat(database): add more convenient `where` methods and support grouped conditions --- packages/auth/src/Install/User.php | 4 +- .../QueryBuilders/DeleteQueryBuilder.php | 2 +- .../HasConvenientWhereMethods.php | 421 +++++++++++++++++ .../HasWhereQueryBuilderMethods.php | 149 +++++- .../QueryBuilders/SelectQueryBuilder.php | 2 +- .../QueryBuilders/UpdateQueryBuilder.php | 2 +- .../QueryBuilders/WhereGroupBuilder.php | 175 +++++++ .../database/src/Builder/WhereOperator.php | 54 +++ packages/database/src/IsDatabaseModel.php | 4 +- .../src/QueryStatements/CountStatement.php | 7 +- .../src/QueryStatements/DeleteStatement.php | 7 +- .../QueryStatements/HasWhereStatements.php | 2 + .../src/QueryStatements/InsertStatement.php | 5 +- .../src/QueryStatements/SelectStatement.php | 13 +- .../src/QueryStatements/UpdateStatement.php | 7 +- .../QueryStatements/WhereGroupStatement.php | 50 ++ .../QueryStatements/CountStatementTest.php | 15 +- .../QueryStatements/DeleteStatementTest.php | 5 +- .../QueryStatements/InsertStatementTest.php | 10 +- .../QueryStatements/SelectStatementTest.php | 12 +- .../QueryStatements/UpdateStatementTest.php | 11 +- .../Builder/ConvenientWhereMethodsTest.php | 445 +++++++++++++++++ .../Builder/CountQueryBuilderTest.php | 436 ++++++++++++++--- .../Builder/DeleteQueryBuilderTest.php | 77 ++- .../Builder/InsertQueryBuilderTest.php | 30 +- .../Database/Builder/NestedWhereTest.php | 151 ++++++ .../Builder/SelectQueryBuilderTest.php | 123 ++--- .../Builder/UpdateQueryBuilderTest.php | 104 ++-- .../Database/Builder/WhereOperatorTest.php | 179 +++++++ .../ConvenientDateWhereMethodsTest.php | 394 +++++++++++++++ .../Database/ConvenientWhereMethodsTest.php | 447 ++++++++++++++++++ .../Database/Fixtures/BookStatus.php | 13 + .../Database/GenericDatabaseTest.php | 2 +- .../Database/GroupedWhereMethodsTest.php | 363 ++++++++++++++ .../Database/MultiDatabaseTest.php | 23 +- .../Commands/DatabaseSeedCommandTest.php | 8 +- tests/Integration/ORM/IsDatabaseModelTest.php | 6 +- .../ORM/Mappers/QueryMapperTest.php | 17 +- 38 files changed, 3392 insertions(+), 383 deletions(-) create mode 100644 packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php create mode 100644 packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php create mode 100644 packages/database/src/Builder/WhereOperator.php create mode 100644 packages/database/src/QueryStatements/WhereGroupStatement.php create mode 100644 tests/Integration/Database/Builder/ConvenientWhereMethodsTest.php create mode 100644 tests/Integration/Database/Builder/NestedWhereTest.php create mode 100644 tests/Integration/Database/Builder/WhereOperatorTest.php create mode 100644 tests/Integration/Database/ConvenientDateWhereMethodsTest.php create mode 100644 tests/Integration/Database/ConvenientWhereMethodsTest.php create mode 100644 tests/Integration/Database/Fixtures/BookStatus.php create mode 100644 tests/Integration/Database/GroupedWhereMethodsTest.php diff --git a/packages/auth/src/Install/User.php b/packages/auth/src/Install/User.php index 36d457201..e1621165b 100644 --- a/packages/auth/src/Install/User.php +++ b/packages/auth/src/Install/User.php @@ -78,7 +78,9 @@ private function resolvePermission(string|UnitEnum|Permission $permission): Perm $permission instanceof UnitEnum => $permission->name, }; - $permission = Permission::select()->whereField('name', $name)->first(); + $permission = Permission::select() + ->where('name', $name) + ->first(); return $permission ?? new Permission($name)->save(); } diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index f67ff0355..9c78d5368 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -64,7 +64,7 @@ public function toSql(): string public function build(mixed ...$bindings): Query { if ($this->model->isObjectModel() && is_object($this->model->instance)) { - $this->whereField( + $this->where( $this->model->getPrimaryKey(), $this->model->getPrimaryKeyValue()->id, ); diff --git a/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php b/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php new file mode 100644 index 000000000..2114f22a0 --- /dev/null +++ b/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php @@ -0,0 +1,421 @@ + } + */ + protected function buildCondition(string $fieldDefinition, WhereOperator $operator, mixed $value): array + { + $sql = $fieldDefinition; + $bindings = []; + + switch ($operator) { + case WhereOperator::IS_NULL: + $sql .= ' IS NULL'; + break; + + case WhereOperator::IS_NOT_NULL: + $sql .= ' IS NOT NULL'; + break; + + case WhereOperator::IN: + case WhereOperator::NOT_IN: + if (is_a($value, UnitEnum::class, allow_string: true)) { + $value = $value::cases(); + } + + if (! is_array($value)) { + throw new \InvalidArgumentException("{$operator->value} operator requires an array of values"); + } + + $value = array_map( + fn (mixed $value) => match (true) { + $value instanceof BackedEnum => $value->value, + $value instanceof UnitEnum => $value->name, + $value instanceof ArrayAccess => (array) $value, + default => $value, + }, + $value, + ); + + $placeholders = str_repeat('?,', times: count($value) - 1) . '?'; + $sql .= " {$operator->value} ({$placeholders})"; + $bindings = array_values($value); + break; + + case WhereOperator::BETWEEN: + case WhereOperator::NOT_BETWEEN: + if (! is_array($value) || count($value) !== 2) { + throw new \InvalidArgumentException("{$operator->value} operator requires an array with exactly 2 values"); + } + + $sql .= " {$operator->value} ? AND ?"; + $bindings = array_map( + fn (DateTime|string|float|int|Countable $value) => match (true) { + $value instanceof Countable => count($value), + default => $value, + }, + $value, + ); + break; + + default: + if ($operator->requiresValue() && $value === null) { + throw new \InvalidArgumentException("{$operator->value} operator requires a value"); + } + + if ($operator->requiresValue()) { + $sql .= " {$operator->value} ?"; + $bindings[] = $value; + } else { + $sql .= " {$operator->value}"; + } + break; + } + + return [ + 'sql' => $sql, + 'bindings' => $bindings, + ]; + } + + /** + * Adds a `WHERE IN` condition. + * + * @param class-string|UnitEnum|array $values + */ + public function whereIn(string $field, string|UnitEnum|array|ArrayAccess $values): self + { + return $this->where($field, $values, WhereOperator::IN); + } + + /** + * Adds a `WHERE NOT IN` condition. + * + * @param class-string|UnitEnum|array $values + */ + public function whereNotIn(string $field, string|UnitEnum|array|ArrayAccess $values): self + { + return $this->where($field, $values, WhereOperator::NOT_IN); + } + + /** + * Adds a `WHERE BETWEEN` condition. + */ + public function whereBetween(string $field, DateTime|string|float|int|Countable $min, DateTime|string|float|int|Countable $max): self + { + return $this->where($field, [$min, $max], WhereOperator::BETWEEN); + } + + /** + * Adds a `WHERE NOT BETWEEN` condition. + */ + public function whereNotBetween(string $field, DateTime|string|float|int|Countable $min, DateTime|string|float|int|Countable $max): self + { + return $this->where($field, [$min, $max], WhereOperator::NOT_BETWEEN); + } + + /** + * Adds a `WHERE IS NULL` condition. + */ + public function whereNull(string $field): self + { + return $this->where($field, null, WhereOperator::IS_NULL); + } + + /** + * Adds a `WHERE IS NOT NULL` condition. + */ + public function whereNotNull(string $field): self + { + return $this->where($field, null, WhereOperator::IS_NOT_NULL); + } + + /** + * Adds a `WHERE NOT` condition (shorthand for != operator). + */ + public function whereNot(string $field, mixed $value): self + { + return $this->where($field, $value, WhereOperator::NOT_EQUALS); + } + + /** + * Adds a `WHERE LIKE` condition. + */ + public function whereLike(string $field, string $value): self + { + return $this->where($field, $value, WhereOperator::LIKE); + } + + /** + * Adds a `WHERE NOT LIKE` condition. + */ + public function whereNotLike(string $field, string $value): self + { + return $this->where($field, $value, WhereOperator::NOT_LIKE); + } + + /** + * Adds an `OR WHERE IN` condition. + * + * @param class-string|UnitEnum|array $values + */ + public function orWhereIn(string $field, string|UnitEnum|array|ArrayAccess $values): self + { + return $this->orWhere($field, $values, WhereOperator::IN); + } + + /** + * Adds an `OR WHERE NOT IN` condition. + * + * @param class-string|UnitEnum|array $values + */ + public function orWhereNotIn(string $field, string|UnitEnum|array|ArrayAccess $values): self + { + return $this->orWhere($field, $values, WhereOperator::NOT_IN); + } + + /** + * Adds an `OR WHERE BETWEEN` condition. + */ + public function orWhereBetween(string $field, DateTime|string|float|int|Countable $min, DateTime|string|float|int|Countable $max): self + { + return $this->orWhere($field, [$min, $max], WhereOperator::BETWEEN); + } + + /** + * Adds an `OR WHERE NOT BETWEEN` condition. + */ + public function orWhereNotBetween(string $field, DateTime|string|float|int|Countable $min, DateTime|string|float|int|Countable $max): self + { + return $this->orWhere($field, [$min, $max], WhereOperator::NOT_BETWEEN); + } + + /** + * Adds an `OR WHERE IS NULL` condition. + */ + public function orWhereNull(string $field): self + { + return $this->orWhere($field, null, WhereOperator::IS_NULL); + } + + /** + * Adds an `OR WHERE IS NOT NULL` condition. + */ + public function orWhereNotNull(string $field): self + { + return $this->orWhere($field, null, WhereOperator::IS_NOT_NULL); + } + + /** + * Adds an `OR WHERE NOT` condition (shorthand for != operator). + */ + public function orWhereNot(string $field, mixed $value): self + { + return $this->orWhere($field, $value, WhereOperator::NOT_EQUALS); + } + + /** + * Adds an `OR WHERE LIKE` condition. + */ + public function orWhereLike(string $field, string $value): self + { + return $this->orWhere($field, $value, WhereOperator::LIKE); + } + + /** + * Adds an `OR WHERE NOT LIKE` condition. + */ + public function orWhereNotLike(string $field, string $value): self + { + return $this->orWhere($field, $value, WhereOperator::NOT_LIKE); + } + + /** + * Adds a `WHERE` condition for records from today. + */ + public function whereToday(string $field): self + { + $today = DateTime::now(); + + return $this->whereBetween($field, $today->startOfDay(), $today->endOfDay()); + } + + /** + * Adds a `WHERE` condition for records from yesterday. + */ + public function whereYesterday(string $field): self + { + $yesterday = DateTime::now()->minusDay(); + + return $this->whereBetween($field, $yesterday->startOfDay(), $yesterday->endOfDay()); + } + + /** + * Adds a `WHERE` condition for records from this week. + */ + public function whereThisWeek(string $field): self + { + $today = DateTime::now(); + + return $this->whereBetween($field, $today->startOfWeek(), $today->endOfWeek()); + } + + /** + * Adds a `WHERE` condition for records from last week. + */ + public function whereLastWeek(string $field): self + { + $lastWeek = DateTime::now()->minusDays(7); + + return $this->whereBetween($field, $lastWeek->startOfWeek(), $lastWeek->endOfWeek()); + } + + /** + * Adds a `WHERE` condition for records from this month. + */ + public function whereThisMonth(string $field): self + { + $today = DateTime::now(); + + return $this->whereBetween($field, $today->startOfMonth(), $today->endOfMonth()); + } + + /** + * Adds a `WHERE` condition for records from last month. + */ + public function whereLastMonth(string $field): self + { + $lastMonth = DateTime::now()->minusMonths(1); + + return $this->whereBetween($field, $lastMonth->startOfMonth(), $lastMonth->endOfMonth()); + } + + /** + * Adds a `WHERE` condition for records from this year. + */ + public function whereThisYear(string $field): self + { + $today = DateTime::now(); + + return $this->whereBetween($field, $today->startOfYear(), $today->endOfYear()); + } + + /** + * Adds a `WHERE` condition for records from last year. + */ + public function whereLastYear(string $field): self + { + $lastYear = DateTime::now()->minusYears(1); + + return $this->whereBetween($field, $lastYear->startOfYear(), $lastYear->endOfYear()); + } + + /** + * Adds a `WHERE` condition for records created after a specific date. + */ + public function whereAfter(string $field, DateTime|string $date): self + { + return $this->where($field, DateTime::parse($date), WhereOperator::GREATER_THAN); + } + + /** + * Adds a `WHERE` condition for records created before a specific date. + */ + public function whereBefore(string $field, DateTime|string $date): self + { + return $this->where($field, DateTime::parse($date), WhereOperator::LESS_THAN); + } + + /** + * Adds an `OR WHERE` condition for records from today. + */ + public function orWhereToday(string $field): self + { + $today = DateTime::now(); + return $this->orWhereBetween($field, $today->startOfDay(), $today->endOfDay()); + } + + /** + * Adds an `OR WHERE` condition for records from yesterday. + */ + public function orWhereYesterday(string $field): self + { + $yesterday = DateTime::now()->minusDay(); + + return $this->orWhereBetween($field, $yesterday->startOfDay(), $yesterday->endOfDay()); + } + + /** + * Adds an `OR WHERE` condition for records from this week. + */ + public function orWhereThisWeek(string $field): self + { + $today = DateTime::now(); + + return $this->orWhereBetween($field, $today->startOfWeek(), $today->endOfWeek()); + } + + /** + * Adds an `OR WHERE` condition for records from this month. + */ + public function orWhereThisMonth(string $field): self + { + $today = DateTime::now(); + + return $this->orWhereBetween($field, $today->startOfMonth(), $today->endOfMonth()); + } + + /** + * Adds an `OR WHERE` condition for records from this year. + */ + public function orWhereThisYear(string $field): self + { + $today = DateTime::now(); + + return $this->orWhereBetween($field, $today->startOfYear(), $today->endOfYear()); + } + + /** + * Adds an `OR WHERE` condition for records created after a specific date. + */ + public function orWhereAfter(string $field, DateTime|string $date): self + { + return $this->orWhere($field, DateTime::parse($date), WhereOperator::GREATER_THAN); + } + + /** + * Adds an `OR WHERE` condition for records created before a specific date. + */ + public function orWhereBefore(string $field, DateTime|string $date): self + { + return $this->orWhere($field, DateTime::parse($date), WhereOperator::LESS_THAN); + } + + /** + * Abstract method that must be implemented by classes using this trait. + * Should add a basic WHERE condition. + */ + abstract public function where(string $field, mixed $value, string|WhereOperator $operator = WhereOperator::EQUALS): self; + + /** + * Abstract method that must be implemented by classes using this trait. + * Should add an OR WHERE condition. + */ + abstract public function orWhere(string $field, mixed $value, WhereOperator $operator = WhereOperator::EQUALS): self; +} diff --git a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php index a58594e54..806d42a1c 100644 --- a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php @@ -2,7 +2,10 @@ namespace Tempest\Database\Builder\QueryBuilders; +use ArrayAccess; +use Closure; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\Builder\WhereOperator; use Tempest\Database\QueryStatements\HasWhereStatements; use Tempest\Database\QueryStatements\WhereStatement; @@ -14,41 +17,157 @@ */ trait HasWhereQueryBuilderMethods { + use HasConvenientWhereMethods; + abstract private function getModel(): ModelInspector; abstract private function getStatementForWhere(): HasWhereStatements; - /** @return self */ - public function where(string $where, mixed ...$bindings): self + /** + * Adds a where condition to the query. + * + * @return self + */ + public function where(string $field, mixed $value, string|WhereOperator $operator = WhereOperator::EQUALS): self { - if ($this->getStatementForWhere()->where->isNotEmpty() && ! str($where)->trim()->startsWith(['AND', 'OR'])) { - return $this->andWhere($where, ...$bindings); + $operator = WhereOperator::fromOperator($operator); + $fieldDefinition = $this->getModel()->getFieldDefinition($field); + $condition = $this->buildCondition((string) $fieldDefinition, $operator, $value); + + if ($this->getStatementForWhere()->where->isNotEmpty()) { + return $this->andWhere($field, $value, $operator); } - $this->getStatementForWhere()->where[] = new WhereStatement($where); + $this->getStatementForWhere()->where[] = new WhereStatement($condition['sql']); + $this->bind(...$condition['bindings']); + + return $this; + } + + /** + * Adds an `AND WHERE` condition to the query. + * + * @return self + */ + public function andWhere(string $field, mixed $value, WhereOperator $operator = WhereOperator::EQUALS): self + { + $operator = WhereOperator::fromOperator($operator); + $fieldDefinition = $this->getModel()->getFieldDefinition($field); + $condition = $this->buildCondition((string) $fieldDefinition, $operator, $value); + + $this->getStatementForWhere()->where[] = new WhereStatement("AND {$condition['sql']}"); + $this->bind(...$condition['bindings']); + + return $this; + } + + /** + * Adds an `OR WHERE` condition to the query. + * + * @return self + */ + public function orWhere(string $field, mixed $value, WhereOperator $operator = WhereOperator::EQUALS): self + { + $operator = WhereOperator::fromOperator($operator); + $fieldDefinition = $this->getModel()->getFieldDefinition($field); + $condition = $this->buildCondition((string) $fieldDefinition, $operator, $value); + + $this->getStatementForWhere()->where[] = new WhereStatement("OR {$condition['sql']}"); + $this->bind(...$condition['bindings']); + + return $this; + } + + /** + * Adds a raw SQL `WHERE` condition to the query. + * + * @return self + */ + public function whereRaw(string $rawCondition, mixed ...$bindings): self + { + if ($this->getStatementForWhere()->where->isNotEmpty() && ! str($rawCondition)->trim()->startsWith(['AND', 'OR'])) { + return $this->andWhereRaw($rawCondition, ...$bindings); + } + $this->getStatementForWhere()->where[] = new WhereStatement($rawCondition); $this->bind(...$bindings); return $this; } - /** @return self */ - public function andWhere(string $where, mixed ...$bindings): self + /** + * Adds a raw SQL `AND WHERE` condition to the query. + * + * @return self + */ + public function andWhereRaw(string $rawCondition, mixed ...$bindings): self { - return $this->where("AND {$where}", ...$bindings); + $this->getStatementForWhere()->where[] = new WhereStatement("AND {$rawCondition}"); + $this->bind(...$bindings); + + return $this; } - /** @return self */ - public function orWhere(string $where, mixed ...$bindings): self + /** + * Adds a raw SQL `OR WHERE` condition to the query. + * + * @return self + */ + public function orWhereRaw(string $rawCondition, mixed ...$bindings): self { - return $this->where("OR {$where}", ...$bindings); + $this->getStatementForWhere()->where[] = new WhereStatement("OR {$rawCondition}"); + $this->bind(...$bindings); + + return $this; } - /** @return self */ - public function whereField(string $field, mixed $value): self + /** + * Adds a grouped where statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. + * + * @param Closure(WhereGroupBuilder):void $callback + * @return self + */ + public function whereGroup(Closure $callback): self { - $field = $this->getModel()->getFieldDefinition($field); + $groupBuilder = new WhereGroupBuilder($this->getModel()); + $callback($groupBuilder); + $group = $groupBuilder->build(); + + if (! $group->conditions->isEmpty()) { + $this->getStatementForWhere()->where[] = $group; + $this->bind(...$groupBuilder->getBindings()); + } + + return $this; + } + + /** + * Adds a grouped `AND WHERE` statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. + * + * @param Closure(WhereGroupBuilder):void $callback + * @return self + */ + public function andWhereGroup(Closure $callback): self + { + if ($this->getStatementForWhere()->where->isNotEmpty()) { + $this->getStatementForWhere()->where[] = new WhereStatement('AND'); + } + + return $this->whereGroup($callback); + } + + /** + * Adds a grouped `OR WHERE` statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. + * + * @param Closure(WhereGroupBuilder):void $callback + * @return self + */ + public function orWhereGroup(Closure $callback): self + { + if ($this->getStatementForWhere()->where->isNotEmpty()) { + $this->getStatementForWhere()->where[] = new WhereStatement('OR'); + } - return $this->where("{$field} = ?", $value); + return $this->whereGroup($callback); } } diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index e9a726e3f..095aacd2c 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -100,7 +100,7 @@ public function paginate(int $itemsPerPage = 20, int $currentPage = 1, int $maxL /** @return T|null */ public function get(Id $id): mixed { - return $this->whereField('id', $id)->first(); + return $this->where('id', $id)->first(); } /** @return T[] */ diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index e2db702e3..7e36bbf53 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -82,7 +82,7 @@ public function build(mixed ...$bindings): Query $this->update->values = $values; if ($this->model->isObjectModel() && is_object($this->model->instance)) { - $this->whereField( + $this->where( $this->model->getPrimaryKey(), $this->model->getPrimaryKeyValue()->id, ); diff --git a/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php b/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php new file mode 100644 index 000000000..e49ad07b5 --- /dev/null +++ b/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php @@ -0,0 +1,175 @@ + */ + private array $conditions = []; + + /** @var array */ + private array $bindings = []; + + public function __construct( + private readonly ModelInspector $model, + ) {} + + /** + * Adds a `WHERE` condition to the group. + */ + public function where(string $field, mixed $value = null, string|WhereOperator $operator = WhereOperator::EQUALS): self + { + return $this->andWhere($field, $value, WhereOperator::fromOperator($operator)); + } + + /** + * Adds a `WHERE` condition to the group. + */ + public function andWhere(string $field, mixed $value = null, WhereOperator $operator = WhereOperator::EQUALS): self + { + $fieldDefinition = $this->model->getFieldDefinition($field); + $condition = $this->buildCondition((string) $fieldDefinition, $operator, $value); + + if ($this->conditions !== []) { + $condition['sql'] = "AND {$condition['sql']}"; + } + + $this->conditions[] = new WhereStatement($condition['sql']); + $this->bindings = [...$this->bindings, ...$condition['bindings']]; + + return $this; + } + + /** + * Adds a `OR WHERE` condition to the group. + */ + public function orWhere(string $field, mixed $value = null, string|WhereOperator $operator = WhereOperator::EQUALS): self + { + $operator = WhereOperator::fromOperator($operator); + $fieldDefinition = $this->model->getFieldDefinition($field); + $condition = $this->buildCondition((string) $fieldDefinition, $operator, $value); + + if ($this->conditions !== []) { + $condition['sql'] = "OR {$condition['sql']}"; + } + + $this->conditions[] = new WhereStatement($condition['sql']); + $this->bindings = [...$this->bindings, ...$condition['bindings']]; + + return $this; + } + + /** + * Adds a raw SQL `WHERE` condition to the group. + */ + public function whereRaw(string $rawCondition, mixed ...$bindings): self + { + if ($this->conditions !== [] && ! str($rawCondition)->trim()->startsWith(['AND', 'OR'])) { + $rawCondition = "AND {$rawCondition}"; + } + + $this->conditions[] = new WhereStatement($rawCondition); + $this->bindings = [...$this->bindings, ...$bindings]; + + return $this; + } + + /** + * Adds a raw SQL `AND WHERE` condition to the group. + */ + public function andWhereRaw(string $rawCondition, mixed ...$bindings): self + { + if ($this->conditions !== []) { + $rawCondition = "AND {$rawCondition}"; + } + + $this->conditions[] = new WhereStatement($rawCondition); + $this->bindings = [...$this->bindings, ...$bindings]; + + return $this; + } + + /** + * Adds a raw SQL `OR WHERE` condition to the group. + */ + public function orWhereRaw(string $rawCondition, mixed ...$bindings): self + { + if ($this->conditions !== []) { + $rawCondition = "OR {$rawCondition}"; + } + + $this->conditions[] = new WhereStatement($rawCondition); + $this->bindings = [...$this->bindings, ...$bindings]; + + return $this; + } + + /** + * Adds another nested where statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. + * + * @param Closure(WhereGroupBuilder):void $callback + * @param 'AND'|'OR' $operator + */ + public function whereGroup(Closure $callback, string $operator = 'AND'): self + { + $groupBuilder = new WhereGroupBuilder($this->model); + $callback($groupBuilder); + + $group = $groupBuilder->build(); + + if (! $group->conditions->isEmpty()) { + if ($this->conditions !== []) { + $this->conditions[] = new WhereStatement($operator); + } + + $this->conditions[] = $group; + $this->bindings = [...$this->bindings, ...$groupBuilder->getBindings()]; + } + + return $this; + } + + /** + * Adds another nested `AND WHERE` statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. + * + * @param Closure(WhereGroupBuilder):void $callback + */ + public function andWhereGroup(Closure $callback): self + { + return $this->whereGroup($callback, 'AND'); + } + + /** + * Adds another nested `OR WHERE` statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. + * + * @param Closure(WhereGroupBuilder):void $callback + */ + public function orWhereGroup(Closure $callback): self + { + return $this->whereGroup($callback, 'OR'); + } + + public function build(): WhereGroupStatement + { + return new WhereGroupStatement( + conditions: arr($this->conditions), + ); + } + + public function getBindings(): array + { + return $this->bindings; + } +} diff --git a/packages/database/src/Builder/WhereOperator.php b/packages/database/src/Builder/WhereOperator.php new file mode 100644 index 000000000..c0d52a030 --- /dev/null +++ b/packages/database/src/Builder/WhereOperator.php @@ -0,0 +1,54 @@ +'; + case GREATER_THAN = '>'; + case GREATER_THAN_OR_EQUAL = '>='; + case LESS_THAN = '<'; + case LESS_THAN_OR_EQUAL = '<='; + case LIKE = 'LIKE'; + case NOT_LIKE = 'NOT LIKE'; + case ILIKE = 'ILIKE'; + case NOT_ILIKE = 'NOT ILIKE'; + case IN = 'IN'; + case NOT_IN = 'NOT IN'; + case IS_NULL = 'IS NULL'; + case IS_NOT_NULL = 'IS NOT NULL'; + case BETWEEN = 'BETWEEN'; + case NOT_BETWEEN = 'NOT BETWEEN'; + case EXISTS = 'EXISTS'; + case NOT_EXISTS = 'NOT EXISTS'; + case REGEXP = 'REGEXP'; + case NOT_REGEXP = 'NOT REGEXP'; + case RLIKE = 'RLIKE'; + case NOT_RLIKE = 'NOT RLIKE'; + + public static function fromOperator(WhereOperator|string $value): self + { + if ($value instanceof self) { + return $value; + } + + return self::from(strtoupper($value)); + } + + public function requiresValue(): bool + { + return ! in_array($this, [self::IS_NULL, self::IS_NOT_NULL], strict: true); + } + + public function requiresMultipleValues(): bool + { + return in_array($this, [self::IN, self::NOT_IN, self::BETWEEN, self::NOT_BETWEEN], strict: true); + } + + public function supportsArray(): bool + { + return in_array($this, [self::IN, self::NOT_IN], strict: true); + } +} diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 27a98c1eb..80085ef2b 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -60,7 +60,7 @@ public static function find(mixed ...$conditions): SelectQueryBuilder { $query = self::select(); - array_walk($conditions, fn ($value, $column) => $query->whereField($column, $value)); + array_walk($conditions, fn ($value, $column) => $query->where($column, $value)); return $query; } @@ -93,7 +93,7 @@ public static function findOrNew(array $find, array $update): self $existing = self::select()->bind(...$find); foreach ($find as $key => $value) { - $existing = $existing->where("{$key} = :{$key}"); + $existing = $existing->whereRaw("{$key} = :{$key}"); } $model = $existing->first() ?? self::new(...$find); diff --git a/packages/database/src/QueryStatements/CountStatement.php b/packages/database/src/QueryStatements/CountStatement.php index f1fcbbf52..4d402f4f6 100644 --- a/packages/database/src/QueryStatements/CountStatement.php +++ b/packages/database/src/QueryStatements/CountStatement.php @@ -34,11 +34,12 @@ public function compile(DatabaseDialect $dialect): string if ($this->where->isNotEmpty()) { $query[] = 'WHERE ' . $this->where - ->map(fn (WhereStatement $where) => $where->compile($dialect)) - ->implode(PHP_EOL); + ->map(fn (QueryStatement $where) => $where->compile($dialect)) + ->filter(fn (string $compiled) => $compiled !== '') + ->implode(' '); } - return $query->implode(PHP_EOL); + return $query->implode(' '); } public function getCountArgument(): string diff --git a/packages/database/src/QueryStatements/DeleteStatement.php b/packages/database/src/QueryStatements/DeleteStatement.php index dd00bd35b..a8a880a5d 100644 --- a/packages/database/src/QueryStatements/DeleteStatement.php +++ b/packages/database/src/QueryStatements/DeleteStatement.php @@ -30,10 +30,11 @@ public function compile(DatabaseDialect $dialect): string if ($this->where->isNotEmpty()) { $query[] = 'WHERE ' . $this->where - ->map(fn (WhereStatement $where) => $where->compile($dialect)) - ->implode(PHP_EOL); + ->map(fn (QueryStatement $where) => $where->compile($dialect)) + ->filter(fn (string $compiled) => $compiled !== '') + ->implode(' '); } - return $query->implode(PHP_EOL); + return $query->implode(' '); } } diff --git a/packages/database/src/QueryStatements/HasWhereStatements.php b/packages/database/src/QueryStatements/HasWhereStatements.php index f836e7e85..489ccdfbf 100644 --- a/packages/database/src/QueryStatements/HasWhereStatements.php +++ b/packages/database/src/QueryStatements/HasWhereStatements.php @@ -6,7 +6,9 @@ interface HasWhereStatements { + /** @var ImmutableArray */ public ImmutableArray $where { get; + set; } } diff --git a/packages/database/src/QueryStatements/InsertStatement.php b/packages/database/src/QueryStatements/InsertStatement.php index 29f9c0fd0..803d4acc9 100644 --- a/packages/database/src/QueryStatements/InsertStatement.php +++ b/packages/database/src/QueryStatements/InsertStatement.php @@ -47,10 +47,7 @@ public function compile(DatabaseDialect $dialect): string ->implode(', '); $sql = sprintf( - <<table, $columns->map(fn (string $column) => "`{$column}`")->implode(', '), $entryPlaceholders, diff --git a/packages/database/src/QueryStatements/SelectStatement.php b/packages/database/src/QueryStatements/SelectStatement.php index 32c9dee45..3a2afc4f7 100644 --- a/packages/database/src/QueryStatements/SelectStatement.php +++ b/packages/database/src/QueryStatements/SelectStatement.php @@ -45,13 +45,14 @@ public function compile(DatabaseDialect $dialect): string if ($this->join->isNotEmpty()) { $query[] = $this->join ->map(fn (JoinStatement $join) => $join->compile($dialect)) - ->implode(PHP_EOL); + ->implode(' '); } if ($this->where->isNotEmpty()) { $query[] = 'WHERE ' . $this->where - ->map(fn (WhereStatement $where) => $where->compile($dialect)) - ->implode(PHP_EOL); + ->map(fn (WhereStatement|WhereGroupStatement $where) => $where->compile($dialect)) + ->filter(fn (string $compiled) => $compiled !== '') + ->implode(' '); } if ($this->groupBy->isNotEmpty()) { @@ -63,7 +64,7 @@ public function compile(DatabaseDialect $dialect): string if ($this->having->isNotEmpty()) { $query[] = 'HAVING ' . $this->having ->map(fn (HavingStatement $having) => $having->compile($dialect)) - ->implode(PHP_EOL); + ->implode(' '); } if ($this->orderBy->isNotEmpty()) { @@ -83,10 +84,10 @@ public function compile(DatabaseDialect $dialect): string if ($this->raw->isNotEmpty()) { $query[] = $this->raw ->map(fn (RawStatement $raw) => $raw->compile($dialect)) - ->implode(PHP_EOL); + ->implode(' '); } - $compiled = $query->implode(PHP_EOL); + $compiled = $query->implode(' '); return $compiled; } diff --git a/packages/database/src/QueryStatements/UpdateStatement.php b/packages/database/src/QueryStatements/UpdateStatement.php index 3fe2f06ee..3a76ea687 100644 --- a/packages/database/src/QueryStatements/UpdateStatement.php +++ b/packages/database/src/QueryStatements/UpdateStatement.php @@ -40,10 +40,11 @@ public function compile(DatabaseDialect $dialect): string if ($this->where->isNotEmpty()) { $query[] = 'WHERE ' . $this->where - ->map(fn (WhereStatement $where) => $where->compile($dialect)) - ->implode(PHP_EOL); + ->map(fn (WhereStatement|WhereGroupStatement $where) => $where->compile($dialect)) + ->filter(fn (string $compiled) => $compiled !== '') + ->implode(' '); } - return $query->implode(PHP_EOL); + return $query->implode(' '); } } diff --git a/packages/database/src/QueryStatements/WhereGroupStatement.php b/packages/database/src/QueryStatements/WhereGroupStatement.php new file mode 100644 index 000000000..2bdc2d95c --- /dev/null +++ b/packages/database/src/QueryStatements/WhereGroupStatement.php @@ -0,0 +1,50 @@ + $conditions + */ + public function __construct( + public ImmutableArray $conditions = new ImmutableArray(), + ) {} + + public function compile(DatabaseDialect $dialect): string + { + if ($this->conditions->isEmpty()) { + return ''; + } + + $compiled = $this->conditions + ->map(fn (QueryStatement $condition) => $condition->compile($dialect)) + ->filter(fn (string $condition) => $condition !== ''); + + if ($compiled->isEmpty()) { + return ''; + } + + if ($compiled->count() === 1) { + return $compiled[0]; + } + + $joined = $compiled->implode(' '); + + return "({$joined})"; + } + + public function addCondition(QueryStatement $condition): self + { + return new self($this->conditions->append($condition)); + } + + public function addGroup(WhereGroupStatement $group): self + { + return new self($this->conditions->append($group)); + } +} diff --git a/packages/database/tests/QueryStatements/CountStatementTest.php b/packages/database/tests/QueryStatements/CountStatementTest.php index 816e5966c..97bdf5e0a 100644 --- a/packages/database/tests/QueryStatements/CountStatementTest.php +++ b/packages/database/tests/QueryStatements/CountStatementTest.php @@ -18,10 +18,7 @@ public function test_count_statement(): void column: null, ); - $expected = <<assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); } @@ -35,10 +32,7 @@ public function test_count_statement_with_specified_column(): void column: 'foobar', ); - $expected = <<assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); } @@ -54,10 +48,7 @@ public function test_count_statement_with_distinct_specified_column(): void $statement->distinct = true; - $expected = <<assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); } diff --git a/packages/database/tests/QueryStatements/DeleteStatementTest.php b/packages/database/tests/QueryStatements/DeleteStatementTest.php index 567d62ede..ed44c1833 100644 --- a/packages/database/tests/QueryStatements/DeleteStatementTest.php +++ b/packages/database/tests/QueryStatements/DeleteStatementTest.php @@ -22,10 +22,7 @@ public function test_delete(): void where: arr([new WhereStatement('`bar` = "1"')]), ); - $expected = <<assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); $this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE)); diff --git a/packages/database/tests/QueryStatements/InsertStatementTest.php b/packages/database/tests/QueryStatements/InsertStatementTest.php index 7a26fcb80..b8050f864 100644 --- a/packages/database/tests/QueryStatements/InsertStatementTest.php +++ b/packages/database/tests/QueryStatements/InsertStatementTest.php @@ -21,18 +21,12 @@ public function test_insert_statement(): void arr(['foo' => 3, 'bar' => 4]), ])); - $expected = <<assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); $this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE)); - $expectedPostgres = <<assertSame($expectedPostgres, $statement->compile(DatabaseDialect::POSTGRESQL)); } diff --git a/packages/database/tests/QueryStatements/SelectStatementTest.php b/packages/database/tests/QueryStatements/SelectStatementTest.php index dcf4f6c22..988d71c24 100644 --- a/packages/database/tests/QueryStatements/SelectStatementTest.php +++ b/packages/database/tests/QueryStatements/SelectStatementTest.php @@ -33,17 +33,7 @@ public function test_select(): void offset: 100, ); - $expectedMysql = <<assertSame($expectedMysql, $statement->compile(DatabaseDialect::MYSQL)); } diff --git a/packages/database/tests/QueryStatements/UpdateStatementTest.php b/packages/database/tests/QueryStatements/UpdateStatementTest.php index 5995dd3da..266772573 100644 --- a/packages/database/tests/QueryStatements/UpdateStatementTest.php +++ b/packages/database/tests/QueryStatements/UpdateStatementTest.php @@ -24,11 +24,7 @@ public function test_update(): void where: arr([new WhereStatement('`bar` = ?')]), ); - $expected = <<assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); $this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE)); @@ -68,10 +64,7 @@ public function test_no_exception_when_no_conditions_with_explicit_allow_all(): allowAll: true, ); - $expected = <<assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); } diff --git a/tests/Integration/Database/Builder/ConvenientWhereMethodsTest.php b/tests/Integration/Database/Builder/ConvenientWhereMethodsTest.php new file mode 100644 index 000000000..e72b52274 --- /dev/null +++ b/tests/Integration/Database/Builder/ConvenientWhereMethodsTest.php @@ -0,0 +1,445 @@ +select() + ->whereIn('category', ['fiction', 'mystery', 'thriller']) + ->build(); + + $expected = 'SELECT * FROM `books` WHERE books.category IN (?,?,?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['fiction', 'mystery', 'thriller'], $query->bindings); + } + + public function test_select_where_not_in(): void + { + $query = query('books') + ->select() + ->whereNotIn('status', ['draft', 'archived']) + ->build(); + + $expected = 'SELECT * FROM `books` WHERE books.status NOT IN (?,?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['draft', 'archived'], $query->bindings); + } + + public function test_select_where_between(): void + { + $query = query('books') + ->select() + ->whereBetween('publication_year', 2020, 2024) + ->build(); + + $expected = 'SELECT * FROM `books` WHERE books.publication_year BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([2020, 2024], $query->bindings); + } + + public function test_select_where_not_between(): void + { + $query = query('books') + ->select() + ->whereNotBetween('price', 10.0, 50.0) + ->build(); + + $expected = 'SELECT * FROM `books` WHERE books.price NOT BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([10.0, 50.0], $query->bindings); + } + + public function test_select_where_null(): void + { + $query = query('books') + ->select() + ->whereNull('deleted_at') + ->build(); + + $expected = 'SELECT * FROM `books` WHERE books.deleted_at IS NULL'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([], $query->bindings); + } + + public function test_select_where_not_null(): void + { + $query = query('books') + ->select() + ->whereNotNull('published_at') + ->build(); + + $expected = 'SELECT * FROM `books` WHERE books.published_at IS NOT NULL'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([], $query->bindings); + } + + public function test_select_where_not(): void + { + $query = query('books') + ->select() + ->whereNot('status', 'draft') + ->build(); + + $expected = 'SELECT * FROM `books` WHERE books.status != ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['draft'], $query->bindings); + } + + public function test_select_where_like(): void + { + $query = query('books') + ->select() + ->whereLike('title', '%fantasy%') + ->build(); + + $expected = 'SELECT * FROM `books` WHERE books.title LIKE ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['%fantasy%'], $query->bindings); + } + + public function test_select_where_not_like(): void + { + $query = query('books') + ->select() + ->whereNotLike('title', '%test%') + ->build(); + + $expected = 'SELECT * FROM `books` WHERE books.title NOT LIKE ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['%test%'], $query->bindings); + } + + public function test_update_where_in(): void + { + $query = query('books') + ->update(title: 'New Title') + ->whereIn('category', ['fiction', 'mystery']) + ->build(); + + $expected = 'UPDATE `books` SET title = ? WHERE books.category IN (?,?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['New Title', 'fiction', 'mystery'], $query->bindings); + } + + public function test_update_where_between(): void + { + $query = query('books') + ->update(status: 'updated') + ->whereBetween('rating', 3.0, 5.0) + ->build(); + + $expected = 'UPDATE `books` SET status = ? WHERE books.rating BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['updated', 3.0, 5.0], $query->bindings); + } + + public function test_update_where_null(): void + { + $query = query('books') + ->update(status: 'archived') + ->whereNull('deleted_at') + ->build(); + + $expected = 'UPDATE `books` SET status = ? WHERE books.deleted_at IS NULL'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['archived'], $query->bindings); + } + + public function test_delete_where_in(): void + { + $query = query('books') + ->delete() + ->whereIn('status', ['draft', 'archived']) + ->build(); + + $expected = 'DELETE FROM `books` WHERE books.status IN (?,?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['draft', 'archived'], $query->bindings); + } + + public function test_delete_where_not_null(): void + { + $query = query('books') + ->delete() + ->whereNotNull('deleted_at') + ->build(); + + $expected = 'DELETE FROM `books` WHERE books.deleted_at IS NOT NULL'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([], $query->bindings); + } + + public function test_complex_chaining_with_convenient_methods(): void + { + $query = query('books') + ->select() + ->whereIn('category', ['fiction', 'mystery']) + ->whereNotNull('published_at') + ->whereBetween('rating', 3.0, 5.0) + ->orWhereNot('status', 'draft') + ->orWhereLike('title', '%bestseller%') + ->build(); + + $expected = 'SELECT * FROM `books` WHERE books.category IN (?,?) AND books.published_at IS NOT NULL AND books.rating BETWEEN ? AND ? OR books.status != ? OR books.title LIKE ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['fiction', 'mystery', 3.0, 5.0, 'draft', '%bestseller%'], $query->bindings); + } + + public function test_convenient_methods_in_where_groups(): void + { + $query = query('books') + ->select() + ->where('published', true) + ->andWhereGroup(function ($group): void { + $group + ->whereIn('category', ['fiction', 'mystery']) + ->orWhereNull('featured_at'); + }) + ->orWhereGroup(function ($group): void { + $group + ->whereBetween('rating', 4.0, 5.0) + ->whereNotLike('title', '%draft%'); + }) + ->build(); + + $expected = 'SELECT * FROM `books` WHERE books.published = ? AND (books.category IN (?,?) OR books.featured_at IS NULL) OR (books.rating BETWEEN ? AND ? AND books.title NOT LIKE ?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([true, 'fiction', 'mystery', 4.0, 5.0, '%draft%'], $query->bindings); + } + + public function test_nested_where_groups_with_convenient_methods(): void + { + $query = query('books') + ->select() + ->whereIn('status', ['published', 'featured']) + ->andWhereGroup(function ($group): void { + $group + ->whereNotNull('published_at') + ->orWhereGroup(function ($innerGroup): void { + $innerGroup + ->whereBetween('rating', 4.0, 5.0) + ->whereNotIn('category', ['children']); + }); + }) + ->build(); + + $expected = 'SELECT * FROM `books` WHERE books.status IN (?,?) AND (books.published_at IS NOT NULL OR (books.rating BETWEEN ? AND ? AND books.category NOT IN (?)))'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['published', 'featured', 4.0, 5.0, 'children'], $query->bindings); + } + + // Note: Validation error tests removed since API changed to two separate arguments + + public function test_all_convenient_methods_together(): void + { + $query = query('books') + ->select() + ->whereIn('category', ['fiction']) + ->whereNotIn('status', ['draft']) + ->whereBetween('rating', 3.0, 5.0) + ->whereNotBetween('price', 100.0, 200.0) + ->whereNull('deleted_at') + ->whereNotNull('published_at') + ->whereNot('featured', false) + ->whereLike('title', '%adventure%') + ->whereNotLike('description', '%boring%') + ->orWhereIn('tags', ['bestseller']) + ->orWhereNotIn('awards', ['none']) + ->orWhereBetween('pages', 200, 400) + ->orWhereNotBetween('weight', 2.0, 5.0) + ->orWhereNull('special_edition') + ->orWhereNotNull('isbn') + ->orWhereNot('limited_edition', true) + ->orWhereLike('publisher', '%Penguin%') + ->orWhereNotLike('format', '%digital%') + ->build(); + + $expected = 'SELECT * FROM `books` WHERE books.category IN (?) AND books.status NOT IN (?) AND books.rating BETWEEN ? AND ? AND books.price NOT BETWEEN ? AND ? AND books.deleted_at IS NULL AND books.published_at IS NOT NULL AND books.featured != ? AND books.title LIKE ? AND books.description NOT LIKE ? OR books.tags IN (?) OR books.awards NOT IN (?) OR books.pages BETWEEN ? AND ? OR books.weight NOT BETWEEN ? AND ? OR books.special_edition IS NULL OR books.isbn IS NOT NULL OR books.limited_edition != ? OR books.publisher LIKE ? OR books.format NOT LIKE ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([ + 'fiction', + 'draft', + 3.0, + 5.0, + 100.0, + 200.0, + false, + '%adventure%', + '%boring%', + 'bestseller', + 'none', + 200, + 400, + 2.0, + 5.0, + true, + '%Penguin%', + '%digital%', + ], $query->bindings); + } + + public function test_where_between_with_tempest_datetime(): void + { + $startDate = DateTime::parse('2024-01-01 00:00:00'); + $endDate = DateTime::parse('2024-12-31 23:59:59'); + + $query = query('events') + ->select() + ->whereBetween('created_at', $startDate, $endDate) + ->build(); + + $expected = 'SELECT * FROM `events` WHERE events.created_at BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([$startDate, $endDate], $query->bindings); + } + + public function test_where_between_with_mixed_datetime_types(): void + { + $startDate = DateTime::parse('2024-01-01 00:00:00'); + $endDate = '2024-12-31 23:59:59'; + + $query = query('events') + ->select() + ->whereBetween('created_at', $startDate, $endDate) + ->build(); + + $expected = 'SELECT * FROM `events` WHERE events.created_at BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([$startDate, $endDate], $query->bindings); + } + + public function test_where_not_between_with_tempest_datetime(): void + { + $startDate = DateTime::parse('2024-06-01 00:00:00'); + $endDate = DateTime::parse('2024-08-31 23:59:59'); + + $query = query('events') + ->select() + ->whereNotBetween('created_at', $startDate, $endDate) + ->build(); + + $expected = 'SELECT * FROM `events` WHERE events.created_at NOT BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([$startDate, $endDate], $query->bindings); + } + + public function test_or_where_between_with_tempest_datetime(): void + { + $startDate = DateTime::parse('2024-01-01 00:00:00'); + $endDate = DateTime::parse('2024-03-31 23:59:59'); + + $query = query('events') + ->select() + ->where('status', 'active') + ->orWhereBetween('created_at', $startDate, $endDate) + ->build(); + + $expected = 'SELECT * FROM `events` WHERE events.status = ? OR events.created_at BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['active', $startDate, $endDate], $query->bindings); + } + + public function test_or_where_not_between_with_tempest_datetime(): void + { + $startDate = DateTime::parse('2024-07-01 00:00:00'); + $endDate = DateTime::parse('2024-09-30 23:59:59'); + + $query = query('events') + ->select() + ->where('priority', 'high') + ->orWhereNotBetween('created_at', $startDate, $endDate) + ->build(); + + $expected = 'SELECT * FROM `events` WHERE events.priority = ? OR events.created_at NOT BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['high', $startDate, $endDate], $query->bindings); + } + + public function test_where_between_with_datetime_convenience_methods(): void + { + $today = DateTime::now(); + $startDate = $today->startOfDay(); + $endDate = $today->endOfDay(); + + $query = query('events') + ->select() + ->whereBetween('created_at', $startDate, $endDate) + ->build(); + + $expected = 'SELECT * FROM `events` WHERE events.created_at BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([$startDate, $endDate], $query->bindings); + } + + public function test_where_between_with_datetime_start_and_end_of_month(): void + { + $today = DateTime::now(); + $startDate = $today->startOfMonth(); + $endDate = $today->endOfMonth(); + + $query = query('events') + ->select() + ->whereBetween('created_at', $startDate, $endDate) + ->build(); + + $expected = 'SELECT * FROM `events` WHERE events.created_at BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([$startDate, $endDate], $query->bindings); + } + + public function test_where_between_with_datetime_start_and_end_of_week(): void + { + // Use a safe date in the middle of the month to avoid edge cases + $baseDate = DateTime::parse('2024-08-15 12:00:00'); + $startDate = $baseDate->startOfWeek(); + $endDate = $baseDate->endOfWeek(); + + $query = query('events') + ->select() + ->whereBetween('created_at', $startDate, $endDate) + ->build(); + + $expected = 'SELECT * FROM `events` WHERE events.created_at BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([$startDate, $endDate], $query->bindings); + } +} diff --git a/tests/Integration/Database/Builder/CountQueryBuilderTest.php b/tests/Integration/Database/Builder/CountQueryBuilderTest.php index 6e8011cac..fcf9519ef 100644 --- a/tests/Integration/Database/Builder/CountQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/CountQueryBuilderTest.php @@ -10,6 +10,7 @@ use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; +use Tests\Tempest\Integration\Database\Fixtures\BookStatus; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use function Tempest\Database\query; @@ -23,18 +24,12 @@ public function test_simple_count_query(): void { $query = query('chapters') ->count() - ->where('`title` = ?', 'Timeline Taxi') - ->andWhere('`index` <> ?', '1') - ->orWhere('`createdAt` > ?', '2025-01-01') + ->whereRaw('`title` = ?', 'Timeline Taxi') + ->andWhereRaw('`index` <> ?', '1') + ->orWhereRaw('`createdAt` > ?', '2025-01-01') ->build(); - $expected = << ? - OR `createdAt` > ? - SQL; + $expected = 'SELECT COUNT(*) AS count FROM chapters WHERE title = ? AND index <> ? OR createdAt > ?'; $sql = $query->toSql(); $bindings = $query->bindings; @@ -51,10 +46,7 @@ public function test_count_query_with_specified_asterisk(): void $sql = $query->toSql(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } @@ -65,10 +57,7 @@ public function test_count_query_with_specified_field(): void $sql = $query->toSql(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } @@ -102,10 +91,7 @@ public function test_count_query_with_distinct_specified_field(): void $sql = $query->toSql(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } @@ -116,10 +102,7 @@ public function test_count_from_model(): void $sql = $query->toSql(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } @@ -131,26 +114,20 @@ public function test_count_query_with_conditions(): void ->when( true, fn (CountQueryBuilder $query) => $query - ->where('`title` = ?', 'Timeline Taxi') - ->andWhere('`index` <> ?', '1') - ->orWhere('`createdAt` > ?', '2025-01-01'), + ->whereRaw('`title` = ?', 'Timeline Taxi') + ->andWhereRaw('`index` <> ?', '1') + ->orWhereRaw('`createdAt` > ?', '2025-01-01'), ) ->when( false, fn (CountQueryBuilder $query) => $query - ->where('`title` = ?', 'Timeline Uber') - ->andWhere('`index` <> ?', '2') - ->orWhere('`createdAt` > ?', '2025-01-02'), + ->whereRaw('`title` = ?', 'Timeline Uber') + ->andWhereRaw('`index` <> ?', '2') + ->orWhereRaw('`createdAt` > ?', '2025-01-02'), ) ->build(); - $expected = << ? - OR `createdAt` > ? - SQL; + $expected = 'SELECT COUNT(*) AS `count` FROM `chapters` WHERE `title` = ? AND `index` <> ? OR `createdAt` > ?'; $sql = $query->toSql(); $bindings = $query->bindings; @@ -173,43 +150,380 @@ public function test_count(): void $this->assertSame(2, $count); } - public function test_multiple_where(): void + public function test_multiple_where_raw(): void { $sql = query('books') ->count() - ->where('title = ?', 'a') - ->where('author_id = ?', 1) - ->where('OR author_id = ?', 2) - ->where('AND author_id <> NULL') + ->whereRaw('title = ?', 'a') + ->whereRaw('author_id = ?', 1) + ->whereRaw('OR author_id = ?', 2) + ->whereRaw('AND author_id <> NULL') ->toSql(); - $expected = << NULL - SQL; + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE title = ? AND author_id = ? OR author_id = ? AND author_id <> NULL'; $this->assertSameWithoutBackticks($expected, $sql); } - public function test_multiple_where_field(): void + public function test_multiple_where(): void { $sql = query('books') ->count() - ->whereField('title', 'a') - ->whereField('author_id', 1) + ->where('title', 'a') + ->where('author_id', 1) ->toSql(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } + + public function test_where_in(): void + { + $query = query('books') + ->count() + ->whereIn('category', ['fiction', 'mystery', 'thriller']) + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.category IN (?,?,?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['fiction', 'mystery', 'thriller'], $query->bindings); + } + + public function test_where_in_with_enum_class(): void + { + $query = query('books') + ->count() + ->whereIn('status', BookStatus::class) + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.status IN (?,?,?,?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['draft', 'published', 'archived', 'featured'], $query->bindings); + } + + public function test_where_in_with_enums(): void + { + $query = query('books') + ->count() + ->whereIn('status', [BookStatus::PUBLISHED, BookStatus::FEATURED]) + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.status IN (?,?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['published', 'featured'], $query->bindings); + } + + public function test_where_not_in(): void + { + $query = query('books') + ->count() + ->whereNotIn('status', ['draft', 'archived']) + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.status NOT IN (?,?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['draft', 'archived'], $query->bindings); + } + + public function test_where_not_in_with_enums(): void + { + $query = query('books') + ->count() + ->whereNotIn('status', [BookStatus::DRAFT, BookStatus::ARCHIVED]) + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.status NOT IN (?,?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['draft', 'archived'], $query->bindings); + } + + public function test_where_between(): void + { + $query = query('books') + ->count() + ->whereBetween('publication_year', 2020, 2024) + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.publication_year BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([2020, 2024], $query->bindings); + } + + public function test_where_not_between(): void + { + $query = query('books') + ->count() + ->whereNotBetween('price', 10.0, 50.0) + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.price NOT BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([10.0, 50.0], $query->bindings); + } + + public function test_where_null(): void + { + $query = query('books') + ->count() + ->whereNull('deleted_at') + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.deleted_at IS NULL'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([], $query->bindings); + } + + public function test_where_not_null(): void + { + $query = query('books') + ->count() + ->whereNotNull('published_at') + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published_at IS NOT NULL'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([], $query->bindings); + } + + public function test_where_not(): void + { + $query = query('books') + ->count() + ->whereNot('status', 'draft') + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.status != ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['draft'], $query->bindings); + } + + public function test_where_like(): void + { + $query = query('books') + ->count() + ->whereLike('title', '%fantasy%') + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.title LIKE ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['%fantasy%'], $query->bindings); + } + + public function test_where_not_like(): void + { + $query = query('books') + ->count() + ->whereNotLike('title', '%test%') + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.title NOT LIKE ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['%test%'], $query->bindings); + } + + public function test_or_where_in(): void + { + $query = query('books') + ->count() + ->where('published', true) + ->orWhereIn('category', ['fiction', 'mystery']) + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.category IN (?,?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([true, 'fiction', 'mystery'], $query->bindings); + } + + public function test_or_where_not_in(): void + { + $query = query('books') + ->count() + ->where('published', true) + ->orWhereNotIn('status', ['draft', 'archived']) + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.status NOT IN (?,?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([true, 'draft', 'archived'], $query->bindings); + } + + public function test_or_where_between(): void + { + $query = query('books') + ->count() + ->where('published', true) + ->orWhereBetween('rating', 4.0, 5.0) + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.rating BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([true, 4.0, 5.0], $query->bindings); + } + + public function test_or_where_not_between(): void + { + $query = query('books') + ->count() + ->where('published', true) + ->orWhereNotBetween('price', 20.0, 80.0) + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.price NOT BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([true, 20.0, 80.0], $query->bindings); + } + + public function test_or_where_null(): void + { + $query = query('books') + ->count() + ->where('published', true) + ->orWhereNull('deleted_at') + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.deleted_at IS NULL'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([true], $query->bindings); + } + + public function test_or_where_not_null(): void + { + $query = query('books') + ->count() + ->where('published', false) + ->orWhereNotNull('featured_at') + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.featured_at IS NOT NULL'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([false], $query->bindings); + } + + public function test_or_where_not(): void + { + $query = query('books') + ->count() + ->where('published', true) + ->orWhereNot('status', 'archived') + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.status != ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([true, 'archived'], $query->bindings); + } + + public function test_or_where_like(): void + { + $query = query('books') + ->count() + ->where('published', true) + ->orWhereLike('description', '%adventure%') + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.description LIKE ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([true, '%adventure%'], $query->bindings); + } + + public function test_or_where_not_like(): void + { + $query = query('books') + ->count() + ->where('published', true) + ->orWhereNotLike('title', '%boring%') + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.title NOT LIKE ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([true, '%boring%'], $query->bindings); + } + + public function test_chained_convenient_where_methods(): void + { + $query = query('books') + ->count() + ->whereIn('category', ['fiction', 'mystery']) + ->whereNotNull('published_at') + ->whereBetween('rating', 3.0, 5.0) + ->whereNot('status', 'draft') + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.category IN (?,?) AND books.published_at IS NOT NULL AND books.rating BETWEEN ? AND ? AND books.status != ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['fiction', 'mystery', 3.0, 5.0, 'draft'], $query->bindings); + } + + public function test_mixed_convenient_and_or_where_methods(): void + { + $query = query('books') + ->count() + ->whereIn('category', ['fiction']) + ->orWhereNull('featured_at') + ->orWhereNotBetween('price', 100.0, 200.0) + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.category IN (?) OR books.featured_at IS NULL OR books.price NOT BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['fiction', 100.0, 200.0], $query->bindings); + } + + public function test_convenient_where_methods_in_groups(): void + { + $query = query('books') + ->count() + ->whereIn('status', ['published', 'featured']) + ->andWhereGroup(function ($group): void { + $group + ->whereNotNull('published_at') + ->orWhereBetween('rating', 4.0, 5.0); + }) + ->build(); + + $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.status IN (?,?) AND (books.published_at IS NOT NULL OR books.rating BETWEEN ? AND ?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['published', 'featured', 4.0, 5.0], $query->bindings); + } + + public function test_nested_where_with_count_query(): void + { + $query = query('books') + ->count() + ->whereRaw('published = ?', true) + ->orWhereGroup(function ($group): void { + $group + ->whereRaw('status = ?', 'featured') + ->andWhereRaw('rating >= ?', 4.5); + }) + ->build(); + + $expected = 'SELECT COUNT(*) AS count FROM books WHERE published = ? OR (status = ? AND rating >= ?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([true, 'featured', 4.5], $query->bindings); + } } diff --git a/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php b/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php index 70ddfa36f..7b3d9fa39 100644 --- a/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php @@ -18,14 +18,11 @@ public function test_delete_on_plain_table(): void { $query = query('foo') ->delete() - ->where('`bar` = ?', 'boo') + ->whereRaw('`bar` = ?', 'boo') ->build(); $this->assertSameWithoutBackticks( - <<toSql(), ); @@ -43,9 +40,7 @@ public function test_delete_on_model_table(): void ->build(); $this->assertSameWithoutBackticks( - <<toSql(), ); } @@ -60,10 +55,7 @@ public function test_delete_on_model_object(): void ->build(); $this->assertSameWithoutBackticks( - <<toSql(), ); @@ -79,19 +71,16 @@ public function test_delete_on_plain_table_with_conditions(): void ->delete() ->when( true, - fn (DeleteQueryBuilder $query) => $query->where('`bar` = ?', 'boo'), + fn (DeleteQueryBuilder $query) => $query->whereRaw('`bar` = ?', 'boo'), ) ->when( false, - fn (DeleteQueryBuilder $query) => $query->where('`bar` = ?', 'foo'), + fn (DeleteQueryBuilder $query) => $query->whereRaw('`bar` = ?', 'foo'), ) ->build(); $this->assertSameWithoutBackticks( - <<toSql(), ); @@ -110,48 +99,56 @@ public function test_delete_with_non_object_model(): void ['id' => 2, 'name' => 'Other'], )->execute(); - query('authors')->delete()->where('id = ?', 1)->execute(); + query('authors')->delete()->whereRaw('id = ?', 1)->execute(); - $count = query('authors')->count()->where('id = ?', 1)->execute(); + $count = query('authors')->count()->whereRaw('id = ?', 1)->execute(); $this->assertSame(0, $count); } - public function test_multiple_where(): void + public function test_multiple_where_raw(): void { $sql = query('books') ->delete() - ->where('title = ?', 'a') - ->where('author_id = ?', 1) - ->where('OR author_id = ?', 2) - ->where('AND author_id <> NULL') + ->whereRaw('title = ?', 'a') + ->whereRaw('author_id = ?', 1) + ->whereRaw('OR author_id = ?', 2) + ->whereRaw('AND author_id <> NULL') ->toSql(); - $expected = << NULL - SQL; + $expected = 'DELETE FROM `books` WHERE title = ? AND author_id = ? OR author_id = ? AND author_id <> NULL'; $this->assertSameWithoutBackticks($expected, $sql); } - public function test_multiple_where_field(): void + public function test_multiple_where(): void { $sql = query('books') ->delete() - ->whereField('title', 'a') - ->whereField('author_id', 1) + ->where('title', 'a') + ->where('author_id', 1) ->toSql(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } + + public function test_nested_where_with_delete_query(): void + { + $query = query('books') + ->delete() + ->whereRaw('status = ?', 'draft') + ->andWhereGroup(function ($group): void { + $group + ->whereRaw('created_at < ?', '2022-01-01') + ->andWhereRaw('author_id IS NULL'); + }) + ->build(); + + $expected = 'DELETE FROM books WHERE status = ? AND (created_at < ? AND author_id IS NULL)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['draft', '2022-01-01'], $query->bindings); + } } diff --git a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php index 5d40f38d9..2d5e828c1 100644 --- a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php @@ -32,10 +32,7 @@ public function test_insert_on_plain_table(): void ) ->build(); - $expected = $this->buildExpectedInsert(<<buildExpectedInsert('INSERT INTO `chapters` (`title`, `index`) VALUES (?, ?)'); $this->assertSameWithoutBackticks( $expected, @@ -60,10 +57,7 @@ public function test_insert_with_batch(): void ->insert(...$arrayOfStuff) ->build(); - $expected = $this->buildExpectedInsert(<<buildExpectedInsert('INSERT INTO `chapters` (`chapter`, `index`) VALUES (?, ?), (?, ?), (?, ?)'); $this->assertSameWithoutBackticks( $expected, @@ -90,10 +84,7 @@ public function test_insert_on_model_table(): void ) ->build(); - $expected = $this->buildExpectedInsert(<<buildExpectedInsert('INSERT INTO `authors` (`name`, `type`, `publisher_id`) VALUES (?, ?, ?), (?, ?, ?)'); $this->assertSameWithoutBackticks($expected, $query->toSql()); $this->assertSame(['brent', 'a', null, 'other name', 'b', null], $query->bindings); @@ -114,10 +105,7 @@ public function test_insert_on_model_table_with_new_relation(): void ) ->build(); - $expectedBookQuery = $this->buildExpectedInsert(<<buildExpectedInsert('INSERT INTO `books` (`title`, `author_id`) VALUES (?, ?)'); $this->assertSameWithoutBackticks($expectedBookQuery, $bookQuery->toSql()); $this->assertSame('Timeline Taxi', $bookQuery->bindings[0]); @@ -125,10 +113,7 @@ public function test_insert_on_model_table_with_new_relation(): void $authorQuery = $bookQuery->bindings[1]; - $expectedAuthorQuery = $this->buildExpectedInsert(<<buildExpectedInsert('INSERT INTO `authors` (`name`) VALUES (?)'); $this->assertSameWithoutBackticks($expectedAuthorQuery, $authorQuery->toSql()); $this->assertSame('Brent', $authorQuery->bindings[0]); @@ -150,10 +135,7 @@ public function test_insert_on_model_table_with_existing_relation(): void ) ->build(); - $expectedBookQuery = $this->buildExpectedInsert(<<buildExpectedInsert('INSERT INTO `books` (`title`, `author_id`) VALUES (?, ?)'); $this->assertSameWithoutBackticks($expectedBookQuery, $bookQuery->toSql()); $this->assertSame('Timeline Taxi', $bookQuery->bindings[0]); diff --git a/tests/Integration/Database/Builder/NestedWhereTest.php b/tests/Integration/Database/Builder/NestedWhereTest.php new file mode 100644 index 000000000..34552fbd7 --- /dev/null +++ b/tests/Integration/Database/Builder/NestedWhereTest.php @@ -0,0 +1,151 @@ +select() + ->whereRaw('title = ?', 'test') + ->andWhereGroup(function ($group): void { + $group + ->whereRaw('author_id = ?', 1) + ->orWhereRaw('author_id = ?', 2); + }) + ->build(); + + $expected = 'SELECT * FROM books WHERE title = ? AND (author_id = ? OR author_id = ?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['test', 1, 2], $query->bindings); + } + + public function test_nested_where_with_or_group(): void + { + $query = query('books') + ->select() + ->whereRaw('status = ?', 'active') + ->orWhereGroup(function ($group): void { + $group + ->whereRaw('priority = ?', 'high') + ->andWhereRaw('urgent = ?', true); + }) + ->build(); + + $expected = 'SELECT * FROM books WHERE status = ? OR (priority = ? AND urgent = ?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['active', 'high', true], $query->bindings); + } + + public function test_deeply_nested_where_groups(): void + { + $query = query('books') + ->select() + ->whereRaw('published = ?', true) + ->andWhereGroup(function ($group): void { + $group + ->whereRaw('category = ?', 'fiction') + ->orWhereGroup(function ($innerGroup): void { + $innerGroup + ->whereRaw('author_name = ?', 'Tolkien') + ->andWhereRaw('rating > ?', 4.5); + }); + }) + ->build(); + + $expected = 'SELECT * FROM books WHERE published = ? AND (category = ? OR (author_name = ? AND rating > ?))'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([true, 'fiction', 'Tolkien', 4.5], $query->bindings); + } + + public function test_complex_nested_where_scenario(): void + { + // Test a realistic complex query: + // WHERE status = 'published' + // AND ( + // (category = 'fiction' AND rating > 4.0) + // OR + // (category = 'non-fiction' AND author_id IN (1, 2, 3)) + // ) + // AND created_at > '2024-01-01' + + $query = query('books') + ->select() + ->whereRaw('status = ?', 'published') + ->andWhereGroup(function ($group): void { + $group + ->andWhereGroup(function ($innerGroup): void { + $innerGroup + ->whereRaw('category = ?', 'fiction') + ->andWhereRaw('rating > ?', 4.0); + }) + ->orWhereGroup(function ($innerGroup): void { + $innerGroup + ->whereRaw('category = ?', 'non-fiction') + ->andWhereRaw('author_id IN (?, ?, ?)', 1, 2, 3); + }); + }) + ->andWhereRaw('created_at > ?', '2024-01-01') + ->build(); + + $expected = 'SELECT * FROM books WHERE status = ? AND ((category = ? AND rating > ?) OR (category = ? AND author_id IN (?, ?, ?))) AND created_at > ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([ + 'published', + 'fiction', + 4.0, + 'non-fiction', + 1, + 2, + 3, + '2024-01-01', + ], $query->bindings); + } + + public function test_where_group_without_existing_conditions(): void + { + // Test starting with a group + $query = query('books') + ->select() + ->whereGroup(function ($group): void { + $group + ->whereRaw('title LIKE ?', '%test%') + ->orWhereRaw('description LIKE ?', '%test%'); + }) + ->build(); + + $expected = 'SELECT * FROM books WHERE (title LIKE ? OR description LIKE ?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['%test%', '%test%'], $query->bindings); + } + + public function test_nested_where_with_where(): void + { + $query = query('books') + ->select() + ->where('published', true) + ->andWhereGroup(function ($group): void { + $group + ->whereRaw('category = ?', 'fiction') + ->orWhereRaw('priority = ?', 'high'); + }) + ->build(); + + $expected = 'SELECT * FROM books WHERE books.published = ? AND (category = ? OR priority = ?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([true, 'fiction', 'high'], $query->bindings); + } +} diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index 565a3480d..8ce6feddf 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -29,20 +29,13 @@ public function test_select_query(): void { $query = query('chapters') ->select('title', 'index') - ->where('`title` = ?', 'Timeline Taxi') - ->andWhere('`index` <> ?', '1') - ->orWhere('`createdAt` > ?', '2025-01-01') + ->whereRaw('`title` = ?', 'Timeline Taxi') + ->andWhereRaw('`index` <> ?', '1') + ->orWhereRaw('`createdAt` > ?', '2025-01-01') ->orderBy('`index` ASC') ->build(); - $expected = << ? - OR createdAt > ? - ORDER BY index ASC - SQL; + $expected = 'SELECT title, index FROM chapters WHERE title = ? AND index <> ? OR createdAt > ? ORDER BY index ASC'; $sql = $query->toSql(); $bindings = $query->bindings; @@ -57,10 +50,7 @@ public function test_select_without_any_fields_specified(): void $sql = $query->toSql(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } @@ -71,10 +61,7 @@ public function test_select_from_model(): void $sql = $query->toSql(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } @@ -83,20 +70,13 @@ public function test_multiple_where(): void { $sql = query('books') ->select() - ->where('title = ?', 'a') - ->where('author_id = ?', 1) - ->where('OR author_id = ?', 2) - ->where('AND author_id <> NULL') + ->whereRaw('title = ?', 'a') + ->whereRaw('author_id = ?', 1) + ->whereRaw('OR author_id = ?', 2) + ->whereRaw('AND author_id <> NULL') ->toSql(); - $expected = << NULL - SQL; + $expected = 'SELECT * FROM `books` WHERE title = ? AND author_id = ? OR author_id = ? AND author_id <> NULL'; $this->assertSameWithoutBackticks($expected, $sql); } @@ -105,16 +85,11 @@ public function test_multiple_where_field(): void { $sql = query('books') ->select() - ->whereField('title', 'a') - ->whereField('author_id', 1) + ->where('title', 'a') + ->where('author_id', 1) ->toSql(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } @@ -133,7 +108,7 @@ public function test_where_statement(): void Book::new(title: 'C')->save(); Book::new(title: 'D')->save(); - $book = Book::select()->where('title = ?', 'B')->first(); + $book = Book::select()->whereRaw('title = ?', 'B')->first(); $this->assertSame('B', $book->title); } @@ -246,7 +221,7 @@ public function test_chunk(): void $this->assertCount(4, $results); $results = []; - Book::select()->where("title <> 'A'")->chunk(function (array $chunk) use (&$results): void { + Book::select()->whereRaw("title <> 'A'")->chunk(function (array $chunk) use (&$results): void { $results = [...$results, ...$chunk]; }, 2); $this->assertCount(3, $results); @@ -276,28 +251,21 @@ public function test_select_query_with_conditions(): void ->when( true, fn (SelectQueryBuilder $query) => $query - ->where('`title` = ?', 'Timeline Taxi') - ->andWhere('`index` <> ?', '1') - ->orWhere('`createdAt` > ?', '2025-01-01'), + ->whereRaw('`title` = ?', 'Timeline Taxi') + ->andWhereRaw('`index` <> ?', '1') + ->orWhereRaw('`createdAt` > ?', '2025-01-01'), ) ->when( false, fn (SelectQueryBuilder $query) => $query - ->where('`title` = ?', 'Timeline Uber') - ->andWhere('`index` <> ?', '2') - ->orWhere('`createdAt` > ?', '2025-01-02'), + ->whereRaw('`title` = ?', 'Timeline Uber') + ->andWhereRaw('`index` <> ?', '2') + ->orWhereRaw('`createdAt` > ?', '2025-01-02'), ) ->orderBy('`index` ASC') ->build(); - $expected = << ? - OR `createdAt` > ? - ORDER BY `index` ASC - SQL; + $expected = 'SELECT title, index FROM `chapters` WHERE `title` = ? AND `index` <> ? OR `createdAt` > ? ORDER BY `index` ASC'; $sql = $query->toSql(); $bindings = $query->bindings; @@ -317,7 +285,7 @@ public function test_select_first_with_non_object_model(): void $author = query('authors') ->select() - ->whereField('id', 2) + ->where('id', 2) ->first(); $this->assertSame(['id' => 2, 'name' => 'Other', 'type' => null, 'publisher_id' => null], $author); @@ -335,7 +303,7 @@ public function test_select_all_with_non_object_model(): void $authors = query('authors') ->select() - ->where('name <> ?', 'Brent') + ->whereRaw('name <> ?', 'Brent') ->all(); $this->assertSame( @@ -348,10 +316,10 @@ public function test_select_includes_belongs_to(): void { $query = query(Book::class)->select(); - $this->assertSameWithoutBackticks(<<build()->toSql()); + $this->assertSameWithoutBackticks( + 'SELECT books.title AS `books.title`, books.author_id AS `books.author_id`, books.id AS `books.id` FROM `books`', + $query->build()->toSql(), + ); } public function test_with_belongs_to_relation(): void @@ -361,13 +329,10 @@ public function test_with_belongs_to_relation(): void ->with('author', 'chapters', 'isbn') ->build(); - $this->assertSameWithoutBackticks(<<toSql()); + $this->assertSameWithoutBackticks( + 'SELECT books.title AS `books.title`, books.author_id AS `books.author_id`, books.id AS `books.id`, authors.name AS `author.name`, authors.type AS `author.type`, authors.publisher_id AS `author.publisher_id`, authors.id AS `author.id`, chapters.title AS `chapters.title`, chapters.contents AS `chapters.contents`, chapters.book_id AS `chapters.book_id`, chapters.id AS `chapters.id`, isbns.value AS `isbn.value`, isbns.book_id AS `isbn.book_id`, isbns.id AS `isbn.id` FROM `books` LEFT JOIN authors ON authors.id = books.author_id LEFT JOIN chapters ON chapters.book_id = books.id LEFT JOIN isbns ON isbns.book_id = books.id', + $query->toSql(), + ); } public function test_select_query_execute_with_relations(): void @@ -400,12 +365,10 @@ public function test_eager_loads_combined_with_manual_loads(): void { $query = AWithEager::select()->with('b.c')->toSql(); - $this->assertSameWithoutBackticks(<<assertSameWithoutBackticks( + 'SELECT a.b_id AS `a.b_id`, a.id AS `a.id`, b.c_id AS `b.c_id`, b.id AS `b.id`, c.name AS `b.c.name`, c.id AS `b.c.id` FROM `a` LEFT JOIN b ON b.id = a.b_id LEFT JOIN c ON c.id = b.c_id', + $query, + ); } public function test_group_by(): void @@ -415,11 +378,7 @@ public function test_group_by(): void ->groupBy('name') ->toSql(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } @@ -431,11 +390,7 @@ public function test_having(): void ->having('name = ?', 'Brent') ->toSql(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php index d5cb9edfc..1a80d68f1 100644 --- a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php @@ -29,15 +29,11 @@ public function test_update_on_plain_table(): void title: 'Chapter 01', index: 1, ) - ->where('`id` = ?', 10) + ->whereRaw('`id` = ?', 10) ->build(); $this->assertSameWithoutBackticks( - <<toSql(), ); @@ -55,10 +51,7 @@ public function test_global_update(): void ->build(); $this->assertSameWithoutBackticks( - <<toSql(), ); @@ -84,15 +77,11 @@ public function test_model_update_with_values(): void ->update( title: 'Chapter 02', ) - ->where('`id` = ?', 10) + ->whereRaw('`id` = ?', 10) ->build(); $this->assertSameWithoutBackticks( - <<toSql(), ); @@ -116,11 +105,7 @@ public function test_model_update_with_object(): void ->build(); $this->assertSameWithoutBackticks( - <<toSql(), ); @@ -159,11 +144,7 @@ public function test_insert_new_relation_on_update(): void ->build(); $this->assertSameWithoutBackticks( - <<toSql(), ); @@ -171,10 +152,7 @@ public function test_insert_new_relation_on_update(): void $authorQuery = $bookQuery->bindings[0]; - $expected = <<container->get(Database::class)->dialect === DatabaseDialect::POSTGRESQL) { $expected .= ' RETURNING *'; @@ -199,11 +177,7 @@ public function test_attach_existing_relation_on_update(): void ->build(); $this->assertSameWithoutBackticks( - <<toSql(), ); @@ -247,20 +221,16 @@ public function test_update_on_plain_table_with_conditions(): void ) ->when( true, - fn (UpdateQueryBuilder $query) => $query->where('`id` = ?', 10), + fn (UpdateQueryBuilder $query) => $query->whereRaw('`id` = ?', 10), ) ->when( false, - fn (UpdateQueryBuilder $query) => $query->where('`id` = ?', 20), + fn (UpdateQueryBuilder $query) => $query->whereRaw('`id` = ?', 20), ) ->build(); $this->assertSameWithoutBackticks( - <<toSql(), ); @@ -281,9 +251,9 @@ public function test_update_with_non_object_model(): void query('authors')->update( name: 'Brendt', - )->where('id = ?', 1)->execute(); + )->whereRaw('id = ?', 1)->execute(); - $count = query('authors')->count()->where('name = ?', 'Brendt')->execute(); + $count = query('authors')->count()->whereRaw('name = ?', 'Brendt')->execute(); $this->assertSame(1, $count); } @@ -294,20 +264,13 @@ public function test_multiple_where(): void ->update( title: 'Timeline Taxi', ) - ->where('title = ?', 'a') - ->where('author_id = ?', 1) - ->where('OR author_id = ?', 2) - ->where('AND author_id <> NULL') + ->whereRaw('title = ?', 'a') + ->whereRaw('author_id = ?', 1) + ->whereRaw('OR author_id = ?', 2) + ->whereRaw('AND author_id <> NULL') ->toSql(); - $expected = << NULL - SQL; + $expected = 'UPDATE `books` SET title = ? WHERE title = ? AND author_id = ? OR author_id = ? AND author_id <> NULL'; $this->assertSameWithoutBackticks($expected, $sql); } @@ -318,17 +281,30 @@ public function test_multiple_where_field(): void ->update( title: 'Timeline Taxi', ) - ->whereField('title', 'a') - ->whereField('author_id', 1) + ->where('title', 'a') + ->where('author_id', 1) ->toSql(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } + + public function test_nested_where_with_update_query(): void + { + $query = query('books') + ->update(status: 'archived') + ->whereRaw('published = ?', true) + ->andWhereGroup(function ($group): void { + $group + ->whereRaw('views < ?', 100) + ->orWhereRaw('last_accessed < ?', '2023-01-01'); + }) + ->build(); + + $expected = 'UPDATE books SET status = ? WHERE published = ? AND (views < ? OR last_accessed < ?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['archived', true, 100, '2023-01-01'], $query->bindings); + } } diff --git a/tests/Integration/Database/Builder/WhereOperatorTest.php b/tests/Integration/Database/Builder/WhereOperatorTest.php new file mode 100644 index 000000000..82ddaeaf0 --- /dev/null +++ b/tests/Integration/Database/Builder/WhereOperatorTest.php @@ -0,0 +1,179 @@ +select() + ->where('title', 'Test Book') + ->build(); + + $expected = 'SELECT * FROM books WHERE books.title = ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['Test Book'], $query->bindings); + } + + public function test_where_with_explicit_operator(): void + { + $query = query('books') + ->select() + ->where('rating', 4.0, WhereOperator::GREATER_THAN) + ->build(); + + $expected = 'SELECT * FROM books WHERE books.rating > ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([4.0], $query->bindings); + } + + public function test_where_with_string_operator(): void + { + $query = query('books') + ->select() + ->where('title', '%fantasy%', 'like') + ->build(); + + $expected = 'SELECT * FROM books WHERE books.title LIKE ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['%fantasy%'], $query->bindings); + } + + public function test_where_in_operator(): void + { + $query = query('books') + ->select() + ->where('category', ['fiction', 'mystery', 'thriller'], WhereOperator::IN) + ->build(); + + $expected = 'SELECT * FROM books WHERE books.category IN (?,?,?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['fiction', 'mystery', 'thriller'], $query->bindings); + } + + public function test_where_between_operator(): void + { + $query = query('books') + ->select() + ->where('publication_year', [2020, 2024], WhereOperator::BETWEEN) + ->build(); + + $expected = 'SELECT * FROM books WHERE books.publication_year BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([2020, 2024], $query->bindings); + } + + public function test_where_is_null_operator(): void + { + $query = query('books') + ->select() + ->where('deleted_at', null, WhereOperator::IS_NULL) + ->build(); + + $expected = 'SELECT * FROM books WHERE books.deleted_at IS NULL'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([], $query->bindings); + } + + public function test_multiple_where_conditions(): void + { + $query = query('books') + ->select() + ->where('published', true) + ->andWhere('rating', 4.0, WhereOperator::GREATER_THAN_OR_EQUAL) + ->orWhere('category', 'bestseller') + ->build(); + + $expected = 'SELECT * FROM books WHERE books.published = ? AND books.rating >= ? OR books.category = ?'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([true, 4.0, 'bestseller'], $query->bindings); + } + + public function test_where_raw_for_complex_conditions(): void + { + $query = query('books') + ->select() + ->where('published', true) + ->andWhereRaw('(title LIKE ? OR description LIKE ?)', '%test%', '%test%') + ->build(); + + $expected = 'SELECT * FROM books WHERE books.published = ? AND (title LIKE ? OR description LIKE ?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([true, '%test%', '%test%'], $query->bindings); + } + + public function test_nested_where_groups_with_new_api(): void + { + $query = query('books') + ->select() + ->where('published', true) + ->andWhereGroup(function ($group): void { + $group + ->where('category', 'fiction') + ->orWhere('rating', 4.5, WhereOperator::GREATER_THAN); + }) + ->build(); + + $expected = 'SELECT * FROM books WHERE books.published = ? AND (books.category = ? OR books.rating > ?)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame([true, 'fiction', 4.5], $query->bindings); + } + + public function test_mixed_raw_and_typed_conditions_in_groups(): void + { + $query = query('books') + ->select() + ->where('status', 'published') + ->andWhereGroup(function ($group): void { + $group + ->where('category', ['fiction', 'mystery'], WhereOperator::IN) + ->orWhereRaw('custom_field IS NOT NULL'); + }) + ->build(); + + $expected = 'SELECT * FROM books WHERE books.status = ? AND (books.category IN (?,?) OR custom_field IS NOT NULL)'; + + $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSame(['published', 'fiction', 'mystery'], $query->bindings); + } + + public function test_error_handling_for_in_operator_without_array(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('IN operator requires an array of values'); + + query('books') + ->select() + ->where('category', 'fiction', WhereOperator::IN) + ->build(); + } + + public function test_error_handling_for_between_operator_with_wrong_array_size(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('BETWEEN operator requires an array with exactly 2 values'); + + query('books') + ->select() + ->where('year', [2020, 2021, 2022], WhereOperator::BETWEEN) + ->build(); + } +} diff --git a/tests/Integration/Database/ConvenientDateWhereMethodsTest.php b/tests/Integration/Database/ConvenientDateWhereMethodsTest.php new file mode 100644 index 000000000..5895d3a6b --- /dev/null +++ b/tests/Integration/Database/ConvenientDateWhereMethodsTest.php @@ -0,0 +1,394 @@ +clock = $this->clock('2025-08-02 12:00:00'); + + $this->migrate(CreateMigrationsTable::class, CreateEventTable::class); + $this->seedTestData(); + } + + private function seedTestData(): void + { + $now = $this->clock->now(); + + query(Event::class)->insert( + name: 'Today event 1', + created_at: $now->withTime(10, 0), + event_date: $now->withTime(14, 0), + )->execute(); + + query(Event::class)->insert( + name: 'Today event 2', + created_at: $now->withTime(11, 0), + event_date: $now->withTime(16, 0), + )->execute(); + + $yesterday = $now->minusDay(); + query(Event::class)->insert( + name: 'Yesterday event', + created_at: $yesterday->withTime(9, 0), + event_date: $yesterday->withTime(13, 0), + )->execute(); + + $thisWeekSunday = DateTime::parse('2025-08-03 17:00:00'); + query(Event::class)->insert( + name: 'This week Sunday event', + created_at: $thisWeekSunday->withTime(8, 0), + event_date: $thisWeekSunday, + )->execute(); + + $lastWeekTuesday = DateTime::parse('2025-07-22 15:00:00'); + query(Event::class)->insert( + name: 'Last week event 1', + created_at: $lastWeekTuesday->withTime(10, 0), + event_date: $lastWeekTuesday, + )->execute(); + + $lastWeekWednesday = DateTime::parse('2025-07-23 16:00:00'); + query(Event::class)->insert( + name: 'Last week event 2', + created_at: $lastWeekWednesday->withTime(11, 0), + event_date: $lastWeekWednesday, + )->execute(); + + $thisMonthDay = DateTime::parse('2025-08-10 10:00:00'); + query(Event::class)->insert( + name: 'This month event', + created_at: $thisMonthDay, + event_date: $thisMonthDay->withTime(14, 0), + )->execute(); + + $lastMonth = DateTime::parse('2025-07-15 10:00:00'); + query(Event::class)->insert( + name: 'Last month event', + created_at: $lastMonth, + event_date: $lastMonth->withTime(16, 0), + )->execute(); + + $thisYearDay = DateTime::parse('2025-03-15 10:00:00'); + query(Event::class)->insert( + name: 'This year event', + created_at: $thisYearDay, + event_date: $thisYearDay->withTime(11, 0), + )->execute(); + + $lastYear = DateTime::parse('2024-08-02 10:00:00'); + query(Event::class)->insert( + name: 'Last year event', + created_at: $lastYear, + event_date: $lastYear->withTime(14, 0), + )->execute(); + + $future = $now->plusDays(30); + query(Event::class)->insert( + name: 'Future event', + created_at: $now, + event_date: $future->withTime(10, 0), + )->execute(); + } + + public function test_where_today(): void + { + $events = query(Event::class) + ->select() + ->whereToday('event_date') + ->all(); + + $this->assertCount(2, $events); + + foreach ($events as $event) { + $this->assertStringContainsString('Today event', $event->name); + $this->assertTrue($event->event_date->isToday()); + } + } + + public function test_where_yesterday(): void + { + $events = query(Event::class) + ->select() + ->whereYesterday('event_date') + ->all(); + + $this->assertCount(1, $events); + $this->assertSame('Yesterday event', $events[0]->name); + + $this->assertTrue($events[0]->event_date->isYesterday()); + } + + public function test_where_this_week(): void + { + $events = query(Event::class) + ->select() + ->whereThisWeek('event_date') + ->all(); + + $this->assertCount(4, $events); + + foreach ($events as $event) { + $this->assertTrue($event->event_date->isCurrentWeek()); + } + } + + public function test_where_last_week(): void + { + $events = query(Event::class)->select()->whereLastWeek('event_date')->all(); + + $this->assertCount(2, $events); + foreach ($events as $event) { + $this->assertStringContainsString('Last week event', $event->name); + } + } + + public function test_where_this_month(): void + { + $events = query(Event::class) + ->select() + ->whereThisMonth('event_date') + ->all(); + + $this->assertCount(5, $events); + + foreach ($events as $event) { + $this->assertTrue($event->event_date->isCurrentMonth()); + } + } + + public function test_where_last_month(): void + { + $events = query(Event::class) + ->select() + ->whereLastMonth('event_date') + ->all(); + + $this->assertCount(3, $events); + + $names = array_column($events, 'name'); + $this->assertContains('Last month event', $names); + $this->assertContains('Last week event 1', $names); + $this->assertContains('Last week event 2', $names); + + foreach ($events as $event) { + $this->assertTrue($event->event_date->isPreviousMonth()); + } + } + + public function test_where_this_year(): void + { + $events = query(Event::class)->select()->whereThisYear('event_date')->all(); + + $this->assertCount(10, $events); + + foreach ($events as $event) { + if ($event->name !== 'Last year event') { + $this->assertTrue($event->event_date->isCurrentYear()); + } + } + } + + public function test_where_last_year(): void + { + $events = query(Event::class)->select()->whereLastYear('event_date')->all(); + + $this->assertCount(1, $events); + $this->assertSame('Last year event', $events[0]->name); + $this->assertTrue($events[0]->event_date->isPreviousYear()); + } + + public function test_where_after(): void + { + $cutoffDate = $this->clock->now()->plusDays(15); + $events = query(Event::class) + ->select() + ->whereAfter('event_date', $cutoffDate) + ->all(); + + $this->assertCount(1, $events); + $this->assertSame('Future event', $events[0]->name); + + $this->assertTrue($events[0]->event_date->isAfter($cutoffDate)); + } + + public function test_where_before(): void + { + $cutoffDate = $this->clock->now()->minusDays(15); + + $events = query(Event::class) + ->select() + ->whereBefore('event_date', $cutoffDate) + ->all(); + + $this->assertGreaterThanOrEqual(1, count($events)); + + /** @var Event $event */ + foreach ($events as $event) { + $this->assertTrue($event->event_date->isBefore($cutoffDate)); + } + } + + public function test_where_between_with_datetime(): void + { + $start = $this->clock->now()->minusDays(2); + $end = $this->clock->now()->plusDays(1); + + $events = query(Event::class) + ->select() + ->whereBetween('event_date', $start, $end) + ->all(); + + // Today and yesterday events + $this->assertCount(3, $events); + + /** @var Event $event */ + foreach ($events as $event) { + $this->assertTrue($event->event_date->betweenTimeInclusive($start, $end)); + } + } + + public function test_or_where_today(): void + { + $events = query(Event::class) + ->select() + ->whereLastYear('event_date') + ->orWhereToday('event_date') + ->all(); + + $this->assertCount(3, $events); + + $hasLastYear = false; + $todayEventsCount = 0; + + foreach ($events as $event) { + if ($event->name === 'Last year event') { + $hasLastYear = true; + } elseif (str_contains($event->name, 'Today event')) { + $todayEventsCount++; + } + } + + $this->assertTrue($hasLastYear); + $this->assertSame(2, $todayEventsCount); + } + + public function test_or_where_yesterday(): void + { + $events = query(Event::class) + ->select() + ->whereLastMonth('event_date') + ->orWhereYesterday('event_date') + ->all(); + + $this->assertCount(4, $events); + + $names = array_column($events, 'name'); + $this->assertContains('Last month event', $names); + $this->assertContains('Last week event 1', $names); + $this->assertContains('Last week event 2', $names); + $this->assertContains('Yesterday event', $names); + } + + public function test_complex_date_query_combination(): void + { + $events = query(Event::class) + ->select() + ->whereThisWeek('event_date') + ->orWhereThisMonth('created_at') + ->all(); + + $this->assertGreaterThanOrEqual(4, count($events)); + } + + public function test_chaining_date_methods(): void + { + $events = query(Event::class) + ->select() + ->whereToday('event_date') + ->whereToday('created_at') + ->all(); + + $this->assertCount(2, $events); + + foreach ($events as $event) { + $this->assertTrue($event->event_date->isToday()); + $this->assertTrue($event->created_at->isToday()); + } + } + + public function test_where_methods_with_string_dates(): void + { + $stringDate = '2025-08-02'; + $events = query(Event::class) + ->select() + ->whereAfter('event_date', $stringDate) + ->all(); + + $this->assertGreaterThanOrEqual(1, count($events)); + } + + public function test_edge_case_month_boundary(): void + { + $this->clock('2025-07-31 23:59:59'); + + $events = query(Event::class) + ->select() + ->whereToday('created_at') + ->all(); + + $this->assertCount(0, $events); + } +} + +final class CreateEventTable implements DatabaseMigration +{ + private(set) string $name = '0000-00-10_create_events_table'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(Event::class) + ->primary() + ->text('name') + ->datetime('created_at') + ->datetime('event_date'); + } + + public function down(): QueryStatement + { + return DropTableStatement::forModel(Event::class); + } +} + +final class Event +{ + use IsDatabaseModel; + + public function __construct( + public string $name, + public DateTime $created_at, + public DateTime $event_date, + ) {} +} diff --git a/tests/Integration/Database/ConvenientWhereMethodsTest.php b/tests/Integration/Database/ConvenientWhereMethodsTest.php new file mode 100644 index 000000000..4e28d1465 --- /dev/null +++ b/tests/Integration/Database/ConvenientWhereMethodsTest.php @@ -0,0 +1,447 @@ +migrate(CreateMigrationsTable::class, CreateUserTable::class); + $this->seedTestData(); + } + + private function seedTestData(): void + { + $users = [ + ['name' => 'Alice', 'email' => 'alice@example.com', 'age' => 25, 'status' => UserStatus::ACTIVE, 'role' => UserRole::USER, 'score' => 85.5], + ['name' => 'Bob', 'email' => 'bob@example.com', 'age' => 30, 'status' => UserStatus::INACTIVE, 'role' => UserRole::ADMIN, 'score' => 92.0], + ['name' => 'Charlie', 'email' => 'charlie@example.com', 'age' => 35, 'status' => UserStatus::ACTIVE, 'role' => UserRole::USER, 'score' => 78.3], + ['name' => 'Diana', 'email' => 'diana@example.com', 'age' => 28, 'status' => UserStatus::PENDING, 'role' => UserRole::MODERATOR, 'score' => 88.7], + ['name' => 'Eve', 'email' => 'eve@example.com', 'age' => 40, 'status' => UserStatus::ACTIVE, 'role' => UserRole::USER, 'score' => 95.2], + ['name' => 'Frank', 'email' => null, 'age' => 22, 'status' => UserStatus::INACTIVE, 'role' => UserRole::USER, 'score' => 72.1], + ['name' => 'Grace', 'email' => 'grace@example.com', 'age' => 33, 'status' => UserStatus::ACTIVE, 'role' => UserRole::ADMIN, 'score' => 89.4], + ]; + + foreach ($users as $userData) { + query(User::class)->insert( + name: $userData['name'], + email: $userData['email'], + age: $userData['age'], + status: $userData['status'], + role: $userData['role'], + score: $userData['score'], + created_at: DateTime::now(), + )->execute(); + } + } + + public function test_where_in_with_array(): void + { + $users = query(User::class) + ->select() + ->whereIn('name', ['Alice', 'Bob', 'Charlie']) + ->all(); + + $this->assertCount(3, $users); + + $names = array_column($users, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Bob', $names); + $this->assertContains('Charlie', $names); + } + + public function test_where_in_with_enum_class(): void + { + $users = query(User::class) + ->select() + ->whereIn('status', UserStatus::class) + ->all(); + + $this->assertCount(7, $users); + } + + public function test_where_in_with_enum_values(): void + { + $users = query(User::class) + ->select() + ->whereIn('status', [UserStatus::ACTIVE, UserStatus::PENDING]) + ->all(); + + foreach ($users as $user) { + $this->assertContains($user->status, [UserStatus::ACTIVE, UserStatus::PENDING]); + } + } + + public function test_where_not_in(): void + { + $users = query(User::class) + ->select() + ->whereNotIn('role', [UserRole::ADMIN]) + ->all(); + + foreach ($users as $user) { + $this->assertNotSame(UserRole::ADMIN, $user->role); + } + } + + public function test_where_between(): void + { + $users = query(User::class) + ->select() + ->whereBetween('age', 25, 35) + ->all(); + + foreach ($users as $user) { + $this->assertGreaterThanOrEqual(25, $user->age); + $this->assertLessThanOrEqual(35, $user->age); + } + } + + public function test_where_not_between(): void + { + $users = query(User::class) + ->select() + ->whereNotBetween('age', 25, 35) + ->all(); + + foreach ($users as $user) { + $this->assertTrue($user->age < 25 || $user->age > 35); + } + } + + public function test_where_null(): void + { + $users = query(User::class) + ->select() + ->whereNull('email') + ->all(); + + $this->assertCount(1, $users); + $this->assertSame('Frank', $users[0]->name); + $this->assertNull($users[0]->email); + } + + public function test_where_not_null(): void + { + $users = query(User::class) + ->select() + ->whereNotNull('email') + ->all(); + + foreach ($users as $user) { + $this->assertNotNull($user->email); + } + } + + public function test_where_not(): void + { + $users = query(User::class) + ->select() + ->whereNot('status', UserStatus::ACTIVE) + ->all(); + + foreach ($users as $user) { + $this->assertNotSame(UserStatus::ACTIVE, $user->status); + } + } + + public function test_where_like(): void + { + $users = query(User::class) + ->select() + ->whereLike('email', '%@example.com') + ->all(); + + foreach ($users as $user) { + $this->assertStringEndsWith('@example.com', $user->email); + } + } + + public function test_where_not_like(): void + { + $users = query(User::class) + ->select() + ->whereNotNull('email') + ->whereNotLike('email', '%alice%') + ->all(); + + foreach ($users as $user) { + $this->assertStringNotContainsString('alice', $user->email); + } + } + + public function test_or_where_in(): void + { + $users = query(User::class) + ->select() + ->where('status', UserStatus::PENDING) + ->orWhereIn('role', [UserRole::ADMIN]) + ->all(); + + $this->assertCount(3, $users); + + $names = array_column($users, 'name'); + $this->assertContains('Diana', $names); + $this->assertContains('Bob', $names); + $this->assertContains('Grace', $names); + } + + public function test_or_where_not_in(): void + { + $users = query(User::class) + ->select() + ->where('name', 'Alice') + ->orWhereNotIn('status', [UserStatus::ACTIVE, UserStatus::PENDING]) + ->all(); + + $this->assertCount(3, $users); + + $names = array_column($users, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Bob', $names); + $this->assertContains('Frank', $names); + } + + public function test_or_where_between(): void + { + $users = query(User::class) + ->select() + ->where('name', 'Frank') + ->orWhereBetween('age', 35, 40) + ->all(); + + $this->assertCount(3, $users); + + $names = array_column($users, 'name'); + $this->assertContains('Frank', $names); + $this->assertContains('Charlie', $names); + $this->assertContains('Eve', $names); + } + + public function test_or_where_not_between(): void + { + $users = query(User::class) + ->select() + ->where('name', 'Diana') + ->orWhereNotBetween('age', 25, 35) + ->all(); + + $this->assertCount(3, $users); + + $names = array_column($users, 'name'); + $this->assertContains('Diana', $names); + $this->assertContains('Frank', $names); + $this->assertContains('Eve', $names); + } + + public function test_or_where_null(): void + { + $users = query(User::class) + ->select() + ->where('role', UserRole::ADMIN) + ->orWhereNull('email') + ->all(); + + $this->assertCount(3, $users); + + $names = array_column($users, 'name'); + $this->assertContains('Bob', $names); + $this->assertContains('Grace', $names); + $this->assertContains('Frank', $names); + } + + public function test_or_where_not_null(): void + { + $users = query(User::class) + ->select() + ->where('age', 22) + ->orWhereNotNull('email') + ->all(); + + foreach ($users as $user) { + $this->assertTrue($user->age === 22 || $user->email !== null); + } + } + + public function test_or_where_not(): void + { + $users = query(User::class) + ->select() + ->where('name', 'Alice') + ->orWhereNot('status', UserStatus::ACTIVE) + ->all(); + + $this->assertCount(4, $users); + + $names = array_column($users, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Bob', $names); + $this->assertContains('Diana', $names); + $this->assertContains('Frank', $names); + } + + public function test_or_where_like(): void + { + $users = query(User::class) + ->select() + ->where('name', 'Frank') + ->orWhereLike('email', '%alice%') + ->all(); + + $this->assertCount(2, $users); + + $names = array_column($users, 'name'); + $this->assertContains('Frank', $names); + $this->assertContains('Alice', $names); + } + + public function test_or_where_not_like(): void + { + $users = query(User::class) + ->select() + ->where('age', 22) + ->orWhereNotLike('email', '%example.com') + ->all(); + + $this->assertCount(1, $users); + $this->assertSame('Frank', $users[0]->name); + } + + public function test_complex_where_combination(): void + { + $users = query(User::class) + ->select() + ->whereIn('status', [UserStatus::ACTIVE, UserStatus::PENDING]) + ->whereBetween('age', 25, 35) + ->whereNotNull('email') + ->all(); + + foreach ($users as $user) { + $this->assertContains($user->status, [UserStatus::ACTIVE, UserStatus::PENDING]); + $this->assertGreaterThanOrEqual(25, $user->age); + $this->assertLessThanOrEqual(35, $user->age); + $this->assertNotNull($user->email); + } + } + + public function test_chaining_or_conditions(): void + { + $users = query(User::class) + ->select() + ->where('age', 22) + ->orWhereIn('role', [UserRole::ADMIN]) + ->orWhereNull('email') + ->all(); + + $this->assertCount(3, $users); + + $names = array_column($users, 'name'); + $this->assertContains('Frank', $names); + $this->assertContains('Bob', $names); + $this->assertContains('Grace', $names); + } + + public function test_where_in_throws_exception_for_invalid_value(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('IN operator requires an array of values'); + + query(User::class) + ->select() + ->whereIn('name', 'not-an-array') + ->all(); + } + + public function test_where_between_throws_exception_for_invalid_array(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('BETWEEN operator requires an array with exactly 2 values'); + + query(User::class) + ->select() + ->where('age', [25], WhereOperator::BETWEEN) + ->all(); + } + + public function test_where_between_throws_exception_for_too_many_values(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('BETWEEN operator requires an array with exactly 2 values'); + + query(User::class) + ->select() + ->where('age', [25, 30, 35], WhereOperator::BETWEEN) + ->all(); + } +} + +final class CreateUserTable implements DatabaseMigration +{ + private(set) string $name = '0000-00-20_create_users_table'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(User::class) + ->primary() + ->text('name') + ->text('email', nullable: true) + ->integer('age') + ->text('status') + ->text('role') + ->float('score') + ->datetime('created_at'); + } + + public function down(): QueryStatement + { + return DropTableStatement::forModel(User::class); + } +} + +final class User +{ + use IsDatabaseModel; + + public function __construct( + public string $name, + public ?string $email, + public int $age, + public UserStatus $status, + public UserRole $role, + public float $score, + public DateTime $created_at, + ) {} +} + +enum UserStatus: string +{ + case ACTIVE = 'active'; + case INACTIVE = 'inactive'; + case PENDING = 'pending'; +} + +enum UserRole: string +{ + case USER = 'user'; + case ADMIN = 'admin'; + case MODERATOR = 'moderator'; +} diff --git a/tests/Integration/Database/Fixtures/BookStatus.php b/tests/Integration/Database/Fixtures/BookStatus.php new file mode 100644 index 000000000..f2a78f8c8 --- /dev/null +++ b/tests/Integration/Database/Fixtures/BookStatus.php @@ -0,0 +1,13 @@ +assertException(QueryWasInvalid::class, function (): void { - query('books')->update(title: 'Timeline Taxi')->where('title = ?')->execute(); + query('books')->update(title: 'Timeline Taxi')->whereRaw('title = ?')->execute(); }); } } diff --git a/tests/Integration/Database/GroupedWhereMethodsTest.php b/tests/Integration/Database/GroupedWhereMethodsTest.php new file mode 100644 index 000000000..2d3f12713 --- /dev/null +++ b/tests/Integration/Database/GroupedWhereMethodsTest.php @@ -0,0 +1,363 @@ +migrate(CreateMigrationsTable::class, CreateProductTable::class); + $this->seedTestData(); + } + + private function seedTestData(): void + { + $products = [ + ['name' => 'Laptop', 'category' => 'electronics', 'price' => 999.99, 'in_stock' => true, 'rating' => 4.5, 'brand' => 'TechCorp'], + ['name' => 'Mouse', 'category' => 'electronics', 'price' => 29.99, 'in_stock' => true, 'rating' => 4.2, 'brand' => 'TechCorp'], + ['name' => 'Keyboard', 'category' => 'electronics', 'price' => 79.99, 'in_stock' => false, 'rating' => 4.3, 'brand' => 'TypeMaster'], + ['name' => 'Chair', 'category' => 'furniture', 'price' => 199.99, 'in_stock' => true, 'rating' => 4.1, 'brand' => 'ComfortZone'], + ['name' => 'Desk', 'category' => 'furniture', 'price' => 299.99, 'in_stock' => false, 'rating' => 4.0, 'brand' => 'ComfortZone'], + ['name' => 'Monitor', 'category' => 'electronics', 'price' => 249.99, 'in_stock' => true, 'rating' => 4.4, 'brand' => 'ViewPro'], + ['name' => 'Lamp', 'category' => 'furniture', 'price' => 49.99, 'in_stock' => true, 'rating' => 3.8, 'brand' => 'LightUp'], + ['name' => 'Phone', 'category' => 'electronics', 'price' => 699.99, 'in_stock' => false, 'rating' => 4.6, 'brand' => 'MobileTech'], + ]; + + foreach ($products as $productData) { + query(Product::class)->insert( + name: $productData['name'], + category: $productData['category'], + price: $productData['price'], + in_stock: $productData['in_stock'], + rating: $productData['rating'], + brand: $productData['brand'], + created_at: DateTime::now(), + )->execute(); + } + } + + public function test_simple_where_group(): void + { + $products = query(Product::class) + ->select() + ->whereGroup(function ($query): void { + $query + ->where('category', 'electronics') + ->where('in_stock', true); + }) + ->all(); + + foreach ($products as $product) { + $this->assertSame('electronics', $product->category); + $this->assertTrue($product->in_stock); + } + } + + public function test_and_where_group(): void + { + $products = query(Product::class) + ->select() + ->where('category', 'electronics') + ->andWhereGroup(function ($query): void { + $query + ->where('price', 100.0, WhereOperator::GREATER_THAN) + ->where('in_stock', true); + }) + ->all(); + + foreach ($products as $product) { + $this->assertSame('electronics', $product->category); + $this->assertGreaterThan(100.0, $product->price); + $this->assertTrue($product->in_stock); + } + } + + public function test_or_where_group(): void + { + $products = query(Product::class) + ->select() + ->where('category', 'furniture') + ->orWhereGroup(function ($query): void { + $query + ->where('price', 500.0, WhereOperator::GREATER_THAN) + ->where('brand', 'TechCorp'); + }) + ->all(); + + $this->assertCount(4, $products); // All furniture (Chair, Desk, Lamp) + Laptop (>500 + TechCorp) + + $furnitureCount = 0; + $expensiveTechCorpCount = 0; + + foreach ($products as $product) { + if ($product->category === 'furniture') { + $furnitureCount++; + } elseif ($product->price > 500.0 && $product->brand === 'TechCorp') { + $expensiveTechCorpCount++; + } + } + + $this->assertSame(3, $furnitureCount); + $this->assertSame(1, $expensiveTechCorpCount); + } + + public function test_nested_where_groups(): void + { + $products = query(Product::class) + ->select() + ->whereGroup(function ($query): void { + $query + ->where('category', 'electronics') + ->orWhereGroup(function ($subQuery): void { + $subQuery + ->where('category', 'furniture') + ->where('price', 200.0, WhereOperator::LESS_THAN); + }); + }) + ->where('in_stock', true) + ->all(); + + $this->assertCount(5, $products); + + foreach ($products as $product) { + $this->assertTrue($product->in_stock); + $this->assertTrue($product->category === 'electronics' || $product->category === 'furniture' && $product->price < 200.0); + } + } + + public function test_complex_grouped_conditions(): void + { + $products = query(Product::class) + ->select() + ->whereGroup(function ($query): void { + $query + ->where('brand', 'TechCorp') + ->orWhere('brand', 'ViewPro'); + }) + ->andWhereGroup(function ($query): void { + $query + ->where('rating', 4.0, WhereOperator::GREATER_THAN_OR_EQUAL) + ->where('price', 300.0, WhereOperator::LESS_THAN); + }) + ->all(); + + $this->assertCount(2, $products); + + foreach ($products as $product) { + $this->assertContains($product->brand, ['TechCorp', 'ViewPro']); + $this->assertGreaterThanOrEqual(4.0, $product->rating); + $this->assertLessThan(300.0, $product->price); + } + } + + public function test_where_group_with_convenient_methods(): void + { + $products = query(Product::class) + ->select() + ->whereGroup(function ($query): void { + $query + ->whereIn('category', ['electronics', 'furniture']) + ->whereBetween('price', 50.0, 250.0) + ->whereNotNull('brand'); + }) + ->all(); + + foreach ($products as $product) { + $this->assertContains($product->category, ['electronics', 'furniture']); + $this->assertGreaterThanOrEqual(50.0, $product->price); + $this->assertLessThanOrEqual(250.0, $product->price); + $this->assertNotNull($product->brand); + } + } + + public function test_where_group_with_raw_conditions(): void + { + $products = query(Product::class) + ->select() + ->whereGroup(function ($query): void { + $query + ->whereRaw('price > ?', 100.0) + ->andWhereRaw('rating >= ?', 4.0); + }) + ->all(); + + foreach ($products as $product) { + $this->assertGreaterThan(100.0, $product->price); + $this->assertGreaterThanOrEqual(4.0, $product->rating); + } + } + + public function test_where_group_with_or_raw_conditions(): void + { + $products = query(Product::class) + ->select() + ->whereGroup(function ($query): void { + $query + ->where('brand', 'TechCorp') + ->orWhereRaw('rating > ?', 4.5); + }) + ->all(); + + $techCorpCount = 0; + $highRatingCount = 0; + + foreach ($products as $product) { + if ($product->brand === 'TechCorp') { + $techCorpCount++; + } elseif ($product->rating > 4.5) { + $highRatingCount++; + } + } + + $this->assertGreaterThanOrEqual(2, $techCorpCount); + $this->assertGreaterThanOrEqual(1, $highRatingCount); + } + + public function test_empty_where_group_is_ignored(): void + { + $products = query(Product::class) + ->select() + ->where('category', 'electronics') + ->whereGroup(function (): void {}) + ->all(); + + foreach ($products as $product) { + $this->assertSame('electronics', $product->category); + } + } + + public function test_multiple_where_groups(): void + { + $products = query(Product::class) + ->select() + ->whereGroup(function ($query): void { + $query + ->where('category', 'electronics') + ->orWhere('category', 'furniture'); + }) + ->andWhereGroup(function ($query): void { + $query + ->where('in_stock', true) + ->orWhere('rating', 4.5, WhereOperator::GREATER_THAN); + }) + ->all(); + + foreach ($products as $product) { + $this->assertContains($product->category, ['electronics', 'furniture']); + $this->assertTrue($product->in_stock || $product->rating > 4.5); + } + } + + public function test_where_group_with_all_logical_operators(): void + { + $products = query(Product::class) + ->select() + ->whereGroup(function ($query): void { + $query + ->where('brand', 'TechCorp') + ->andWhere('category', 'electronics') + ->orWhere('price', 300.0, WhereOperator::GREATER_THAN); + }) + ->all(); + + $this->assertGreaterThanOrEqual(3, $products); + + $techCorpElectronicsCount = 0; + $expensiveCount = 0; + + foreach ($products as $product) { + if ($product->brand === 'TechCorp' && $product->category === 'electronics') { + $techCorpElectronicsCount++; + } elseif ($product->price > 300.0) { + $expensiveCount++; + } + } + + $this->assertGreaterThanOrEqual(2, $techCorpElectronicsCount); + $this->assertGreaterThanOrEqual(1, $expensiveCount); + } + + public function test_deeply_nested_where_groups(): void + { + $products = query(Product::class) + ->select() + ->whereGroup(function ($query): void { + $query + ->where('category', 'electronics') + ->orWhereGroup(function ($subQuery): void { + $subQuery + ->where('category', 'furniture') + ->andWhereGroup(function ($deepQuery): void { + $deepQuery + ->where('price', 150.0, WhereOperator::GREATER_THAN) + ->orWhere('brand', 'LightUp'); + }); + }); + }) + ->all(); + + $this->assertGreaterThanOrEqual(6, $products); + + foreach ($products as $product) { + $isValid = $product->category === 'electronics' || $product->category === 'furniture' && ($product->price > 150.0 || $product->brand === 'LightUp'); + $this->assertTrue($isValid); + } + } +} + +final class CreateProductTable implements DatabaseMigration +{ + private(set) string $name = '0000-00-30_create_products_table'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(Product::class) + ->primary() + ->text('name') + ->text('category') + ->float('price') + ->boolean('in_stock') + ->float('rating') + ->text('brand') + ->datetime('created_at'); + } + + public function down(): QueryStatement + { + return DropTableStatement::forModel(Product::class); + } +} + +final class Product +{ + use IsDatabaseModel; + + public function __construct( + public string $name, + public string $category, + public float $price, + public bool $in_stock, + public float $rating, + public string $brand, + public DateTime $created_at, + ) {} +} diff --git a/tests/Integration/Database/MultiDatabaseTest.php b/tests/Integration/Database/MultiDatabaseTest.php index 0f3aa6ca5..05a3b435d 100644 --- a/tests/Integration/Database/MultiDatabaseTest.php +++ b/tests/Integration/Database/MultiDatabaseTest.php @@ -121,13 +121,26 @@ public function test_with_multiple_connections(): void $this->assertSame('Backup 1', $publishersBackup[0]->name); $this->assertSame('Backup 2', $publishersBackup[1]->name); - query(Publisher::class)->update(name: 'Updated Main 1')->where('id = ?', 1)->onDatabase('main')->execute(); - query(Publisher::class)->update(name: 'Updated Backup 1')->where('id = ?', 1)->onDatabase('backup')->execute(); + query(Publisher::class) + ->update(name: 'Updated Main 1') + ->whereRaw('id = ?', 1) + ->onDatabase('main') + ->execute(); + + query(Publisher::class) + ->update(name: 'Updated Backup 1') + ->whereRaw('id = ?', 1) + ->onDatabase('backup') + ->execute(); - $this->assertSame('Updated Main 1', query(Publisher::class)->select()->where('id = ?', 1)->onDatabase('main')->first()->name); - $this->assertSame('Updated Backup 1', query(Publisher::class)->select()->where('id = ?', 1)->onDatabase('backup')->first()->name); + $this->assertSame('Updated Main 1', query(Publisher::class)->select()->whereRaw('id = ?', 1)->onDatabase('main')->first()->name); + $this->assertSame('Updated Backup 1', query(Publisher::class)->select()->whereRaw('id = ?', 1)->onDatabase('backup')->first()->name); - query(Publisher::class)->delete()->where('id = ?', 1)->onDatabase('main')->execute(); + query(Publisher::class) + ->delete() + ->whereRaw('id = ?', 1) + ->onDatabase('main') + ->execute(); $this->assertSame(1, query(Publisher::class)->count()->onDatabase('main')->execute()); $this->assertSame(2, query(Publisher::class)->count()->onDatabase('backup')->execute()); diff --git a/tests/Integration/Framework/Commands/DatabaseSeedCommandTest.php b/tests/Integration/Framework/Commands/DatabaseSeedCommandTest.php index 54630403b..626c640b2 100644 --- a/tests/Integration/Framework/Commands/DatabaseSeedCommandTest.php +++ b/tests/Integration/Framework/Commands/DatabaseSeedCommandTest.php @@ -82,10 +82,10 @@ public function test_seed_all(): void $this->assertSame(2, query(Book::class)->count()->execute()); - $book = Book::select()->whereField('title', 'Timeline Taxi')->first(); + $book = Book::select()->where('title', 'Timeline Taxi')->first(); $this->assertNotNull($book); - $book = Book::select()->whereField('title', 'Timeline Taxi 2')->first(); + $book = Book::select()->where('title', 'Timeline Taxi 2')->first(); $this->assertNotNull($book); } @@ -120,10 +120,10 @@ public function test_seed_via_migrate_fresh(): void $this->assertSame(2, query(Book::class)->count()->execute()); - $book = Book::select()->whereField('title', 'Timeline Taxi')->first(); + $book = Book::select()->where('title', 'Timeline Taxi')->first(); $this->assertNotNull($book); - $book = Book::select()->whereField('title', 'Timeline Taxi 2')->first(); + $book = Book::select()->where('title', 'Timeline Taxi 2')->first(); $this->assertNotNull($book); } diff --git a/tests/Integration/ORM/IsDatabaseModelTest.php b/tests/Integration/ORM/IsDatabaseModelTest.php index b4852c732..4c8cf4eb5 100644 --- a/tests/Integration/ORM/IsDatabaseModelTest.php +++ b/tests/Integration/ORM/IsDatabaseModelTest.php @@ -477,8 +477,8 @@ public function test_update_or_create(): void ['title' => 'B'], ); - $this->assertNull(Book::select()->whereField('title', 'A')->first()); - $this->assertNotNull(Book::select()->whereField('title', 'B')->first()); + $this->assertNull(Book::select()->where('title', 'A')->first()); + $this->assertNotNull(Book::select()->where('title', 'B')->first()); } public function test_delete(): void @@ -635,7 +635,7 @@ public function test_date_field(): void ->execute(); /** @var DateTimeModel $model */ - $model = query(DateTimeModel::class)->select()->whereField('id', $id)->first(); + $model = query(DateTimeModel::class)->select()->where('id', $id)->first(); $this->assertSame('2024-01-01 00:00:00', $model->phpDateTime->format('Y-m-d H:i:s')); $this->assertSame('2024-01-01 00:00:00', $model->tempestDateTime->format('yyyy-MM-dd HH:mm:ss')); diff --git a/tests/Integration/ORM/Mappers/QueryMapperTest.php b/tests/Integration/ORM/Mappers/QueryMapperTest.php index 3343b4874..7b02d4953 100644 --- a/tests/Integration/ORM/Mappers/QueryMapperTest.php +++ b/tests/Integration/ORM/Mappers/QueryMapperTest.php @@ -4,13 +4,10 @@ namespace Tests\Tempest\Integration\ORM\Mappers; -use Tempest\Database\Builder\QueryBuilders\UpdateQueryBuilder; use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\Database; use Tempest\Database\Id; -use Tempest\Database\Query; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; -use Tests\Tempest\Fixtures\Modules\Books\Models\Book; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use function Tempest\Database\query; @@ -30,12 +27,10 @@ public function test_insert_query(): void $expected = match ($dialect) { DatabaseDialect::POSTGRESQL => <<<'SQL' - INSERT INTO authors (name) - VALUES (?) RETURNING * + INSERT INTO authors (name) VALUES (?) RETURNING * SQL, default => <<<'SQL' - INSERT INTO `authors` (`name`) - VALUES (?) + INSERT INTO `authors` (`name`) VALUES (?) SQL, }; @@ -53,14 +48,10 @@ public function test_update_query(): void $expected = match ($dialect) { DatabaseDialect::POSTGRESQL => <<<'SQL' - UPDATE authors - SET name = ? - WHERE authors.id = ? + UPDATE authors SET name = ? WHERE authors.id = ? SQL, default => <<<'SQL' - UPDATE `authors` - SET `name` = ? - WHERE `authors`.`id` = ? + UPDATE `authors` SET `name` = ? WHERE `authors`.`id` = ? SQL, }; From b033b391440460675907a868cfc601f36830bb64 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 3 Aug 2025 02:09:49 +0200 Subject: [PATCH 02/51] fix(database): handle boolean serialization based on database dialect --- packages/database/src/GenericDatabase.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/database/src/GenericDatabase.php b/packages/database/src/GenericDatabase.php index 2cda2b581..7a102f9e8 100644 --- a/packages/database/src/GenericDatabase.php +++ b/packages/database/src/GenericDatabase.php @@ -124,6 +124,14 @@ private function resolveBindings(Query $query): array $bindings = []; foreach ($query->bindings as $key => $value) { + // Database handle booleans differently. We might need a database-aware serializer at some point. + if (is_bool($value)) { + $value = match ($this->dialect) { + DatabaseDialect::POSTGRESQL => $value ? 'true' : 'false', + default => $value ? '1' : '0', + }; + } + if ($value instanceof Query) { $value = $value->execute(); } elseif ($serializer = $this->serializerFactory->forValue($value)) { From 716e95a7fd9216248e2153b44364e8533212ecd6 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 3 Aug 2025 18:46:40 +0200 Subject: [PATCH 03/51] feat(database): add `toRawSql` to query builders --- .../QueryBuilders/CountQueryBuilder.php | 8 +- .../QueryBuilders/DeleteQueryBuilder.php | 8 +- .../HasConvenientWhereMethods.php | 19 +- .../HasWhereQueryBuilderMethods.php | 1 - .../QueryBuilders/InsertQueryBuilder.php | 8 +- .../QueryBuilders/SelectQueryBuilder.php | 8 +- .../QueryBuilders/UpdateQueryBuilder.php | 8 +- .../QueryBuilders/WhereGroupBuilder.php | 1 - packages/database/src/GenericDatabase.php | 6 +- .../src/Migrations/MigrationManager.php | 2 +- packages/database/src/Query.php | 97 +++- tests/Integration/Database/ToRawSqlTest.php | 459 ++++++++++++++++++ .../ORM/Mappers/QueryMapperTest.php | 4 +- 13 files changed, 604 insertions(+), 25 deletions(-) create mode 100644 tests/Integration/Database/ToRawSqlTest.php diff --git a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php index edda35f1b..4bb7e767f 100644 --- a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php @@ -11,6 +11,7 @@ use Tempest\Database\QueryStatements\CountStatement; use Tempest\Database\QueryStatements\HasWhereStatements; use Tempest\Support\Conditions\HasConditions; +use Tempest\Support\Str\ImmutableString; use function Tempest\Database\model; @@ -67,11 +68,16 @@ public function bind(mixed ...$bindings): self return $this; } - public function toSql(): string + public function toSql(): ImmutableString { return $this->build()->toSql(); } + public function toRawSql(): ImmutableString + { + return $this->build()->toRawSql(); + } + public function build(mixed ...$bindings): Query { return new Query($this->count, [...$this->bindings, ...$bindings])->onDatabase($this->onDatabase); diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index 9c78d5368..cfdeda3d9 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -8,6 +8,7 @@ use Tempest\Database\QueryStatements\DeleteStatement; use Tempest\Database\QueryStatements\HasWhereStatements; use Tempest\Support\Conditions\HasConditions; +use Tempest\Support\Str\ImmutableString; use function Tempest\Database\model; @@ -56,11 +57,16 @@ public function bind(mixed ...$bindings): self return $this; } - public function toSql(): string + public function toSql(): ImmutableString { return $this->build()->toSql(); } + public function toRawSql(): ImmutableString + { + return $this->build()->toRawSql(); + } + public function build(mixed ...$bindings): Query { if ($this->model->isObjectModel() && is_object($this->model->instance)) { diff --git a/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php b/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php index 2114f22a0..f377bc449 100644 --- a/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php @@ -7,6 +7,7 @@ use Countable; use Tempest\Database\Builder\WhereOperator; use Tempest\DateTime\DateTime; +use Tempest\DateTime\DateTimeInterface; use UnitEnum; /** @@ -66,7 +67,7 @@ protected function buildCondition(string $fieldDefinition, WhereOperator $operat $sql .= " {$operator->value} ? AND ?"; $bindings = array_map( - fn (DateTime|string|float|int|Countable $value) => match (true) { + fn (DateTimeInterface|string|float|int|Countable $value) => match (true) { $value instanceof Countable => count($value), default => $value, }, @@ -117,7 +118,7 @@ public function whereNotIn(string $field, string|UnitEnum|array|ArrayAccess $val /** * Adds a `WHERE BETWEEN` condition. */ - public function whereBetween(string $field, DateTime|string|float|int|Countable $min, DateTime|string|float|int|Countable $max): self + public function whereBetween(string $field, DateTimeInterface|string|float|int|Countable $min, DateTimeInterface|string|float|int|Countable $max): self { return $this->where($field, [$min, $max], WhereOperator::BETWEEN); } @@ -125,7 +126,7 @@ public function whereBetween(string $field, DateTime|string|float|int|Countable /** * Adds a `WHERE NOT BETWEEN` condition. */ - public function whereNotBetween(string $field, DateTime|string|float|int|Countable $min, DateTime|string|float|int|Countable $max): self + public function whereNotBetween(string $field, DateTimeInterface|string|float|int|Countable $min, DateTimeInterface|string|float|int|Countable $max): self { return $this->where($field, [$min, $max], WhereOperator::NOT_BETWEEN); } @@ -193,7 +194,7 @@ public function orWhereNotIn(string $field, string|UnitEnum|array|ArrayAccess $v /** * Adds an `OR WHERE BETWEEN` condition. */ - public function orWhereBetween(string $field, DateTime|string|float|int|Countable $min, DateTime|string|float|int|Countable $max): self + public function orWhereBetween(string $field, DateTimeInterface|string|float|int|Countable $min, DateTimeInterface|string|float|int|Countable $max): self { return $this->orWhere($field, [$min, $max], WhereOperator::BETWEEN); } @@ -201,7 +202,7 @@ public function orWhereBetween(string $field, DateTime|string|float|int|Countabl /** * Adds an `OR WHERE NOT BETWEEN` condition. */ - public function orWhereNotBetween(string $field, DateTime|string|float|int|Countable $min, DateTime|string|float|int|Countable $max): self + public function orWhereNotBetween(string $field, DateTimeInterface|string|float|int|Countable $min, DateTimeInterface|string|float|int|Countable $max): self { return $this->orWhere($field, [$min, $max], WhereOperator::NOT_BETWEEN); } @@ -329,7 +330,7 @@ public function whereLastYear(string $field): self /** * Adds a `WHERE` condition for records created after a specific date. */ - public function whereAfter(string $field, DateTime|string $date): self + public function whereAfter(string $field, DateTimeInterface|string $date): self { return $this->where($field, DateTime::parse($date), WhereOperator::GREATER_THAN); } @@ -337,7 +338,7 @@ public function whereAfter(string $field, DateTime|string $date): self /** * Adds a `WHERE` condition for records created before a specific date. */ - public function whereBefore(string $field, DateTime|string $date): self + public function whereBefore(string $field, DateTimeInterface|string $date): self { return $this->where($field, DateTime::parse($date), WhereOperator::LESS_THAN); } @@ -394,7 +395,7 @@ public function orWhereThisYear(string $field): self /** * Adds an `OR WHERE` condition for records created after a specific date. */ - public function orWhereAfter(string $field, DateTime|string $date): self + public function orWhereAfter(string $field, DateTimeInterface|string $date): self { return $this->orWhere($field, DateTime::parse($date), WhereOperator::GREATER_THAN); } @@ -402,7 +403,7 @@ public function orWhereAfter(string $field, DateTime|string $date): self /** * Adds an `OR WHERE` condition for records created before a specific date. */ - public function orWhereBefore(string $field, DateTime|string $date): self + public function orWhereBefore(string $field, DateTimeInterface|string $date): self { return $this->orWhere($field, DateTime::parse($date), WhereOperator::LESS_THAN); } diff --git a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php index 806d42a1c..0a8235604 100644 --- a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php @@ -2,7 +2,6 @@ namespace Tempest\Database\Builder\QueryBuilders; -use ArrayAccess; use Closure; use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Builder\WhereOperator; diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index 0ed9f5114..e782b8130 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -14,6 +14,7 @@ use Tempest\Reflection\ClassReflector; use Tempest\Support\Arr\ImmutableArray; use Tempest\Support\Conditions\HasConditions; +use Tempest\Support\Str\ImmutableString; use function Tempest\Database\model; @@ -60,11 +61,16 @@ public function execute(mixed ...$bindings): Id return $id; } - public function toSql(): string + public function toSql(): ImmutableString { return $this->build()->toSql(); } + public function toRawSql(): ImmutableString + { + return $this->build()->toRawSql(); + } + public function build(mixed ...$bindings): Query { foreach ($this->resolveData() as $data) { diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 095aacd2c..6f00bb447 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -22,6 +22,7 @@ use Tempest\Support\Conditions\HasConditions; use Tempest\Support\Paginator\PaginatedData; use Tempest\Support\Paginator\Paginator; +use Tempest\Support\Str\ImmutableString; use function Tempest\Database\model; use function Tempest\map; @@ -210,11 +211,16 @@ public function bind(mixed ...$bindings): self return $this; } - public function toSql(): string + public function toSql(): ImmutableString { return $this->build()->toSql(); } + public function toRawSql(): ImmutableString + { + return $this->build()->toRawSql(); + } + public function build(mixed ...$bindings): Query { foreach ($this->joins as $join) { diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index 7e36bbf53..12feb4ef7 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -13,6 +13,7 @@ use Tempest\Mapper\SerializerFactory; use Tempest\Support\Arr\ImmutableArray; use Tempest\Support\Conditions\HasConditions; +use Tempest\Support\Str\ImmutableString; use function Tempest\Database\model; use function Tempest\Support\arr; @@ -68,11 +69,16 @@ public function bind(mixed ...$bindings): self return $this; } - public function toSql(): string + public function toSql(): ImmutableString { return $this->build()->toSql(); } + public function toRawSql(): ImmutableString + { + return $this->build()->toRawSql(); + } + public function build(mixed ...$bindings): Query { $values = $this->resolveValues(); diff --git a/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php b/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php index e49ad07b5..1618098aa 100644 --- a/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php @@ -2,7 +2,6 @@ namespace Tempest\Database\Builder\QueryBuilders; -use ArrayAccess; use Closure; use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Builder\WhereOperator; diff --git a/packages/database/src/GenericDatabase.php b/packages/database/src/GenericDatabase.php index 7a102f9e8..b34c28b87 100644 --- a/packages/database/src/GenericDatabase.php +++ b/packages/database/src/GenericDatabase.php @@ -44,7 +44,7 @@ public function execute(BuildsQuery|Query $query): void $bindings = $this->resolveBindings($query); try { - $statement = $this->connection->prepare($query->toSql()); + $statement = $this->connection->prepare($query->toSql()->toString()); $statement->execute($bindings); @@ -60,7 +60,7 @@ public function getLastInsertId(): ?Id $sql = $this->lastQuery->toSql(); // TODO: properly determine whether a query is an insert or not - if (! str_starts_with($sql, 'INSERT')) { + if (! $sql->trim()->startsWith('INSERT')) { return null; } @@ -83,7 +83,7 @@ public function fetch(BuildsQuery|Query $query): array $bindings = $this->resolveBindings($query); try { - $pdoQuery = $this->connection->prepare($query->toSql()); + $pdoQuery = $this->connection->prepare($query->toSql()->toString()); $pdoQuery->execute($bindings); diff --git a/packages/database/src/Migrations/MigrationManager.php b/packages/database/src/Migrations/MigrationManager.php index 4b68ced00..72ea7e210 100644 --- a/packages/database/src/Migrations/MigrationManager.php +++ b/packages/database/src/Migrations/MigrationManager.php @@ -323,7 +323,7 @@ private function getMinifiedSqlFromStatement(?QueryStatement $statement): string $query = new Query($statement->compile($this->dialect)); // Remove comments - $sql = preg_replace('/--.*$/m', '', $query->toSql()); // Remove SQL single-line comments + $sql = preg_replace('/--.*$/m', '', $query->toSql()->toString()); // Remove SQL single-line comments $sql = preg_replace('/\/\*[\s\S]*?\*\//', '', $sql); // Remove block comments // Remove blank lines and excessive spaces diff --git a/packages/database/src/Query.php b/packages/database/src/Query.php index 24a45de1e..baefbaa43 100644 --- a/packages/database/src/Query.php +++ b/packages/database/src/Query.php @@ -5,6 +5,7 @@ namespace Tempest\Database; use Tempest\Database\Config\DatabaseDialect; +use Tempest\Support\Str\ImmutableString; use function Tempest\get; @@ -54,10 +55,12 @@ public function fetchFirst(mixed ...$bindings): ?array return $this->database->fetchFirst($this->withBindings($bindings)); } - public function toSql(): string + /** + * Returns the SQL statement without the bindings. + */ + public function toSql(): ImmutableString { $sql = $this->sql; - $dialect = $this->dialect; if ($sql instanceof QueryStatement) { @@ -68,7 +71,95 @@ public function toSql(): string $sql = str_replace('`', '', $sql); } - return $sql; + return new ImmutableString($sql); + } + + /** + * Returns the SQL statement with bindings. This method may generate syntax errors, it is not recommended to use it other than for debugging. + */ + public function toRawSql(): ImmutableString + { + $sql = $this->toSql(); + $resolvedBindings = $this->resolveBindingsForDisplay(); + + if (! array_is_list($resolvedBindings)) { + return $this->replaceNamedBindings((string) $sql, $resolvedBindings); + } + + return $this->replacePositionalBindings((string) $sql, array_values($resolvedBindings)); + } + + private function replaceNamedBindings(string $sql, array $bindings): ImmutableString + { + foreach ($bindings as $key => $value) { + $placeholder = ':' . $key; + $formattedValue = $this->formatValueForSql($value); + $sql = str_replace($placeholder, $formattedValue, $sql); + } + + return new ImmutableString($sql); + } + + private function replacePositionalBindings(string $sql, array $bindings): ImmutableString + { + $bindingIndex = 0; + $result = ''; + $length = strlen($sql); + + for ($i = 0; $i < $length; $i++) { + if ($sql[$i] === '?' && $bindingIndex < count($bindings)) { + $value = $bindings[$bindingIndex]; + $result .= $this->formatValueForSql($value); + $bindingIndex++; + } else { + $result .= $sql[$i]; + } + } + + return new ImmutableString($result); + } + + private function resolveBindingsForDisplay(): array + { + $bindings = []; + + foreach ($this->bindings as $key => $value) { + if (is_bool($value)) { + $value = match ($this->dialect) { + DatabaseDialect::POSTGRESQL => $value ? 'true' : 'false', + default => $value ? '1' : '0', + }; + } + + if ($value instanceof Query) { + $value = '(' . $value->toRawSql() . ')'; + } + + $bindings[$key] = $value; + } + + return $bindings; + } + + private function formatValueForSql(mixed $value): string + { + if ($value === null) { + return 'NULL'; + } + + if (is_string($value)) { + if (str_starts_with($value, '(') && str_ends_with($value, ')')) { + return $value; + } + + return "'" . str_replace("'", "''", $value) . "'"; + } + + if (is_numeric($value)) { + return (string) $value; + } + + return "'" . str_replace("'", "''", (string) $value) . "'"; } public function append(string $append): self diff --git a/tests/Integration/Database/ToRawSqlTest.php b/tests/Integration/Database/ToRawSqlTest.php new file mode 100644 index 000000000..f5e104b4c --- /dev/null +++ b/tests/Integration/Database/ToRawSqlTest.php @@ -0,0 +1,459 @@ +assertSameWithoutBackticks( + expected: 'SELECT * FROM books', + actual: query('books')->select()->toRawSql()->toString(), + ); + } + + public function test_select_query_to_raw_sql_with_where_clause(): void + { + $this->assertSameWithoutBackticks( + expected: "SELECT * FROM books WHERE books.title = 'The Hobbit'", + actual: query('books')->select()->where('title', 'The Hobbit')->toRawSql()->toString(), + ); + } + + public function test_select_query_to_raw_sql_with_multiple_bindings(): void + { + $this->assertSameWithoutBackticks( + expected: "SELECT title, author_id FROM books WHERE books.title = 'The Hobbit' AND books.author_id = 1 AND books.category IN ('fantasy','adventure')", + actual: query('books') + ->select('title', 'author_id') + ->where('title', 'The Hobbit') + ->where('author_id', 1) + ->whereIn('category', ['fantasy', 'adventure']) + ->toRawSql() + ->toString(), + ); + } + + public function test_select_query_to_raw_sql_with_raw_where(): void + { + $this->assertSameWithoutBackticks( + expected: "SELECT * FROM books WHERE published_date > '2020-01-01' AND rating >= 4.5", + actual: query('books') + ->select() + ->whereRaw('published_date > ?', '2020-01-01') + ->whereRaw('rating >= ?', 4.5) + ->toRawSql() + ->toString(), + ); + } + + public function test_select_query_to_raw_sql_with_boolean_values(): void + { + $rawSql = query('books') + ->select() + ->where('published', true) + ->where('featured', false) + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: 'published', haystack: $rawSql); + $this->assertStringContainsString(needle: 'featured', haystack: $rawSql); + } + + public function test_select_query_to_raw_sql_with_null_values(): void + { + $this->assertSameWithoutBackticks( + expected: 'SELECT * FROM books WHERE books.deleted_at IS NULL AND books.published_at IS NOT NULL', + actual: query('books') + ->select() + ->whereNull('deleted_at') + ->whereNotNull('published_at') + ->toRawSql() + ->toString(), + ); + } + + public function test_select_query_to_raw_sql_with_numeric_values(): void + { + $this->assertSameWithoutBackticks( + expected: 'SELECT * FROM books WHERE books.price = 29.99 AND books.pages = 350', + actual: query('books') + ->select() + ->where('price', 29.99) + ->where('pages', 350) + ->toRawSql() + ->toString(), + ); + } + + public function test_select_query_to_raw_sql_with_between_clause(): void + { + $this->assertSameWithoutBackticks( + expected: 'SELECT * FROM books WHERE books.price BETWEEN 10 AND 50', + actual: query('books') + ->select() + ->whereBetween('price', 10.0, 50.0) + ->toRawSql() + ->toString(), + ); + } + + public function test_select_query_to_raw_sql_with_like_clause(): void + { + $this->assertSameWithoutBackticks( + expected: "SELECT * FROM books WHERE books.title LIKE '%fantasy%'", + actual: query('books') + ->select() + ->whereLike('title', '%fantasy%') + ->toRawSql() + ->toString(), + ); + } + + public function test_select_query_to_raw_sql_with_order_and_limit(): void + { + $rawSql = query('books') + ->select() + ->where('published', true) + ->orderBy('created_at DESC') + ->limit(10) + ->offset(5) + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: 'ORDER BY created_at DESC', haystack: $rawSql); + $this->assertStringContainsString(needle: 'LIMIT 10', haystack: $rawSql); + $this->assertStringContainsString(needle: 'OFFSET 5', haystack: $rawSql); + } + + public function test_insert_query_to_raw_sql(): void + { + $rawSql = query('books') + ->insert(['title' => 'New Book', 'author_id' => 1, 'published' => true]) + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: 'INSERT INTO', haystack: $rawSql); + $this->assertStringContainsString(needle: 'books', haystack: $rawSql); + $this->assertStringContainsString(needle: "'New Book'", haystack: $rawSql); + } + + public function test_update_query_to_raw_sql(): void + { + $rawSql = query('books') + ->update(title: 'Updated Title') + ->where('id', 1) + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: 'UPDATE', haystack: $rawSql); + $this->assertStringContainsString(needle: 'books', haystack: $rawSql); + $this->assertStringContainsString(needle: "'Updated Title'", haystack: $rawSql); + $this->assertStringContainsString(needle: 'WHERE', haystack: $rawSql); + } + + public function test_delete_query_to_raw_sql(): void + { + $rawSql = query('books') + ->delete() + ->where('published', false) + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: 'DELETE FROM', haystack: $rawSql); + $this->assertStringContainsString(needle: 'books', haystack: $rawSql); + $this->assertStringContainsString(needle: 'WHERE', haystack: $rawSql); + } + + public function test_count_query_to_raw_sql(): void + { + $rawSql = query('books') + ->count() + ->where('published', true) + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: 'SELECT COUNT(*)', haystack: $rawSql); + $this->assertStringContainsString(needle: 'FROM', haystack: $rawSql); + $this->assertStringContainsString(needle: 'books', haystack: $rawSql); + $this->assertStringContainsString(needle: 'WHERE', haystack: $rawSql); + $this->assertStringContainsString(needle: 'published', haystack: $rawSql); + } + + public function test_count_query_with_column_to_raw_sql(): void + { + $rawSql = query('books') + ->count('author_id') + ->where('published', true) + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: 'SELECT COUNT(', haystack: $rawSql); + $this->assertStringContainsString(needle: 'author_id', haystack: $rawSql); + $this->assertStringContainsString(needle: 'FROM', haystack: $rawSql); + $this->assertStringContainsString(needle: 'books', haystack: $rawSql); + $this->assertStringContainsString(needle: 'WHERE', haystack: $rawSql); + $this->assertStringContainsString(needle: 'published', haystack: $rawSql); + } + + public function test_count_distinct_query_to_raw_sql(): void + { + $rawSql = query('books') + ->count('author_id') + ->distinct() + ->where('published', true) + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: 'SELECT COUNT(DISTINCT', haystack: $rawSql); + $this->assertStringContainsString(needle: 'author_id', haystack: $rawSql); + $this->assertStringContainsString(needle: 'FROM', haystack: $rawSql); + $this->assertStringContainsString(needle: 'books', haystack: $rawSql); + } + + public function test_complex_select_query_with_groups_to_raw_sql(): void + { + $rawSql = query('books') + ->select() + ->where('published', true) + ->whereGroup(function ($group): void { + $group + ->where('category', 'fiction') + ->orWhere('rating', 4.5, WhereOperator::GREATER_THAN_OR_EQUAL); + }) + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: 'WHERE', haystack: $rawSql); + $this->assertStringContainsString(needle: 'published', haystack: $rawSql); + $this->assertStringContainsString(needle: '(', haystack: $rawSql); + $this->assertStringContainsString(needle: 'OR', haystack: $rawSql); + $this->assertStringContainsString(needle: ')', haystack: $rawSql); + } + + public function test_raw_sql_with_string_escaping(): void + { + $rawSql = query('books') + ->select() + ->where('title', "Book with 'quotes'") + ->where('description', 'Text with "double quotes"') + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: "'Book with ''quotes'''", haystack: $rawSql); + $this->assertStringContainsString(needle: '"double quotes"', haystack: $rawSql); + } + + public function test_raw_sql_with_model_queries(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $author = Author::new(name: 'Test Author', type: AuthorType::A); + $author->save(); + + $rawSql = Author::select() + ->where('name', 'Test Author') + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: 'SELECT', haystack: $rawSql); + $this->assertStringContainsString(needle: 'FROM', haystack: $rawSql); + $this->assertStringContainsString(needle: 'authors', haystack: $rawSql); + $this->assertStringContainsString(needle: "'Test Author'", haystack: $rawSql); + } + + public function test_raw_sql_preserves_field_aliases(): void + { + $rawSql = query('books') + ->select('title as book_title', 'author_id as author') + ->where('published', true) + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: 'AS', haystack: $rawSql); + $this->assertStringContainsString(needle: 'book_title', haystack: $rawSql); + $this->assertStringContainsString(needle: 'author', haystack: $rawSql); + } + + public function test_raw_sql_with_joins(): void + { + $rawSql = query('books') + ->select('books.title', 'authors.name') + ->join('LEFT JOIN authors ON authors.id = books.author_id') + ->where('published', true) + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: 'LEFT JOIN authors', haystack: $rawSql); + $this->assertStringContainsString(needle: 'ON authors.id = books.author_id', haystack: $rawSql); + $this->assertStringContainsString(needle: 'WHERE', haystack: $rawSql); + $this->assertStringContainsString(needle: 'published', haystack: $rawSql); + } + + public function test_raw_sql_with_group_by_and_having(): void + { + $rawSql = query('books') + ->select('author_id', 'COUNT(*) as book_count') + ->where('published', true) + ->groupBy('author_id') + ->having('COUNT(*) > ?', 1) + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: 'GROUP BY author_id', haystack: $rawSql); + $this->assertStringContainsString(needle: 'HAVING COUNT(*) > 1', haystack: $rawSql); + } + + public function test_raw_sql_with_raw_subquery_in_where(): void + { + $rawSql = query('books') + ->select() + ->whereRaw('author_id IN (SELECT id FROM authors WHERE type = ?)', 'a') + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: 'WHERE author_id IN (', haystack: $rawSql); + $this->assertStringContainsString(needle: 'SELECT id FROM authors', haystack: $rawSql); + $this->assertStringContainsString(needle: "type = 'a'", haystack: $rawSql); + $this->assertStringContainsString(needle: ')', haystack: $rawSql); + } + + public function test_raw_sql_handles_array_values_properly(): void + { + $rawSql = query('books') + ->select() + ->whereIn('category', ['fiction', 'mystery', 'thriller']) + ->whereNotIn('status', ['draft', 'archived']) + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: "('fiction','mystery','thriller')", haystack: $rawSql); + $this->assertStringContainsString(needle: "('draft','archived')", haystack: $rawSql); + } + + public function test_raw_sql_with_enum_values(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + $rawSql = query('authors') + ->select() + ->where('type', AuthorType::A->value) + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: "'a'", haystack: $rawSql); + } + + public function test_raw_sql_consistency_across_database_dialects(): void + { + $rawSql = query('books') + ->select('title', 'author_id') + ->where('published', true) + ->where('rating', 4.5, WhereOperator::GREATER_THAN_OR_EQUAL) + ->orderBy('created_at DESC') + ->limit(5) + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: 'SELECT', haystack: $rawSql); + $this->assertStringContainsString(needle: 'title', haystack: $rawSql); + $this->assertStringContainsString(needle: 'author_id', haystack: $rawSql); + $this->assertStringContainsString(needle: 'FROM', haystack: $rawSql); + $this->assertStringContainsString(needle: 'books', haystack: $rawSql); + $this->assertStringContainsString(needle: 'WHERE', haystack: $rawSql); + $this->assertStringContainsString(needle: 'published', haystack: $rawSql); + $this->assertStringContainsString(needle: 'rating', haystack: $rawSql); + $this->assertStringContainsString(needle: '4.5', haystack: $rawSql); + $this->assertStringContainsString(needle: 'ORDER BY created_at DESC', haystack: $rawSql); + $this->assertStringContainsString(needle: 'LIMIT 5', haystack: $rawSql); + } + + public function test_raw_sql_handles_null_and_empty_values(): void + { + $rawSql = query('books') + ->select() + ->whereNull('deleted_at') + ->where('title', '') + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: 'deleted_at', haystack: $rawSql); + $this->assertStringContainsString(needle: 'IS NULL', haystack: $rawSql); + $this->assertStringContainsString(needle: 'title', haystack: $rawSql); + } + + public function test_raw_sql_with_named_bindings(): void + { + $query = new Query( + 'SELECT * FROM books WHERE title = :title AND author_id = :author_id', + ['title' => 'The Hobbit', 'author_id' => 1], + ); + + $rawSql = $query->toRawSql()->toString(); + + $this->assertStringContainsString(needle: "'The Hobbit'", haystack: $rawSql); + $this->assertStringContainsString(needle: '1', haystack: $rawSql); + $this->assertStringNotContainsString(needle: ':title', haystack: $rawSql); + $this->assertStringNotContainsString(needle: ':author_id', haystack: $rawSql); + } + + public function test_raw_sql_with_positional_bindings(): void + { + $query = new Query( + 'SELECT * FROM books WHERE title = ? AND author_id = ? AND rating > ?', + ['The Hobbit', 1, 4.5], + ); + + $rawSql = $query->toRawSql()->toString(); + + $this->assertStringContainsString(needle: "'The Hobbit'", haystack: $rawSql); + $this->assertStringContainsString(needle: '1', haystack: $rawSql); + $this->assertStringContainsString(needle: '4.5', haystack: $rawSql); + $this->assertStringNotContainsString(needle: '?', haystack: $rawSql); + } + + public function test_raw_sql_with_mixed_data_types(): void + { + $rawSql = query('books') + ->select() + ->where('title', 'Mixed Types Test') + ->where('id', 42) + ->where('price', 19.99) + ->where('published', true) + ->whereNull('deleted_at') + ->toRawSql() + ->toString(); + + $this->assertStringContainsString(needle: "'Mixed Types Test'", haystack: $rawSql); + $this->assertStringContainsString(needle: '42', haystack: $rawSql); + $this->assertStringContainsString(needle: '19.99', haystack: $rawSql); + $this->assertStringContainsString(needle: 'deleted_at', haystack: $rawSql); + $this->assertStringContainsString(needle: 'IS NULL', haystack: $rawSql); + } +} diff --git a/tests/Integration/ORM/Mappers/QueryMapperTest.php b/tests/Integration/ORM/Mappers/QueryMapperTest.php index 7b02d4953..723566ce5 100644 --- a/tests/Integration/ORM/Mappers/QueryMapperTest.php +++ b/tests/Integration/ORM/Mappers/QueryMapperTest.php @@ -34,7 +34,7 @@ public function test_insert_query(): void SQL, }; - $this->assertSame($expected, $query->toSql()); + $this->assertSame($expected, $query->toSql()->toString()); $this->assertSame(['test'], $query->bindings); } @@ -55,7 +55,7 @@ public function test_update_query(): void SQL, }; - $this->assertSame($expected, $query->toSql()); + $this->assertSame($expected, $query->toSql()->toString()); $this->assertSame(['other', 1], $query->bindings); } From a403a0622eebab97189b98922e140b1511bb7428 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 3 Aug 2025 20:06:21 +0200 Subject: [PATCH 04/51] test(core): accept stringable in `assertSameWithoutBackticks` --- tests/Integration/FrameworkIntegrationTestCase.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/Integration/FrameworkIntegrationTestCase.php b/tests/Integration/FrameworkIntegrationTestCase.php index 46932cd01..09f0d73e5 100644 --- a/tests/Integration/FrameworkIntegrationTestCase.php +++ b/tests/Integration/FrameworkIntegrationTestCase.php @@ -5,6 +5,7 @@ namespace Tests\Tempest\Integration; use InvalidArgumentException; +use Stringable; use Tempest\Database\DatabaseInitializer; use Tempest\Database\Migrations\MigrationManager; use Tempest\Discovery\DiscoveryLocation; @@ -139,7 +140,7 @@ protected function assertSnippetsMatch(string $expected, string $actual): void $this->assertSame($expected, $actual); } - protected function assertSameWithoutBackticks(string $expected, string $actual): void + protected function assertSameWithoutBackticks(Stringable|string $expected, Stringable|string $actual): void { $clean = function (string $string): string { return str($string) @@ -149,8 +150,8 @@ protected function assertSameWithoutBackticks(string $expected, string $actual): }; $this->assertSame( - $clean($expected), - $clean($actual), + $clean((string) $expected), + $clean((string) $actual), ); } From 352faed573be19d7b232eab1e5b35f873876d628 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 3 Aug 2025 20:06:42 +0200 Subject: [PATCH 05/51] test(database): ignore expected phpstan false positive --- tests/Integration/Database/ConvenientWhereMethodsTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/Database/ConvenientWhereMethodsTest.php b/tests/Integration/Database/ConvenientWhereMethodsTest.php index 4e28d1465..1d6709293 100644 --- a/tests/Integration/Database/ConvenientWhereMethodsTest.php +++ b/tests/Integration/Database/ConvenientWhereMethodsTest.php @@ -367,7 +367,7 @@ public function test_where_in_throws_exception_for_invalid_value(): void query(User::class) ->select() - ->whereIn('name', 'not-an-array') + ->whereIn('name', 'not-an-array') // @phpstan-ignore argument.type ->all(); } From 31cf73fb79c0b23f621b68013503a3e9ceac9f3b Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 3 Aug 2025 21:38:35 +0200 Subject: [PATCH 06/51] refactor(database): put fixture in individual test files --- tests/Integration/Database/.gitignore | 1 + .../Builder/CountQueryBuilderTest.php | 9 +- .../Integration/Database/Fixtures/.gitignore | 2 - .../Database/Fixtures/BookStatus.php | 13 --- .../Fixtures/DtoForModelWithSerializer.php | 17 ---- .../DtoForModelWithSerializerOnProperty.php | 10 -- .../Fixtures/DtoForModelWithVirtual.php | 10 -- .../Database/Fixtures/EnumForCreateTable.php | 9 -- .../Database/Fixtures/HasOneRelationModel.php | 29 ------ .../Database/Fixtures/MigrationForBackup.php | 30 ------ .../Database/Fixtures/MigrationForMain.php | 30 ------ .../Fixtures/ModelWithSerializedDto.php | 12 --- .../ModelWithSerializedDtoProperty.php | 15 --- .../Database/Fixtures/ModelWithVirtualDto.php | 14 --- .../Fixtures/ModelWithVirtualHasMany.php | 15 --- .../Database/Fixtures/OwnerModel.php | 28 ------ .../Database/Fixtures/RelationModel.php | 33 ------- .../{ => ModelInspector}/BelongsToTest.php | 62 +++++++++++-- .../{ => ModelInspector}/HasManyTest.php | 63 +++++++++++-- .../{ => ModelInspector}/HasOneTest.php | 60 ++++++++++-- .../ModelInspector/ModelInspectorTest.php | 91 +++++++++++++++++++ .../{ => ModelInspector}/ModelWithDtoTest.php | 33 +++++-- .../Database/ModelInspectorTest.php | 34 ------- .../Database/MultiDatabaseTest.php | 74 +++++++++++---- .../CreateEnumTypeStatementTest.php | 11 ++- .../CreateTableStatementTest.php | 15 ++- .../DropEnumTypeStatementTest.php | 13 ++- .../QueryStatements/EnumStatementTest.php | 15 ++- 28 files changed, 380 insertions(+), 368 deletions(-) create mode 100644 tests/Integration/Database/.gitignore delete mode 100644 tests/Integration/Database/Fixtures/.gitignore delete mode 100644 tests/Integration/Database/Fixtures/BookStatus.php delete mode 100644 tests/Integration/Database/Fixtures/DtoForModelWithSerializer.php delete mode 100644 tests/Integration/Database/Fixtures/DtoForModelWithSerializerOnProperty.php delete mode 100644 tests/Integration/Database/Fixtures/DtoForModelWithVirtual.php delete mode 100644 tests/Integration/Database/Fixtures/EnumForCreateTable.php delete mode 100644 tests/Integration/Database/Fixtures/HasOneRelationModel.php delete mode 100644 tests/Integration/Database/Fixtures/MigrationForBackup.php delete mode 100644 tests/Integration/Database/Fixtures/MigrationForMain.php delete mode 100644 tests/Integration/Database/Fixtures/ModelWithSerializedDto.php delete mode 100644 tests/Integration/Database/Fixtures/ModelWithSerializedDtoProperty.php delete mode 100644 tests/Integration/Database/Fixtures/ModelWithVirtualDto.php delete mode 100644 tests/Integration/Database/Fixtures/ModelWithVirtualHasMany.php delete mode 100644 tests/Integration/Database/Fixtures/OwnerModel.php delete mode 100644 tests/Integration/Database/Fixtures/RelationModel.php rename tests/Integration/Database/{ => ModelInspector}/BelongsToTest.php (55%) rename tests/Integration/Database/{ => ModelInspector}/HasManyTest.php (56%) rename tests/Integration/Database/{ => ModelInspector}/HasOneTest.php (60%) create mode 100644 tests/Integration/Database/ModelInspector/ModelInspectorTest.php rename tests/Integration/Database/{ => ModelInspector}/ModelWithDtoTest.php (53%) delete mode 100644 tests/Integration/Database/ModelInspectorTest.php diff --git a/tests/Integration/Database/.gitignore b/tests/Integration/Database/.gitignore new file mode 100644 index 000000000..9b1dffd90 --- /dev/null +++ b/tests/Integration/Database/.gitignore @@ -0,0 +1 @@ +*.sqlite diff --git a/tests/Integration/Database/Builder/CountQueryBuilderTest.php b/tests/Integration/Database/Builder/CountQueryBuilderTest.php index fcf9519ef..5d4069600 100644 --- a/tests/Integration/Database/Builder/CountQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/CountQueryBuilderTest.php @@ -10,7 +10,6 @@ use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; -use Tests\Tempest\Integration\Database\Fixtures\BookStatus; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use function Tempest\Database\query; @@ -527,3 +526,11 @@ public function test_nested_where_with_count_query(): void $this->assertSame([true, 'featured', 4.5], $query->bindings); } } + +enum BookStatus: string +{ + case DRAFT = 'draft'; + case PUBLISHED = 'published'; + case ARCHIVED = 'archived'; + case FEATURED = 'featured'; +} diff --git a/tests/Integration/Database/Fixtures/.gitignore b/tests/Integration/Database/Fixtures/.gitignore deleted file mode 100644 index 767aa8132..000000000 --- a/tests/Integration/Database/Fixtures/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -backup.sqlite -main.sqlite \ No newline at end of file diff --git a/tests/Integration/Database/Fixtures/BookStatus.php b/tests/Integration/Database/Fixtures/BookStatus.php deleted file mode 100644 index f2a78f8c8..000000000 --- a/tests/Integration/Database/Fixtures/BookStatus.php +++ /dev/null @@ -1,13 +0,0 @@ -tag === 'backup'; - } - - public function up(): QueryStatement - { - return new CreateTableStatement('backup_table')->primary(); - } - - public function down(): QueryStatement - { - return new DropTableStatement('backup_table'); - } -} diff --git a/tests/Integration/Database/Fixtures/MigrationForMain.php b/tests/Integration/Database/Fixtures/MigrationForMain.php deleted file mode 100644 index 5e738cf13..000000000 --- a/tests/Integration/Database/Fixtures/MigrationForMain.php +++ /dev/null @@ -1,30 +0,0 @@ -tag === 'main'; - } - - public function up(): QueryStatement - { - return new CreateTableStatement('main_table')->primary(); - } - - public function down(): QueryStatement - { - return new DropTableStatement('main_table'); - } -} diff --git a/tests/Integration/Database/Fixtures/ModelWithSerializedDto.php b/tests/Integration/Database/Fixtures/ModelWithSerializedDto.php deleted file mode 100644 index 7c37a4487..000000000 --- a/tests/Integration/Database/Fixtures/ModelWithSerializedDto.php +++ /dev/null @@ -1,12 +0,0 @@ -getRelation('relation'); $this->assertInstanceOf(BelongsTo::class, $relation); @@ -26,7 +27,7 @@ public function test_belongs_to(): void public function test_belongs_to_with_relation_join_field(): void { - $model = model(OwnerModel::class); + $model = model(BelongsToTestOwnerModel::class); $relation = $model->getRelation('relationJoinField'); $this->assertInstanceOf(BelongsTo::class, $relation); @@ -39,7 +40,7 @@ public function test_belongs_to_with_relation_join_field(): void public function test_belongs_to_with_relation_join_field_and_table(): void { - $model = model(OwnerModel::class); + $model = model(BelongsToTestOwnerModel::class); $relation = $model->getRelation('relationJoinFieldAndTable'); $this->assertInstanceOf(BelongsTo::class, $relation); @@ -52,7 +53,7 @@ public function test_belongs_to_with_relation_join_field_and_table(): void public function test_belongs_to_with_owner_join_field(): void { - $model = model(OwnerModel::class); + $model = model(BelongsToTestOwnerModel::class); $relation = $model->getRelation('ownerJoinField'); $this->assertInstanceOf(BelongsTo::class, $relation); @@ -65,7 +66,7 @@ public function test_belongs_to_with_owner_join_field(): void public function test_belongs_to_with_owner_join_field_and_table(): void { - $model = model(OwnerModel::class); + $model = model(BelongsToTestOwnerModel::class); $relation = $model->getRelation('ownerJoinFieldAndTable'); $this->assertInstanceOf(BelongsTo::class, $relation); @@ -78,7 +79,7 @@ public function test_belongs_to_with_owner_join_field_and_table(): void public function test_belongs_to_with_parent(): void { - $model = model(OwnerModel::class); + $model = model(BelongsToTestOwnerModel::class); $relation = $model->getRelation('relation')->setParent('parent'); $this->assertSame( @@ -87,3 +88,48 @@ public function test_belongs_to_with_parent(): void ); } } + +#[Table('relation')] +final class BelongsToTestRelationModel +{ + /** @var \Tests\Tempest\Integration\Database\ModelInspector\BelongsToTestOwnerModel[] */ + public array $owners = []; + + /** @var \Tests\Tempest\Integration\Database\ModelInspector\BelongsToTestOwnerModel[] */ + #[HasMany(ownerJoin: 'overwritten_id')] + public array $ownerJoinField = []; + + /** @var \Tests\Tempest\Integration\Database\ModelInspector\BelongsToTestOwnerModel[] */ + #[HasMany(ownerJoin: 'overwritten.overwritten_id')] + public array $ownerJoinFieldAndTable = []; + + /** @var \Tests\Tempest\Integration\Database\ModelInspector\BelongsToTestOwnerModel[] */ + #[HasMany(relationJoin: 'overwritten_id')] + public array $relationJoinField = []; + + /** @var \Tests\Tempest\Integration\Database\ModelInspector\BelongsToTestOwnerModel[] */ + #[HasMany(relationJoin: 'overwritten.overwritten_id')] + public array $relationJoinFieldAndTable = []; + + public string $name; +} + +#[Table('owner')] +final class BelongsToTestOwnerModel +{ + public BelongsToTestRelationModel $relation; + + #[BelongsTo(relationJoin: 'overwritten_id')] + public BelongsToTestRelationModel $relationJoinField; + + #[BelongsTo(relationJoin: 'overwritten.overwritten_id')] + public BelongsToTestRelationModel $relationJoinFieldAndTable; + + #[BelongsTo(ownerJoin: 'overwritten_id')] + public BelongsToTestRelationModel $ownerJoinField; + + #[BelongsTo(ownerJoin: 'overwritten.overwritten_id')] + public BelongsToTestRelationModel $ownerJoinFieldAndTable; + + public string $name; +} diff --git a/tests/Integration/Database/HasManyTest.php b/tests/Integration/Database/ModelInspector/HasManyTest.php similarity index 56% rename from tests/Integration/Database/HasManyTest.php rename to tests/Integration/Database/ModelInspector/HasManyTest.php index b48bf3d26..9ad7b32e3 100644 --- a/tests/Integration/Database/HasManyTest.php +++ b/tests/Integration/Database/ModelInspector/HasManyTest.php @@ -1,11 +1,11 @@ getRelation('owners'); $this->assertInstanceOf(HasMany::class, $relation); @@ -26,7 +26,7 @@ public function test_has_many(): void public function test_has_many_with_overwritten_owner_join_field(): void { - $model = model(RelationModel::class); + $model = model(HasManyTestRelationModel::class); $relation = $model->getRelation('ownerJoinField'); $this->assertInstanceOf(HasMany::class, $relation); @@ -38,7 +38,7 @@ public function test_has_many_with_overwritten_owner_join_field(): void public function test_has_many_with_overwritten_owner_join_field_and_table(): void { - $model = model(RelationModel::class); + $model = model(HasManyTestRelationModel::class); $relation = $model->getRelation('ownerJoinFieldAndTable'); $this->assertInstanceOf(HasMany::class, $relation); @@ -50,7 +50,7 @@ public function test_has_many_with_overwritten_owner_join_field_and_table(): voi public function test_has_many_with_overwritten_relation_join_field(): void { - $model = model(RelationModel::class); + $model = model(HasManyTestRelationModel::class); $relation = $model->getRelation('relationJoinField'); $this->assertInstanceOf(HasMany::class, $relation); @@ -62,7 +62,7 @@ public function test_has_many_with_overwritten_relation_join_field(): void public function test_has_many_with_overwritten_relation_join_field_and_table(): void { - $model = model(RelationModel::class); + $model = model(HasManyTestRelationModel::class); $relation = $model->getRelation('relationJoinFieldAndTable'); $this->assertInstanceOf(HasMany::class, $relation); @@ -74,7 +74,7 @@ public function test_has_many_with_overwritten_relation_join_field_and_table(): public function test_has_many_with_parent(): void { - $model = model(RelationModel::class); + $model = model(HasManyTestRelationModel::class); $relation = $model->getRelation('owners')->setParent('parent'); $this->assertSame( @@ -83,3 +83,48 @@ public function test_has_many_with_parent(): void ); } } + +#[Table('relation')] +final class HasManyTestRelationModel +{ + /** @var \Tests\Tempest\Integration\Database\ModelInspector\HasManyTestOwnerModel[] */ + public array $owners = []; + + /** @var \Tests\Tempest\Integration\Database\ModelInspector\HasManyTestOwnerModel[] */ + #[HasMany(ownerJoin: 'overwritten_id')] + public array $ownerJoinField = []; + + /** @var \Tests\Tempest\Integration\Database\ModelInspector\HasManyTestOwnerModel[] */ + #[HasMany(ownerJoin: 'overwritten.overwritten_id')] + public array $ownerJoinFieldAndTable = []; + + /** @var \Tests\Tempest\Integration\Database\ModelInspector\HasManyTestOwnerModel[] */ + #[HasMany(relationJoin: 'overwritten_id')] + public array $relationJoinField = []; + + /** @var \Tests\Tempest\Integration\Database\ModelInspector\HasManyTestOwnerModel[] */ + #[HasMany(relationJoin: 'overwritten.overwritten_id')] + public array $relationJoinFieldAndTable = []; + + public string $name; +} + +#[Table('owner')] +final class HasManyTestOwnerModel +{ + public HasManyTestRelationModel $relation; + + #[BelongsTo(relationJoin: 'overwritten_id')] + public HasManyTestRelationModel $relationJoinField; + + #[BelongsTo(relationJoin: 'overwritten.overwritten_id')] + public HasManyTestRelationModel $relationJoinFieldAndTable; + + #[BelongsTo(ownerJoin: 'overwritten_id')] + public HasManyTestRelationModel $ownerJoinField; + + #[BelongsTo(ownerJoin: 'overwritten.overwritten_id')] + public HasManyTestRelationModel $ownerJoinFieldAndTable; + + public string $name; +} diff --git a/tests/Integration/Database/HasOneTest.php b/tests/Integration/Database/ModelInspector/HasOneTest.php similarity index 60% rename from tests/Integration/Database/HasOneTest.php rename to tests/Integration/Database/ModelInspector/HasOneTest.php index a7bb3543f..548b49869 100644 --- a/tests/Integration/Database/HasOneTest.php +++ b/tests/Integration/Database/ModelInspector/HasOneTest.php @@ -1,11 +1,12 @@ getRelation('owner'); $this->assertInstanceOf(HasOne::class, $relation); @@ -26,7 +27,7 @@ public function test_has_one(): void public function test_has_one_with_overwritten_owner_join_field(): void { - $model = model(HasOneRelationModel::class); + $model = model(HasOneTestRelationModel::class); $relation = $model->getRelation('ownerJoinField'); $this->assertInstanceOf(HasOne::class, $relation); @@ -38,7 +39,7 @@ public function test_has_one_with_overwritten_owner_join_field(): void public function test_has_one_with_overwritten_owner_join_field_and_table(): void { - $model = model(HasOneRelationModel::class); + $model = model(HasOneTestRelationModel::class); $relation = $model->getRelation('ownerJoinFieldAndTable'); $this->assertInstanceOf(HasOne::class, $relation); @@ -50,7 +51,7 @@ public function test_has_one_with_overwritten_owner_join_field_and_table(): void public function test_has_one_with_overwritten_relation_join_field(): void { - $model = model(HasOneRelationModel::class); + $model = model(HasOneTestRelationModel::class); $relation = $model->getRelation('relationJoinField'); $this->assertInstanceOf(HasOne::class, $relation); @@ -62,7 +63,7 @@ public function test_has_one_with_overwritten_relation_join_field(): void public function test_has_one_with_overwritten_relation_join_field_and_table(): void { - $model = model(HasOneRelationModel::class); + $model = model(HasOneTestRelationModel::class); $relation = $model->getRelation('relationJoinFieldAndTable'); $this->assertInstanceOf(HasOne::class, $relation); @@ -74,7 +75,7 @@ public function test_has_one_with_overwritten_relation_join_field_and_table(): v public function test_has_one_with_parent(): void { - $model = model(HasOneRelationModel::class); + $model = model(HasOneTestRelationModel::class); $relation = $model->getRelation('owner')->setParent('parent'); $this->assertSame( @@ -83,3 +84,44 @@ public function test_has_one_with_parent(): void ); } } + +#[Table('relation')] +final class HasOneTestRelationModel +{ + #[HasOne] + public HasOneTestOwnerModel $owner; + + #[HasOne(ownerJoin: 'overwritten_id')] + public HasOneTestOwnerModel $ownerJoinField; + + #[HasOne(ownerJoin: 'overwritten.overwritten_id')] + public HasOneTestOwnerModel $ownerJoinFieldAndTable; + + #[HasOne(relationJoin: 'overwritten_id')] + public HasOneTestOwnerModel $relationJoinField; + + #[HasOne(relationJoin: 'overwritten.overwritten_id')] + public HasOneTestOwnerModel $relationJoinFieldAndTable; + + public string $name; +} + +#[Table('owner')] +final class HasOneTestOwnerModel +{ + public HasOneTestRelationModel $relation; + + #[BelongsTo(relationJoin: 'overwritten_id')] + public HasOneTestRelationModel $relationJoinField; + + #[BelongsTo(relationJoin: 'overwritten.overwritten_id')] + public HasOneTestRelationModel $relationJoinFieldAndTable; + + #[BelongsTo(ownerJoin: 'overwritten_id')] + public HasOneTestRelationModel $ownerJoinField; + + #[BelongsTo(ownerJoin: 'overwritten.overwritten_id')] + public HasOneTestRelationModel $ownerJoinFieldAndTable; + + public string $name; +} diff --git a/tests/Integration/Database/ModelInspector/ModelInspectorTest.php b/tests/Integration/Database/ModelInspector/ModelInspectorTest.php new file mode 100644 index 000000000..78faf6260 --- /dev/null +++ b/tests/Integration/Database/ModelInspector/ModelInspectorTest.php @@ -0,0 +1,91 @@ +assertFalse(model(ModelInspectorTestModelWithVirtualHasMany::class)->isRelation('dtos')); + } + + public function test_virtual_property_is_never_a_relation(): void + { + $this->assertFalse(model(ModelInspectorTestModelWithVirtualDto::class)->isRelation('dto')); + } + + public function test_serialized_property_type_is_never_a_relation(): void + { + $this->assertFalse(model(ModelInspectorTestModelWithSerializedDto::class)->isRelation('dto')); + } + + public function test_serialized_property_is_never_a_relation(): void + { + $this->assertFalse(model(ModelInspectorTestModelWithSerializedDtoProperty::class)->isRelation('dto')); + } +} + +final class ModelInspectorTestDtoForModelWithVirtual +{ + public function __construct( + public string $data, + ) {} +} + +final class ModelInspectorTestModelWithVirtualHasMany +{ + use IsDatabaseModel; + + #[Virtual] + /** @var \Tests\Tempest\Integration\Database\ModelInspector\ModelInspectorTestDtoForModelWithVirtual[] $dto */ + public array $dtos; +} + +final class ModelInspectorTestModelWithVirtualDto +{ + use IsDatabaseModel; + + #[Virtual] + public ModelInspectorTestDtoForModelWithVirtual $dto; +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class ModelInspectorTestDtoForModelWithSerializer +{ + public function __construct( + public string $data, + ) {} +} + +final class ModelInspectorTestModelWithSerializedDto +{ + use IsDatabaseModel; + + public ModelInspectorTestDtoForModelWithSerializer $dto; +} + +final class ModelInspectorTestDtoForModelWithSerializerOnProperty +{ + public function __construct( + public string $data, + ) {} +} + +final class ModelInspectorTestModelWithSerializedDtoProperty +{ + use IsDatabaseModel; + + #[SerializeWith(DtoSerializer::class)] + public ModelInspectorTestDtoForModelWithSerializerOnProperty $dto; +} diff --git a/tests/Integration/Database/ModelWithDtoTest.php b/tests/Integration/Database/ModelInspector/ModelWithDtoTest.php similarity index 53% rename from tests/Integration/Database/ModelWithDtoTest.php rename to tests/Integration/Database/ModelInspector/ModelWithDtoTest.php index c4084158b..987573e68 100644 --- a/tests/Integration/Database/ModelWithDtoTest.php +++ b/tests/Integration/Database/ModelInspector/ModelWithDtoTest.php @@ -1,13 +1,16 @@ assertFalse($definition->isRelation('dto')); } @@ -27,7 +30,7 @@ public function test_dto_is_skipped_as_relation(): void public function up(): QueryStatement { - return CreateTableStatement::forModel(ModelWithSerializedDto::class) + return CreateTableStatement::forModel(ModelWithDtoTestModelWithSerializedDto::class) ->primary() ->dto('dto'); } @@ -40,10 +43,26 @@ public function down(): null $this->migrate(CreateMigrationsTable::class, $migration); - ModelWithSerializedDto::new(dto: new DtoForModelWithSerializer('test'))->save(); + ModelWithDtoTestModelWithSerializedDto::new(dto: new ModelWithDtoTestDtoForModelWithSerializer('test'))->save(); - $model = ModelWithSerializedDto::get(1); + $model = ModelWithDtoTestModelWithSerializedDto::get(1); $this->assertSame('test', $model->dto->data); } } + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class ModelWithDtoTestDtoForModelWithSerializer +{ + public function __construct( + public string $data, + ) {} +} + +final class ModelWithDtoTestModelWithSerializedDto +{ + use IsDatabaseModel; + + public ModelWithDtoTestDtoForModelWithSerializer $dto; +} diff --git a/tests/Integration/Database/ModelInspectorTest.php b/tests/Integration/Database/ModelInspectorTest.php deleted file mode 100644 index 8b0f0f425..000000000 --- a/tests/Integration/Database/ModelInspectorTest.php +++ /dev/null @@ -1,34 +0,0 @@ -assertFalse(model(ModelWithVirtualHasMany::class)->isRelation('dtos')); - } - - public function test_virtual_property_is_never_a_relation(): void - { - $this->assertFalse(model(ModelWithVirtualDto::class)->isRelation('dto')); - } - - public function test_serialized_property_type_is_never_a_relation(): void - { - $this->assertFalse(model(ModelWithSerializedDto::class)->isRelation('dto')); - } - - public function test_serialized_property_is_never_a_relation(): void - { - $this->assertFalse(model(ModelWithSerializedDtoProperty::class)->isRelation('dto')); - } -} diff --git a/tests/Integration/Database/MultiDatabaseTest.php b/tests/Integration/Database/MultiDatabaseTest.php index 05a3b435d..4136b7288 100644 --- a/tests/Integration/Database/MultiDatabaseTest.php +++ b/tests/Integration/Database/MultiDatabaseTest.php @@ -2,24 +2,26 @@ namespace Tests\Tempest\Integration\Database; -use PDOException; use Tempest\Container\Exceptions\TaggedDependencyCouldNotBeResolved; use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\Config\MysqlConfig; use Tempest\Database\Config\SQLiteConfig; use Tempest\Database\Database; use Tempest\Database\DatabaseInitializer; +use Tempest\Database\DatabaseMigration; use Tempest\Database\Exceptions\QueryWasInvalid; use Tempest\Database\Id; use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\Migrations\Migration; use Tempest\Database\Migrations\MigrationManager; +use Tempest\Database\QueryStatement; +use Tempest\Database\QueryStatements\CreateTableStatement; +use Tempest\Database\QueryStatements\DropTableStatement; +use Tempest\Database\ShouldMigrate; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; use Tests\Tempest\Fixtures\Modules\Books\Models\Publisher; -use Tests\Tempest\Integration\Database\Fixtures\MigrationForBackup; -use Tests\Tempest\Integration\Database\Fixtures\MigrationForMain; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use Tests\Tempest\Integration\TestingDatabaseInitializer; @@ -39,8 +41,8 @@ protected function setUp(): void } $files = [ - __DIR__ . '/Fixtures/main.sqlite', - __DIR__ . '/Fixtures/backup.sqlite', + __DIR__ . '/db-main.sqlite', + __DIR__ . '/db-backup.sqlite', ]; foreach ($files as $file) { @@ -55,12 +57,12 @@ protected function setUp(): void $this->container->addInitializer(DatabaseInitializer::class); $this->container->config(new SQLiteConfig( - path: __DIR__ . '/Fixtures/main.sqlite', + path: __DIR__ . '/db-main.sqlite', tag: 'main', )); $this->container->config(new SQLiteConfig( - path: __DIR__ . '/Fixtures/backup.sqlite', + path: __DIR__ . '/db-backup.sqlite', tag: 'backup', )); } @@ -155,7 +157,7 @@ public function test_with_different_dialects(): void } $this->container->config(new SQLiteConfig( - path: __DIR__ . '/Fixtures/main.sqlite', + path: __DIR__ . '/db-main.sqlite', tag: 'sqlite-main', )); @@ -264,25 +266,25 @@ public function test_should_migrate(): void $migrationManager->onDatabase('main')->executeUp(new CreateMigrationsTable()); $migrationManager->onDatabase('backup')->executeUp(new CreateMigrationsTable()); - $migrationManager->onDatabase('main')->executeUp(new MigrationForMain()); - $migrationManager->onDatabase('main')->executeUp(new MigrationForBackup()); + $migrationManager->onDatabase('main')->executeUp(new MultiDatabaseTestMigrationForMain()); + $migrationManager->onDatabase('main')->executeUp(new MultiDatabaseTestMigrationForBackup()); $this->assertTableExists('main_table', 'main'); $this->assertTableDoesNotExist('backup_table', 'main'); - $migrationManager->onDatabase('backup')->executeUp(new MigrationForMain()); - $migrationManager->onDatabase('backup')->executeUp(new MigrationForBackup()); + $migrationManager->onDatabase('backup')->executeUp(new MultiDatabaseTestMigrationForMain()); + $migrationManager->onDatabase('backup')->executeUp(new MultiDatabaseTestMigrationForBackup()); $this->assertTableExists('backup_table', 'backup'); $this->assertTableDoesNotExist('main_table', 'backup'); - $migrationManager->onDatabase('main')->executeDown(new MigrationForMain()); - $migrationManager->onDatabase('main')->executeDown(new MigrationForBackup()); + $migrationManager->onDatabase('main')->executeDown(new MultiDatabaseTestMigrationForMain()); + $migrationManager->onDatabase('main')->executeDown(new MultiDatabaseTestMigrationForBackup()); $this->assertTableDoesNotExist('main_table', 'main'); $this->assertTableDoesNotExist('backup_table', 'main'); - $migrationManager->onDatabase('backup')->executeDown(new MigrationForBackup()); - $migrationManager->onDatabase('backup')->executeDown(new MigrationForMain()); + $migrationManager->onDatabase('backup')->executeDown(new MultiDatabaseTestMigrationForBackup()); + $migrationManager->onDatabase('backup')->executeDown(new MultiDatabaseTestMigrationForMain()); $this->assertTableDoesNotExist('backup_table', 'backup'); $this->assertTableDoesNotExist('main_table', 'backup'); } @@ -362,3 +364,43 @@ private function assertTableDoesNotExist(string $tableName, string $onDatabase): ); } } + +final class MultiDatabaseTestMigrationForMain implements DatabaseMigration, ShouldMigrate +{ + public string $name = '000_main'; + + public function shouldMigrate(Database $database): bool + { + return $database->tag === 'main'; + } + + public function up(): QueryStatement + { + return new CreateTableStatement('main_table')->primary(); + } + + public function down(): QueryStatement + { + return new DropTableStatement('main_table'); + } +} + +final class MultiDatabaseTestMigrationForBackup implements DatabaseMigration, ShouldMigrate +{ + public string $name = '000_backup'; + + public function shouldMigrate(Database $database): bool + { + return $database->tag === 'backup'; + } + + public function up(): QueryStatement + { + return new CreateTableStatement('backup_table')->primary(); + } + + public function down(): QueryStatement + { + return new DropTableStatement('backup_table'); + } +} diff --git a/tests/Integration/Database/QueryStatements/CreateEnumTypeStatementTest.php b/tests/Integration/Database/QueryStatements/CreateEnumTypeStatementTest.php index b7f135501..3fa2f5928 100644 --- a/tests/Integration/Database/QueryStatements/CreateEnumTypeStatementTest.php +++ b/tests/Integration/Database/QueryStatements/CreateEnumTypeStatementTest.php @@ -4,7 +4,6 @@ use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\QueryStatements\CreateEnumTypeStatement; -use Tests\Tempest\Integration\Database\Fixtures\EnumForCreateTable; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; final class CreateEnumTypeStatementTest extends FrameworkIntegrationTestCase @@ -12,14 +11,20 @@ final class CreateEnumTypeStatementTest extends FrameworkIntegrationTestCase public function test_it_can_compile_create_enum_type_statement(): void { $enumStatement = new CreateEnumTypeStatement( - enumClass: EnumForCreateTable::class, + enumClass: CreateEnumTypeStatementTestEnumForCreateTable::class, ); $this->assertSame( <<compile(DatabaseDialect::POSTGRESQL), ); } } + +enum CreateEnumTypeStatementTestEnumForCreateTable: string +{ + case FOO = 'foo'; + case BAR = 'bar'; +} diff --git a/tests/Integration/Database/QueryStatements/CreateTableStatementTest.php b/tests/Integration/Database/QueryStatements/CreateTableStatementTest.php index 18ff0358a..c5ccd1e31 100644 --- a/tests/Integration/Database/QueryStatements/CreateTableStatementTest.php +++ b/tests/Integration/Database/QueryStatements/CreateTableStatementTest.php @@ -18,7 +18,6 @@ use Tempest\Database\QueryStatements\CreateEnumTypeStatement; use Tempest\Database\QueryStatements\CreateTableStatement; use Tempest\Database\QueryStatements\DropEnumTypeStatement; -use Tests\Tempest\Integration\Database\Fixtures\EnumForCreateTable; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; /** @@ -128,8 +127,8 @@ public function test_enum_statement(): void public function up(): QueryStatement { return new CompoundStatement( - new DropEnumTypeStatement(EnumForCreateTable::class), - new CreateEnumTypeStatement(EnumForCreateTable::class), + new DropEnumTypeStatement(CreateTableStatementTestEnumForCreateTable::class), + new CreateEnumTypeStatement(CreateTableStatementTestEnumForCreateTable::class), ); } @@ -150,8 +149,8 @@ public function up(): QueryStatement return new CreateTableStatement('test_table') ->enum( name: 'enum', - enumClass: EnumForCreateTable::class, - default: EnumForCreateTable::BAR, + enumClass: CreateTableStatementTestEnumForCreateTable::class, + default: CreateTableStatementTestEnumForCreateTable::BAR, ); } @@ -282,3 +281,9 @@ public function test_string_method_with_custom_parameters(): void $this->assertSame($varcharStatement, $stringStatement); } } + +enum CreateTableStatementTestEnumForCreateTable: string +{ + case FOO = 'foo'; + case BAR = 'bar'; +} diff --git a/tests/Integration/Database/QueryStatements/DropEnumTypeStatementTest.php b/tests/Integration/Database/QueryStatements/DropEnumTypeStatementTest.php index 680ead1fc..83a0fccd0 100644 --- a/tests/Integration/Database/QueryStatements/DropEnumTypeStatementTest.php +++ b/tests/Integration/Database/QueryStatements/DropEnumTypeStatementTest.php @@ -1,10 +1,9 @@ assertSame( <<compile(DatabaseDialect::POSTGRESQL), ); } } + +enum DropEnumTypeStatementTestEnumForCreateTable: string +{ + case FOO = 'foo'; + case BAR = 'bar'; +} diff --git a/tests/Integration/Database/QueryStatements/EnumStatementTest.php b/tests/Integration/Database/QueryStatements/EnumStatementTest.php index 6cde0d6ef..ea1684d2d 100644 --- a/tests/Integration/Database/QueryStatements/EnumStatementTest.php +++ b/tests/Integration/Database/QueryStatements/EnumStatementTest.php @@ -4,7 +4,6 @@ use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\QueryStatements\EnumStatement; -use Tests\Tempest\Integration\Database\Fixtures\EnumForCreateTable; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; final class EnumStatementTest extends FrameworkIntegrationTestCase @@ -13,7 +12,7 @@ public function test_it_can_compile_an_enum_statement_for_mysql(): void { $enumStatement = new EnumStatement( name: 'enum', - enumClass: EnumForCreateTable::class, + enumClass: EnumStatementTestEnumForCreateTable::class, ); $this->assertSame( @@ -26,7 +25,7 @@ public function test_it_can_compile_an_enum_statement_for_sqlite(): void { $enumStatement = new EnumStatement( name: 'enum', - enumClass: EnumForCreateTable::class, + enumClass: EnumStatementTestEnumForCreateTable::class, ); $this->assertSame( @@ -39,12 +38,18 @@ public function test_it_can_compile_an_enum_statement_for_postgresql(): void { $enumStatement = new EnumStatement( name: 'enum', - enumClass: EnumForCreateTable::class, + enumClass: EnumStatementTestEnumForCreateTable::class, ); $this->assertSame( - '"enum" "Tests\Tempest\Integration\Database\Fixtures\EnumForCreateTable" NOT NULL', + '"enum" "Tests\Tempest\Integration\Database\QueryStatements\EnumStatementTestEnumForCreateTable" NOT NULL', $enumStatement->compile(DatabaseDialect::POSTGRESQL), ); } } + +enum EnumStatementTestEnumForCreateTable: string +{ + case FOO = 'foo'; + case BAR = 'bar'; +} From 54323cc1c9bb17208f45ed650ed0eeaf5b274d77 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 3 Aug 2025 23:43:18 +0200 Subject: [PATCH 07/51] refactor(database): rename `model` to `inspect` --- packages/database/src/BelongsTo.php | 8 ++++---- packages/database/src/Builder/ModelInspector.php | 6 +++--- .../Builder/QueryBuilders/CountQueryBuilder.php | 4 ++-- .../Builder/QueryBuilders/DeleteQueryBuilder.php | 4 ++-- .../Builder/QueryBuilders/InsertQueryBuilder.php | 6 +++--- .../Builder/QueryBuilders/SelectQueryBuilder.php | 6 +++--- .../Builder/QueryBuilders/UpdateQueryBuilder.php | 4 ++-- packages/database/src/HasMany.php | 10 +++++----- packages/database/src/HasOne.php | 6 +++--- packages/database/src/IsDatabaseModel.php | 10 +++++----- .../database/src/Mappers/SelectModelMapper.php | 8 ++++---- .../database/src/Migrations/MigrationManager.php | 4 ++-- .../src/QueryStatements/AlterTableStatement.php | 4 ++-- .../src/QueryStatements/CreateTableStatement.php | 4 ++-- .../src/QueryStatements/DropTableStatement.php | 4 ++-- packages/database/src/functions.php | 2 +- .../tests/Fixtures/CreateMigrationsTable.php | 4 ++-- ...does_not_simplify_class_names_by_default__1.txt | 2 +- ...s_not_simplify_implements_when_specified__1.txt | 4 ++-- ...anipulatorTest__removes_class_attributes__1.txt | 4 ++-- .../ClassManipulatorTest__set_aliases__1.txt | 4 ++-- .../ClassManipulatorTest__sets_class_final__1.txt | 4 ++-- ...lassManipulatorTest__sets_class_readonly__1.txt | 4 ++-- .../ClassManipulatorTest__sets_strict_types__1.txt | 4 ++-- ...rTest__simplifies_class_names_by_default__1.txt | 4 ++-- ...ClassManipulatorTest__unsets_class_final__1.txt | 4 ++-- ...ssManipulatorTest__unsets_class_readonly__1.txt | 4 ++-- ...lassManipulatorTest__unsets_strict_types__1.txt | 4 ++-- .../ClassManipulatorTest__updates_namespace__1.txt | 4 ++-- ...orTest__updates_namespace_multiple_times__1.txt | 4 ++-- tests/Integration/Database/DatabaseConfigTest.php | 4 ++-- .../Database/ModelInspector/BelongsToTest.php | 14 +++++++------- .../Database/ModelInspector/HasManyTest.php | 14 +++++++------- .../Database/ModelInspector/HasOneTest.php | 14 +++++++------- .../Database/ModelInspector/ModelInspectorTest.php | 10 +++++----- .../Database/ModelInspector/ModelWithDtoTest.php | 4 ++-- tests/Integration/ORM/IsDatabaseModelTest.php | 10 +++++----- 37 files changed, 107 insertions(+), 107 deletions(-) diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index 8722ad539..d307248c6 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -46,14 +46,14 @@ public function getOwnerFieldName(): string } } - $relationModel = model($this->property->getType()->asClass()); + $relationModel = inspect($this->property->getType()->asClass()); return str($relationModel->getTableName())->singularizeLastWord() . '_' . $relationModel->getPrimaryKey(); } public function getSelectFields(): ImmutableArray { - $relationModel = model($this->property->getType()->asClass()); + $relationModel = inspect($this->property->getType()->asClass()); return $relationModel ->getSelectFields() @@ -70,8 +70,8 @@ public function getSelectFields(): ImmutableArray public function getJoinStatement(): JoinStatement { - $relationModel = model($this->property->getType()->asClass()); - $ownerModel = model($this->property->getClass()); + $relationModel = inspect($this->property->getType()->asClass()); + $ownerModel = inspect($this->property->getClass()); $relationJoin = $this->getRelationJoin($relationModel); $ownerJoin = $this->getOwnerJoin($ownerModel); diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index 8fd3f9803..fa9257362 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -21,7 +21,7 @@ use Tempest\Validation\SkipValidation; use Tempest\Validation\Validator; -use function Tempest\Database\model; +use function Tempest\Database\inspect; use function Tempest\get; use function Tempest\Support\arr; use function Tempest\Support\str; @@ -292,7 +292,7 @@ public function resolveRelations(string $relationString, string $parent = ''): a unset($relationNames[0]); - $relationModel = model($currentRelation); + $relationModel = inspect($currentRelation); $newRelationString = implode('.', $relationNames); $currentRelation->setParent($parent); @@ -334,7 +334,7 @@ public function resolveEagerRelations(string $parent = ''): array $currentRelationName, ), '.'); - foreach (model($currentRelation)->resolveEagerRelations($newParent) as $name => $nestedEagerRelation) { + foreach (inspect($currentRelation)->resolveEagerRelations($newParent) as $name => $nestedEagerRelation) { $relations[$name] = $nestedEagerRelation; } } diff --git a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php index 4bb7e767f..984286319 100644 --- a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php @@ -13,7 +13,7 @@ use Tempest\Support\Conditions\HasConditions; use Tempest\Support\Str\ImmutableString; -use function Tempest\Database\model; +use function Tempest\Database\inspect; /** * @template T of object @@ -35,7 +35,7 @@ final class CountQueryBuilder implements BuildsQuery */ public function __construct(string|object $model, ?string $column = null) { - $this->model = model($model); + $this->model = inspect($model); $this->count = new CountStatement( table: $this->model->getTableDefinition(), diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index cfdeda3d9..2944dec2e 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -10,7 +10,7 @@ use Tempest\Support\Conditions\HasConditions; use Tempest\Support\Str\ImmutableString; -use function Tempest\Database\model; +use function Tempest\Database\inspect; /** * @template TModelClass of object @@ -32,7 +32,7 @@ final class DeleteQueryBuilder implements BuildsQuery */ public function __construct(string|object $model) { - $this->model = model($model); + $this->model = inspect($model); $this->delete = new DeleteStatement($this->model->getTableDefinition()); } diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index e782b8130..48b51ee06 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -16,7 +16,7 @@ use Tempest\Support\Conditions\HasConditions; use Tempest\Support\Str\ImmutableString; -use function Tempest\Database\model; +use function Tempest\Database\inspect; /** * @template T of object @@ -42,7 +42,7 @@ public function __construct( private readonly array $rows, private readonly SerializerFactory $serializerFactory, ) { - $this->model = model($model); + $this->model = inspect($model); $this->insert = new InsertStatement($this->model->getTableDefinition()); } @@ -119,7 +119,7 @@ private function resolveData(): array } // The rest are model objects - $definition = model($model); + $definition = inspect($model); $modelClass = new ClassReflector($model); diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 6f00bb447..8363b9675 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -24,7 +24,7 @@ use Tempest\Support\Paginator\Paginator; use Tempest\Support\Str\ImmutableString; -use function Tempest\Database\model; +use function Tempest\Database\inspect; use function Tempest\map; /** @@ -51,7 +51,7 @@ final class SelectQueryBuilder implements BuildsQuery */ public function __construct(string|object $model, ?ImmutableArray $fields = null) { - $this->model = model($model); + $this->model = inspect($model); $this->select = new SelectStatement( table: $this->model->getTableDefinition(), @@ -246,7 +246,7 @@ private function clone(): self /** @return \Tempest\Database\Relation[] */ private function getIncludedRelations(): array { - $definition = model($this->model->getName()); + $definition = inspect($this->model->getName()); if (! $definition->isObjectModel()) { return []; diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index 12feb4ef7..cc8ecffdb 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -15,7 +15,7 @@ use Tempest\Support\Conditions\HasConditions; use Tempest\Support\Str\ImmutableString; -use function Tempest\Database\model; +use function Tempest\Database\inspect; use function Tempest\Support\arr; /** @@ -41,7 +41,7 @@ public function __construct( private readonly array|ImmutableArray $values, private readonly SerializerFactory $serializerFactory, ) { - $this->model = model($model); + $this->model = inspect($model); $this->update = new UpdateStatement( table: $this->model->getTableDefinition(), diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index b04fe96ad..c796157e2 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -38,7 +38,7 @@ public function setParent(string $name): self public function getSelectFields(): ImmutableArray { - $relationModel = model($this->property->getIterableType()->asClass()); + $relationModel = inspect($this->property->getIterableType()->asClass()); return $relationModel ->getSelectFields() @@ -53,12 +53,12 @@ public function getSelectFields(): ImmutableArray public function primaryKey(): string { - return model($this->property->getIterableType()->asClass())->getPrimaryKey(); + return inspect($this->property->getIterableType()->asClass())->getPrimaryKey(); } public function idField(): string { - $relationModel = model($this->property->getIterableType()->asClass()); + $relationModel = inspect($this->property->getIterableType()->asClass()); return sprintf( '%s.%s', @@ -69,8 +69,8 @@ public function idField(): string public function getJoinStatement(): JoinStatement { - $ownerModel = model($this->property->getIterableType()->asClass()); - $relationModel = model($this->property->getClass()); + $ownerModel = inspect($this->property->getIterableType()->asClass()); + $relationModel = inspect($this->property->getClass()); $ownerJoin = $this->getOwnerJoin($ownerModel, $relationModel); $relationJoin = $this->getRelationJoin($relationModel); diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index 878452587..1f1dfadb5 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -38,7 +38,7 @@ public function setParent(string $name): self public function getSelectFields(): ImmutableArray { - $relationModel = model($this->property->getType()->asClass()); + $relationModel = inspect($this->property->getType()->asClass()); return $relationModel ->getSelectFields() @@ -53,8 +53,8 @@ public function getSelectFields(): ImmutableArray public function getJoinStatement(): JoinStatement { - $ownerModel = model($this->property->getType()->asClass()); - $relationModel = model($this->property->getClass()); + $ownerModel = inspect($this->property->getType()->asClass()); + $relationModel = inspect($this->property->getClass()); $ownerJoin = $this->getOwnerJoin($ownerModel, $relationModel); $relationJoin = $this->getRelationJoin($relationModel); diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 80085ef2b..e6acc4b2c 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -72,7 +72,7 @@ public static function count(): CountQueryBuilder public static function create(mixed ...$params): self { - model(self::class)->validate(...$params); + inspect(self::class)->validate(...$params); $model = self::new(...$params); @@ -162,9 +162,9 @@ public function load(string ...$relations): self public function save(): self { - $model = model($this); + $model = inspect($this); - $model->validate(...model($this)->getPropertyValues()); + $model->validate(...inspect($this)->getPropertyValues()); if (! isset($this->id)) { $query = query($this::class)->insert($this); @@ -172,7 +172,7 @@ public function save(): self $this->id = $query->execute(); } else { query($this)->update( - ...model($this)->getPropertyValues(), + ...inspect($this)->getPropertyValues(), )->execute(); } @@ -181,7 +181,7 @@ public function save(): self public function update(mixed ...$params): self { - model(self::class)->validate(...$params); + inspect(self::class)->validate(...$params); foreach ($params as $key => $value) { $this->{$key} = $value; diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index f1be7877e..265f351da 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -11,7 +11,7 @@ use Tempest\Mapper\Mapper; use Tempest\Support\Arr\MutableArray; -use function Tempest\Database\model; +use function Tempest\Database\inspect; use function Tempest\map; use function Tempest\Support\arr; @@ -25,7 +25,7 @@ public function canMap(mixed $from, mixed $to): bool public function map(mixed $from, mixed $to): array { - $model = model($to); + $model = inspect($to); $idField = $model->getPrimaryFieldName(); @@ -58,7 +58,7 @@ private function values(ModelInspector $model, array $data): array } $mapped = []; - $relationModel = model($relation); + $relationModel = inspect($relation); foreach ($value as $item) { $mapped[] = $this->values($relationModel, $item); @@ -115,7 +115,7 @@ public function normalizeRow(ModelInspector $model, array $row, MutableArray $da break; } - $currentModel = model($relation); + $currentModel = inspect($relation); } if ($key) { diff --git a/packages/database/src/Migrations/MigrationManager.php b/packages/database/src/Migrations/MigrationManager.php index 72ea7e210..b7eb84bfd 100644 --- a/packages/database/src/Migrations/MigrationManager.php +++ b/packages/database/src/Migrations/MigrationManager.php @@ -22,7 +22,7 @@ use Tempest\Database\ShouldMigrate; use Throwable; -use function Tempest\Database\model; +use function Tempest\Database\inspect; use function Tempest\Database\query; use function Tempest\event; @@ -80,7 +80,7 @@ public function down(): void } event(new MigrationFailed( - name: model(Migration::class)->getTableDefinition()->name, + name: inspect(Migration::class)->getTableDefinition()->name, exception: new TableWasNotFound(), )); diff --git a/packages/database/src/QueryStatements/AlterTableStatement.php b/packages/database/src/QueryStatements/AlterTableStatement.php index 3963cb149..1ba8b7421 100644 --- a/packages/database/src/QueryStatements/AlterTableStatement.php +++ b/packages/database/src/QueryStatements/AlterTableStatement.php @@ -10,7 +10,7 @@ use Tempest\Database\QueryStatement; use Tempest\Support\Str\ImmutableString; -use function Tempest\Database\model; +use function Tempest\Database\inspect; use function Tempest\Support\arr; use function Tempest\Support\str; @@ -26,7 +26,7 @@ public function __construct( /** @param class-string $modelClass */ public static function forModel(string $modelClass): self { - return new self(model($modelClass)->getTableDefinition()->name); + return new self(inspect($modelClass)->getTableDefinition()->name); } public function add(QueryStatement $statement): self diff --git a/packages/database/src/QueryStatements/CreateTableStatement.php b/packages/database/src/QueryStatements/CreateTableStatement.php index f642923dc..43718cc98 100644 --- a/packages/database/src/QueryStatements/CreateTableStatement.php +++ b/packages/database/src/QueryStatements/CreateTableStatement.php @@ -13,7 +13,7 @@ use Tempest\Support\Str\ImmutableString; use UnitEnum; -use function Tempest\Database\model; +use function Tempest\Database\inspect; use function Tempest\Support\arr; use function Tempest\Support\str; @@ -29,7 +29,7 @@ public function __construct( /** @param class-string $modelClass */ public static function forModel(string $modelClass): self { - return new self(model($modelClass)->getTableDefinition()->name); + return new self(inspect($modelClass)->getTableDefinition()->name); } /** diff --git a/packages/database/src/QueryStatements/DropTableStatement.php b/packages/database/src/QueryStatements/DropTableStatement.php index 849161cdb..a836b1744 100644 --- a/packages/database/src/QueryStatements/DropTableStatement.php +++ b/packages/database/src/QueryStatements/DropTableStatement.php @@ -8,7 +8,7 @@ use Tempest\Database\HasLeadingStatements; use Tempest\Database\QueryStatement; -use function Tempest\Database\model; +use function Tempest\Database\inspect; final class DropTableStatement implements QueryStatement, HasLeadingStatements { @@ -30,7 +30,7 @@ public function dropReference(string $foreign): self /** @param class-string $modelClass */ public static function forModel(string $modelClass): self { - return new self(model($modelClass)->getTableDefinition()->name); + return new self(inspect($modelClass)->getTableDefinition()->name); } public function compile(DatabaseDialect $dialect): string diff --git a/packages/database/src/functions.php b/packages/database/src/functions.php index 5fe92c674..c2aeccbf5 100644 --- a/packages/database/src/functions.php +++ b/packages/database/src/functions.php @@ -19,7 +19,7 @@ function query(string|object $model): QueryBuilder * @param class-string|string|T $model * @return ModelInspector */ - function model(string|object $model): ModelInspector + function inspect(string|object $model): ModelInspector { return new ModelInspector($model); } diff --git a/packages/generation/tests/Fixtures/CreateMigrationsTable.php b/packages/generation/tests/Fixtures/CreateMigrationsTable.php index 05fd819c7..b8eb8c78e 100644 --- a/packages/generation/tests/Fixtures/CreateMigrationsTable.php +++ b/packages/generation/tests/Fixtures/CreateMigrationsTable.php @@ -9,7 +9,7 @@ use Tempest\Generation\Tests\Fixtures\Database\FakeQueryStatement; use Tempest\Generation\Tests\Fixtures\Database\MigrationModel as Model; -use function Tempest\Database\model; +use function Tempest\Database\inspect; #[TestAttribute] final readonly class CreateMigrationsTable implements FakeMigration @@ -21,7 +21,7 @@ public function getName(): string public function up(): FakeQueryStatement { - return new FakeCreateTableStatement(model(Model::class)->getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/__snapshots__/ClassManipulatorTest__does_not_simplify_class_names_by_default__1.txt b/packages/generation/tests/__snapshots__/ClassManipulatorTest__does_not_simplify_class_names_by_default__1.txt index 27a5ad70a..6140d8901 100644 --- a/packages/generation/tests/__snapshots__/ClassManipulatorTest__does_not_simplify_class_names_by_default__1.txt +++ b/packages/generation/tests/__snapshots__/ClassManipulatorTest__does_not_simplify_class_names_by_default__1.txt @@ -15,7 +15,7 @@ final readonly class CreateMigrationsTable implements FakeMigration public function up(): FakeQueryStatement { - return new Database\FakeCreateTableStatement(\Tempest\Database\model(Database\MigrationModel::class)->getTableDefinition()->name) + return new Database\FakeCreateTableStatement(\Tempest\Database\inspect(Database\MigrationModel::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/__snapshots__/ClassManipulatorTest__does_not_simplify_implements_when_specified__1.txt b/packages/generation/tests/__snapshots__/ClassManipulatorTest__does_not_simplify_implements_when_specified__1.txt index 841de48e3..117e23e4b 100644 --- a/packages/generation/tests/__snapshots__/ClassManipulatorTest__does_not_simplify_implements_when_specified__1.txt +++ b/packages/generation/tests/__snapshots__/ClassManipulatorTest__does_not_simplify_implements_when_specified__1.txt @@ -6,7 +6,7 @@ use Tempest\Generation\Tests\Fixtures\Database\FakeCreateTableStatement; use Tempest\Generation\Tests\Fixtures\Database\FakeQueryStatement; use Tempest\Generation\Tests\Fixtures\Database\MigrationModel; -use function Tempest\Database\model; +use function Tempest\Database\inspect; #[TestAttribute] final readonly class CreateMigrationsTable implements Database\FakeMigration @@ -18,7 +18,7 @@ final readonly class CreateMigrationsTable implements Database\FakeMigration public function up(): FakeQueryStatement { - return new FakeCreateTableStatement(model(MigrationModel::class)->getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(MigrationModel::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/__snapshots__/ClassManipulatorTest__removes_class_attributes__1.txt b/packages/generation/tests/__snapshots__/ClassManipulatorTest__removes_class_attributes__1.txt index c9cd5f32f..b12c50f84 100644 --- a/packages/generation/tests/__snapshots__/ClassManipulatorTest__removes_class_attributes__1.txt +++ b/packages/generation/tests/__snapshots__/ClassManipulatorTest__removes_class_attributes__1.txt @@ -7,7 +7,7 @@ use Tempest\Generation\Tests\Fixtures\Database\FakeMigration; use Tempest\Generation\Tests\Fixtures\Database\FakeQueryStatement; use Tempest\Generation\Tests\Fixtures\Database\MigrationModel; -use function Tempest\Database\model; +use function Tempest\Database\inspect; final readonly class CreateMigrationsTable implements FakeMigration { @@ -18,7 +18,7 @@ final readonly class CreateMigrationsTable implements FakeMigration public function up(): FakeQueryStatement { - return new FakeCreateTableStatement(model(MigrationModel::class)->getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(MigrationModel::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/__snapshots__/ClassManipulatorTest__set_aliases__1.txt b/packages/generation/tests/__snapshots__/ClassManipulatorTest__set_aliases__1.txt index 833a1b45a..8e23d5049 100644 --- a/packages/generation/tests/__snapshots__/ClassManipulatorTest__set_aliases__1.txt +++ b/packages/generation/tests/__snapshots__/ClassManipulatorTest__set_aliases__1.txt @@ -8,7 +8,7 @@ use Tempest\Generation\Tests\Fixtures\Database\FakeQueryStatement; use Tempest\Generation\Tests\Fixtures\Database\MigrationModel; use Tempest\Generation\Tests\Fixtures\Database\MigrationModel as Model; -use function Tempest\Database\model; +use function Tempest\Database\inspect; #[TestAttribute] final readonly class CreateMigrationsTable implements FakeMigration @@ -20,7 +20,7 @@ final readonly class CreateMigrationsTable implements FakeMigration public function up(): FakeQueryStatement { - return new FakeCreateTableStatement(model(Model::class)->getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/__snapshots__/ClassManipulatorTest__sets_class_final__1.txt b/packages/generation/tests/__snapshots__/ClassManipulatorTest__sets_class_final__1.txt index 6de5e6cc1..be25dc9cf 100644 --- a/packages/generation/tests/__snapshots__/ClassManipulatorTest__sets_class_final__1.txt +++ b/packages/generation/tests/__snapshots__/ClassManipulatorTest__sets_class_final__1.txt @@ -7,7 +7,7 @@ use Tempest\Generation\Tests\Fixtures\Database\FakeMigration; use Tempest\Generation\Tests\Fixtures\Database\FakeQueryStatement; use Tempest\Generation\Tests\Fixtures\Database\MigrationModel; -use function Tempest\Database\model; +use function Tempest\Database\inspect; #[TestAttribute] final readonly class CreateMigrationsTable implements FakeMigration @@ -19,7 +19,7 @@ final readonly class CreateMigrationsTable implements FakeMigration public function up(): FakeQueryStatement { - return new FakeCreateTableStatement(model(MigrationModel::class)->getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(MigrationModel::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/__snapshots__/ClassManipulatorTest__sets_class_readonly__1.txt b/packages/generation/tests/__snapshots__/ClassManipulatorTest__sets_class_readonly__1.txt index 6de5e6cc1..be25dc9cf 100644 --- a/packages/generation/tests/__snapshots__/ClassManipulatorTest__sets_class_readonly__1.txt +++ b/packages/generation/tests/__snapshots__/ClassManipulatorTest__sets_class_readonly__1.txt @@ -7,7 +7,7 @@ use Tempest\Generation\Tests\Fixtures\Database\FakeMigration; use Tempest\Generation\Tests\Fixtures\Database\FakeQueryStatement; use Tempest\Generation\Tests\Fixtures\Database\MigrationModel; -use function Tempest\Database\model; +use function Tempest\Database\inspect; #[TestAttribute] final readonly class CreateMigrationsTable implements FakeMigration @@ -19,7 +19,7 @@ final readonly class CreateMigrationsTable implements FakeMigration public function up(): FakeQueryStatement { - return new FakeCreateTableStatement(model(MigrationModel::class)->getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(MigrationModel::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/__snapshots__/ClassManipulatorTest__sets_strict_types__1.txt b/packages/generation/tests/__snapshots__/ClassManipulatorTest__sets_strict_types__1.txt index 9151ef2f4..b2465d4db 100644 --- a/packages/generation/tests/__snapshots__/ClassManipulatorTest__sets_strict_types__1.txt +++ b/packages/generation/tests/__snapshots__/ClassManipulatorTest__sets_strict_types__1.txt @@ -9,7 +9,7 @@ use Tempest\Generation\Tests\Fixtures\Database\FakeMigration; use Tempest\Generation\Tests\Fixtures\Database\FakeQueryStatement; use Tempest\Generation\Tests\Fixtures\Database\MigrationModel; -use function Tempest\Database\model; +use function Tempest\Database\inspect; #[TestAttribute] final readonly class CreateMigrationsTable implements FakeMigration @@ -21,7 +21,7 @@ final readonly class CreateMigrationsTable implements FakeMigration public function up(): FakeQueryStatement { - return new FakeCreateTableStatement(model(MigrationModel::class)->getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(MigrationModel::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/__snapshots__/ClassManipulatorTest__simplifies_class_names_by_default__1.txt b/packages/generation/tests/__snapshots__/ClassManipulatorTest__simplifies_class_names_by_default__1.txt index 6de5e6cc1..be25dc9cf 100644 --- a/packages/generation/tests/__snapshots__/ClassManipulatorTest__simplifies_class_names_by_default__1.txt +++ b/packages/generation/tests/__snapshots__/ClassManipulatorTest__simplifies_class_names_by_default__1.txt @@ -7,7 +7,7 @@ use Tempest\Generation\Tests\Fixtures\Database\FakeMigration; use Tempest\Generation\Tests\Fixtures\Database\FakeQueryStatement; use Tempest\Generation\Tests\Fixtures\Database\MigrationModel; -use function Tempest\Database\model; +use function Tempest\Database\inspect; #[TestAttribute] final readonly class CreateMigrationsTable implements FakeMigration @@ -19,7 +19,7 @@ final readonly class CreateMigrationsTable implements FakeMigration public function up(): FakeQueryStatement { - return new FakeCreateTableStatement(model(MigrationModel::class)->getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(MigrationModel::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/__snapshots__/ClassManipulatorTest__unsets_class_final__1.txt b/packages/generation/tests/__snapshots__/ClassManipulatorTest__unsets_class_final__1.txt index 0463a00b2..ffce4b97e 100644 --- a/packages/generation/tests/__snapshots__/ClassManipulatorTest__unsets_class_final__1.txt +++ b/packages/generation/tests/__snapshots__/ClassManipulatorTest__unsets_class_final__1.txt @@ -7,7 +7,7 @@ use Tempest\Generation\Tests\Fixtures\Database\FakeMigration; use Tempest\Generation\Tests\Fixtures\Database\FakeQueryStatement; use Tempest\Generation\Tests\Fixtures\Database\MigrationModel; -use function Tempest\Database\model; +use function Tempest\Database\inspect; #[TestAttribute] readonly class CreateMigrationsTable implements FakeMigration @@ -19,7 +19,7 @@ readonly class CreateMigrationsTable implements FakeMigration public function up(): FakeQueryStatement { - return new FakeCreateTableStatement(model(MigrationModel::class)->getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(MigrationModel::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/__snapshots__/ClassManipulatorTest__unsets_class_readonly__1.txt b/packages/generation/tests/__snapshots__/ClassManipulatorTest__unsets_class_readonly__1.txt index 9eae09976..d2c88eeff 100644 --- a/packages/generation/tests/__snapshots__/ClassManipulatorTest__unsets_class_readonly__1.txt +++ b/packages/generation/tests/__snapshots__/ClassManipulatorTest__unsets_class_readonly__1.txt @@ -7,7 +7,7 @@ use Tempest\Generation\Tests\Fixtures\Database\FakeMigration; use Tempest\Generation\Tests\Fixtures\Database\FakeQueryStatement; use Tempest\Generation\Tests\Fixtures\Database\MigrationModel; -use function Tempest\Database\model; +use function Tempest\Database\inspect; #[TestAttribute] final class CreateMigrationsTable implements FakeMigration @@ -19,7 +19,7 @@ final class CreateMigrationsTable implements FakeMigration public function up(): FakeQueryStatement { - return new FakeCreateTableStatement(model(MigrationModel::class)->getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(MigrationModel::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/__snapshots__/ClassManipulatorTest__unsets_strict_types__1.txt b/packages/generation/tests/__snapshots__/ClassManipulatorTest__unsets_strict_types__1.txt index 6de5e6cc1..be25dc9cf 100644 --- a/packages/generation/tests/__snapshots__/ClassManipulatorTest__unsets_strict_types__1.txt +++ b/packages/generation/tests/__snapshots__/ClassManipulatorTest__unsets_strict_types__1.txt @@ -7,7 +7,7 @@ use Tempest\Generation\Tests\Fixtures\Database\FakeMigration; use Tempest\Generation\Tests\Fixtures\Database\FakeQueryStatement; use Tempest\Generation\Tests\Fixtures\Database\MigrationModel; -use function Tempest\Database\model; +use function Tempest\Database\inspect; #[TestAttribute] final readonly class CreateMigrationsTable implements FakeMigration @@ -19,7 +19,7 @@ final readonly class CreateMigrationsTable implements FakeMigration public function up(): FakeQueryStatement { - return new FakeCreateTableStatement(model(MigrationModel::class)->getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(MigrationModel::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/__snapshots__/ClassManipulatorTest__updates_namespace__1.txt b/packages/generation/tests/__snapshots__/ClassManipulatorTest__updates_namespace__1.txt index 0f0a575bd..1c635832f 100644 --- a/packages/generation/tests/__snapshots__/ClassManipulatorTest__updates_namespace__1.txt +++ b/packages/generation/tests/__snapshots__/ClassManipulatorTest__updates_namespace__1.txt @@ -8,7 +8,7 @@ use Tempest\Generation\Tests\Fixtures\Database\FakeQueryStatement; use Tempest\Generation\Tests\Fixtures\Database\MigrationModel; use Tempest\Generation\Tests\Fixtures\TestAttribute; -use function Tempest\Database\model; +use function Tempest\Database\inspect; #[TestAttribute] final readonly class CreateMigrationsTable implements FakeMigration @@ -20,7 +20,7 @@ final readonly class CreateMigrationsTable implements FakeMigration public function up(): FakeQueryStatement { - return new FakeCreateTableStatement(model(MigrationModel::class)->getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(MigrationModel::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/packages/generation/tests/__snapshots__/ClassManipulatorTest__updates_namespace_multiple_times__1.txt b/packages/generation/tests/__snapshots__/ClassManipulatorTest__updates_namespace_multiple_times__1.txt index 72be2e4bc..360960129 100644 --- a/packages/generation/tests/__snapshots__/ClassManipulatorTest__updates_namespace_multiple_times__1.txt +++ b/packages/generation/tests/__snapshots__/ClassManipulatorTest__updates_namespace_multiple_times__1.txt @@ -8,7 +8,7 @@ use Tempest\Generation\Tests\Fixtures\Database\FakeQueryStatement; use Tempest\Generation\Tests\Fixtures\Database\MigrationModel; use Tempest\Generation\Tests\Fixtures\TestAttribute; -use function Tempest\Database\model; +use function Tempest\Database\inspect; #[TestAttribute] final readonly class CreateMigrationsTable implements FakeMigration @@ -20,7 +20,7 @@ final readonly class CreateMigrationsTable implements FakeMigration public function up(): FakeQueryStatement { - return new FakeCreateTableStatement(model(MigrationModel::class)->getTableDefinition()->name) + return new FakeCreateTableStatement(inspect(MigrationModel::class)->getTableDefinition()->name) ->primary() ->text('name'); } diff --git a/tests/Integration/Database/DatabaseConfigTest.php b/tests/Integration/Database/DatabaseConfigTest.php index 028f962bd..3a1d16a74 100644 --- a/tests/Integration/Database/DatabaseConfigTest.php +++ b/tests/Integration/Database/DatabaseConfigTest.php @@ -11,7 +11,7 @@ use Tests\Tempest\Fixtures\Models\MultiWordModel; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\Database\model; +use function Tempest\Database\inspect; /** * @internal @@ -27,6 +27,6 @@ public function test_strategy_is_taken_into_account(string $strategy, string $ex namingStrategy: new $strategy(), )); - $this->assertSame($expected, model(MultiWordModel::class)->getTableDefinition()->name); + $this->assertSame($expected, inspect(MultiWordModel::class)->getTableDefinition()->name); } } diff --git a/tests/Integration/Database/ModelInspector/BelongsToTest.php b/tests/Integration/Database/ModelInspector/BelongsToTest.php index 895667229..ffcb13e2f 100644 --- a/tests/Integration/Database/ModelInspector/BelongsToTest.php +++ b/tests/Integration/Database/ModelInspector/BelongsToTest.php @@ -8,13 +8,13 @@ use Tempest\Database\Table; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\Database\model; +use function Tempest\Database\inspect; final class BelongsToTest extends FrameworkIntegrationTestCase { public function test_belongs_to(): void { - $model = model(BelongsToTestOwnerModel::class); + $model = inspect(BelongsToTestOwnerModel::class); $relation = $model->getRelation('relation'); $this->assertInstanceOf(BelongsTo::class, $relation); @@ -27,7 +27,7 @@ public function test_belongs_to(): void public function test_belongs_to_with_relation_join_field(): void { - $model = model(BelongsToTestOwnerModel::class); + $model = inspect(BelongsToTestOwnerModel::class); $relation = $model->getRelation('relationJoinField'); $this->assertInstanceOf(BelongsTo::class, $relation); @@ -40,7 +40,7 @@ public function test_belongs_to_with_relation_join_field(): void public function test_belongs_to_with_relation_join_field_and_table(): void { - $model = model(BelongsToTestOwnerModel::class); + $model = inspect(BelongsToTestOwnerModel::class); $relation = $model->getRelation('relationJoinFieldAndTable'); $this->assertInstanceOf(BelongsTo::class, $relation); @@ -53,7 +53,7 @@ public function test_belongs_to_with_relation_join_field_and_table(): void public function test_belongs_to_with_owner_join_field(): void { - $model = model(BelongsToTestOwnerModel::class); + $model = inspect(BelongsToTestOwnerModel::class); $relation = $model->getRelation('ownerJoinField'); $this->assertInstanceOf(BelongsTo::class, $relation); @@ -66,7 +66,7 @@ public function test_belongs_to_with_owner_join_field(): void public function test_belongs_to_with_owner_join_field_and_table(): void { - $model = model(BelongsToTestOwnerModel::class); + $model = inspect(BelongsToTestOwnerModel::class); $relation = $model->getRelation('ownerJoinFieldAndTable'); $this->assertInstanceOf(BelongsTo::class, $relation); @@ -79,7 +79,7 @@ public function test_belongs_to_with_owner_join_field_and_table(): void public function test_belongs_to_with_parent(): void { - $model = model(BelongsToTestOwnerModel::class); + $model = inspect(BelongsToTestOwnerModel::class); $relation = $model->getRelation('relation')->setParent('parent'); $this->assertSame( diff --git a/tests/Integration/Database/ModelInspector/HasManyTest.php b/tests/Integration/Database/ModelInspector/HasManyTest.php index 9ad7b32e3..80f2fbf02 100644 --- a/tests/Integration/Database/ModelInspector/HasManyTest.php +++ b/tests/Integration/Database/ModelInspector/HasManyTest.php @@ -8,13 +8,13 @@ use Tempest\Database\Table; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\Database\model; +use function Tempest\Database\inspect; final class HasManyTest extends FrameworkIntegrationTestCase { public function test_has_many(): void { - $model = model(HasManyTestRelationModel::class); + $model = inspect(HasManyTestRelationModel::class); $relation = $model->getRelation('owners'); $this->assertInstanceOf(HasMany::class, $relation); @@ -26,7 +26,7 @@ public function test_has_many(): void public function test_has_many_with_overwritten_owner_join_field(): void { - $model = model(HasManyTestRelationModel::class); + $model = inspect(HasManyTestRelationModel::class); $relation = $model->getRelation('ownerJoinField'); $this->assertInstanceOf(HasMany::class, $relation); @@ -38,7 +38,7 @@ public function test_has_many_with_overwritten_owner_join_field(): void public function test_has_many_with_overwritten_owner_join_field_and_table(): void { - $model = model(HasManyTestRelationModel::class); + $model = inspect(HasManyTestRelationModel::class); $relation = $model->getRelation('ownerJoinFieldAndTable'); $this->assertInstanceOf(HasMany::class, $relation); @@ -50,7 +50,7 @@ public function test_has_many_with_overwritten_owner_join_field_and_table(): voi public function test_has_many_with_overwritten_relation_join_field(): void { - $model = model(HasManyTestRelationModel::class); + $model = inspect(HasManyTestRelationModel::class); $relation = $model->getRelation('relationJoinField'); $this->assertInstanceOf(HasMany::class, $relation); @@ -62,7 +62,7 @@ public function test_has_many_with_overwritten_relation_join_field(): void public function test_has_many_with_overwritten_relation_join_field_and_table(): void { - $model = model(HasManyTestRelationModel::class); + $model = inspect(HasManyTestRelationModel::class); $relation = $model->getRelation('relationJoinFieldAndTable'); $this->assertInstanceOf(HasMany::class, $relation); @@ -74,7 +74,7 @@ public function test_has_many_with_overwritten_relation_join_field_and_table(): public function test_has_many_with_parent(): void { - $model = model(HasManyTestRelationModel::class); + $model = inspect(HasManyTestRelationModel::class); $relation = $model->getRelation('owners')->setParent('parent'); $this->assertSame( diff --git a/tests/Integration/Database/ModelInspector/HasOneTest.php b/tests/Integration/Database/ModelInspector/HasOneTest.php index 548b49869..48852589e 100644 --- a/tests/Integration/Database/ModelInspector/HasOneTest.php +++ b/tests/Integration/Database/ModelInspector/HasOneTest.php @@ -9,13 +9,13 @@ use Tempest\Database\Table; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\Database\model; +use function Tempest\Database\inspect; final class HasOneTest extends FrameworkIntegrationTestCase { public function test_has_one(): void { - $model = model(HasOneTestRelationModel::class); + $model = inspect(HasOneTestRelationModel::class); $relation = $model->getRelation('owner'); $this->assertInstanceOf(HasOne::class, $relation); @@ -27,7 +27,7 @@ public function test_has_one(): void public function test_has_one_with_overwritten_owner_join_field(): void { - $model = model(HasOneTestRelationModel::class); + $model = inspect(HasOneTestRelationModel::class); $relation = $model->getRelation('ownerJoinField'); $this->assertInstanceOf(HasOne::class, $relation); @@ -39,7 +39,7 @@ public function test_has_one_with_overwritten_owner_join_field(): void public function test_has_one_with_overwritten_owner_join_field_and_table(): void { - $model = model(HasOneTestRelationModel::class); + $model = inspect(HasOneTestRelationModel::class); $relation = $model->getRelation('ownerJoinFieldAndTable'); $this->assertInstanceOf(HasOne::class, $relation); @@ -51,7 +51,7 @@ public function test_has_one_with_overwritten_owner_join_field_and_table(): void public function test_has_one_with_overwritten_relation_join_field(): void { - $model = model(HasOneTestRelationModel::class); + $model = inspect(HasOneTestRelationModel::class); $relation = $model->getRelation('relationJoinField'); $this->assertInstanceOf(HasOne::class, $relation); @@ -63,7 +63,7 @@ public function test_has_one_with_overwritten_relation_join_field(): void public function test_has_one_with_overwritten_relation_join_field_and_table(): void { - $model = model(HasOneTestRelationModel::class); + $model = inspect(HasOneTestRelationModel::class); $relation = $model->getRelation('relationJoinFieldAndTable'); $this->assertInstanceOf(HasOne::class, $relation); @@ -75,7 +75,7 @@ public function test_has_one_with_overwritten_relation_join_field_and_table(): v public function test_has_one_with_parent(): void { - $model = model(HasOneTestRelationModel::class); + $model = inspect(HasOneTestRelationModel::class); $relation = $model->getRelation('owner')->setParent('parent'); $this->assertSame( diff --git a/tests/Integration/Database/ModelInspector/ModelInspectorTest.php b/tests/Integration/Database/ModelInspector/ModelInspectorTest.php index 78faf6260..54a9c7806 100644 --- a/tests/Integration/Database/ModelInspector/ModelInspectorTest.php +++ b/tests/Integration/Database/ModelInspector/ModelInspectorTest.php @@ -10,28 +10,28 @@ use Tempest\Mapper\SerializeWith; use Tests\Tempest\Integration\IntegrationTestCase; -use function Tempest\Database\model; +use function Tempest\Database\inspect; final class ModelInspectorTest extends IntegrationTestCase { public function test_virtual_array_is_never_a_relation(): void { - $this->assertFalse(model(ModelInspectorTestModelWithVirtualHasMany::class)->isRelation('dtos')); + $this->assertFalse(inspect(ModelInspectorTestModelWithVirtualHasMany::class)->isRelation('dtos')); } public function test_virtual_property_is_never_a_relation(): void { - $this->assertFalse(model(ModelInspectorTestModelWithVirtualDto::class)->isRelation('dto')); + $this->assertFalse(inspect(ModelInspectorTestModelWithVirtualDto::class)->isRelation('dto')); } public function test_serialized_property_type_is_never_a_relation(): void { - $this->assertFalse(model(ModelInspectorTestModelWithSerializedDto::class)->isRelation('dto')); + $this->assertFalse(inspect(ModelInspectorTestModelWithSerializedDto::class)->isRelation('dto')); } public function test_serialized_property_is_never_a_relation(): void { - $this->assertFalse(model(ModelInspectorTestModelWithSerializedDtoProperty::class)->isRelation('dto')); + $this->assertFalse(inspect(ModelInspectorTestModelWithSerializedDtoProperty::class)->isRelation('dto')); } } diff --git a/tests/Integration/Database/ModelInspector/ModelWithDtoTest.php b/tests/Integration/Database/ModelInspector/ModelWithDtoTest.php index 987573e68..444edb289 100644 --- a/tests/Integration/Database/ModelInspector/ModelWithDtoTest.php +++ b/tests/Integration/Database/ModelInspector/ModelWithDtoTest.php @@ -13,13 +13,13 @@ use Tempest\Mapper\SerializeWith; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\Database\model; +use function Tempest\Database\inspect; final class ModelWithDtoTest extends FrameworkIntegrationTestCase { public function test_model_inspector_is_relation_with_dto(): void { - $definition = model(ModelWithDtoTestModelWithSerializedDto::class); + $definition = inspect(ModelWithDtoTestModelWithSerializedDto::class); $this->assertFalse($definition->isRelation('dto')); } diff --git a/tests/Integration/ORM/IsDatabaseModelTest.php b/tests/Integration/ORM/IsDatabaseModelTest.php index 4c8cf4eb5..646a50c6f 100644 --- a/tests/Integration/ORM/IsDatabaseModelTest.php +++ b/tests/Integration/ORM/IsDatabaseModelTest.php @@ -57,7 +57,7 @@ use Tests\Tempest\Integration\ORM\Models\StaticMethodTableNameModel; use Tests\Tempest\Integration\ORM\Models\ThroughModel; -use function Tempest\Database\model; +use function Tempest\Database\inspect; use function Tempest\Database\query; use function Tempest\map; @@ -564,9 +564,9 @@ public function test_find(): void public function test_table_name_overrides(): void { - $this->assertEquals('base_models', model(BaseModel::class)->getTableDefinition()->name); - $this->assertEquals('custom_attribute_table_name', model(AttributeTableNameModel::class)->getTableDefinition()->name); - $this->assertEquals('custom_static_method_table_name', model(StaticMethodTableNameModel::class)->getTableDefinition()->name); + $this->assertEquals('base_models', inspect(BaseModel::class)->getTableDefinition()->name); + $this->assertEquals('custom_attribute_table_name', inspect(AttributeTableNameModel::class)->getTableDefinition()->name); + $this->assertEquals('custom_static_method_table_name', inspect(StaticMethodTableNameModel::class)->getTableDefinition()->name); } public function test_validation_on_create(): void @@ -608,7 +608,7 @@ public function test_validation_on_new(): void public function test_skipped_validation(): void { try { - model(ModelWithValidation::class)->validate( + inspect(ModelWithValidation::class)->validate( index: -1, skip: -1, ); From 37017fa1e94c2100c73d848da5d6b35018937820 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 4 Aug 2025 02:50:40 +0200 Subject: [PATCH 08/51] feat(database): extract `IsDatabaseModel` in a model query builder --- .../src/Builder/ModelQueryBuilder.php | 276 +++++++++++++ .../ModelDidNotHavePrimaryColumn.php | 19 + packages/database/src/IsDatabaseModel.php | 243 ++++++----- packages/database/src/functions.php | 13 + .../Builder/ModelQueryBuilderTest.php | 386 ++++++++++++++++++ 5 files changed, 846 insertions(+), 91 deletions(-) create mode 100644 packages/database/src/Builder/ModelQueryBuilder.php create mode 100644 packages/database/src/Exceptions/ModelDidNotHavePrimaryColumn.php create mode 100644 tests/Integration/Database/Builder/ModelQueryBuilderTest.php diff --git a/packages/database/src/Builder/ModelQueryBuilder.php b/packages/database/src/Builder/ModelQueryBuilder.php new file mode 100644 index 000000000..012c81439 --- /dev/null +++ b/packages/database/src/Builder/ModelQueryBuilder.php @@ -0,0 +1,276 @@ + */ + private string $model, + ) {} + + /** + * Returns a builder for selecting records using this model's table. + * + * @return SelectQueryBuilder + */ + public function select(): SelectQueryBuilder + { + return query($this->model)->select(); + } + + /** + * Returns a builder for inserting records using this model's table. + * + * @return InsertQueryBuilder + */ + public function insert(): InsertQueryBuilder + { + return query($this->model)->insert(); + } + + /** + * Returns a builder for updating records using this model's table. + * + * @return UpdateQueryBuilder + */ + public function update(): UpdateQueryBuilder + { + return query($this->model)->update(); + } + + /** + * Returns a builder for deleting records using this model's table. + * + * @return DeleteQueryBuilder + */ + public function delete(): DeleteQueryBuilder + { + return query($this->model)->delete(); + } + + /** + * Returns a builder for counting records using this model's table. + * + * @return CountQueryBuilder + */ + public function count(): CountQueryBuilder + { + return query($this->model)->count(); + } + + /** + * Creates a new instance of this model without persisting it to the database. + * + * @return TModel + */ + public function new(mixed ...$params): object + { + return make($this->model)->from($params); + } + + /** + * Finds a model instance by its ID. + * + * @return TModel + */ + public function findById(string|int|Id $id): object + { + if (! $this->supportsIds()) { + throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'findById'); + } + + return $this->get($id); + } + + /** + * Finds a model instance by its ID. + * + * @return TModel + */ + public function resolve(string|int|Id $id): object + { + if (! $this->supportsIds()) { + throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'resolve'); + } + + return $this->get($id); + } + + /** + * Gets a model instance by its ID, optionally loading the given relationships. + * + * @return TModel|null + */ + public function get(string|int|Id $id, array $relations = []): ?object + { + if (! $this->supportsIds()) { + throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'get'); + } + + $id = match (true) { + $id instanceof Id => $id, + default => new Id($id), + }; + + return $this->select() + ->with(...$relations) + ->get($id); + } + + /** + * Gets all records from the model's table. + * + * @return TModel[] + */ + public function all(array $relations = []): array + { + return $this->select() + ->with(...$relations) + ->all(); + } + + /** + * Finds records based on their columns. + * + * **Example** + * ```php + * model(MagicUser::class)->find(name: 'Frieren'); + * ``` + * + * @return SelectQueryBuilder + */ + public function find(mixed ...$conditions): SelectQueryBuilder + { + $query = $this->select(); + + foreach ($conditions as $field => $value) { + $query->where($field, $value); + } + + return $query; + } + + /** + * Creates a new model instance and persists it to the database. + * + * **Example** + * ```php + * model(MagicUser::class)->create(name: 'Frieren', kind: Kind::ELF); + * ``` + * + * @return TModel + */ + public function create(mixed ...$params): object + { + inspect($this->model)->validate(...$params); + + $model = $this->new(...$params); + + $id = query($this->model) + ->insert($model) + ->build() + ->execute(); + + if ($id !== null && property_exists($model, 'id')) { + $model->id = new Id($id); + } + + return $model; + } + + /** + * Finds an existing model instance or creates a new one if it doesn't exist, without persisting it to the database. + * + * **Example** + * ```php + * $model = model(MagicUser::class)->findOrNew( + * find: ['name' => 'Frieren'], + * update: ['kind' => Kind::ELF], + * ); + * ``` + * + * @param array $find Properties to search for in the existing model. + * @param array $update Properties to update or set on the model if it is found or created. + * @return TModel + */ + public function findOrNew(array $find, array $update): object + { + $existing = $this->select(); + + foreach ($find as $key => $value) { + $existing = $existing->where($key, $value); + } + + $model = $existing->first() ?? $this->new(...$find); + + foreach ($update as $key => $value) { + $model->{$key} = $value; + } + + return $model; + } + + /** + * Finds an existing model instance or creates a new one if it doesn't exist, and persists it to the database. + * + * **Example** + * ```php + * $model = model(MagicUser::class)->updateOrCreate( + * find: ['name' => 'Frieren'], + * update: ['kind' => Kind::ELF], + * ); + * ``` + * + * @param array $find Properties to search for in the existing model. + * @param array $update Properties to update or set on the model if it is found or created. + * @return TModel + */ + public function updateOrCreate(array $find, array $update): object + { + if (! $this->supportsIds()) { + throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'updateOrCreate'); + } + + $model = $this->findOrNew($find, $update); + + if (! isset($model->id)) { + return $this->create(...$update); + } + + query($model) + ->update(...$update) + ->execute(); + + foreach ($update as $key => $value) { + $model->{$key} = $value; + } + + return $model; + } + + /** + * Checks if the model supports ID-based operations. + */ + private function supportsIds(): bool + { + return property_exists($this->model, 'id'); + } +} diff --git a/packages/database/src/Exceptions/ModelDidNotHavePrimaryColumn.php b/packages/database/src/Exceptions/ModelDidNotHavePrimaryColumn.php new file mode 100644 index 000000000..51d07ef37 --- /dev/null +++ b/packages/database/src/Exceptions/ModelDidNotHavePrimaryColumn.php @@ -0,0 +1,19 @@ + + */ + public static function select(): SelectQueryBuilder { - return make(self::class)->from($params); + return model(self::class)->select(); } - public static function resolve(string $input): static + /** + * Returns a builder for inserting records using this model's table. + * + * @return InsertQueryBuilder + */ + public static function insert(): InsertQueryBuilder { - return self::get(new Id($input)); + return model(self::class)->insert(); } /** - * @return \Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder + * Returns a builder for counting records using this model's table. + * + * @return CountQueryBuilder */ - public static function select(): SelectQueryBuilder + public static function count(): CountQueryBuilder { - return query(self::class)->select(); + return model(self::class)->count(); } - /** @return self[] */ - public static function all(array $relations = []): array + /** + * Creates a new instance of this model without persisting it to the database. + */ + public static function new(mixed ...$params): self { - return self::select() - ->with(...$relations) - ->all(); + return model(self::class)->new(...$params); } - public static function get(string|int|Id $id, array $relations = []): ?self + /** + * Finds a model instance by its ID. + */ + public static function findById(string|int|Id $id): static { - if (! ($id instanceof Id)) { - $id = new Id($id); - } - - return self::select() - ->with(...$relations) - ->get($id); + return self::resolve($id); } - public static function find(mixed ...$conditions): SelectQueryBuilder + /** + * Finds a model instance by its ID. + */ + public static function resolve(string|int|Id $id): static { - $query = self::select(); + return model(self::class)->resolve($id); + } - array_walk($conditions, fn ($value, $column) => $query->where($column, $value)); + /** + * Gets a model instance by its ID, optionally loading the given relationships. + */ + public static function get(string|int|Id $id, array $relations = []): ?self + { + return model(self::class)->get($id, $relations); + } - return $query; + /** + * Gets all records from the model's table. + * + * @return self[] + */ + public static function all(array $relations = []): array + { + return model(self::class)->all($relations); } - public static function count(): CountQueryBuilder + /** + * Finds records based on their columns. + * + * **Example** + * ```php + * MagicUser::find(name: 'Frieren'); + * ``` + * + * @return SelectQueryBuilder + */ + public static function find(mixed ...$conditions): SelectQueryBuilder { - return query(self::class)->count(); + return model(self::class)->find(...$conditions); } + /** + * Creates a new model instance and persists it to the database. + * + * **Example** + * ```php + * MagicUser::create(name: 'Frieren', kind: Kind::ELF); + * ``` + * + * @return self + */ public static function create(mixed ...$params): self { - inspect(self::class)->validate(...$params); - - $model = self::new(...$params); - - $id = query(self::class) - ->insert($model) - ->build() - ->execute(); - - if ($id !== null) { - $model->id = new Id($id); - } - - return $model; + return model(self::class)->create(...$params); } + /** + * Finds an existing model instance or creates a new one if it doesn't exist, without persisting it to the database. + * + * **Example** + * ```php + * $model = MagicUser::findOrNew( + * find: ['name' => 'Frieren'], + * update: ['kind' => Kind::ELF], + * ); + * ``` + * + * @param array $find Properties to search for in the existing model. + * @param array $update Properties to update or set on the model if it is found or created. + * @return self + */ public static function findOrNew(array $find, array $update): self { - $existing = self::select()->bind(...$find); - - foreach ($find as $key => $value) { - $existing = $existing->whereRaw("{$key} = :{$key}"); - } - - $model = $existing->first() ?? self::new(...$find); - - foreach ($update as $key => $value) { - $model->{$key} = $value; - } - - return $model; + return model(self::class)->findOrNew($find, $update); } + /** + * Finds an existing model instance or creates a new one if it doesn't exist, and persists it to the database. + * + * **Example** + * ```php + * $model = MagicUser::findOrNew( + * find: ['name' => 'Frieren'], + * update: ['kind' => Kind::ELF], + * ); + * ``` + * + * @param array $find Properties to search for in the existing model. + * @param array $update Properties to update or set on the model if it is found or created. + * @return TModel + */ public static function updateOrCreate(array $find, array $update): self { - $model = self::findOrNew($find, $update); - - if (! isset($model->id)) { - return self::create(...$update); - } - - return $model->save(); + return model(self::class)->updateOrCreate($find, $update); } + /** + * Refreshes the model instance with the latest data from the database. + */ public function refresh(): self { $model = self::find(id: $this->id)->first(); + foreach (new ClassReflector($model)->getPublicProperties() as $property) { $property->setValue($this, $property->getValue($model)); } @@ -126,29 +176,9 @@ public function refresh(): self return $this; } - public function __get(string $name): mixed - { - $property = PropertyReflector::fromParts($this, $name); - - if ($property->hasAttribute(Lazy::class)) { - $this->load($name); - - return $property->getValue($this); - } - - $type = $property->getType(); - - if ($type->isRelation()) { - throw new RelationWasMissing($this, $name); - } - - if ($type->isBuiltIn()) { - throw new ValueWasMissing($this, $name); - } - - throw new RelationWasMissing($this, $name); - } - + /** + * Loads the specified relations on the model instance. + */ public function load(string ...$relations): self { $new = self::get($this->id, $relations); @@ -160,25 +190,30 @@ public function load(string ...$relations): self return $this; } + /** + * Saves the model to the database. + */ public function save(): self { $model = inspect($this); - $model->validate(...inspect($this)->getPropertyValues()); if (! isset($this->id)) { - $query = query($this::class)->insert($this); - - $this->id = $query->execute(); + $this->id = query($this::class) + ->insert($this) + ->execute(); } else { - query($this)->update( - ...inspect($this)->getPropertyValues(), - )->execute(); + query($this) + ->update(...inspect($this)->getPropertyValues()) + ->execute(); } return $this; } + /** + * Updates the specified columns and persist the model to the database. + */ public function update(mixed ...$params): self { inspect(self::class)->validate(...$params); @@ -195,6 +230,9 @@ public function update(mixed ...$params): self return $this; } + /** + * Deletes this model from the database. + */ public function delete(): void { query($this) @@ -202,4 +240,27 @@ public function delete(): void ->build() ->execute(); } + + public function __get(string $name): mixed + { + $property = PropertyReflector::fromParts($this, $name); + + if ($property->hasAttribute(Lazy::class)) { + $this->load($name); + + return $property->getValue($this); + } + + $type = $property->getType(); + + if ($type->isRelation()) { + throw new RelationWasMissing($this, $name); + } + + if ($type->isBuiltIn()) { + throw new ValueWasMissing($this, $name); + } + + throw new RelationWasMissing($this, $name); + } } diff --git a/packages/database/src/functions.php b/packages/database/src/functions.php index c2aeccbf5..9e316b8fe 100644 --- a/packages/database/src/functions.php +++ b/packages/database/src/functions.php @@ -2,6 +2,7 @@ namespace Tempest\Database { use Tempest\Database\Builder\ModelInspector; + use Tempest\Database\Builder\ModelQueryBuilder; use Tempest\Database\Builder\QueryBuilders\QueryBuilder; /** @@ -23,4 +24,16 @@ function inspect(string|object $model): ModelInspector { return new ModelInspector($model); } + + /** + * Provides model-related convenient query methods. + * + * @template TModel of object + * @param class-string $modelClass + * @return ModelQueryBuilder + */ + function model(string $modelClass): ModelQueryBuilder + { + return new ModelQueryBuilder($modelClass); + } } diff --git a/tests/Integration/Database/Builder/ModelQueryBuilderTest.php b/tests/Integration/Database/Builder/ModelQueryBuilderTest.php new file mode 100644 index 000000000..99fdae60a --- /dev/null +++ b/tests/Integration/Database/Builder/ModelQueryBuilderTest.php @@ -0,0 +1,386 @@ +migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); + + $builderWithId = model(TestUserModel::class)->select(); + $builderWithoutId = model(TestUserModelWithoutId::class)->select(); + + $this->assertInstanceOf(SelectQueryBuilder::class, $builderWithId); + $this->assertInstanceOf(SelectQueryBuilder::class, $builderWithoutId); + } + + public function test_insert(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); + + $builderWithId = model(TestUserModel::class)->insert(); + $builderWithoutId = model(TestUserModelWithoutId::class)->insert(); + + $this->assertInstanceOf(InsertQueryBuilder::class, $builderWithId); + $this->assertInstanceOf(InsertQueryBuilder::class, $builderWithoutId); + } + + public function test_update(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); + + $builderWithId = model(TestUserModel::class)->update(); + $builderWithoutId = model(TestUserModelWithoutId::class)->update(); + + $this->assertInstanceOf(UpdateQueryBuilder::class, $builderWithId); + $this->assertInstanceOf(UpdateQueryBuilder::class, $builderWithoutId); + } + + public function test_delete(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); + + $builderWithId = model(TestUserModel::class)->delete(); + $builderWithoutId = model(TestUserModelWithoutId::class)->delete(); + + $this->assertInstanceOf(DeleteQueryBuilder::class, $builderWithId); + $this->assertInstanceOf(DeleteQueryBuilder::class, $builderWithoutId); + } + + public function test_count(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); + + $builderWithId = model(TestUserModel::class)->count(); + $builderWithoutId = model(TestUserModelWithoutId::class)->count(); + + $this->assertInstanceOf(CountQueryBuilder::class, $builderWithId); + $this->assertInstanceOf(CountQueryBuilder::class, $builderWithoutId); + } + + public function test_new(): void + { + $modelWithId = model(TestUserModel::class)->new(name: 'Frieren'); + $modelWithoutId = model(TestUserModelWithoutId::class)->new(name: 'Fern'); + + $this->assertInstanceOf(TestUserModel::class, $modelWithId); + $this->assertSame('Frieren', $modelWithId->name); + + $this->assertInstanceOf(TestUserModelWithoutId::class, $modelWithoutId); + $this->assertSame('Fern', $modelWithoutId->name); + } + + public function test_resolve_with_id_model(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); + + $created = model(TestUserModel::class)->create(name: 'Stark'); + $resolved = model(TestUserModel::class)->resolve($created->id); + + $this->assertInstanceOf(TestUserModel::class, $resolved); + $this->assertSame('Stark', $resolved->name); + $this->assertTrue($created->id->equals($resolved->id)); + } + + public function test_resolve_throws_for_model_without_id(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWithoutIdMigration::class); + + $this->expectException(ModelDidNotHavePrimaryColumn::class); + $this->expectExceptionMessage( + "`Tests\Tempest\Integration\Database\Builder\TestUserModelWithoutId` does not have a primary column defined, which is required for the `resolve` method.", + ); + + model(TestUserModelWithoutId::class)->resolve(1); + } + + public function test_get_with_id_model(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); + + $created = model(TestUserModel::class)->create(name: 'Himmel'); + $retrieved = model(TestUserModel::class)->get($created->id); + + $this->assertNotNull($retrieved); + $this->assertSame('Himmel', $retrieved->name); + $this->assertTrue($created->id->equals($retrieved->id)); + } + + public function test_get_throws_for_model_without_id(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWithoutIdMigration::class); + + $this->expectException(ModelDidNotHavePrimaryColumn::class); + $this->expectExceptionMessage( + "`Tests\Tempest\Integration\Database\Builder\TestUserModelWithoutId` does not have a primary column defined, which is required for the `get` method.", + ); + + model(TestUserModelWithoutId::class)->get(1); + } + + public function test_all(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); + + model(TestUserModel::class)->create(name: 'Fern'); + model(TestUserModel::class)->create(name: 'Stark'); + + model(TestUserModelWithoutId::class)->create(name: 'Eisen'); + model(TestUserModelWithoutId::class)->create(name: 'Heiter'); + + $allWithId = model(TestUserModel::class)->all(); + $allWithoutId = model(TestUserModelWithoutId::class)->all(); + + $this->assertCount(2, $allWithId); + $this->assertInstanceOf(TestUserModel::class, $allWithId[0]); + $this->assertInstanceOf(TestUserModel::class, $allWithId[1]); + + $this->assertCount(2, $allWithoutId); + $this->assertInstanceOf(TestUserModelWithoutId::class, $allWithoutId[0]); + $this->assertInstanceOf(TestUserModelWithoutId::class, $allWithoutId[1]); + } + + public function test_find(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); + + model(TestUserModel::class)->create(name: 'Frieren'); + model(TestUserModel::class)->create(name: 'Fern'); + + model(TestUserModelWithoutId::class)->create(name: 'Ubel'); + model(TestUserModelWithoutId::class)->create(name: 'Land'); + + $builderWithId = model(TestUserModel::class)->find(name: 'Frieren'); + $builderWithoutId = model(TestUserModelWithoutId::class)->find(name: 'Ubel'); + + $this->assertInstanceOf(SelectQueryBuilder::class, $builderWithId); + $this->assertInstanceOf(SelectQueryBuilder::class, $builderWithoutId); + + $resultWithId = $builderWithId->first(); + $resultWithoutId = $builderWithoutId->first(); + + $this->assertNotNull($resultWithId); + $this->assertSame('Frieren', $resultWithId->name); + + $this->assertNotNull($resultWithoutId); + $this->assertSame('Ubel', $resultWithoutId->name); + } + + public function test_create(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); + + $createdWithId = model(TestUserModel::class)->create(name: 'Ubel'); + $createdWithoutId = model(TestUserModelWithoutId::class)->create(name: 'Serie'); + + $this->assertInstanceOf(TestUserModel::class, $createdWithId); + $this->assertInstanceOf(Id::class, $createdWithId->id); + $this->assertSame('Ubel', $createdWithId->name); + + $this->assertInstanceOf(TestUserModelWithoutId::class, $createdWithoutId); + $this->assertSame('Serie', $createdWithoutId->name); + } + + public function test_find_or_new_finds_existing(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); + + $existingWithId = model(TestUserModel::class)->create(name: 'Serie'); + $existingWithoutId = model(TestUserModelWithoutId::class)->create(name: 'Macht'); + + $resultWithId = model(TestUserModel::class)->findOrNew( + find: ['name' => 'Serie'], + update: ['name' => 'Updated Serie'], + ); + + $resultWithoutId = model(TestUserModelWithoutId::class)->findOrNew( + find: ['name' => 'Macht'], + update: ['name' => 'Updated Macht'], + ); + + $this->assertInstanceOf(TestUserModel::class, $resultWithId); + $this->assertTrue($existingWithId->id->equals($resultWithId->id)); + $this->assertSame('Updated Serie', $resultWithId->name); + + $this->assertInstanceOf(TestUserModelWithoutId::class, $resultWithoutId); + $this->assertSame('Updated Macht', $resultWithoutId->name); + } + + public function test_find_or_new_creates_new(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); + + $resultWithId = model(TestUserModel::class)->findOrNew( + find: ['name' => 'NonExistent'], + update: ['name' => 'Updated Name'], + ); + + $resultWithoutId = model(TestUserModelWithoutId::class)->findOrNew( + find: ['name' => 'NonExistent'], + update: ['name' => 'Updated Name'], + ); + + $this->assertInstanceOf(TestUserModel::class, $resultWithId); + $this->assertFalse(isset($resultWithId->id)); + $this->assertSame('Updated Name', $resultWithId->name); + + $this->assertInstanceOf(TestUserModelWithoutId::class, $resultWithoutId); + $this->assertSame('Updated Name', $resultWithoutId->name); + } + + public function test_update_or_create_updates_existing(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); + + $existingWithId = model(TestUserModel::class)->create(name: 'Qual'); + + $resultWithId = model(TestUserModel::class)->updateOrCreate( + find: ['name' => 'Qual'], + update: ['name' => 'Updated Qual'], + ); + + $this->assertInstanceOf(TestUserModel::class, $resultWithId); + $this->assertTrue($existingWithId->id->equals($resultWithId->id)); + $this->assertSame('Updated Qual', $resultWithId->name); + } + + public function test_update_or_create_creates_new(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); + + $resultWithId = model(TestUserModel::class)->updateOrCreate( + find: ['name' => 'NonExistent'], + update: ['name' => 'Aura'], + ); + + $this->assertInstanceOf(TestUserModel::class, $resultWithId); + $this->assertInstanceOf(Id::class, $resultWithId->id); + $this->assertSame('Aura', $resultWithId->name); + } + + public function test_get_with_string_id(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); + + $created = model(TestUserModel::class)->create(name: 'Heiter'); + $retrieved = model(TestUserModel::class)->get((string) $created->id->id); + + $this->assertNotNull($retrieved); + $this->assertSame('Heiter', $retrieved->name); + $this->assertTrue($created->id->equals($retrieved->id)); + } + + public function test_get_with_int_id(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); + + $created = model(TestUserModel::class)->create(name: 'Eisen'); + $retrieved = model(TestUserModel::class)->get($created->id->id); + + $this->assertNotNull($retrieved); + $this->assertSame('Eisen', $retrieved->name); + $this->assertTrue($created->id->equals($retrieved->id)); + } + + public function test_get_returns_null_for_non_existent_id(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); + + $result = model(TestUserModel::class)->get(new Id(999)); + + $this->assertNull($result); + } + + public function test_find_by_id_throws_for_model_without_id(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWithoutIdMigration::class); + + $this->expectException(ModelDidNotHavePrimaryColumn::class); + $this->expectExceptionMessage( + "`Tests\Tempest\Integration\Database\Builder\TestUserModelWithoutId` does not have a primary column defined, which is required for the `findById` method.", + ); + + model(TestUserModelWithoutId::class)->findById(1); + } + + public function test_update_or_create_throws_for_model_without_id(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWithoutIdMigration::class); + + $this->expectException(ModelDidNotHavePrimaryColumn::class); + $this->expectExceptionMessage( + "`Tests\Tempest\Integration\Database\Builder\TestUserModelWithoutId` does not have a primary column defined, which is required for the `updateOrCreate` method.", + ); + + model(TestUserModelWithoutId::class)->updateOrCreate( + find: ['name' => 'Denken'], + update: ['name' => 'Updated Denken'], + ); + } +} + +final class TestUserModel +{ + public ?Id $id = null; + + public function __construct( + public string $name, + ) {} +} + +final class TestUserModelWithoutId +{ + public function __construct( + public string $name, + ) {} +} + +use Tempest\Database\DatabaseMigration; +use Tempest\Database\QueryStatement; +use Tempest\Database\QueryStatements\CreateTableStatement; + +final class TestModelWrapperMigration implements DatabaseMigration +{ + public string $name = '000_test_model_wrapper'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(TestUserModel::class) + ->primary() + ->text('name'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class TestModelWithoutIdMigration implements DatabaseMigration +{ + public string $name = '001_test_model_without_id'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(TestUserModelWithoutId::class) + ->text('name'); + } + + public function down(): ?QueryStatement + { + return null; + } +} From 605c74f6eb5ee9b545499bf311a42b09bf86666b Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 4 Aug 2025 03:54:06 +0200 Subject: [PATCH 09/51] refactor(database): support custom primary keys and enforce their presence when needed --- packages/database/src/BelongsTo.php | 20 ++- .../database/src/Builder/ModelInspector.php | 46 ++++++- .../src/Builder/ModelQueryBuilder.php | 31 +++-- .../QueryBuilders/DeleteQueryBuilder.php | 11 +- .../QueryBuilders/SelectQueryBuilder.php | 7 +- .../QueryBuilders/UpdateQueryBuilder.php | 16 ++- .../ModelDidNotHavePrimaryColumn.php | 9 ++ .../ModelHadMultiplePrimaryColumns.php | 23 ++++ packages/database/src/HasMany.php | 33 ++++- packages/database/src/HasOne.php | 17 ++- .../src/Mappers/SelectModelMapper.php | 5 +- .../Database/Builder/CustomPrimaryKeyTest.php | 130 ++++++++++++++++++ .../Builder/ModelQueryBuilderTest.php | 57 ++++++++ .../Database/ModelInspector/BelongsToTest.php | 37 ++++- .../Database/ModelInspector/HasManyTest.php | 38 ++++- .../Database/ModelInspector/HasOneTest.php | 38 ++++- 16 files changed, 469 insertions(+), 49 deletions(-) create mode 100644 packages/database/src/Exceptions/ModelHadMultiplePrimaryColumns.php create mode 100644 tests/Integration/Database/Builder/CustomPrimaryKeyTest.php diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index d307248c6..fcd2c03ba 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -6,6 +6,7 @@ use Attribute; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Reflection\PropertyReflector; @@ -47,8 +48,13 @@ public function getOwnerFieldName(): string } $relationModel = inspect($this->property->getType()->asClass()); + $primaryKey = $relationModel->getPrimaryKey(); - return str($relationModel->getTableName())->singularizeLastWord() . '_' . $relationModel->getPrimaryKey(); + if ($primaryKey === null) { + throw ModelDidNotHavePrimaryColumn::neededForRelation($relationModel->getName(), 'BelongsTo'); + } + + return str($relationModel->getTableName())->singularizeLastWord() . '_' . $primaryKey; } public function getSelectFields(): ImmutableArray @@ -97,11 +103,13 @@ private function getRelationJoin(ModelInspector $relationModel): string return $relationJoin; } - return sprintf( - '%s.%s', - $relationModel->getTableName(), - $relationModel->getPrimaryKey(), - ); + $primaryKey = $relationModel->getPrimaryKey(); + + if ($primaryKey === null) { + throw ModelDidNotHavePrimaryColumn::neededForRelation($relationModel->getName(), 'BelongsTo'); + } + + return sprintf('%s.%s', $relationModel->getTableName(), $primaryKey); } private function getOwnerJoin(ModelInspector $ownerModel): string diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index fa9257362..86acbdf67 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -6,6 +6,8 @@ use Tempest\Database\BelongsTo; use Tempest\Database\Config\DatabaseConfig; use Tempest\Database\Eager; +use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; +use Tempest\Database\Exceptions\ModelHadMultiplePrimaryColumns; use Tempest\Database\HasMany; use Tempest\Database\HasOne; use Tempest\Database\Id; @@ -381,14 +383,42 @@ public function getName(): string return $this->instance; } - public function getPrimaryFieldName(): string + public function getQualifiedPrimaryKey(): ?string { - return $this->getTableDefinition()->name . '.' . $this->getPrimaryKey(); + $primaryKey = $this->getPrimaryKey(); + + return $primaryKey !== null + ? ($this->getTableDefinition()->name . '.' . $primaryKey) + : null; + } + + public function getPrimaryKey(): ?string + { + return $this->getPrimaryKeyProperty()?->getName(); } - public function getPrimaryKey(): string + public function hasPrimaryKey(): bool { - return 'id'; + return $this->getPrimaryKeyProperty() !== null; + } + + public function getPrimaryKeyProperty(): ?PropertyReflector + { + if (! $this->isObjectModel()) { + return null; + } + + $idProperties = arr($this->reflector->getProperties()) + ->filter(fn (PropertyReflector $property) => $property->getType()->getName() === Id::class); + + return match ($idProperties->count()) { + 0 => null, + 1 => $idProperties->first(), + default => throw ModelHadMultiplePrimaryColumns::found( + model: $this->model, + properties: $idProperties->map(fn (PropertyReflector $property) => $property->getName())->toArray(), + ), + }; } public function getPrimaryKeyValue(): ?Id @@ -401,6 +431,12 @@ public function getPrimaryKeyValue(): ?Id return null; } - return $this->instance->{$this->getPrimaryKey()}; + $primaryKey = $this->getPrimaryKey(); + + if ($primaryKey === null) { + return null; + } + + return $this->instance->{$primaryKey}; } } diff --git a/packages/database/src/Builder/ModelQueryBuilder.php b/packages/database/src/Builder/ModelQueryBuilder.php index 012c81439..4ddf368c5 100644 --- a/packages/database/src/Builder/ModelQueryBuilder.php +++ b/packages/database/src/Builder/ModelQueryBuilder.php @@ -93,7 +93,7 @@ public function new(mixed ...$params): object */ public function findById(string|int|Id $id): object { - if (! $this->supportsIds()) { + if (! inspect($this->model)->hasPrimaryKey()) { throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'findById'); } @@ -107,7 +107,7 @@ public function findById(string|int|Id $id): object */ public function resolve(string|int|Id $id): object { - if (! $this->supportsIds()) { + if (! inspect($this->model)->hasPrimaryKey()) { throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'resolve'); } @@ -121,7 +121,7 @@ public function resolve(string|int|Id $id): object */ public function get(string|int|Id $id, array $relations = []): ?object { - if (! $this->supportsIds()) { + if (! inspect($this->model)->hasPrimaryKey()) { throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'get'); } @@ -189,8 +189,12 @@ public function create(mixed ...$params): object ->build() ->execute(); - if ($id !== null && property_exists($model, 'id')) { - $model->id = new Id($id); + $inspector = inspect($this->model); + $primaryKeyProperty = $inspector->getPrimaryKeyProperty(); + + if ($id !== null && $primaryKeyProperty !== null) { + $primaryKeyName = $primaryKeyProperty->getName(); + $model->{$primaryKeyName} = new Id($id); } return $model; @@ -245,13 +249,18 @@ public function findOrNew(array $find, array $update): object */ public function updateOrCreate(array $find, array $update): object { - if (! $this->supportsIds()) { + $inspector = inspect($this->model); + + if (! $inspector->hasPrimaryKey()) { throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'updateOrCreate'); } $model = $this->findOrNew($find, $update); - if (! isset($model->id)) { + $primaryKeyProperty = $inspector->getPrimaryKeyProperty(); + $primaryKeyName = $primaryKeyProperty->getName(); + + if (! isset($model->{$primaryKeyName})) { return $this->create(...$update); } @@ -265,12 +274,4 @@ public function updateOrCreate(array $find, array $update): object return $model; } - - /** - * Checks if the model supports ID-based operations. - */ - private function supportsIds(): bool - { - return property_exists($this->model, 'id'); - } } diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index 2944dec2e..aedb9b57c 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -69,11 +69,12 @@ public function toRawSql(): ImmutableString public function build(mixed ...$bindings): Query { - if ($this->model->isObjectModel() && is_object($this->model->instance)) { - $this->where( - $this->model->getPrimaryKey(), - $this->model->getPrimaryKeyValue()->id, - ); + if ($this->model->isObjectModel() && is_object($this->model->instance) && $this->model->hasPrimaryKey()) { + $primaryKeyValue = $this->model->getPrimaryKeyValue(); + + if ($primaryKeyValue !== null) { + $this->where($this->model->getPrimaryKey(), $primaryKeyValue->id); + } } return new Query($this->delete, [...$this->bindings, ...$bindings])->onDatabase($this->onDatabase); diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 8363b9675..9b1af24a0 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -6,6 +6,7 @@ use Closure; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\Id; use Tempest\Database\Mappers\SelectModelMapper; use Tempest\Database\OnDatabase; @@ -101,7 +102,11 @@ public function paginate(int $itemsPerPage = 20, int $currentPage = 1, int $maxL /** @return T|null */ public function get(Id $id): mixed { - return $this->where('id', $id)->first(); + if (! $this->model->hasPrimaryKey()) { + throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model->getName(), 'get'); + } + + return $this->where($this->model->getPrimaryKey(), $id)->first(); } /** @return T[] */ diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index cc8ecffdb..833d77a59 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -83,15 +83,19 @@ public function build(mixed ...$bindings): Query { $values = $this->resolveValues(); - unset($values['id']); + if ($this->model->hasPrimaryKey()) { + $primaryKey = $this->model->getPrimaryKey(); + unset($values[$primaryKey]); + } $this->update->values = $values; - if ($this->model->isObjectModel() && is_object($this->model->instance)) { - $this->where( - $this->model->getPrimaryKey(), - $this->model->getPrimaryKeyValue()->id, - ); + if ($this->model->isObjectModel() && is_object($this->model->instance) && $this->model->hasPrimaryKey()) { + $primaryKeyValue = $this->model->getPrimaryKeyValue(); + + if ($primaryKeyValue !== null) { + $this->where($this->model->getPrimaryKey(), $primaryKeyValue->id); + } } foreach ($values as $value) { diff --git a/packages/database/src/Exceptions/ModelDidNotHavePrimaryColumn.php b/packages/database/src/Exceptions/ModelDidNotHavePrimaryColumn.php index 51d07ef37..f2e8d3ce2 100644 --- a/packages/database/src/Exceptions/ModelDidNotHavePrimaryColumn.php +++ b/packages/database/src/Exceptions/ModelDidNotHavePrimaryColumn.php @@ -16,4 +16,13 @@ public static function neededForMethod(string|object $model, string $method): se return new self("`{$model}` does not have a primary column defined, which is required for the `{$method}` method."); } + + public static function neededForRelation(string|object $model, string $relationType): self + { + if (is_object($model)) { + $model = get_class($model); + } + + return new self("`{$model}` does not have a primary column defined, which is required for `{$relationType}` relationships."); + } } diff --git a/packages/database/src/Exceptions/ModelHadMultiplePrimaryColumns.php b/packages/database/src/Exceptions/ModelHadMultiplePrimaryColumns.php new file mode 100644 index 000000000..3d07c446c --- /dev/null +++ b/packages/database/src/Exceptions/ModelHadMultiplePrimaryColumns.php @@ -0,0 +1,23 @@ +join(); + + return new self("`{$model}` has multiple `Id` properties ({$propertyNames}). Only one `Id` property is allowed per model."); + } +} diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index c796157e2..3defcdb68 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -6,6 +6,7 @@ use Attribute; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Reflection\PropertyReflector; @@ -53,17 +54,29 @@ public function getSelectFields(): ImmutableArray public function primaryKey(): string { - return inspect($this->property->getIterableType()->asClass())->getPrimaryKey(); + $relationModel = inspect($this->property->getIterableType()->asClass()); + $primaryKey = $relationModel->getPrimaryKey(); + + if ($primaryKey === null) { + throw ModelDidNotHavePrimaryColumn::neededForRelation($relationModel->getName(), 'HasMany'); + } + + return $primaryKey; } public function idField(): string { $relationModel = inspect($this->property->getIterableType()->asClass()); + $primaryKey = $relationModel->getPrimaryKey(); + + if ($primaryKey === null) { + throw ModelDidNotHavePrimaryColumn::neededForRelation($relationModel->getName(), 'HasMany'); + } return sprintf( '%s.%s', $this->property->getName(), - $relationModel->getPrimaryKey(), + $primaryKey, ); } @@ -99,10 +112,16 @@ private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relati return $ownerJoin; } + $primaryKey = $relationModel->getPrimaryKey(); + + if ($primaryKey === null) { + throw ModelDidNotHavePrimaryColumn::neededForRelation($relationModel->getName(), 'HasMany'); + } + return sprintf( '%s.%s', $ownerModel->getTableName(), - str($relationModel->getTableName())->singularizeLastWord() . '_' . $relationModel->getPrimaryKey(), + str($relationModel->getTableName())->singularizeLastWord() . '_' . $primaryKey, ); } @@ -122,10 +141,16 @@ private function getRelationJoin(ModelInspector $relationModel): string return $relationJoin; } + $primaryKey = $relationModel->getPrimaryKey(); + + if ($primaryKey === null) { + throw ModelDidNotHavePrimaryColumn::neededForRelation($relationModel->getName(), 'HasMany'); + } + return sprintf( '%s.%s', $relationModel->getTableName(), - $relationModel->getPrimaryKey(), + $primaryKey, ); } } diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index 1f1dfadb5..7b7677745 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -6,6 +6,7 @@ use Attribute; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Reflection\PropertyReflector; @@ -83,10 +84,16 @@ private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relati return $ownerJoin; } + $primaryKey = $relationModel->getPrimaryKey(); + + if ($primaryKey === null) { + throw ModelDidNotHavePrimaryColumn::neededForRelation($relationModel->getName(), 'HasOne'); + } + return sprintf( '%s.%s', $ownerModel->getTableName(), - str($relationModel->getTableName())->singularizeLastWord() . '_' . $relationModel->getPrimaryKey(), + str($relationModel->getTableName())->singularizeLastWord() . '_' . $primaryKey, ); } @@ -106,10 +113,16 @@ private function getRelationJoin(ModelInspector $relationModel): string return $relationJoin; } + $primaryKey = $relationModel->getPrimaryKey(); + + if ($primaryKey === null) { + throw ModelDidNotHavePrimaryColumn::neededForRelation($relationModel->getName(), 'HasOne'); + } + return sprintf( '%s.%s', $relationModel->getTableName(), - $relationModel->getPrimaryKey(), + $primaryKey, ); } } diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index 265f351da..0a09ee9cf 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -5,6 +5,7 @@ use Exception; use Tempest\Database\BelongsTo; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\HasMany; use Tempest\Database\HasOne; use Tempest\Discovery\SkipDiscovery; @@ -27,10 +28,10 @@ public function map(mixed $from, mixed $to): array { $model = inspect($to); - $idField = $model->getPrimaryFieldName(); + $idField = $model->getQualifiedPrimaryKey(); $parsed = arr($from) - ->groupBy(fn (array $data, int $i) => $data[$idField] ?? $i) + ->groupBy(fn (array $data, int $i) => $idField !== null ? ($data[$idField] ?? $i) : $i) ->map(fn (array $rows) => $this->normalizeFields($model, $rows)) ->values(); diff --git a/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php b/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php new file mode 100644 index 000000000..4bd5f16a0 --- /dev/null +++ b/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php @@ -0,0 +1,130 @@ +migrate(CreateMigrationsTable::class, CreateFrierenModelMigration::class); + + $frieren = model(FrierenModel::class)->create(name: 'Frieren', magic: 'Time Magic'); + + $this->assertInstanceOf(FrierenModel::class, $frieren); + $this->assertInstanceOf(Id::class, $frieren->uuid); + $this->assertSame('Frieren', $frieren->name); + $this->assertSame('Time Magic', $frieren->magic); + + $retrieved = model(FrierenModel::class)->get($frieren->uuid); + $this->assertNotNull($retrieved); + $this->assertSame('Frieren', $retrieved->name); + $this->assertTrue($frieren->uuid->equals($retrieved->uuid)); + } + + public function test_update_or_create_with_custom_primary_key(): void + { + $this->migrate(CreateMigrationsTable::class, CreateFrierenModelMigration::class); + + $frieren = model(FrierenModel::class)->create(name: 'Frieren', magic: 'Time Magic'); + + $updated = model(FrierenModel::class)->updateOrCreate( + find: ['name' => 'Frieren'], + update: ['magic' => 'Advanced Time Magic'], + ); + + $this->assertTrue($frieren->uuid->equals($updated->uuid)); + $this->assertSame('Advanced Time Magic', $updated->magic); + } + + public function test_model_with_multiple_id_properties_throws_exception(): void + { + $this->expectException(ModelHadMultiplePrimaryColumns::class); + $this->expectExceptionMessage( + '`Tests\Tempest\Integration\Database\Builder\ModelWithMultipleIds` has multiple `Id` properties (uuid and external_id). Only one `Id` property is allowed per model.', + ); + + inspect(ModelWithMultipleIds::class)->getPrimaryKey(); + } + + public function test_model_without_id_property_still_works(): void + { + $this->migrate(CreateMigrationsTable::class, CreateModelWithoutIdMigration::class); + + $model = model(ModelWithoutId::class)->new(name: 'Test'); + $this->assertInstanceOf(ModelWithoutId::class, $model); + $this->assertSame('Test', $model->name); + } +} + +final class FrierenModel +{ + public ?Id $uuid = null; + + public function __construct( + public string $name, + public string $magic, + ) {} +} + +final class ModelWithMultipleIds +{ + public ?Id $uuid = null; + + public ?Id $external_id = null; + + public function __construct( + public string $name = 'test', + ) {} +} + +final class ModelWithoutId +{ + public function __construct( + public string $name, + ) {} +} + +final class CreateFrierenModelMigration implements DatabaseMigration +{ + public string $name = '001_create_frieren_model'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(FrierenModel::class) + ->primary(name: 'uuid') + ->text('name') + ->text('magic'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateModelWithoutIdMigration implements DatabaseMigration +{ + public string $name = '002_create_model_without_id'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(ModelWithoutId::class) + ->text('name'); + } + + public function down(): ?QueryStatement + { + return null; + } +} diff --git a/tests/Integration/Database/Builder/ModelQueryBuilderTest.php b/tests/Integration/Database/Builder/ModelQueryBuilderTest.php index 99fdae60a..250f6dae8 100644 --- a/tests/Integration/Database/Builder/ModelQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/ModelQueryBuilderTest.php @@ -330,6 +330,37 @@ public function test_update_or_create_throws_for_model_without_id(): void update: ['name' => 'Updated Denken'], ); } + + public function test_custom_primary_key_name(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWithCustomPrimaryKeyMigration::class); + + $created = model(TestUserModelWithCustomPrimaryKey::class)->create(name: 'Fern'); + + $this->assertInstanceOf(TestUserModelWithCustomPrimaryKey::class, $created); + $this->assertInstanceOf(Id::class, $created->uuid); + $this->assertSame('Fern', $created->name); + + $retrieved = model(TestUserModelWithCustomPrimaryKey::class)->get($created->uuid); + $this->assertNotNull($retrieved); + $this->assertSame('Fern', $retrieved->name); + $this->assertTrue($created->uuid->equals($retrieved->uuid)); + } + + public function test_custom_primary_key_update_or_create(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWithCustomPrimaryKeyMigration::class); + + $original = model(TestUserModelWithCustomPrimaryKey::class)->create(name: 'Stark'); + + $updated = model(TestUserModelWithCustomPrimaryKey::class)->updateOrCreate( + find: ['name' => 'Stark'], + update: ['name' => 'Stark the Strong'], + ); + + $this->assertTrue($original->uuid->equals($updated->uuid)); + $this->assertSame('Stark the Strong', $updated->name); + } } final class TestUserModel @@ -384,3 +415,29 @@ public function down(): ?QueryStatement return null; } } + +final class TestUserModelWithCustomPrimaryKey +{ + public ?Id $uuid = null; + + public function __construct( + public string $name, + ) {} +} + +final class TestModelWithCustomPrimaryKeyMigration implements DatabaseMigration +{ + public string $name = '002_test_model_with_custom_primary_key'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(TestUserModelWithCustomPrimaryKey::class) + ->primary(name: 'uuid') + ->text('name'); + } + + public function down(): ?QueryStatement + { + return null; + } +} diff --git a/tests/Integration/Database/ModelInspector/BelongsToTest.php b/tests/Integration/Database/ModelInspector/BelongsToTest.php index ffcb13e2f..9733f092c 100644 --- a/tests/Integration/Database/ModelInspector/BelongsToTest.php +++ b/tests/Integration/Database/ModelInspector/BelongsToTest.php @@ -4,7 +4,9 @@ use Tempest\Database\BelongsTo; use Tempest\Database\Config\DatabaseDialect; +use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\HasMany; +use Tempest\Database\Id; use Tempest\Database\Table; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -84,14 +86,29 @@ public function test_belongs_to_with_parent(): void $this->assertSame( 'relation.name AS `parent.relation.name`', - $relation->getSelectFields()[0]->compile(DatabaseDialect::SQLITE), + $relation->getSelectFields()[1]->compile(DatabaseDialect::SQLITE), ); } + + public function test_belongs_to_throws_exception_for_model_without_primary_key(): void + { + $model = inspect(BelongsToTestOwnerWithoutIdModel::class); + $relation = $model->getRelation('relation'); + + $this->expectException(ModelDidNotHavePrimaryColumn::class); + $this->expectExceptionMessage( + "`Tests\Tempest\Integration\Database\ModelInspector\BelongsToTestRelationWithoutIdModel` does not have a primary column defined, which is required for `BelongsTo` relationships.", + ); + + $relation->getJoinStatement(); + } } #[Table('relation')] final class BelongsToTestRelationModel { + public Id $id; + /** @var \Tests\Tempest\Integration\Database\ModelInspector\BelongsToTestOwnerModel[] */ public array $owners = []; @@ -117,6 +134,8 @@ final class BelongsToTestRelationModel #[Table('owner')] final class BelongsToTestOwnerModel { + public Id $id; + public BelongsToTestRelationModel $relation; #[BelongsTo(relationJoin: 'overwritten_id')] @@ -132,4 +151,20 @@ final class BelongsToTestOwnerModel public BelongsToTestRelationModel $ownerJoinFieldAndTable; public string $name; + + public BelongsToTestRelationModel $relationNoPrimaryKey; +} + +#[Table('relation_no_primary_key')] +final class BelongsToTestRelationWithoutIdModel +{ + public string $name; +} + +#[Table('owner_no_primary_key')] +final class BelongsToTestOwnerWithoutIdModel +{ + public BelongsToTestRelationWithoutIdModel $relation; + + public string $name; } diff --git a/tests/Integration/Database/ModelInspector/HasManyTest.php b/tests/Integration/Database/ModelInspector/HasManyTest.php index 80f2fbf02..d53eccc7d 100644 --- a/tests/Integration/Database/ModelInspector/HasManyTest.php +++ b/tests/Integration/Database/ModelInspector/HasManyTest.php @@ -4,7 +4,9 @@ use Tempest\Database\BelongsTo; use Tempest\Database\Config\DatabaseDialect; +use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\HasMany; +use Tempest\Database\Id; use Tempest\Database\Table; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -79,14 +81,29 @@ public function test_has_many_with_parent(): void $this->assertSame( 'owner.relation_id AS `parent.owners.relation_id`', - $relation->getSelectFields()[0]->compile(DatabaseDialect::SQLITE), + $relation->getSelectFields()[1]->compile(DatabaseDialect::SQLITE), ); } + + public function test_has_many_throws_exception_for_model_without_primary_key(): void + { + $model = inspect(HasManyTestRelationWithoutIdModel::class); + $relation = $model->getRelation('owners'); + + $this->expectException(ModelDidNotHavePrimaryColumn::class); + $this->expectExceptionMessage( + "`Tests\Tempest\Integration\Database\ModelInspector\HasManyTestRelationWithoutIdModel` does not have a primary column defined, which is required for `HasMany` relationships.", + ); + + $relation->getJoinStatement(); + } } #[Table('relation')] final class HasManyTestRelationModel { + public Id $id; + /** @var \Tests\Tempest\Integration\Database\ModelInspector\HasManyTestOwnerModel[] */ public array $owners = []; @@ -109,9 +126,20 @@ final class HasManyTestRelationModel public string $name; } +#[Table('relation')] +final class HasManyTestRelationWithoutIdModel +{ + /** @var \Tests\Tempest\Integration\Database\ModelInspector\HasManyTestOwnerWithoutIdModel[] */ + public array $owners = []; + + public string $name; +} + #[Table('owner')] final class HasManyTestOwnerModel { + public Id $id; + public HasManyTestRelationModel $relation; #[BelongsTo(relationJoin: 'overwritten_id')] @@ -128,3 +156,11 @@ final class HasManyTestOwnerModel public string $name; } + +#[Table('owner')] +final class HasManyTestOwnerWithoutIdModel +{ + public HasManyTestRelationWithoutIdModel $relation; + + public string $name; +} diff --git a/tests/Integration/Database/ModelInspector/HasOneTest.php b/tests/Integration/Database/ModelInspector/HasOneTest.php index 48852589e..245c9b942 100644 --- a/tests/Integration/Database/ModelInspector/HasOneTest.php +++ b/tests/Integration/Database/ModelInspector/HasOneTest.php @@ -4,8 +4,10 @@ use Tempest\Database\BelongsTo; use Tempest\Database\Config\DatabaseDialect; +use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\HasMany; use Tempest\Database\HasOne; +use Tempest\Database\Id; use Tempest\Database\Table; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -80,14 +82,29 @@ public function test_has_one_with_parent(): void $this->assertSame( 'owner.relation_id AS `parent.owner.relation_id`', - $relation->getSelectFields()[0]->compile(DatabaseDialect::SQLITE), + $relation->getSelectFields()[1]->compile(DatabaseDialect::SQLITE), ); } + + public function test_has_one_throws_exception_for_model_without_primary_key(): void + { + $model = inspect(HasOneTestRelationNoIdModel::class); + $relation = $model->getRelation('owner'); + + $this->expectException(ModelDidNotHavePrimaryColumn::class); + $this->expectExceptionMessage( + "`Tests\Tempest\Integration\Database\ModelInspector\HasOneTestRelationNoIdModel` does not have a primary column defined, which is required for `HasOne` relationships.", + ); + + $relation->getJoinStatement(); + } } #[Table('relation')] final class HasOneTestRelationModel { + public Id $id; + #[HasOne] public HasOneTestOwnerModel $owner; @@ -109,6 +126,8 @@ final class HasOneTestRelationModel #[Table('owner')] final class HasOneTestOwnerModel { + public Id $id; + public HasOneTestRelationModel $relation; #[BelongsTo(relationJoin: 'overwritten_id')] @@ -125,3 +144,20 @@ final class HasOneTestOwnerModel public string $name; } + +#[Table('relation')] +final class HasOneTestRelationNoIdModel +{ + #[HasOne] + public HasOneTestOwnerNoIdModel $owner; + + public string $name; +} + +#[Table('owner')] +final class HasOneTestOwnerNoIdModel +{ + public HasOneTestRelationNoIdModel $relation; + + public string $name; +} From c47178e3e75d130b58a5487f715325a0674bf43b Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 4 Aug 2025 05:28:31 +0200 Subject: [PATCH 10/51] refactor(database): rename `Id` to `PrimaryKey` and extract it from `IsDatabaseModel` --- packages/auth/src/CanAuthenticate.php | 4 +- packages/auth/src/Install/Permission.php | 3 + packages/auth/src/Install/User.php | 3 + packages/auth/src/Install/UserPermission.php | 3 + .../database/src/Builder/ModelInspector.php | 10 +- .../src/Builder/ModelQueryBuilder.php | 14 +- .../QueryBuilders/DeleteQueryBuilder.php | 2 +- .../QueryBuilders/InsertQueryBuilder.php | 6 +- .../QueryBuilders/SelectQueryBuilder.php | 4 +- .../QueryBuilders/UpdateQueryBuilder.php | 8 +- packages/database/src/Casters/IdCaster.php | 20 - .../database/src/Casters/PrimaryKeyCaster.php | 20 + packages/database/src/Database.php | 2 +- packages/database/src/GenericDatabase.php | 4 +- packages/database/src/Id.php | 41 -- packages/database/src/IsDatabaseModel.php | 66 ++- .../database/src/Migrations/Migration.php | 3 + packages/database/src/PrimaryKey.php | 45 ++ packages/database/src/Query.php | 4 +- .../QueryStatements/CanExecuteStatement.php | 4 +- .../database/src/Serializers/IdSerializer.php | 19 - .../src/Serializers/PrimaryKeySerializer.php | 19 + ...r.php => PrimaryKeySerializerProvider.php} | 6 +- .../database/src/Stubs/DatabaseModelStub.php | 3 + .../tests/QueryStatements/StubModel.php | 3 + .../src/SerializerFactoryInitializer.php | 2 +- .../Controllers/ValidationController.php | 8 +- tests/Fixtures/Models/A.php | 3 + tests/Fixtures/Models/AWithEager.php | 3 + tests/Fixtures/Models/AWithLazy.php | 3 + tests/Fixtures/Models/AWithValue.php | 3 + tests/Fixtures/Models/AWithVirtual.php | 5 +- tests/Fixtures/Models/B.php | 3 + tests/Fixtures/Models/BWithEager.php | 3 + tests/Fixtures/Models/C.php | 3 + tests/Fixtures/Models/MultiWordModel.php | 3 + .../Fixtures/Modules/Books/Models/Author.php | 5 + tests/Fixtures/Modules/Books/Models/Book.php | 5 + .../Fixtures/Modules/Books/Models/Chapter.php | 3 + tests/Fixtures/Modules/Books/Models/Isbn.php | 3 + .../Modules/Books/Models/Publisher.php | 4 +- .../Database/Builder/CustomPrimaryKeyTest.php | 10 +- .../Builder/DeleteQueryBuilderTest.php | 4 +- .../Builder/InsertQueryBuilderTest.php | 8 +- .../Builder/ModelQueryBuilderTest.php | 18 +- .../Builder/SelectQueryBuilderTest.php | 8 +- .../Builder/UpdateQueryBuilderTest.php | 12 +- .../ConvenientDateWhereMethodsTest.php | 3 + .../Database/ConvenientWhereMethodsTest.php | 3 + .../Database/GroupedWhereMethodsTest.php | 3 + .../Database/ModelInspector/BelongsToTest.php | 6 +- .../Database/ModelInspector/HasManyTest.php | 6 +- .../Database/ModelInspector/HasOneTest.php | 6 +- .../ModelInspector/ModelInspectorTest.php | 9 + .../ModelInspector/ModelWithDtoTest.php | 3 + .../Database/ModelsWithoutIdTest.php | 433 ++++++++++++++++++ .../Database/MultiDatabaseTest.php | 10 +- .../AlterTableStatementTest.php | 6 +- .../Database/QueryStatements/User.php | 3 + .../Mapper/Fixtures/ObjectFactoryA.php | 3 + .../Fixtures/ObjectFactoryWithValidation.php | 3 + tests/Integration/Mapper/MapperTest.php | 8 +- tests/Integration/ORM/Foo.php | 3 + tests/Integration/ORM/IsDatabaseModelTest.php | 24 +- .../ORM/Mappers/QueryMapperTest.php | 4 +- .../ORM/Models/AttributeTableNameModel.php | 3 + tests/Integration/ORM/Models/BaseModel.php | 3 + tests/Integration/ORM/Models/CarbonModel.php | 3 + tests/Integration/ORM/Models/CasterModel.php | 3 + tests/Integration/ORM/Models/ChildModel.php | 3 + .../Integration/ORM/Models/DateTimeModel.php | 4 +- .../ORM/Models/ModelWithValidation.php | 3 + tests/Integration/ORM/Models/ParentModel.php | 3 + .../ORM/Models/StaticMethodTableNameModel.php | 3 + tests/Integration/ORM/Models/ThroughModel.php | 3 + tests/Integration/Route/RequestTest.php | 10 +- 76 files changed, 804 insertions(+), 208 deletions(-) delete mode 100644 packages/database/src/Casters/IdCaster.php create mode 100644 packages/database/src/Casters/PrimaryKeyCaster.php delete mode 100644 packages/database/src/Id.php create mode 100644 packages/database/src/PrimaryKey.php delete mode 100644 packages/database/src/Serializers/IdSerializer.php create mode 100644 packages/database/src/Serializers/PrimaryKeySerializer.php rename packages/database/src/Serializers/{IdSerializerProvider.php => PrimaryKeySerializerProvider.php} (66%) create mode 100644 tests/Integration/Database/ModelsWithoutIdTest.php diff --git a/packages/auth/src/CanAuthenticate.php b/packages/auth/src/CanAuthenticate.php index 54a7bdc20..1f6c93caf 100644 --- a/packages/auth/src/CanAuthenticate.php +++ b/packages/auth/src/CanAuthenticate.php @@ -4,11 +4,11 @@ namespace Tempest\Auth; -use Tempest\Database\Id; +use Tempest\Database\PrimaryKey; interface CanAuthenticate { - public ?Id $id { + public ?PrimaryKey $id { get; } } diff --git a/packages/auth/src/Install/Permission.php b/packages/auth/src/Install/Permission.php index 76af22e63..0471536aa 100644 --- a/packages/auth/src/Install/Permission.php +++ b/packages/auth/src/Install/Permission.php @@ -6,12 +6,15 @@ use BackedEnum; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use UnitEnum; final class Permission { use IsDatabaseModel; + public PrimaryKey $id; + public function __construct( public string $name, ) {} diff --git a/packages/auth/src/Install/User.php b/packages/auth/src/Install/User.php index e1621165b..f0eacbabc 100644 --- a/packages/auth/src/Install/User.php +++ b/packages/auth/src/Install/User.php @@ -9,6 +9,7 @@ use Tempest\Auth\CanAuthenticate; use Tempest\Auth\CanAuthorize; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use UnitEnum; use function Tempest\Support\arr; @@ -17,6 +18,8 @@ final class User implements CanAuthenticate, CanAuthorize { use IsDatabaseModel; + public PrimaryKey $id; + public string $password; public function __construct( diff --git a/packages/auth/src/Install/UserPermission.php b/packages/auth/src/Install/UserPermission.php index 18cb67a2a..9536de9ba 100644 --- a/packages/auth/src/Install/UserPermission.php +++ b/packages/auth/src/Install/UserPermission.php @@ -5,11 +5,14 @@ namespace Tempest\Auth\Install; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; final class UserPermission { use IsDatabaseModel; + public PrimaryKey $id; + public User $user; public Permission $permission; diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index 86acbdf67..4825b0500 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -10,7 +10,7 @@ use Tempest\Database\Exceptions\ModelHadMultiplePrimaryColumns; use Tempest\Database\HasMany; use Tempest\Database\HasOne; -use Tempest\Database\Id; +use Tempest\Database\PrimaryKey; use Tempest\Database\Relation; use Tempest\Database\Table; use Tempest\Database\Virtual; @@ -359,6 +359,10 @@ public function validate(mixed ...$data): void continue; } + if ($property->getType()->getName() === PrimaryKey::class) { + continue; + } + $failingRulesForProperty = $this->validator->validateValueForProperty( $property, $value, @@ -409,7 +413,7 @@ public function getPrimaryKeyProperty(): ?PropertyReflector } $idProperties = arr($this->reflector->getProperties()) - ->filter(fn (PropertyReflector $property) => $property->getType()->getName() === Id::class); + ->filter(fn (PropertyReflector $property) => $property->getType()->getName() === PrimaryKey::class); return match ($idProperties->count()) { 0 => null, @@ -421,7 +425,7 @@ public function getPrimaryKeyProperty(): ?PropertyReflector }; } - public function getPrimaryKeyValue(): ?Id + public function getPrimaryKeyValue(): ?PrimaryKey { if (! $this->isObjectModel()) { return null; diff --git a/packages/database/src/Builder/ModelQueryBuilder.php b/packages/database/src/Builder/ModelQueryBuilder.php index 4ddf368c5..926898ff2 100644 --- a/packages/database/src/Builder/ModelQueryBuilder.php +++ b/packages/database/src/Builder/ModelQueryBuilder.php @@ -10,7 +10,7 @@ use Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder; use Tempest\Database\Builder\QueryBuilders\UpdateQueryBuilder; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; -use Tempest\Database\Id; +use Tempest\Database\PrimaryKey; use function Tempest\Database\inspect; use function Tempest\Database\query; @@ -91,7 +91,7 @@ public function new(mixed ...$params): object * * @return TModel */ - public function findById(string|int|Id $id): object + public function findById(string|int|PrimaryKey $id): object { if (! inspect($this->model)->hasPrimaryKey()) { throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'findById'); @@ -105,7 +105,7 @@ public function findById(string|int|Id $id): object * * @return TModel */ - public function resolve(string|int|Id $id): object + public function resolve(string|int|PrimaryKey $id): object { if (! inspect($this->model)->hasPrimaryKey()) { throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'resolve'); @@ -119,15 +119,15 @@ public function resolve(string|int|Id $id): object * * @return TModel|null */ - public function get(string|int|Id $id, array $relations = []): ?object + public function get(string|int|PrimaryKey $id, array $relations = []): ?object { if (! inspect($this->model)->hasPrimaryKey()) { throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'get'); } $id = match (true) { - $id instanceof Id => $id, - default => new Id($id), + $id instanceof PrimaryKey => $id, + default => new PrimaryKey($id), }; return $this->select() @@ -194,7 +194,7 @@ public function create(mixed ...$params): object if ($id !== null && $primaryKeyProperty !== null) { $primaryKeyName = $primaryKeyProperty->getName(); - $model->{$primaryKeyName} = new Id($id); + $model->{$primaryKeyName} = new PrimaryKey($id); } return $model; diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index aedb9b57c..c0f51ca68 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -73,7 +73,7 @@ public function build(mixed ...$bindings): Query $primaryKeyValue = $this->model->getPrimaryKeyValue(); if ($primaryKeyValue !== null) { - $this->where($this->model->getPrimaryKey(), $primaryKeyValue->id); + $this->where($this->model->getPrimaryKey(), $primaryKeyValue->value); } } diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index 48b51ee06..9920d2fff 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -6,8 +6,8 @@ use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Exceptions\HasManyRelationCouldNotBeInsterted; use Tempest\Database\Exceptions\HasOneRelationCouldNotBeInserted; -use Tempest\Database\Id; use Tempest\Database\OnDatabase; +use Tempest\Database\PrimaryKey; use Tempest\Database\Query; use Tempest\Database\QueryStatements\InsertStatement; use Tempest\Mapper\SerializerFactory; @@ -46,7 +46,7 @@ public function __construct( $this->insert = new InsertStatement($this->model->getTableDefinition()); } - public function execute(mixed ...$bindings): Id + public function execute(mixed ...$bindings): PrimaryKey { $id = $this->build()->execute(...$bindings); @@ -146,7 +146,7 @@ private function resolveData(): array $value = match (true) { $value === null => null, - isset($value->id) => $value->id->id, + isset($value->id) => $value->id->value, default => new InsertQueryBuilder( $value::class, [$value], diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 9b1af24a0..ae5c2383a 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -7,9 +7,9 @@ use Closure; use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; -use Tempest\Database\Id; use Tempest\Database\Mappers\SelectModelMapper; use Tempest\Database\OnDatabase; +use Tempest\Database\PrimaryKey; use Tempest\Database\Query; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\GroupByStatement; @@ -100,7 +100,7 @@ public function paginate(int $itemsPerPage = 20, int $currentPage = 1, int $maxL } /** @return T|null */ - public function get(Id $id): mixed + public function get(PrimaryKey $id): mixed { if (! $this->model->hasPrimaryKey()) { throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model->getName(), 'get'); diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index 833d77a59..99d39176f 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -5,8 +5,8 @@ use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Exceptions\HasManyRelationCouldNotBeUpdated; use Tempest\Database\Exceptions\HasOneRelationCouldNotBeUpdated; -use Tempest\Database\Id; use Tempest\Database\OnDatabase; +use Tempest\Database\PrimaryKey; use Tempest\Database\Query; use Tempest\Database\QueryStatements\HasWhereStatements; use Tempest\Database\QueryStatements\UpdateStatement; @@ -48,7 +48,7 @@ public function __construct( ); } - public function execute(mixed ...$bindings): ?Id + public function execute(mixed ...$bindings): ?PrimaryKey { return $this->build()->execute(...$bindings); } @@ -94,7 +94,7 @@ public function build(mixed ...$bindings): Query $primaryKeyValue = $this->model->getPrimaryKeyValue(); if ($primaryKeyValue !== null) { - $this->where($this->model->getPrimaryKey(), $primaryKeyValue->id); + $this->where($this->model->getPrimaryKey(), $primaryKeyValue->value); } } @@ -133,7 +133,7 @@ private function resolveValues(): ImmutableArray $value = match (true) { $value === null => null, - isset($value->id) => $value->id->id, + isset($value->id) => $value->id->value, default => new InsertQueryBuilder( $value::class, [$value], diff --git a/packages/database/src/Casters/IdCaster.php b/packages/database/src/Casters/IdCaster.php deleted file mode 100644 index f0c8e988b..000000000 --- a/packages/database/src/Casters/IdCaster.php +++ /dev/null @@ -1,20 +0,0 @@ -lastQuery->toSql(); @@ -71,7 +71,7 @@ public function getLastInsertId(): ?Id $lastInsertId = $this->connection->lastInsertId(); } - return Id::tryFrom($lastInsertId); + return PrimaryKey::tryFrom($lastInsertId); } public function fetch(BuildsQuery|Query $query): array diff --git a/packages/database/src/Id.php b/packages/database/src/Id.php deleted file mode 100644 index 7d74aadc7..000000000 --- a/packages/database/src/Id.php +++ /dev/null @@ -1,41 +0,0 @@ -id : $id; - - $this->id = is_numeric($id) ? ((int) $id) : $id; - } - - public function __toString(): string - { - return "{$this->id}"; - } - - public function equals(self $other): bool - { - return $this->id === $other->id; - } -} diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index d947dbaa7..98e9608bc 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -11,15 +11,11 @@ use Tempest\Database\Exceptions\ValueWasMissing; use Tempest\Reflection\ClassReflector; use Tempest\Reflection\PropertyReflector; -use Tempest\Validation\SkipValidation; use function Tempest\Database\model; trait IsDatabaseModel { - #[SkipValidation] - public Id $id; - /** * Returns a builder for selecting records using this model's table. * @@ -61,7 +57,7 @@ public static function new(mixed ...$params): self /** * Finds a model instance by its ID. */ - public static function findById(string|int|Id $id): static + public static function findById(string|int|PrimaryKey $id): static { return self::resolve($id); } @@ -69,7 +65,7 @@ public static function findById(string|int|Id $id): static /** * Finds a model instance by its ID. */ - public static function resolve(string|int|Id $id): static + public static function resolve(string|int|PrimaryKey $id): static { return model(self::class)->resolve($id); } @@ -77,7 +73,7 @@ public static function resolve(string|int|Id $id): static /** * Gets a model instance by its ID, optionally loading the given relationships. */ - public static function get(string|int|Id $id, array $relations = []): ?self + public static function get(string|int|PrimaryKey $id, array $relations = []): ?self { return model(self::class)->get($id, $relations); } @@ -167,10 +163,19 @@ public static function updateOrCreate(array $find, array $update): self */ public function refresh(): self { - $model = self::find(id: $this->id)->first(); + $model = inspect($this); + + if (! $model->hasPrimaryKey()) { + throw Exceptions\ModelDidNotHavePrimaryColumn::neededForMethod($this, 'refresh'); + } + + $primaryKeyProperty = $model->getPrimaryKeyProperty(); + $primaryKeyValue = $primaryKeyProperty->getValue($this); + + $refreshed = self::find(id: $primaryKeyValue)->first(); - foreach (new ClassReflector($model)->getPublicProperties() as $property) { - $property->setValue($this, $property->getValue($model)); + foreach (new ClassReflector($refreshed)->getPublicProperties() as $property) { + $property->setValue($this, $property->getValue($refreshed)); } return $this; @@ -181,7 +186,16 @@ public function refresh(): self */ public function load(string ...$relations): self { - $new = self::get($this->id, $relations); + $model = inspect($this); + + if (! $model->hasPrimaryKey()) { + throw Exceptions\ModelDidNotHavePrimaryColumn::neededForMethod($this, 'load'); + } + + $primaryKeyProperty = $model->getPrimaryKeyProperty(); + $primaryKeyValue = $primaryKeyProperty->getValue($this); + + $new = self::get($primaryKeyValue, $relations); foreach (new ClassReflector($new)->getPublicProperties() as $property) { $property->setValue($this, $property->getValue($new)); @@ -198,16 +212,36 @@ public function save(): self $model = inspect($this); $model->validate(...inspect($this)->getPropertyValues()); - if (! isset($this->id)) { - $this->id = query($this::class) + // Models without primary keys always insert + if (! $model->hasPrimaryKey()) { + query($this::class) ->insert($this) ->execute(); - } else { - query($this) - ->update(...inspect($this)->getPropertyValues()) + + return $this; + } + + $primaryKeyProperty = $model->getPrimaryKeyProperty(); + $isInitialized = $primaryKeyProperty->isInitialized($this); + $primaryKeyValue = $isInitialized ? $primaryKeyProperty->getValue($this) : null; + + // If there is a primary key property but it's not set, we insert the model + // to generate the id and populate the model instance with it + if ($primaryKeyValue === null) { + $id = query($this::class) + ->insert($this) ->execute(); + + $primaryKeyProperty->setValue($this, $id); + + return $this; } + // Is the model was already save, we update it + query($this) + ->update(...inspect($this)->getPropertyValues()) + ->execute(); + return $this; } diff --git a/packages/database/src/Migrations/Migration.php b/packages/database/src/Migrations/Migration.php index f7bb93960..fbe03df9a 100644 --- a/packages/database/src/Migrations/Migration.php +++ b/packages/database/src/Migrations/Migration.php @@ -5,11 +5,14 @@ namespace Tempest\Database\Migrations; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; final class Migration { use IsDatabaseModel; + public PrimaryKey $id; + public string $name; public string $hash; diff --git a/packages/database/src/PrimaryKey.php b/packages/database/src/PrimaryKey.php new file mode 100644 index 000000000..f330f428b --- /dev/null +++ b/packages/database/src/PrimaryKey.php @@ -0,0 +1,45 @@ +value + : $value; + + $this->value = is_numeric($value) + ? ((int) $value) + : $value; + } + + public function __toString(): string + { + return "{$this->value}"; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } +} diff --git a/packages/database/src/Query.php b/packages/database/src/Query.php index baefbaa43..1a3075852 100644 --- a/packages/database/src/Query.php +++ b/packages/database/src/Query.php @@ -28,7 +28,7 @@ public function __construct( public array $executeAfter = [], ) {} - public function execute(mixed ...$bindings): ?Id + public function execute(mixed ...$bindings): ?PrimaryKey { $this->bindings = [...$this->bindings, ...$bindings]; @@ -41,7 +41,7 @@ public function execute(mixed ...$bindings): ?Id // TODO: add support for "after" queries to attach hasMany relations return isset($query->bindings['id']) - ? new Id($query->bindings['id']) + ? new PrimaryKey($query->bindings['id']) : $database->getLastInsertId(); } diff --git a/packages/database/src/QueryStatements/CanExecuteStatement.php b/packages/database/src/QueryStatements/CanExecuteStatement.php index 82dd9a875..329ae55d8 100644 --- a/packages/database/src/QueryStatements/CanExecuteStatement.php +++ b/packages/database/src/QueryStatements/CanExecuteStatement.php @@ -5,13 +5,13 @@ namespace Tempest\Database\QueryStatements; use Tempest\Database\Config\DatabaseDialect; -use Tempest\Database\Id; +use Tempest\Database\PrimaryKey; use Tempest\Database\Query; use UnitEnum; trait CanExecuteStatement { - public function execute(DatabaseDialect $dialect, null|string|UnitEnum $onDatabase): ?Id + public function execute(DatabaseDialect $dialect, null|string|UnitEnum $onDatabase): ?PrimaryKey { $sql = $this->compile($dialect); diff --git a/packages/database/src/Serializers/IdSerializer.php b/packages/database/src/Serializers/IdSerializer.php deleted file mode 100644 index 48f94f0bc..000000000 --- a/packages/database/src/Serializers/IdSerializer.php +++ /dev/null @@ -1,19 +0,0 @@ -id; - } -} diff --git a/packages/database/src/Serializers/PrimaryKeySerializer.php b/packages/database/src/Serializers/PrimaryKeySerializer.php new file mode 100644 index 000000000..c06d40464 --- /dev/null +++ b/packages/database/src/Serializers/PrimaryKeySerializer.php @@ -0,0 +1,19 @@ +value; + } +} diff --git a/packages/database/src/Serializers/IdSerializerProvider.php b/packages/database/src/Serializers/PrimaryKeySerializerProvider.php similarity index 66% rename from packages/database/src/Serializers/IdSerializerProvider.php rename to packages/database/src/Serializers/PrimaryKeySerializerProvider.php index a8591dd99..a7b950cb2 100644 --- a/packages/database/src/Serializers/IdSerializerProvider.php +++ b/packages/database/src/Serializers/PrimaryKeySerializerProvider.php @@ -3,11 +3,11 @@ namespace Tempest\Database\Serializers; use Tempest\Core\KernelEvent; -use Tempest\Database\Id; +use Tempest\Database\PrimaryKey; use Tempest\EventBus\EventHandler; use Tempest\Mapper\SerializerFactory; -final readonly class IdSerializerProvider +final readonly class PrimaryKeySerializerProvider { public function __construct( private SerializerFactory $serializerFactory, @@ -16,6 +16,6 @@ public function __construct( #[EventHandler(KernelEvent::BOOTED)] public function __invoke(KernelEvent $_event): void { - $this->serializerFactory->addSerializer(Id::class, IdSerializer::class); + $this->serializerFactory->addSerializer(PrimaryKey::class, PrimaryKeySerializer::class); } } diff --git a/packages/database/src/Stubs/DatabaseModelStub.php b/packages/database/src/Stubs/DatabaseModelStub.php index 88e723a35..9a6aa9156 100644 --- a/packages/database/src/Stubs/DatabaseModelStub.php +++ b/packages/database/src/Stubs/DatabaseModelStub.php @@ -5,12 +5,15 @@ namespace Tempest\Database\Stubs; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Validation\Rules\HasLength; final class DatabaseModelStub { use IsDatabaseModel; + public PrimaryKey $id; + public function __construct( #[HasLength(min: 1, max: 120)] public string $title, diff --git a/packages/database/tests/QueryStatements/StubModel.php b/packages/database/tests/QueryStatements/StubModel.php index e962d3e3b..9f4bb4797 100644 --- a/packages/database/tests/QueryStatements/StubModel.php +++ b/packages/database/tests/QueryStatements/StubModel.php @@ -5,8 +5,11 @@ namespace Tempest\Database\Tests\QueryStatements; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; final class StubModel { use IsDatabaseModel; + + public PrimaryKey $id; } diff --git a/packages/mapper/src/SerializerFactoryInitializer.php b/packages/mapper/src/SerializerFactoryInitializer.php index c0a98b5ca..892495256 100644 --- a/packages/mapper/src/SerializerFactoryInitializer.php +++ b/packages/mapper/src/SerializerFactoryInitializer.php @@ -14,7 +14,7 @@ use Tempest\Container\Container; use Tempest\Container\Initializer; use Tempest\Container\Singleton; -use Tempest\Database\Id; +use Tempest\Database\PrimaryKey; use Tempest\DateTime\DateTime; use Tempest\DateTime\DateTimeInterface; use Tempest\Mapper\Serializers\ArrayOfObjectsSerializer; diff --git a/tests/Fixtures/Controllers/ValidationController.php b/tests/Fixtures/Controllers/ValidationController.php index de2b63f58..6ab114f18 100644 --- a/tests/Fixtures/Controllers/ValidationController.php +++ b/tests/Fixtures/Controllers/ValidationController.php @@ -36,10 +36,10 @@ public function book(Book $book): Response $book->load('author'); return new Json([ - 'id' => $book->id->id, + 'id' => $book->id->value, 'title' => $book->title, 'author' => [ - 'id' => $book->author->id->id, + 'id' => $book->author->id->value, 'name' => $book->author->name, ], ]); @@ -53,10 +53,10 @@ public function updateBook(BookRequest $request, Book $book): Response $book->update(title: $request->get('title')); return new Json([ - 'id' => $book->id->id, + 'id' => $book->id->value, 'title' => $book->title, 'author' => [ - 'id' => $book->author->id->id, + 'id' => $book->author->id->value, 'name' => $book->author->name, ], 'chapters' => $book->chapters, diff --git a/tests/Fixtures/Models/A.php b/tests/Fixtures/Models/A.php index 92f290c73..82cdd5659 100644 --- a/tests/Fixtures/Models/A.php +++ b/tests/Fixtures/Models/A.php @@ -6,12 +6,15 @@ use Tempest\Database\Builder\TableDefinition; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; #[\Tempest\Database\Table('a')] final class A { use IsDatabaseModel; + public PrimaryKey $id; + public function __construct( public B $b, ) {} diff --git a/tests/Fixtures/Models/AWithEager.php b/tests/Fixtures/Models/AWithEager.php index b95e72781..d90545a5e 100644 --- a/tests/Fixtures/Models/AWithEager.php +++ b/tests/Fixtures/Models/AWithEager.php @@ -7,12 +7,15 @@ use Tempest\Database\Builder\TableDefinition; use Tempest\Database\Eager; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; #[\Tempest\Database\Table('a')] final class AWithEager { use IsDatabaseModel; + public PrimaryKey $id; + public function __construct( #[Eager] public BWithEager $b, diff --git a/tests/Fixtures/Models/AWithLazy.php b/tests/Fixtures/Models/AWithLazy.php index 3d450b3a2..72a5b907a 100644 --- a/tests/Fixtures/Models/AWithLazy.php +++ b/tests/Fixtures/Models/AWithLazy.php @@ -7,12 +7,15 @@ use Tempest\Database\Builder\TableDefinition; use Tempest\Database\IsDatabaseModel; use Tempest\Database\Lazy; +use Tempest\Database\PrimaryKey; #[\Tempest\Database\Table('a')] final class AWithLazy { use IsDatabaseModel; + public PrimaryKey $id; + public function __construct( #[Lazy] public B $b, diff --git a/tests/Fixtures/Models/AWithValue.php b/tests/Fixtures/Models/AWithValue.php index aa825fd01..86ddd70dc 100644 --- a/tests/Fixtures/Models/AWithValue.php +++ b/tests/Fixtures/Models/AWithValue.php @@ -6,6 +6,7 @@ use Tempest\Database\Builder\TableDefinition; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Database\Table; #[Table('a')] @@ -13,6 +14,8 @@ final class AWithValue { use IsDatabaseModel; + public PrimaryKey $id; + public function __construct( public string $name, ) {} diff --git a/tests/Fixtures/Models/AWithVirtual.php b/tests/Fixtures/Models/AWithVirtual.php index cc95f91be..8e29a56eb 100644 --- a/tests/Fixtures/Models/AWithVirtual.php +++ b/tests/Fixtures/Models/AWithVirtual.php @@ -6,6 +6,7 @@ use Tempest\Database\Builder\TableDefinition; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Database\Table; use Tempest\Database\Virtual; @@ -14,9 +15,11 @@ final class AWithVirtual { use IsDatabaseModel; + public PrimaryKey $id; + #[Virtual] public int $fake { - get => -$this->id->id; + get => -$this->id->value; } public function __construct( diff --git a/tests/Fixtures/Models/B.php b/tests/Fixtures/Models/B.php index 8586dd7ef..ca79db9a8 100644 --- a/tests/Fixtures/Models/B.php +++ b/tests/Fixtures/Models/B.php @@ -5,6 +5,7 @@ namespace Tests\Tempest\Fixtures\Models; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Database\Table; #[Table('b')] @@ -12,6 +13,8 @@ final class B { use IsDatabaseModel; + public PrimaryKey $id; + public function __construct( public C $c, ) {} diff --git a/tests/Fixtures/Models/BWithEager.php b/tests/Fixtures/Models/BWithEager.php index 2dadd9984..d5517207f 100644 --- a/tests/Fixtures/Models/BWithEager.php +++ b/tests/Fixtures/Models/BWithEager.php @@ -6,6 +6,7 @@ use Tempest\Database\Eager; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Database\Table; #[Table('b')] @@ -13,6 +14,8 @@ final class BWithEager { use IsDatabaseModel; + public PrimaryKey $id; + public function __construct( #[Eager] public C $c, diff --git a/tests/Fixtures/Models/C.php b/tests/Fixtures/Models/C.php index 7c887a854..87bbe9cfb 100644 --- a/tests/Fixtures/Models/C.php +++ b/tests/Fixtures/Models/C.php @@ -5,6 +5,7 @@ namespace Tests\Tempest\Fixtures\Models; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Database\Table; #[Table('c')] @@ -12,6 +13,8 @@ final class C { use IsDatabaseModel; + public PrimaryKey $id; + public function __construct( public string $name, ) {} diff --git a/tests/Fixtures/Models/MultiWordModel.php b/tests/Fixtures/Models/MultiWordModel.php index ff30fd9ae..cd42f5116 100644 --- a/tests/Fixtures/Models/MultiWordModel.php +++ b/tests/Fixtures/Models/MultiWordModel.php @@ -5,8 +5,11 @@ namespace Tests\Tempest\Fixtures\Models; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; final class MultiWordModel { use IsDatabaseModel; + + public PrimaryKey $id; } diff --git a/tests/Fixtures/Modules/Books/Models/Author.php b/tests/Fixtures/Modules/Books/Models/Author.php index 771b3554f..011b62e00 100644 --- a/tests/Fixtures/Modules/Books/Models/Author.php +++ b/tests/Fixtures/Modules/Books/Models/Author.php @@ -5,12 +5,17 @@ namespace Tests\Tempest\Fixtures\Modules\Books\Models; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Router\Bindable; +use Tempest\Validation\SkipValidation; final class Author implements Bindable { use IsDatabaseModel; + #[SkipValidation] + public PrimaryKey $id; + public function __construct( public string $name, public ?AuthorType $type = AuthorType::A, diff --git a/tests/Fixtures/Modules/Books/Models/Book.php b/tests/Fixtures/Modules/Books/Models/Book.php index 187cdb790..434fb1594 100644 --- a/tests/Fixtures/Modules/Books/Models/Book.php +++ b/tests/Fixtures/Modules/Books/Models/Book.php @@ -6,13 +6,18 @@ use Tempest\Database\HasOne; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Router\Bindable; use Tempest\Validation\Rules\HasLength; +use Tempest\Validation\SkipValidation; final class Book implements Bindable { use IsDatabaseModel; + #[SkipValidation] + public PrimaryKey $id; + #[HasLength(min: 1, max: 120)] public string $title; diff --git a/tests/Fixtures/Modules/Books/Models/Chapter.php b/tests/Fixtures/Modules/Books/Models/Chapter.php index cfc28c317..74fda7ae2 100644 --- a/tests/Fixtures/Modules/Books/Models/Chapter.php +++ b/tests/Fixtures/Modules/Books/Models/Chapter.php @@ -5,11 +5,14 @@ namespace Tests\Tempest\Fixtures\Modules\Books\Models; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; final class Chapter { use IsDatabaseModel; + public PrimaryKey $id; + public string $title; public ?string $contents; diff --git a/tests/Fixtures/Modules/Books/Models/Isbn.php b/tests/Fixtures/Modules/Books/Models/Isbn.php index f54d76a7c..c3adbc075 100644 --- a/tests/Fixtures/Modules/Books/Models/Isbn.php +++ b/tests/Fixtures/Modules/Books/Models/Isbn.php @@ -3,11 +3,14 @@ namespace Tests\Tempest\Fixtures\Modules\Books\Models; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; final class Isbn { use IsDatabaseModel; + public PrimaryKey $id; + public string $value; public Book $book; diff --git a/tests/Fixtures/Modules/Books/Models/Publisher.php b/tests/Fixtures/Modules/Books/Models/Publisher.php index f3f89b726..26cf743e1 100644 --- a/tests/Fixtures/Modules/Books/Models/Publisher.php +++ b/tests/Fixtures/Modules/Books/Models/Publisher.php @@ -2,11 +2,11 @@ namespace Tests\Tempest\Fixtures\Modules\Books\Models; -use Tempest\Database\Id; +use Tempest\Database\PrimaryKey; final class Publisher { - public Id $id; + public PrimaryKey $id; public string $name; diff --git a/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php b/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php index 4bd5f16a0..485bb63ba 100644 --- a/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php +++ b/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php @@ -4,8 +4,8 @@ use Tempest\Database\DatabaseMigration; use Tempest\Database\Exceptions\ModelHadMultiplePrimaryColumns; -use Tempest\Database\Id; use Tempest\Database\Migrations\CreateMigrationsTable; +use Tempest\Database\PrimaryKey; use Tempest\Database\QueryStatement; use Tempest\Database\QueryStatements\CreateTableStatement; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -22,7 +22,7 @@ public function test_model_with_custom_primary_key_name(): void $frieren = model(FrierenModel::class)->create(name: 'Frieren', magic: 'Time Magic'); $this->assertInstanceOf(FrierenModel::class, $frieren); - $this->assertInstanceOf(Id::class, $frieren->uuid); + $this->assertInstanceOf(PrimaryKey::class, $frieren->uuid); $this->assertSame('Frieren', $frieren->name); $this->assertSame('Time Magic', $frieren->magic); @@ -69,7 +69,7 @@ public function test_model_without_id_property_still_works(): void final class FrierenModel { - public ?Id $uuid = null; + public ?PrimaryKey $uuid = null; public function __construct( public string $name, @@ -79,9 +79,9 @@ public function __construct( final class ModelWithMultipleIds { - public ?Id $uuid = null; + public ?PrimaryKey $uuid = null; - public ?Id $external_id = null; + public ?PrimaryKey $external_id = null; public function __construct( public string $name = 'test', diff --git a/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php b/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php index 7b3d9fa39..a8bc86ee3 100644 --- a/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php @@ -3,8 +3,8 @@ namespace Tests\Tempest\Integration\Database\Builder; use Tempest\Database\Builder\QueryBuilders\DeleteQueryBuilder; -use Tempest\Database\Id; use Tempest\Database\Migrations\CreateMigrationsTable; +use Tempest\Database\PrimaryKey; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; @@ -48,7 +48,7 @@ public function test_delete_on_model_table(): void public function test_delete_on_model_object(): void { $author = new Author(name: 'brent'); - $author->id = new Id(10); + $author->id = new PrimaryKey(10); $query = query($author) ->delete() diff --git a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php index 2d5e828c1..fa1a20f75 100644 --- a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php @@ -6,8 +6,8 @@ use Tempest\Database\Database; use Tempest\Database\Exceptions\HasManyRelationCouldNotBeInsterted; use Tempest\Database\Exceptions\HasOneRelationCouldNotBeInserted; -use Tempest\Database\Id; use Tempest\Database\Migrations\CreateMigrationsTable; +use Tempest\Database\PrimaryKey; use Tempest\Database\Query; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; @@ -124,7 +124,7 @@ public function test_insert_on_model_table_with_existing_relation(): void $book = Book::new( title: 'Timeline Taxi', author: Author::new( - id: new Id(10), + id: new PrimaryKey(10), name: 'Brent', ), ); @@ -173,11 +173,11 @@ public function test_then_method(): void $id = query(Book::class) ->insert(title: 'Timeline Taxi') ->then( - fn (Id $id) => query(Chapter::class)->insert( + fn (PrimaryKey $id) => query(Chapter::class)->insert( ['title' => 'Chapter 01', 'book_id' => $id], ['title' => 'Chapter 02', 'book_id' => $id], ), - fn (Id $id) => query(Chapter::class)->insert( + fn (PrimaryKey $id) => query(Chapter::class)->insert( ['title' => 'Chapter 03', 'book_id' => $id], ), ) diff --git a/tests/Integration/Database/Builder/ModelQueryBuilderTest.php b/tests/Integration/Database/Builder/ModelQueryBuilderTest.php index 250f6dae8..9a3340254 100644 --- a/tests/Integration/Database/Builder/ModelQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/ModelQueryBuilderTest.php @@ -8,8 +8,8 @@ use Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder; use Tempest\Database\Builder\QueryBuilders\UpdateQueryBuilder; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; -use Tempest\Database\Id; use Tempest\Database\Migrations\CreateMigrationsTable; +use Tempest\Database\PrimaryKey; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use function Tempest\Database\model; @@ -187,7 +187,7 @@ public function test_create(): void $createdWithoutId = model(TestUserModelWithoutId::class)->create(name: 'Serie'); $this->assertInstanceOf(TestUserModel::class, $createdWithId); - $this->assertInstanceOf(Id::class, $createdWithId->id); + $this->assertInstanceOf(PrimaryKey::class, $createdWithId->id); $this->assertSame('Ubel', $createdWithId->name); $this->assertInstanceOf(TestUserModelWithoutId::class, $createdWithoutId); @@ -267,7 +267,7 @@ public function test_update_or_create_creates_new(): void ); $this->assertInstanceOf(TestUserModel::class, $resultWithId); - $this->assertInstanceOf(Id::class, $resultWithId->id); + $this->assertInstanceOf(PrimaryKey::class, $resultWithId->id); $this->assertSame('Aura', $resultWithId->name); } @@ -276,7 +276,7 @@ public function test_get_with_string_id(): void $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); $created = model(TestUserModel::class)->create(name: 'Heiter'); - $retrieved = model(TestUserModel::class)->get((string) $created->id->id); + $retrieved = model(TestUserModel::class)->get((string) $created->id->value); $this->assertNotNull($retrieved); $this->assertSame('Heiter', $retrieved->name); @@ -288,7 +288,7 @@ public function test_get_with_int_id(): void $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); $created = model(TestUserModel::class)->create(name: 'Eisen'); - $retrieved = model(TestUserModel::class)->get($created->id->id); + $retrieved = model(TestUserModel::class)->get($created->id->value); $this->assertNotNull($retrieved); $this->assertSame('Eisen', $retrieved->name); @@ -299,7 +299,7 @@ public function test_get_returns_null_for_non_existent_id(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); - $result = model(TestUserModel::class)->get(new Id(999)); + $result = model(TestUserModel::class)->get(new PrimaryKey(999)); $this->assertNull($result); } @@ -338,7 +338,7 @@ public function test_custom_primary_key_name(): void $created = model(TestUserModelWithCustomPrimaryKey::class)->create(name: 'Fern'); $this->assertInstanceOf(TestUserModelWithCustomPrimaryKey::class, $created); - $this->assertInstanceOf(Id::class, $created->uuid); + $this->assertInstanceOf(PrimaryKey::class, $created->uuid); $this->assertSame('Fern', $created->name); $retrieved = model(TestUserModelWithCustomPrimaryKey::class)->get($created->uuid); @@ -365,7 +365,7 @@ public function test_custom_primary_key_update_or_create(): void final class TestUserModel { - public ?Id $id = null; + public ?PrimaryKey $id = null; public function __construct( public string $name, @@ -418,7 +418,7 @@ public function down(): ?QueryStatement final class TestUserModelWithCustomPrimaryKey { - public ?Id $uuid = null; + public ?PrimaryKey $uuid = null; public function __construct( public string $name, diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index 8ce6feddf..2e146e2e7 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -61,7 +61,7 @@ public function test_select_from_model(): void $sql = $query->toSql(); - $expected = 'SELECT authors.name AS `authors.name`, authors.type AS `authors.type`, authors.publisher_id AS `authors.publisher_id`, authors.id AS `authors.id` FROM `authors`'; + $expected = 'SELECT authors.id AS `authors.id`, authors.name AS `authors.name`, authors.type AS `authors.type`, authors.publisher_id AS `authors.publisher_id` FROM `authors`'; $this->assertSameWithoutBackticks($expected, $sql); } @@ -317,7 +317,7 @@ public function test_select_includes_belongs_to(): void $query = query(Book::class)->select(); $this->assertSameWithoutBackticks( - 'SELECT books.title AS `books.title`, books.author_id AS `books.author_id`, books.id AS `books.id` FROM `books`', + 'SELECT books.id AS `books.id`, books.title AS `books.title`, books.author_id AS `books.author_id` FROM `books`', $query->build()->toSql(), ); } @@ -330,7 +330,7 @@ public function test_with_belongs_to_relation(): void ->build(); $this->assertSameWithoutBackticks( - 'SELECT books.title AS `books.title`, books.author_id AS `books.author_id`, books.id AS `books.id`, authors.name AS `author.name`, authors.type AS `author.type`, authors.publisher_id AS `author.publisher_id`, authors.id AS `author.id`, chapters.title AS `chapters.title`, chapters.contents AS `chapters.contents`, chapters.book_id AS `chapters.book_id`, chapters.id AS `chapters.id`, isbns.value AS `isbn.value`, isbns.book_id AS `isbn.book_id`, isbns.id AS `isbn.id` FROM `books` LEFT JOIN authors ON authors.id = books.author_id LEFT JOIN chapters ON chapters.book_id = books.id LEFT JOIN isbns ON isbns.book_id = books.id', + 'SELECT books.id AS `books.id`, books.title AS `books.title`, books.author_id AS `books.author_id`, authors.id AS `author.id`, authors.name AS `author.name`, authors.type AS `author.type`, authors.publisher_id AS `author.publisher_id`, chapters.id AS `chapters.id`, chapters.title AS `chapters.title`, chapters.contents AS `chapters.contents`, chapters.book_id AS `chapters.book_id`, isbns.id AS `isbn.id`, isbns.value AS `isbn.value`, isbns.book_id AS `isbn.book_id` FROM `books` LEFT JOIN authors ON authors.id = books.author_id LEFT JOIN chapters ON chapters.book_id = books.id LEFT JOIN isbns ON isbns.book_id = books.id', $query->toSql(), ); } @@ -366,7 +366,7 @@ public function test_eager_loads_combined_with_manual_loads(): void $query = AWithEager::select()->with('b.c')->toSql(); $this->assertSameWithoutBackticks( - 'SELECT a.b_id AS `a.b_id`, a.id AS `a.id`, b.c_id AS `b.c_id`, b.id AS `b.id`, c.name AS `b.c.name`, c.id AS `b.c.id` FROM `a` LEFT JOIN b ON b.id = a.b_id LEFT JOIN c ON c.id = b.c_id', + 'SELECT a.id AS `a.id`, a.b_id AS `a.b_id`, b.id AS `b.id`, b.c_id AS `b.c_id`, c.id AS `b.c.id`, c.name AS `b.c.name` FROM `a` LEFT JOIN b ON b.id = a.b_id LEFT JOIN c ON c.id = b.c_id', $query, ); } diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php index 1a80d68f1..9aab721cf 100644 --- a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php @@ -8,8 +8,8 @@ use Tempest\Database\Exceptions\HasManyRelationCouldNotBeUpdated; use Tempest\Database\Exceptions\HasOneRelationCouldNotBeUpdated; use Tempest\Database\Exceptions\UpdateStatementWasInvalid; -use Tempest\Database\Id; use Tempest\Database\Migrations\CreateMigrationsTable; +use Tempest\Database\PrimaryKey; use Tempest\Database\Query; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; @@ -94,7 +94,7 @@ public function test_model_update_with_values(): void public function test_model_update_with_object(): void { $book = Book::new( - id: new Id(10), + id: new PrimaryKey(10), title: 'Chapter 01', ); @@ -118,7 +118,7 @@ public function test_model_update_with_object(): void public function test_model_values_get_serialized(): void { $author = Author::new( - id: new Id(10), + id: new PrimaryKey(10), ); $query = query($author) @@ -136,7 +136,7 @@ public function test_model_values_get_serialized(): void public function test_insert_new_relation_on_update(): void { $book = Book::new( - id: new Id(10), + id: new PrimaryKey(10), ); $bookQuery = query($book) @@ -169,11 +169,11 @@ public function test_insert_new_relation_on_update(): void public function test_attach_existing_relation_on_update(): void { $book = Book::new( - id: new Id(10), + id: new PrimaryKey(10), ); $bookQuery = query($book) - ->update(author: Author::new(id: new Id(5), name: 'Brent')) + ->update(author: Author::new(id: new PrimaryKey(5), name: 'Brent')) ->build(); $this->assertSameWithoutBackticks( diff --git a/tests/Integration/Database/ConvenientDateWhereMethodsTest.php b/tests/Integration/Database/ConvenientDateWhereMethodsTest.php index 5895d3a6b..482c7097d 100644 --- a/tests/Integration/Database/ConvenientDateWhereMethodsTest.php +++ b/tests/Integration/Database/ConvenientDateWhereMethodsTest.php @@ -8,6 +8,7 @@ use Tempest\Database\DatabaseMigration; use Tempest\Database\IsDatabaseModel; use Tempest\Database\Migrations\CreateMigrationsTable; +use Tempest\Database\PrimaryKey; use Tempest\Database\QueryStatement; use Tempest\Database\QueryStatements\CreateTableStatement; use Tempest\Database\QueryStatements\DropTableStatement; @@ -386,6 +387,8 @@ final class Event { use IsDatabaseModel; + public PrimaryKey $id; + public function __construct( public string $name, public DateTime $created_at, diff --git a/tests/Integration/Database/ConvenientWhereMethodsTest.php b/tests/Integration/Database/ConvenientWhereMethodsTest.php index 1d6709293..eeb4a2ec3 100644 --- a/tests/Integration/Database/ConvenientWhereMethodsTest.php +++ b/tests/Integration/Database/ConvenientWhereMethodsTest.php @@ -8,6 +8,7 @@ use Tempest\Database\DatabaseMigration; use Tempest\Database\IsDatabaseModel; use Tempest\Database\Migrations\CreateMigrationsTable; +use Tempest\Database\PrimaryKey; use Tempest\Database\QueryStatement; use Tempest\Database\QueryStatements\CreateTableStatement; use Tempest\Database\QueryStatements\DropTableStatement; @@ -421,6 +422,8 @@ final class User { use IsDatabaseModel; + public PrimaryKey $id; + public function __construct( public string $name, public ?string $email, diff --git a/tests/Integration/Database/GroupedWhereMethodsTest.php b/tests/Integration/Database/GroupedWhereMethodsTest.php index 2d3f12713..6839ca438 100644 --- a/tests/Integration/Database/GroupedWhereMethodsTest.php +++ b/tests/Integration/Database/GroupedWhereMethodsTest.php @@ -8,6 +8,7 @@ use Tempest\Database\DatabaseMigration; use Tempest\Database\IsDatabaseModel; use Tempest\Database\Migrations\CreateMigrationsTable; +use Tempest\Database\PrimaryKey; use Tempest\Database\QueryStatement; use Tempest\Database\QueryStatements\CreateTableStatement; use Tempest\Database\QueryStatements\DropTableStatement; @@ -351,6 +352,8 @@ final class Product { use IsDatabaseModel; + public PrimaryKey $id; + public function __construct( public string $name, public string $category, diff --git a/tests/Integration/Database/ModelInspector/BelongsToTest.php b/tests/Integration/Database/ModelInspector/BelongsToTest.php index 9733f092c..7b54859b1 100644 --- a/tests/Integration/Database/ModelInspector/BelongsToTest.php +++ b/tests/Integration/Database/ModelInspector/BelongsToTest.php @@ -6,7 +6,7 @@ use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\HasMany; -use Tempest\Database\Id; +use Tempest\Database\PrimaryKey; use Tempest\Database\Table; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -107,7 +107,7 @@ public function test_belongs_to_throws_exception_for_model_without_primary_key() #[Table('relation')] final class BelongsToTestRelationModel { - public Id $id; + public PrimaryKey $id; /** @var \Tests\Tempest\Integration\Database\ModelInspector\BelongsToTestOwnerModel[] */ public array $owners = []; @@ -134,7 +134,7 @@ final class BelongsToTestRelationModel #[Table('owner')] final class BelongsToTestOwnerModel { - public Id $id; + public PrimaryKey $id; public BelongsToTestRelationModel $relation; diff --git a/tests/Integration/Database/ModelInspector/HasManyTest.php b/tests/Integration/Database/ModelInspector/HasManyTest.php index d53eccc7d..abcf14d89 100644 --- a/tests/Integration/Database/ModelInspector/HasManyTest.php +++ b/tests/Integration/Database/ModelInspector/HasManyTest.php @@ -6,7 +6,7 @@ use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\HasMany; -use Tempest\Database\Id; +use Tempest\Database\PrimaryKey; use Tempest\Database\Table; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -102,7 +102,7 @@ public function test_has_many_throws_exception_for_model_without_primary_key(): #[Table('relation')] final class HasManyTestRelationModel { - public Id $id; + public PrimaryKey $id; /** @var \Tests\Tempest\Integration\Database\ModelInspector\HasManyTestOwnerModel[] */ public array $owners = []; @@ -138,7 +138,7 @@ final class HasManyTestRelationWithoutIdModel #[Table('owner')] final class HasManyTestOwnerModel { - public Id $id; + public PrimaryKey $id; public HasManyTestRelationModel $relation; diff --git a/tests/Integration/Database/ModelInspector/HasOneTest.php b/tests/Integration/Database/ModelInspector/HasOneTest.php index 245c9b942..93349c6bc 100644 --- a/tests/Integration/Database/ModelInspector/HasOneTest.php +++ b/tests/Integration/Database/ModelInspector/HasOneTest.php @@ -7,7 +7,7 @@ use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\HasMany; use Tempest\Database\HasOne; -use Tempest\Database\Id; +use Tempest\Database\PrimaryKey; use Tempest\Database\Table; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -103,7 +103,7 @@ public function test_has_one_throws_exception_for_model_without_primary_key(): v #[Table('relation')] final class HasOneTestRelationModel { - public Id $id; + public PrimaryKey $id; #[HasOne] public HasOneTestOwnerModel $owner; @@ -126,7 +126,7 @@ final class HasOneTestRelationModel #[Table('owner')] final class HasOneTestOwnerModel { - public Id $id; + public PrimaryKey $id; public HasOneTestRelationModel $relation; diff --git a/tests/Integration/Database/ModelInspector/ModelInspectorTest.php b/tests/Integration/Database/ModelInspector/ModelInspectorTest.php index 54a9c7806..c9582b52f 100644 --- a/tests/Integration/Database/ModelInspector/ModelInspectorTest.php +++ b/tests/Integration/Database/ModelInspector/ModelInspectorTest.php @@ -3,6 +3,7 @@ namespace Tests\Tempest\Integration\Database\ModelInspector; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Database\Virtual; use Tempest\Mapper\Casters\DtoCaster; use Tempest\Mapper\CastWith; @@ -46,6 +47,8 @@ final class ModelInspectorTestModelWithVirtualHasMany { use IsDatabaseModel; + public PrimaryKey $id; + #[Virtual] /** @var \Tests\Tempest\Integration\Database\ModelInspector\ModelInspectorTestDtoForModelWithVirtual[] $dto */ public array $dtos; @@ -55,6 +58,8 @@ final class ModelInspectorTestModelWithVirtualDto { use IsDatabaseModel; + public PrimaryKey $id; + #[Virtual] public ModelInspectorTestDtoForModelWithVirtual $dto; } @@ -72,6 +77,8 @@ final class ModelInspectorTestModelWithSerializedDto { use IsDatabaseModel; + public PrimaryKey $id; + public ModelInspectorTestDtoForModelWithSerializer $dto; } @@ -86,6 +93,8 @@ final class ModelInspectorTestModelWithSerializedDtoProperty { use IsDatabaseModel; + public PrimaryKey $id; + #[SerializeWith(DtoSerializer::class)] public ModelInspectorTestDtoForModelWithSerializerOnProperty $dto; } diff --git a/tests/Integration/Database/ModelInspector/ModelWithDtoTest.php b/tests/Integration/Database/ModelInspector/ModelWithDtoTest.php index 444edb289..19b180618 100644 --- a/tests/Integration/Database/ModelInspector/ModelWithDtoTest.php +++ b/tests/Integration/Database/ModelInspector/ModelWithDtoTest.php @@ -5,6 +5,7 @@ use Tempest\Database\DatabaseMigration; use Tempest\Database\IsDatabaseModel; use Tempest\Database\Migrations\CreateMigrationsTable; +use Tempest\Database\PrimaryKey; use Tempest\Database\QueryStatement; use Tempest\Database\QueryStatements\CreateTableStatement; use Tempest\Mapper\Casters\DtoCaster; @@ -64,5 +65,7 @@ final class ModelWithDtoTestModelWithSerializedDto { use IsDatabaseModel; + public PrimaryKey $id; + public ModelWithDtoTestDtoForModelWithSerializer $dto; } diff --git a/tests/Integration/Database/ModelsWithoutIdTest.php b/tests/Integration/Database/ModelsWithoutIdTest.php new file mode 100644 index 000000000..64a6f77d2 --- /dev/null +++ b/tests/Integration/Database/ModelsWithoutIdTest.php @@ -0,0 +1,433 @@ +migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); + + $log = new LogEntry(level: 'INFO', message: 'Frieren discovered ancient magic', context: 'exploration'); + $savedLog = $log->save(); + + $this->assertSame($log, $savedLog); + $this->assertSame('INFO', $savedLog->level); + $this->assertSame('Frieren discovered ancient magic', $savedLog->message); + + $allLogs = model(LogEntry::class)->all(); + $this->assertCount(1, $allLogs); + $this->assertSame('INFO', $allLogs[0]->level); + } + + public function test_save_always_inserts_for_models_without_id(): void + { + $this->migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); + + $log = new LogEntry(level: 'INFO', message: 'Original message', context: 'test'); + $log->save(); + + // Models without primary keys always insert when save() is called + $log->message = 'Modified message'; + $log->save(); + + $allLogs = model(LogEntry::class)->all(); + $this->assertCount(2, $allLogs); + $this->assertSame('Original message', $allLogs[0]->message); + $this->assertSame('Modified message', $allLogs[1]->message); + } + + public function test_update_model_without_id_with_specific_conditions(): void + { + $this->migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); + + model(LogEntry::class)->create( + level: 'INFO', + message: 'Himmel was here', + context: 'memory', + ); + + query(LogEntry::class) + ->update(level: 'NOSTALGIC') + ->where('context', 'memory') + ->execute(); + + $updatedLog = model(LogEntry::class) + ->find(context: 'memory') + ->first(); + + $this->assertSame('NOSTALGIC', $updatedLog->level); + $this->assertSame('Himmel was here', $updatedLog->message); + } + + public function test_delete_operations_on_models_without_id(): void + { + $this->migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); + + model(LogEntry::class)->create( + level: 'TEMP', + message: 'Temporary debug info', + context: 'debug', + ); + + model(LogEntry::class)->create( + level: 'IMPORTANT', + message: 'Frieren awakens', + context: 'story', + ); + + $this->assertCount(2, model(LogEntry::class)->all()); + + query(LogEntry::class) + ->delete() + ->where('level', 'TEMP') + ->execute(); + + $remaining = model(LogEntry::class)->all(); + $this->assertCount(1, $remaining); + $this->assertSame('IMPORTANT', $remaining[0]->level); + } + + public function test_model_without_id_with_unique_constraints(): void + { + $this->migrate(CreateMigrationsTable::class, CreateCacheEntryMigration::class); + + model(CacheEntry::class)->create( + cache_key: 'spell_fire', + cache_value: 'flame_magic_data', + ttl: 3600, + ); + + query(CacheEntry::class) + ->update(cache_value: 'updated_flame_data') + ->where('cache_key', 'spell_fire') + ->execute(); + + $updatedData = model(CacheEntry::class) + ->find(cache_key: 'spell_fire') + ->first(); + + $this->assertSame('updated_flame_data', $updatedData->cache_value); + } + + public function test_relationship_methods_throw_for_models_without_id(): void + { + $this->migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); + + $this->expectException(ModelDidNotHavePrimaryColumn::class); + $this->expectExceptionMessage('does not have a primary column defined, which is required for the `resolve` method'); + + model(LogEntry::class)->resolve(id: 1); + } + + public function test_get_method_throws_for_models_without_id(): void + { + $this->migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); + + $this->expectException(ModelDidNotHavePrimaryColumn::class); + $this->expectExceptionMessage('does not have a primary column defined, which is required for the `get` method'); + + model(LogEntry::class)->get(id: 1); + } + + public function test_update_or_create_throws_for_models_without_id(): void + { + $this->migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); + + $this->expectException(ModelDidNotHavePrimaryColumn::class); + $this->expectExceptionMessage('does not have a primary column defined, which is required for the `updateOrCreate` method'); + + model(LogEntry::class)->updateOrCreate( + find: ['level' => 'INFO'], + update: ['message' => 'test'], + ); + } + + public function test_model_with_mixed_id_and_non_id_properties(): void + { + $this->migrate(CreateMigrationsTable::class, CreateMixedModelMigration::class); + + $mixed = new MixedModel( + regular_field: 'test', + another_field: 'data', + ); + + $mixed->save(); + + $this->assertInstanceOf(PrimaryKey::class, $mixed->id); + $this->assertSame('test', $mixed->regular_field); + + $all = model(MixedModel::class)->all(); + $this->assertCount(1, $all); + $this->assertInstanceOf(PrimaryKey::class, $all[0]->id); + $this->assertSame('test', $all[0]->regular_field); + } + + public function test_refresh_throws_for_models_without_id(): void + { + $this->migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); + + $log = new LogEntry( + level: 'INFO', + message: 'Frieren studies magic', + context: 'training', + ); + + $this->expectException(ModelDidNotHavePrimaryColumn::class); + $this->expectExceptionMessage('does not have a primary column defined, which is required for the `refresh` method'); + + $log->refresh(); + } + + public function test_load_throws_for_models_without_id(): void + { + $this->migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); + + $log = new LogEntry( + level: 'INFO', + message: 'Frieren explores ruins', + context: 'adventure', + ); + + $this->expectException(ModelDidNotHavePrimaryColumn::class); + $this->expectExceptionMessage('does not have a primary column defined, which is required for the `load` method'); + + $log->load('someRelation'); + } + + public function test_refresh_works_for_models_with_id(): void + { + $this->migrate(CreateMigrationsTable::class, CreateMixedModelMigration::class); + + $mixed = model(MixedModel::class)->create( + regular_field: 'original', + another_field: 'data', + ); + + query(MixedModel::class) + ->update(regular_field: 'updated') + ->where('id', $mixed->id->value) + ->execute(); + + $mixed->refresh(); + + $this->assertSame('updated', $mixed->regular_field); + $this->assertSame('data', $mixed->another_field); + } + + public function test_load_works_for_models_with_id(): void + { + $this->migrate(CreateMigrationsTable::class, CreateMixedModelMigration::class); + + $mixed = model(MixedModel::class)->create(regular_field: 'test', another_field: 'data'); + $result = $mixed->load(); + + $this->assertSame($mixed, $result); + $this->assertSame('test', $mixed->regular_field); + } + + public function test_load_with_relation_works_for_models_with_id(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateTestUserMigration::class, + CreateTestProfileMigration::class, + ); + + $user = model(TestUser::class)->create( + name: 'Frieren', + email: 'frieren@magic.elf', + ); + + model(TestProfile::class)->create( + user_id: $user->id->value, + bio: 'Ancient elf mage who loves magic and collecting spells', + age: 1000, + ); + + $userWithProfile = $user->load('profile'); + + $this->assertSame($user, $userWithProfile); + $this->assertSame('Frieren', $user->name); + $this->assertInstanceOf(TestProfile::class, $user->profile); + $this->assertSame('Ancient elf mage who loves magic and collecting spells', $user->profile->bio); + $this->assertSame(1000, $user->profile->age); + } +} + +final class LogEntry +{ + use IsDatabaseModel; + + public function __construct( + public string $level, + public string $message, + public string $context, + ) {} +} + +#[Table('cache_entries')] +final class CacheEntry +{ + use IsDatabaseModel; + + public function __construct( + public string $cache_key, + public string $cache_value, + public int $ttl, + ) {} +} + +final class MixedModel +{ + use IsDatabaseModel; + + public ?PrimaryKey $id = null; + + public function __construct( + public string $regular_field, + public string $another_field, + ) {} +} + +final class TestUser +{ + use IsDatabaseModel; + + public ?PrimaryKey $id = null; + + #[HasOne(ownerJoin: 'user_id')] + public ?TestProfile $profile = null; + + public function __construct( + public string $name, + public string $email, + ) {} +} + +final class TestProfile +{ + use IsDatabaseModel; + + public ?PrimaryKey $id = null; + + #[HasOne] + public ?TestUser $user; + + public function __construct( + public int $user_id, + public string $bio, + public int $age, + ) {} +} + +final class CreateLogEntryMigration implements DatabaseMigration +{ + public string $name = '001_create_log_entries'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(LogEntry::class) + ->text('level') + ->text('message') + ->text('context'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateCacheEntryMigration implements DatabaseMigration +{ + public string $name = '003_create_cache_entries'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(CacheEntry::class) + ->text('cache_key') + ->text('cache_value') + ->integer('ttl') + ->unique('cache_key'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateMixedModelMigration implements DatabaseMigration +{ + public string $name = '005_create_mixed_models'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(MixedModel::class) + ->primary() + ->text('regular_field') + ->text('another_field'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateTestUserMigration implements DatabaseMigration +{ + public string $name = '007_create_test_users'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(TestUser::class) + ->primary() + ->text('name') + ->text('email'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateTestProfileMigration implements DatabaseMigration +{ + public string $name = '009_create_test_profiles'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(TestProfile::class) + ->primary() + ->belongsTo('test_profiles.user_id', 'test_users.id') + ->text('bio') + ->integer('age'); + } + + public function down(): ?QueryStatement + { + return null; + } +} diff --git a/tests/Integration/Database/MultiDatabaseTest.php b/tests/Integration/Database/MultiDatabaseTest.php index 4136b7288..784477aed 100644 --- a/tests/Integration/Database/MultiDatabaseTest.php +++ b/tests/Integration/Database/MultiDatabaseTest.php @@ -10,10 +10,10 @@ use Tempest\Database\DatabaseInitializer; use Tempest\Database\DatabaseMigration; use Tempest\Database\Exceptions\QueryWasInvalid; -use Tempest\Database\Id; use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\Migrations\Migration; use Tempest\Database\Migrations\MigrationManager; +use Tempest\Database\PrimaryKey; use Tempest\Database\QueryStatement; use Tempest\Database\QueryStatements\CreateTableStatement; use Tempest\Database\QueryStatements\DropTableStatement; @@ -78,7 +78,7 @@ public function test_with_multiple_connections(): void query(Publisher::class) ->insert( - id: new Id(1), + id: new PrimaryKey(1), name: 'Main 1', description: 'Description Main 1', ) @@ -87,7 +87,7 @@ public function test_with_multiple_connections(): void query(Publisher::class) ->insert( - id: new Id(2), + id: new PrimaryKey(2), name: 'Main 2', description: 'Description Main 2', ) @@ -96,7 +96,7 @@ public function test_with_multiple_connections(): void query(Publisher::class) ->insert( - id: new Id(1), + id: new PrimaryKey(1), name: 'Backup 1', description: 'Description Backup 1', ) @@ -105,7 +105,7 @@ public function test_with_multiple_connections(): void query(Publisher::class) ->insert( - id: new Id(2), + id: new PrimaryKey(2), name: 'Backup 2', description: 'Description Backup 2', ) diff --git a/tests/Integration/Database/QueryStatements/AlterTableStatementTest.php b/tests/Integration/Database/QueryStatements/AlterTableStatementTest.php index 97800dfc4..ec7ebe25a 100644 --- a/tests/Integration/Database/QueryStatements/AlterTableStatementTest.php +++ b/tests/Integration/Database/QueryStatements/AlterTableStatementTest.php @@ -10,9 +10,9 @@ use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\DatabaseMigration; use Tempest\Database\Exceptions\QueryWasInvalid; -use Tempest\Database\Id; use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\Migrations\Migration as MigrationModel; +use Tempest\Database\PrimaryKey; use Tempest\Database\QueryStatement; use Tempest\Database\QueryStatements\AlterTableStatement; use Tempest\Database\QueryStatements\VarcharStatement; @@ -36,7 +36,7 @@ public function test_it_can_alter_a_table_definition(): void $this->assertCount(2, MigrationModel::all()); $this->assertSame( '0000-01-01_create_users_table', - MigrationModel::get(new Id(2))->name, + MigrationModel::get(new PrimaryKey(2))->name, ); try { @@ -60,7 +60,7 @@ public function test_it_can_alter_a_table_definition(): void $this->assertSame( '0000-01-02_add_email_to_user_table', - MigrationModel::get(new Id(3))->name, + MigrationModel::get(new PrimaryKey(3))->name, ); /** @var User $user */ diff --git a/tests/Integration/Database/QueryStatements/User.php b/tests/Integration/Database/QueryStatements/User.php index c9f147848..ba9d09dee 100644 --- a/tests/Integration/Database/QueryStatements/User.php +++ b/tests/Integration/Database/QueryStatements/User.php @@ -5,11 +5,14 @@ namespace Tests\Tempest\Integration\Database\QueryStatements; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; final class User { use IsDatabaseModel; + public PrimaryKey $id; + public string $name; public string $email; diff --git a/tests/Integration/Mapper/Fixtures/ObjectFactoryA.php b/tests/Integration/Mapper/Fixtures/ObjectFactoryA.php index 0e128c78f..99ebc301c 100644 --- a/tests/Integration/Mapper/Fixtures/ObjectFactoryA.php +++ b/tests/Integration/Mapper/Fixtures/ObjectFactoryA.php @@ -5,12 +5,15 @@ namespace Tests\Tempest\Integration\Mapper\Fixtures; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Mapper\CastWith; final class ObjectFactoryA { use IsDatabaseModel; + public PrimaryKey $id; + #[CastWith(ObjectFactoryACaster::class)] public string $prop; } diff --git a/tests/Integration/Mapper/Fixtures/ObjectFactoryWithValidation.php b/tests/Integration/Mapper/Fixtures/ObjectFactoryWithValidation.php index 8041a66e1..ef561185a 100644 --- a/tests/Integration/Mapper/Fixtures/ObjectFactoryWithValidation.php +++ b/tests/Integration/Mapper/Fixtures/ObjectFactoryWithValidation.php @@ -5,12 +5,15 @@ namespace Tests\Tempest\Integration\Mapper\Fixtures; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Validation\Rules\HasLength; final class ObjectFactoryWithValidation { use IsDatabaseModel; + public PrimaryKey $id; + #[HasLength(min: 2)] public string $prop; } diff --git a/tests/Integration/Mapper/MapperTest.php b/tests/Integration/Mapper/MapperTest.php index 50966b7d4..9f49c4769 100644 --- a/tests/Integration/Mapper/MapperTest.php +++ b/tests/Integration/Mapper/MapperTest.php @@ -46,7 +46,7 @@ public function test_make_object_from_class_string(): void ]); $this->assertSame('test', $author->name); - $this->assertSame(1, $author->id->id); + $this->assertSame(1, $author->id->value); } public function test_make_collection(): void @@ -62,7 +62,7 @@ public function test_make_collection(): void $this->assertCount(1, $authors); $this->assertSame('test', $authors[0]->name); - $this->assertSame(1, $authors[0]->id->id); + $this->assertSame(1, $authors[0]->id->value); } public function test_make_object_from_existing_object(): void @@ -78,7 +78,7 @@ public function test_make_object_from_existing_object(): void ]); $this->assertSame('other', $author->name); - $this->assertSame(1, $author->id->id); + $this->assertSame(1, $author->id->value); } public function test_make_object_with_map_to(): void @@ -94,7 +94,7 @@ public function test_make_object_with_map_to(): void ->to($author); $this->assertSame('other', $author->name); - $this->assertSame(1, $author->id->id); + $this->assertSame(1, $author->id->value); } public function test_make_object_with_has_many_relation(): void diff --git a/tests/Integration/ORM/Foo.php b/tests/Integration/ORM/Foo.php index eda226386..6b7eea7d1 100644 --- a/tests/Integration/ORM/Foo.php +++ b/tests/Integration/ORM/Foo.php @@ -5,10 +5,13 @@ namespace Tests\Tempest\Integration\ORM; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; final class Foo { use IsDatabaseModel; + public PrimaryKey $id; + public string $bar; } diff --git a/tests/Integration/ORM/IsDatabaseModelTest.php b/tests/Integration/ORM/IsDatabaseModelTest.php index 646a50c6f..fc2f70c8d 100644 --- a/tests/Integration/ORM/IsDatabaseModelTest.php +++ b/tests/Integration/ORM/IsDatabaseModelTest.php @@ -9,8 +9,8 @@ use DateTimeImmutable; use Tempest\Database\Exceptions\RelationWasMissing; use Tempest\Database\Exceptions\ValueWasMissing; -use Tempest\Database\Id; use Tempest\Database\Migrations\CreateMigrationsTable; +use Tempest\Database\PrimaryKey; use Tempest\DateTime\DateTime; use Tempest\Mapper\CasterFactory; use Tempest\Mapper\SerializerFactory; @@ -78,12 +78,12 @@ public function test_create_and_update_model(): void ); $this->assertSame('baz', $foo->bar); - $this->assertInstanceOf(Id::class, $foo->id); + $this->assertInstanceOf(PrimaryKey::class, $foo->id); $foo = Foo::get($foo->id); $this->assertSame('baz', $foo->bar); - $this->assertInstanceOf(Id::class, $foo->id); + $this->assertInstanceOf(PrimaryKey::class, $foo->id); $foo->update( bar: 'boo', @@ -107,7 +107,7 @@ public function test_get_with_non_id_object(): void $foo = Foo::get(1); - $this->assertSame(1, $foo->id->id); + $this->assertSame(1, $foo->id->value); } public function test_creating_many_and_saving_preserves_model_id(): void @@ -124,9 +124,9 @@ public function test_creating_many_and_saving_preserves_model_id(): void bar: 'b', ); - $this->assertEquals(1, $a->id->id); + $this->assertEquals(1, $a->id->value); $a->save(); - $this->assertEquals(1, $a->id->id); + $this->assertEquals(1, $a->id->value); } public function test_complex_query(): void @@ -150,12 +150,12 @@ public function test_complex_query(): void $book = Book::get($book->id, relations: ['author']); - $this->assertEquals(1, $book->id->id); + $this->assertEquals(1, $book->id->value); $this->assertSame('Book Title', $book->title); $this->assertSame(AuthorType::B, $book->author->type); $this->assertInstanceOf(Author::class, $book->author); $this->assertSame('Author Name', $book->author->name); - $this->assertEquals(1, $book->author->id->id); + $this->assertEquals(1, $book->author->id->value); } public function test_all_with_relations(): void @@ -183,12 +183,12 @@ public function test_all_with_relations(): void $book = $books[0]; - $this->assertEquals(1, $book->id->id); + $this->assertEquals(1, $book->id->value); $this->assertSame('Book Title', $book->title); $this->assertSame(AuthorType::B, $book->author->type); $this->assertInstanceOf(Author::class, $book->author); $this->assertSame('Author Name', $book->author->name); - $this->assertEquals(1, $book->author->id->id); + $this->assertEquals(1, $book->author->id->value); } public function test_missing_relation_exception(): void @@ -452,7 +452,7 @@ public function test_virtual_property(): void $a = AWithVirtual::select()->first(); - $this->assertSame(-$a->id->id, $a->fake); + $this->assertSame(-$a->id->value, $a->fake); } public function test_update_or_create(): void @@ -581,7 +581,7 @@ public function test_validation_on_create(): void public function test_validation_on_update(): void { $model = ModelWithValidation::new( - id: new Id(1), + id: new PrimaryKey(1), index: 1, ); diff --git a/tests/Integration/ORM/Mappers/QueryMapperTest.php b/tests/Integration/ORM/Mappers/QueryMapperTest.php index 723566ce5..b77e3a0ba 100644 --- a/tests/Integration/ORM/Mappers/QueryMapperTest.php +++ b/tests/Integration/ORM/Mappers/QueryMapperTest.php @@ -6,7 +6,7 @@ use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\Database; -use Tempest\Database\Id; +use Tempest\Database\PrimaryKey; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -40,7 +40,7 @@ public function test_insert_query(): void public function test_update_query(): void { - $author = Author::new(id: new Id(1), name: 'original'); + $author = Author::new(id: new PrimaryKey(1), name: 'original'); $query = query($author)->update(name: 'other')->build(); diff --git a/tests/Integration/ORM/Models/AttributeTableNameModel.php b/tests/Integration/ORM/Models/AttributeTableNameModel.php index 261742460..09509cca0 100644 --- a/tests/Integration/ORM/Models/AttributeTableNameModel.php +++ b/tests/Integration/ORM/Models/AttributeTableNameModel.php @@ -3,10 +3,13 @@ namespace Tests\Tempest\Integration\ORM\Models; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Database\Table; #[Table('custom_attribute_table_name')] final class AttributeTableNameModel { use IsDatabaseModel; + + public PrimaryKey $id; } diff --git a/tests/Integration/ORM/Models/BaseModel.php b/tests/Integration/ORM/Models/BaseModel.php index 89ec567ed..b3398253d 100644 --- a/tests/Integration/ORM/Models/BaseModel.php +++ b/tests/Integration/ORM/Models/BaseModel.php @@ -3,8 +3,11 @@ namespace Tests\Tempest\Integration\ORM\Models; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; final class BaseModel { use IsDatabaseModel; + + public PrimaryKey $id; } diff --git a/tests/Integration/ORM/Models/CarbonModel.php b/tests/Integration/ORM/Models/CarbonModel.php index 72247d737..62dbfe729 100644 --- a/tests/Integration/ORM/Models/CarbonModel.php +++ b/tests/Integration/ORM/Models/CarbonModel.php @@ -6,6 +6,7 @@ use Carbon\Carbon; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Mapper\CastWith; use Tempest\Mapper\SerializeWith; @@ -13,6 +14,8 @@ final class CarbonModel { use IsDatabaseModel; + public PrimaryKey $id; + public function __construct( public Carbon $createdAt, ) {} diff --git a/tests/Integration/ORM/Models/CasterModel.php b/tests/Integration/ORM/Models/CasterModel.php index 9a3a8a375..75b082a8f 100644 --- a/tests/Integration/ORM/Models/CasterModel.php +++ b/tests/Integration/ORM/Models/CasterModel.php @@ -4,11 +4,14 @@ use DateTimeImmutable; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; final class CasterModel { use IsDatabaseModel; + public PrimaryKey $id; + public function __construct( public DateTimeImmutable $date, public array $array_prop, diff --git a/tests/Integration/ORM/Models/ChildModel.php b/tests/Integration/ORM/Models/ChildModel.php index 164985615..a9f72de3f 100644 --- a/tests/Integration/ORM/Models/ChildModel.php +++ b/tests/Integration/ORM/Models/ChildModel.php @@ -6,6 +6,7 @@ use Tempest\Database\HasOne; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Database\Table; #[Table('child')] @@ -13,6 +14,8 @@ final class ChildModel { use IsDatabaseModel; + public PrimaryKey $id; + #[HasOne] public ThroughModel $through; diff --git a/tests/Integration/ORM/Models/DateTimeModel.php b/tests/Integration/ORM/Models/DateTimeModel.php index 16ef5d7ce..bfb522b2e 100644 --- a/tests/Integration/ORM/Models/DateTimeModel.php +++ b/tests/Integration/ORM/Models/DateTimeModel.php @@ -3,13 +3,13 @@ namespace Tests\Tempest\Integration\ORM\Models; use DateTime as NativeDateTime; -use Tempest\Database\Id; +use Tempest\Database\PrimaryKey; use Tempest\DateTime\DateTime; final class DateTimeModel { public function __construct( - public Id $id, + public PrimaryKey $id, public NativeDateTime $phpDateTime, public DateTime $tempestDateTime, ) {} diff --git a/tests/Integration/ORM/Models/ModelWithValidation.php b/tests/Integration/ORM/Models/ModelWithValidation.php index a174368bc..75022a635 100644 --- a/tests/Integration/ORM/Models/ModelWithValidation.php +++ b/tests/Integration/ORM/Models/ModelWithValidation.php @@ -3,6 +3,7 @@ namespace Tests\Tempest\Integration\ORM\Models; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Validation\Rules\IsBetween; use Tempest\Validation\SkipValidation; @@ -10,6 +11,8 @@ final class ModelWithValidation { use IsDatabaseModel; + public PrimaryKey $id; + #[IsBetween(min: 1, max: 10)] public int $index; diff --git a/tests/Integration/ORM/Models/ParentModel.php b/tests/Integration/ORM/Models/ParentModel.php index aef77687d..509e613d2 100644 --- a/tests/Integration/ORM/Models/ParentModel.php +++ b/tests/Integration/ORM/Models/ParentModel.php @@ -5,6 +5,7 @@ namespace Tests\Tempest\Integration\ORM\Models; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Database\Table; #[Table('parent')] @@ -12,6 +13,8 @@ final class ParentModel { use IsDatabaseModel; + public PrimaryKey $id; + public function __construct( public string $name, diff --git a/tests/Integration/ORM/Models/StaticMethodTableNameModel.php b/tests/Integration/ORM/Models/StaticMethodTableNameModel.php index 73f833e9e..a3bf2e9b6 100644 --- a/tests/Integration/ORM/Models/StaticMethodTableNameModel.php +++ b/tests/Integration/ORM/Models/StaticMethodTableNameModel.php @@ -3,10 +3,13 @@ namespace Tests\Tempest\Integration\ORM\Models; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Database\Table; #[Table('custom_static_method_table_name')] final class StaticMethodTableNameModel { use IsDatabaseModel; + + public PrimaryKey $id; } diff --git a/tests/Integration/ORM/Models/ThroughModel.php b/tests/Integration/ORM/Models/ThroughModel.php index 5e0035a42..51b70a34a 100644 --- a/tests/Integration/ORM/Models/ThroughModel.php +++ b/tests/Integration/ORM/Models/ThroughModel.php @@ -6,6 +6,7 @@ use Tempest\Database\BelongsTo; use Tempest\Database\IsDatabaseModel; +use Tempest\Database\PrimaryKey; use Tempest\Database\Table; #[Table('through')] @@ -13,6 +14,8 @@ final class ThroughModel { use IsDatabaseModel; + public PrimaryKey $id; + public function __construct( public ParentModel $parent, public ChildModel $child, diff --git a/tests/Integration/Route/RequestTest.php b/tests/Integration/Route/RequestTest.php index 8009650e1..fe518d93f 100644 --- a/tests/Integration/Route/RequestTest.php +++ b/tests/Integration/Route/RequestTest.php @@ -6,8 +6,8 @@ use PHPUnit\Framework\Attributes\TestWith; use Tempest\Cryptography\Encryption\Encrypter; -use Tempest\Database\Id; use Tempest\Database\Migrations\CreateMigrationsTable; +use Tempest\Database\PrimaryKey; use Tempest\Http\GenericRequest; use Tempest\Http\Method; use Tempest\Http\RequestFactory; @@ -143,8 +143,8 @@ public function test_custom_request_test_with_validation(): void ->assertHasNoValidationsErrors() ->assertStatus(Status::FOUND); - $book = Book::get(new Id(1)); - $this->assertSame(1, $book->id->id); + $book = Book::get(new PrimaryKey(1)); + $this->assertSame(1, $book->id->value); $this->assertSame('a', $book->title); } @@ -172,8 +172,8 @@ public function test_custom_request_test_with_nested_validation(): void ->assertHasNoValidationsErrors() ->assertStatus(Status::FOUND); - $book = Book::get(new Id(1), relations: ['author']); - $this->assertSame(1, $book->id->id); + $book = Book::get(new PrimaryKey(1), relations: ['author']); + $this->assertSame(1, $book->id->value); $this->assertSame('a', $book->title); $this->assertSame('b', $book->author->name); } From 1f35fac56bceb78a833bb6cfb9b962269e8df954 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 4 Aug 2025 06:13:05 +0200 Subject: [PATCH 11/51] feat(database): support custom primary keys when loading relationships --- .../QueryBuilders/InsertQueryBuilder.php | 6 +- .../QueryBuilders/UpdateQueryBuilder.php | 6 +- ...ustomPrimaryKeyRelationshipLoadingTest.php | 513 ++++++++++++++++++ 3 files changed, 521 insertions(+), 4 deletions(-) create mode 100644 tests/Integration/Database/CustomPrimaryKeyRelationshipLoadingTest.php diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index 9920d2fff..a1f695ebf 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -142,11 +142,13 @@ private function resolveData(): array // BelongsTo and reverse HasMany relations are included if ($definition->isRelation($property)) { - $column .= '_id'; + $relationModel = inspect($property->getType()->asClass()); + $primaryKey = $relationModel->getPrimaryKey() ?? 'id'; + $column .= '_' . $primaryKey; $value = match (true) { $value === null => null, - isset($value->id) => $value->id->value, + isset($value->{$primaryKey}) => $value->{$primaryKey}->value, default => new InsertQueryBuilder( $value::class, [$value], diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index 99d39176f..cab5131b5 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -129,11 +129,13 @@ private function resolveValues(): ImmutableArray } if ($this->model->isRelation($property)) { - $column .= '_id'; + $relationModel = inspect($property->getType()->asClass()); + $primaryKey = $relationModel->getPrimaryKey() ?? 'id'; + $column .= '_' . $primaryKey; $value = match (true) { $value === null => null, - isset($value->id) => $value->id->value, + isset($value->{$primaryKey}) => $value->{$primaryKey}->value, default => new InsertQueryBuilder( $value::class, [$value], diff --git a/tests/Integration/Database/CustomPrimaryKeyRelationshipLoadingTest.php b/tests/Integration/Database/CustomPrimaryKeyRelationshipLoadingTest.php new file mode 100644 index 000000000..207794566 --- /dev/null +++ b/tests/Integration/Database/CustomPrimaryKeyRelationshipLoadingTest.php @@ -0,0 +1,513 @@ +migrate( + CreateMigrationsTable::class, + CreateMageWithUuidMigration::class, + CreateGrimoireWithUuidMigration::class, + ); + + $mage = model(MageWithUuid::class)->create( + name: 'Frieren', + element: 'Time', + ); + + $grimoire = model(GrimoireWithUuid::class)->create( + mage_uuid: $mage->uuid->value, + title: 'Ancient Time Magic Compendium', + spells_count: 847, + ); + + $this->assertInstanceOf(PrimaryKey::class, $mage->uuid); + $this->assertInstanceOf(PrimaryKey::class, $grimoire->uuid); + + $loadedMage = model(MageWithUuid::class)->get($mage->uuid); + $loadedMage->load('grimoire'); + + $this->assertInstanceOf(GrimoireWithUuid::class, $loadedMage->grimoire); + $this->assertSame('Ancient Time Magic Compendium', $loadedMage->grimoire->title); + $this->assertSame(847, $loadedMage->grimoire->spells_count); + $this->assertTrue($mage->uuid->equals($loadedMage->uuid)); + $this->assertTrue($grimoire->uuid->equals($loadedMage->grimoire->uuid)); + } + + public function test_has_many_relationship_with_uuid_primary_keys(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateMageWithUuidMigration::class, + CreateSpellWithUuidMigration::class, + ); + + $mage = model(MageWithUuid::class)->create( + name: 'Flamme', + element: 'Fire', + ); + + $spell1 = model(SpellWithUuid::class)->create( + mage_uuid: $mage->uuid->value, + name: 'Zoltraak', + power_level: 95, + mana_cost: 150, + ); + + $spell2 = model(SpellWithUuid::class)->create( + mage_uuid: $mage->uuid->value, + name: 'Volzandia', + power_level: 87, + mana_cost: 120, + ); + + $loadedMage = model(MageWithUuid::class)->get($mage->uuid); + $loadedMage->load('spells'); + + $this->assertIsArray($loadedMage->spells); + $this->assertCount(2, $loadedMage->spells); + + $spellNames = array_map(fn (SpellWithUuid $spell) => $spell->name, $loadedMage->spells); + $this->assertContains('Zoltraak', $spellNames); + $this->assertContains('Volzandia', $spellNames); + + foreach ($loadedMage->spells as $spell) { + $this->assertInstanceOf(SpellWithUuid::class, $spell); + $this->assertInstanceOf(PrimaryKey::class, $spell->uuid); + $this->assertSame($mage->uuid->value, $spell->mage_uuid); + } + } + + public function test_belongs_to_relationship_with_uuid_primary_keys(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateMageWithUuidMigration::class, + CreateSpellWithUuidMigration::class, + ); + + $mage = model(MageWithUuid::class)->create( + name: 'Serie', + element: 'Ancient', + ); + + $spell = model(SpellWithUuid::class)->create( + mage_uuid: $mage->uuid->value, + name: 'Goddess Magic', + power_level: 100, + mana_cost: 999, + ); + + $loadedSpell = model(SpellWithUuid::class)->get($spell->uuid); + $loadedSpell->load('mage'); + + $this->assertInstanceOf(MageWithUuid::class, $loadedSpell->mage); + $this->assertSame('Serie', $loadedSpell->mage->name); + $this->assertSame('Ancient', $loadedSpell->mage->element); + $this->assertTrue($mage->uuid->equals($loadedSpell->mage->uuid)); + } + + public function test_nested_relationship_loading_with_uuid_primary_keys(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateMageWithUuidMigration::class, + CreateGrimoireWithUuidMigration::class, + CreateSpellWithUuidMigration::class, + ); + + $mage = model(MageWithUuid::class)->create( + name: 'Fern', + element: 'Combat', + ); + + $grimoire = model(GrimoireWithUuid::class)->create( + mage_uuid: $mage->uuid->value, + title: 'Combat Magic Fundamentals', + spells_count: 42, + ); + + $spell = model(SpellWithUuid::class)->create( + mage_uuid: $mage->uuid->value, + name: 'Basic Attack Magic', + power_level: 75, + mana_cost: 50, + ); + + $loadedMage = model(MageWithUuid::class)->get($mage->uuid); + $loadedMage->load('grimoire', 'spells'); + + $this->assertInstanceOf(GrimoireWithUuid::class, $loadedMage->grimoire); + $this->assertSame('Combat Magic Fundamentals', $loadedMage->grimoire->title); + + $this->assertIsArray($loadedMage->spells); + $this->assertCount(1, $loadedMage->spells); + $this->assertSame('Basic Attack Magic', $loadedMage->spells[0]->name); + + $loadedSpell = model(SpellWithUuid::class)->get($spell->uuid); + $loadedSpell->load('mage.grimoire'); + + $this->assertInstanceOf(MageWithUuid::class, $loadedSpell->mage); + $this->assertInstanceOf(GrimoireWithUuid::class, $loadedSpell->mage->grimoire); + $this->assertSame('Fern', $loadedSpell->mage->name); + $this->assertSame('Combat Magic Fundamentals', $loadedSpell->mage->grimoire->title); + } + + public function test_relationship_with_custom_foreign_key_naming(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateMageWithUuidMigration::class, + CreateArtifactWithUuidMigration::class, + ); + + $mage = model(MageWithUuid::class)->create( + name: 'Himmel', + element: 'Hero', + ); + + $artifact = model(ArtifactWithUuid::class)->create( + owner_uuid: $mage->uuid->value, + name: 'Hero Sword', + rarity: 'Legendary', + enchantment_level: 10, + ); + + $loadedMage = model(MageWithUuid::class)->get($mage->uuid); + $loadedMage->load('artifacts'); + + $this->assertIsArray($loadedMage->artifacts); + $this->assertCount(1, $loadedMage->artifacts); + $this->assertSame('Hero Sword', $loadedMage->artifacts[0]->name); + $this->assertSame('Legendary', $loadedMage->artifacts[0]->rarity); + + $loadedArtifact = model(ArtifactWithUuid::class)->get($artifact->uuid); + $loadedArtifact->load('owner'); + + $this->assertInstanceOf(MageWithUuid::class, $loadedArtifact->owner); + $this->assertSame('Himmel', $loadedArtifact->owner->name); + $this->assertTrue($mage->uuid->equals($loadedArtifact->owner->uuid)); + } + + public function test_relationship_loading_preserves_uuid_integrity(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateMageWithUuidMigration::class, + CreateSpellWithUuidMigration::class, + ); + + $mage1 = model(MageWithUuid::class)->create(name: 'Stark', element: 'Axe'); + $mage2 = model(MageWithUuid::class)->create(name: 'Eisen', element: 'Monk'); + + $spell1 = model(SpellWithUuid::class)->create( + mage_uuid: $mage1->uuid->value, + name: 'Axe Technique', + power_level: 80, + mana_cost: 30, + ); + + $spell2 = model(SpellWithUuid::class)->create( + mage_uuid: $mage2->uuid->value, + name: 'Warrior Meditation', + power_level: 60, + mana_cost: 20, + ); + + $loadedMage1 = model(MageWithUuid::class)->get($mage1->uuid); + $loadedMage1->load('spells'); + + $loadedMage2 = model(MageWithUuid::class)->get($mage2->uuid); + $loadedMage2->load('spells'); + + $this->assertCount(1, $loadedMage1->spells); + $this->assertCount(1, $loadedMage2->spells); + + $this->assertSame('Axe Technique', $loadedMage1->spells[0]->name); + $this->assertSame('Warrior Meditation', $loadedMage2->spells[0]->name); + + $this->assertSame($mage1->uuid->value, $loadedMage1->spells[0]->mage_uuid); + $this->assertSame($mage2->uuid->value, $loadedMage2->spells[0]->mage_uuid); + + $this->assertFalse($mage1->uuid->equals($mage2->uuid)); + $this->assertFalse($spell1->uuid->equals($spell2->uuid)); + } + + public function test_automatic_uuid_primary_key_detection(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateMageSimpleMigration::class, + CreateSpellSimpleMigration::class, + ); + + $mage = model(MageSimple::class)->create( + name: 'Fern', + element: 'Combat', + ); + + $spell = model(SpellSimple::class)->create( + mage_uuid: $mage->uuid->value, + name: 'Cutting Magic', + power_level: 90, + ); + + $loadedMage = model(MageSimple::class)->get($mage->uuid); + $loadedMage->load('spells'); + + $this->assertCount(1, $loadedMage->spells); + $this->assertSame('Cutting Magic', $loadedMage->spells[0]->name); + + $loadedSpell = model(SpellSimple::class)->get($spell->uuid); + $loadedSpell->load('mage'); + + $this->assertInstanceOf(MageSimple::class, $loadedSpell->mage); + $this->assertSame('Fern', $loadedSpell->mage->name); + } +} + +#[Table('mages_with_uuid')] +final class MageWithUuid +{ + use IsDatabaseModel; + + public ?PrimaryKey $uuid = null; + + #[HasOne(ownerJoin: 'mage_uuid')] + public ?GrimoireWithUuid $grimoire = null; + + /** @var \Tests\Tempest\Integration\Database\SpellWithUuid[] */ + #[HasMany(ownerJoin: 'mage_uuid')] + public array $spells = []; + + /** @var \Tests\Tempest\Integration\Database\ArtifactWithUuid[] */ + #[HasMany(ownerJoin: 'owner_uuid')] + public array $artifacts = []; + + public function __construct( + public string $name, + public string $element, + ) {} +} + +#[Table('grimoires_with_uuid')] +final class GrimoireWithUuid +{ + use IsDatabaseModel; + + public ?PrimaryKey $uuid = null; + + #[HasOne(ownerJoin: 'uuid', relationJoin: 'mage_uuid')] + public ?MageWithUuid $mage = null; + + public function __construct( + public int $mage_uuid, + public string $title, + public int $spells_count, + ) {} +} + +#[Table('spells_with_uuid')] +final class SpellWithUuid +{ + use IsDatabaseModel; + + public ?PrimaryKey $uuid = null; + + #[HasOne(ownerJoin: 'uuid', relationJoin: 'mage_uuid')] + public ?MageWithUuid $mage = null; + + public function __construct( + public int $mage_uuid, + public string $name, + public int $power_level, + public int $mana_cost, + ) {} +} + +#[Table('artifacts_with_uuid')] +final class ArtifactWithUuid +{ + use IsDatabaseModel; + + public ?PrimaryKey $uuid = null; + + #[HasOne(ownerJoin: 'uuid', relationJoin: 'owner_uuid')] + public ?MageWithUuid $owner = null; + + public function __construct( + public int $owner_uuid, + public string $name, + public string $rarity, + public int $enchantment_level, + ) {} +} + +final class CreateMageWithUuidMigration implements DatabaseMigration +{ + public string $name = '001_create_mages_with_uuid'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(MageWithUuid::class) + ->primary(name: 'uuid') + ->text('name') + ->text('element'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateGrimoireWithUuidMigration implements DatabaseMigration +{ + public string $name = '002_create_grimoires_with_uuid'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(GrimoireWithUuid::class) + ->primary(name: 'uuid') + ->belongsTo('grimoires_with_uuid.mage_uuid', 'mages_with_uuid.uuid') + ->text('title') + ->integer('spells_count'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateSpellWithUuidMigration implements DatabaseMigration +{ + public string $name = '003_create_spells_with_uuid'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(SpellWithUuid::class) + ->primary(name: 'uuid') + ->belongsTo('spells_with_uuid.mage_uuid', 'mages_with_uuid.uuid') + ->text('name') + ->integer('power_level') + ->integer('mana_cost'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateArtifactWithUuidMigration implements DatabaseMigration +{ + public string $name = '004_create_artifacts_with_uuid'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(ArtifactWithUuid::class) + ->primary(name: 'uuid') + ->belongsTo('artifacts_with_uuid.owner_uuid', 'mages_with_uuid.uuid') + ->text('name') + ->text('rarity') + ->integer('enchantment_level'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +#[Table('mages')] +final class MageSimple +{ + use IsDatabaseModel; + + public ?PrimaryKey $uuid = null; + + /** @var \Tests\Tempest\Integration\Database\SpellSimple[] */ + #[HasMany] + public array $spells = []; + + public function __construct( + public string $name, + public string $element, + ) {} +} + +#[Table('spells')] +final class SpellSimple +{ + use IsDatabaseModel; + + public ?PrimaryKey $uuid = null; + + #[BelongsTo] + public ?MageSimple $mage = null; + + public function __construct( + public int $mage_uuid, + public string $name, + public int $power_level, + ) {} +} + +final class CreateMageSimpleMigration implements DatabaseMigration +{ + public string $name = '005_create_mages_simple'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(MageSimple::class) + ->primary(name: 'uuid') + ->text('name') + ->text('element'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateSpellSimpleMigration implements DatabaseMigration +{ + public string $name = '006_create_spells_simple'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(SpellSimple::class) + ->primary(name: 'uuid') + ->belongsTo('spells.mage_uuid', 'mages.uuid') + ->text('name') + ->integer('power_level'); + } + + public function down(): ?QueryStatement + { + return null; + } +} From bc2d7db8121a3d699d275467cbaf96a2b08b3e4f Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 4 Aug 2025 07:01:06 +0200 Subject: [PATCH 12/51] feat(database): add inline documentation on query builders --- .../src/Builder/ModelQueryBuilder.php | 75 +++++++++++++-- .../QueryBuilders/CountQueryBuilder.php | 21 ++++- .../QueryBuilders/DeleteQueryBuilder.php | 21 ++++- .../QueryBuilders/InsertQueryBuilder.php | 15 +++ .../Builder/QueryBuilders/QueryBuilder.php | 10 ++ .../QueryBuilders/SelectQueryBuilder.php | 88 ++++++++++++++--- .../QueryBuilders/UpdateQueryBuilder.php | 21 ++++- packages/database/src/functions.php | 25 +++-- .../Builder/ModelQueryBuilderTest.php | 94 ++++++++++++++++++- 9 files changed, 326 insertions(+), 44 deletions(-) diff --git a/packages/database/src/Builder/ModelQueryBuilder.php b/packages/database/src/Builder/ModelQueryBuilder.php index 926898ff2..fed26c569 100644 --- a/packages/database/src/Builder/ModelQueryBuilder.php +++ b/packages/database/src/Builder/ModelQueryBuilder.php @@ -29,36 +29,66 @@ public function __construct( /** * Returns a builder for selecting records using this model's table. * + * **Example** + * ```php + * model(User::class) + * ->select('id', 'username', 'email') + * ->execute(); + * ``` + * * @return SelectQueryBuilder */ - public function select(): SelectQueryBuilder + public function select(string ...$columns): SelectQueryBuilder { - return query($this->model)->select(); + return query($this->model)->select(...$columns); } /** * Returns a builder for inserting records using this model's table. * + * **Example** + * ```php + * model(User::class) + * ->insert(username: 'Frieren') + * ->execute(); + * ``` + * * @return InsertQueryBuilder */ - public function insert(): InsertQueryBuilder + public function insert(mixed ...$values): InsertQueryBuilder { - return query($this->model)->insert(); + return query($this->model)->insert(...$values); } /** * Returns a builder for updating records using this model's table. * + * **Example** + * ```php + * model(User::class) + * ->update(is_admin: true) + * ->whereIn('id', [1, 2, 3]) + * ->execute(); + * ``` + * * @return UpdateQueryBuilder */ - public function update(): UpdateQueryBuilder + public function update(mixed ...$values): UpdateQueryBuilder { - return query($this->model)->update(); + return query($this->model)->update(...$values); } /** * Returns a builder for deleting records using this model's table. * + * **Example** + * ```php + * model(User::class) + * ->delete() + * ->where(name: 'Frieren') + * ->execute(); + * ``` + * * @return DeleteQueryBuilder */ public function delete(): DeleteQueryBuilder @@ -69,6 +99,11 @@ public function delete(): DeleteQueryBuilder /** * Returns a builder for counting records using this model's table. * + * **Example** + * ```php + * model(User::class)->count()->execute(); + * ``` + * * @return CountQueryBuilder */ public function count(): CountQueryBuilder @@ -79,6 +114,11 @@ public function count(): CountQueryBuilder /** * Creates a new instance of this model without persisting it to the database. * + * **Example** + * ```php + * model(User::class)->new(name: 'Frieren'); + * ``` + * * @return TModel */ public function new(mixed ...$params): object @@ -89,6 +129,11 @@ public function new(mixed ...$params): object /** * Finds a model instance by its ID. * + * **Example** + * ```php + * model(User::class)->findById(1); + * ``` + * * @return TModel */ public function findById(string|int|PrimaryKey $id): object @@ -103,6 +148,11 @@ public function findById(string|int|PrimaryKey $id): object /** * Finds a model instance by its ID. * + * **Example** + * ```php + * model(User::class)->resolve(1); + * ``` + * * @return TModel */ public function resolve(string|int|PrimaryKey $id): object @@ -117,6 +167,11 @@ public function resolve(string|int|PrimaryKey $id): object /** * Gets a model instance by its ID, optionally loading the given relationships. * + * **Example** + * ```php + * model(User::class)->get(1); + * ``` + * * @return TModel|null */ public function get(string|int|PrimaryKey $id, array $relations = []): ?object @@ -152,7 +207,7 @@ public function all(array $relations = []): array * * **Example** * ```php - * model(MagicUser::class)->find(name: 'Frieren'); + * model(User::class)->find(name: 'Frieren'); * ``` * * @return SelectQueryBuilder @@ -173,7 +228,7 @@ public function find(mixed ...$conditions): SelectQueryBuilder * * **Example** * ```php - * model(MagicUser::class)->create(name: 'Frieren', kind: Kind::ELF); + * model(User::class)->create(name: 'Frieren', kind: Kind::ELF); * ``` * * @return TModel @@ -205,7 +260,7 @@ public function create(mixed ...$params): object * * **Example** * ```php - * $model = model(MagicUser::class)->findOrNew( + * $model = model(User::class)->findOrNew( * find: ['name' => 'Frieren'], * update: ['kind' => Kind::ELF], * ); @@ -237,7 +292,7 @@ public function findOrNew(array $find, array $update): object * * **Example** * ```php - * $model = model(MagicUser::class)->updateOrCreate( + * $model = model(User::class)->updateOrCreate( * find: ['name' => 'Frieren'], * update: ['kind' => Kind::ELF], * ); diff --git a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php index 984286319..1ecdb7225 100644 --- a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php @@ -43,12 +43,19 @@ public function __construct(string|object $model, ?string $column = null) ); } + /** + * Executes the count query and returns the number of matching records. + */ public function execute(mixed ...$bindings): int { return $this->build()->fetchFirst(...$bindings)[$this->count->getKey()]; } - /** @return self */ + /** + * Modifies the count query to only count distinct values in the specified column. + * + * @return self + */ public function distinct(): self { if ($this->count->column === null || $this->count->column === '*') { @@ -60,7 +67,11 @@ public function distinct(): self return $this; } - /** @return self */ + /** + * Binds the provided values to the query, allowing for parameterized queries. + * + * @return self + */ public function bind(mixed ...$bindings): self { $this->bindings = [...$this->bindings, ...$bindings]; @@ -68,11 +79,17 @@ public function bind(mixed ...$bindings): self return $this; } + /** + * Returns the SQL statement without the bindings. + */ public function toSql(): ImmutableString { return $this->build()->toSql(); } + /** + * Returns the SQL statement with bindings. This method may generate syntax errors, it is not recommended to use it other than for debugging. + */ public function toRawSql(): ImmutableString { return $this->build()->toRawSql(); diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index c0f51ca68..9fd1c69fe 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -36,12 +36,19 @@ public function __construct(string|object $model) $this->delete = new DeleteStatement($this->model->getTableDefinition()); } + /** + * Executes the delete query, removing matching records from the database. + */ public function execute(): void { $this->build()->execute(); } - /** @return self */ + /** + * Allows the delete operation to proceed without WHERE conditions, deleting all records. + * + * @return self + */ public function allowAll(): self { $this->delete->allowAll = true; @@ -49,7 +56,11 @@ public function allowAll(): self return $this; } - /** @return self */ + /** + * Binds the provided values to the query, allowing for parameterized queries. + * + * @return self + */ public function bind(mixed ...$bindings): self { $this->bindings = [...$this->bindings, ...$bindings]; @@ -57,11 +68,17 @@ public function bind(mixed ...$bindings): self return $this; } + /** + * Returns the SQL statement without the bindings. + */ public function toSql(): ImmutableString { return $this->build()->toSql(); } + /** + * Returns the SQL statement with bindings. This method may generate syntax errors, it is not recommended to use it other than for debugging. + */ public function toRawSql(): ImmutableString { return $this->build()->toRawSql(); diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index a1f695ebf..62db62c8c 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -46,6 +46,9 @@ public function __construct( $this->insert = new InsertStatement($this->model->getTableDefinition()); } + /** + * Executes the insert query and returns the primary key of the inserted record. + */ public function execute(mixed ...$bindings): PrimaryKey { $id = $this->build()->execute(...$bindings); @@ -61,11 +64,17 @@ public function execute(mixed ...$bindings): PrimaryKey return $id; } + /** + * Returns the SQL statement without the bindings. + */ public function toSql(): ImmutableString { return $this->build()->toSql(); } + /** + * Returns the SQL statement with bindings. This method may generate syntax errors, it is not recommended to use it other than for debugging. + */ public function toRawSql(): ImmutableString { return $this->build()->toRawSql(); @@ -92,6 +101,9 @@ public function build(mixed ...$bindings): Query return new Query($this->insert, [...$this->bindings, ...$bindings])->onDatabase($this->onDatabase); } + /** + * Binds the provided values to the query, allowing for parameterized queries. + */ public function bind(mixed ...$bindings): self { $this->bindings = [...$this->bindings, ...$bindings]; @@ -99,6 +111,9 @@ public function bind(mixed ...$bindings): self return $this; } + /** + * Registers callbacks to be executed after the insert operation completes. + */ public function then(Closure ...$callbacks): self { $this->after = [...$this->after, ...$callbacks]; diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index 62890a9e1..69c288115 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -20,6 +20,8 @@ public function __construct( ) {} /** + * Creates a `SELECT` query builder for retrieving records from the database. + * * @return SelectQueryBuilder */ public function select(string ...$columns): SelectQueryBuilder @@ -31,6 +33,8 @@ public function select(string ...$columns): SelectQueryBuilder } /** + * Creates an `INSERT` query builder for adding new records to the database. + * * @return InsertQueryBuilder */ public function insert(mixed ...$values): InsertQueryBuilder @@ -47,6 +51,8 @@ public function insert(mixed ...$values): InsertQueryBuilder } /** + * Creates an `UPDATE` query builder for modifying existing records in the database. + * * @return UpdateQueryBuilder */ public function update(mixed ...$values): UpdateQueryBuilder @@ -59,6 +65,8 @@ public function update(mixed ...$values): UpdateQueryBuilder } /** + * Creates a `DELETE` query builder for removing records from the database. + * * @return DeleteQueryBuilder */ public function delete(): DeleteQueryBuilder @@ -67,6 +75,8 @@ public function delete(): DeleteQueryBuilder } /** + * Creates a `COUNT` query builder for counting records in the database. + * * @return CountQueryBuilder */ public function count(?string $column = null): CountQueryBuilder diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index ae5c2383a..e265255e0 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -62,7 +62,11 @@ public function __construct(string|object $model, ?ImmutableArray $fields = null ); } - /** @return T|null */ + /** + * Returns the first record matching the query. + * + * @return T|null + */ public function first(mixed ...$bindings): mixed { $query = $this->build(...$bindings); @@ -82,7 +86,11 @@ public function first(mixed ...$bindings): mixed return $result[array_key_first($result)]; } - /** @return PaginatedData */ + /** + * Returnd length-aware paginated data for the current query. + * + * @return PaginatedData + */ public function paginate(int $itemsPerPage = 20, int $currentPage = 1, int $maxLinks = 10): PaginatedData { $total = new CountQueryBuilder($this->model->model)->execute(); @@ -99,7 +107,11 @@ public function paginate(int $itemsPerPage = 20, int $currentPage = 1, int $maxL ); } - /** @return T|null */ + /** + * Returns the first record matching the given primary key. + * + * @return T|null + */ public function get(PrimaryKey $id): mixed { if (! $this->model->hasPrimaryKey()) { @@ -109,7 +121,11 @@ public function get(PrimaryKey $id): mixed return $this->where($this->model->getPrimaryKey(), $id)->first(); } - /** @return T[] */ + /** + * Returns all records matching the query. + * + * @return T[] + */ public function all(mixed ...$bindings): array { $query = $this->build(...$bindings); @@ -124,7 +140,9 @@ public function all(mixed ...$bindings): array } /** - * @param Closure(T[] $models): void $closure + * Performs multiple queries in chunks, passing each chunk to the provided closure. + * + * @param Closure(T[]): void $closure */ public function chunk(Closure $closure, int $amountPerChunk = 200): void { @@ -142,7 +160,11 @@ public function chunk(Closure $closure, int $amountPerChunk = 200): void } while ($data !== []); } - /** @return self */ + /** + * Orders the results of the query by the given raw SQL statement. + * + * @return self + */ public function orderBy(string $statement): self { $this->select->orderBy[] = new OrderByStatement($statement); @@ -150,7 +172,11 @@ public function orderBy(string $statement): self return $this; } - /** @return self */ + /** + * Groups the results of the query by the given raw SQL statement. + * + * @return self + */ public function groupBy(string $statement): self { $this->select->groupBy[] = new GroupByStatement($statement); @@ -158,7 +184,11 @@ public function groupBy(string $statement): self return $this; } - /** @return self */ + /** + * Adds a `HAVING` clause to the query with the given raw SQL statement. + * + * @return self + */ public function having(string $statement, mixed ...$bindings): self { $this->select->having[] = new HavingStatement($statement); @@ -168,7 +198,11 @@ public function having(string $statement, mixed ...$bindings): self return $this; } - /** @return self */ + /** + * Limits the number of results returned by the query by the specified amount. + * + * @return self + */ public function limit(int $limit): self { $this->select->limit = $limit; @@ -176,7 +210,11 @@ public function limit(int $limit): self return $this; } - /** @return self */ + /** + * Sets the offset for the query, allowing you to skip a number of results. + * + * @return self + */ public function offset(int $offset): self { $this->select->offset = $offset; @@ -184,7 +222,11 @@ public function offset(int $offset): self return $this; } - /** @return self */ + /** + * Joins the specified tables to the query using raw SQL statements, allowing for complex queries across multiple tables. + * + * @return self + */ public function join(string ...$joins): self { $this->joins = [...$this->joins, ...$joins]; @@ -192,7 +234,11 @@ public function join(string ...$joins): self return $this; } - /** @return self */ + /** + * Includes the specified relationships in the query, allowing for eager loading. + * + * @return self + */ public function with(string ...$relations): self { $this->relations = [...$this->relations, ...$relations]; @@ -200,7 +246,11 @@ public function with(string ...$relations): self return $this; } - /** @return self */ + /** + * Adds a raw SQL statement to the query. + * + * @return self + */ public function raw(string $raw): self { $this->select->raw[] = new RawStatement($raw); @@ -208,7 +258,11 @@ public function raw(string $raw): self return $this; } - /** @return self */ + /** + * Binds the provided values to the query, allowing for parameterized queries. + * + * @return self + */ public function bind(mixed ...$bindings): self { $this->bindings = [...$this->bindings, ...$bindings]; @@ -216,11 +270,17 @@ public function bind(mixed ...$bindings): self return $this; } + /** + * Returns the SQL statement without the bindings. + */ public function toSql(): ImmutableString { return $this->build()->toSql(); } + /** + * Returns the SQL statement with bindings. This method may generate syntax errors, it is not recommended to use it other than for debugging. + */ public function toRawSql(): ImmutableString { return $this->build()->toRawSql(); diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index cab5131b5..1d3aa2648 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -48,12 +48,19 @@ public function __construct( ); } + /** + * Executes the update query and returns the primary key of the updated record. + */ public function execute(mixed ...$bindings): ?PrimaryKey { return $this->build()->execute(...$bindings); } - /** @return self */ + /** + * Allows the update operation to proceed without WHERE conditions, updating all records. + * + * @return self + */ public function allowAll(): self { $this->update->allowAll = true; @@ -61,7 +68,11 @@ public function allowAll(): self return $this; } - /** @return self */ + /** + * Binds the provided values to the query, allowing for parameterized queries. + * + * @return self + */ public function bind(mixed ...$bindings): self { $this->bindings = [...$this->bindings, ...$bindings]; @@ -69,11 +80,17 @@ public function bind(mixed ...$bindings): self return $this; } + /** + * Returns the SQL statement without the bindings. + */ public function toSql(): ImmutableString { return $this->build()->toSql(); } + /** + * Returns the SQL statement with bindings. This method may generate syntax errors, it is not recommended to use it other than for debugging. + */ public function toRawSql(): ImmutableString { return $this->build()->toRawSql(); diff --git a/packages/database/src/functions.php b/packages/database/src/functions.php index 9e316b8fe..aaf68ef0c 100644 --- a/packages/database/src/functions.php +++ b/packages/database/src/functions.php @@ -6,6 +6,8 @@ use Tempest\Database\Builder\QueryBuilders\QueryBuilder; /** + * Creates a new query builder instance for the given model or table name. + * * @template T of object * @param class-string|string|T $model * @return QueryBuilder @@ -15,16 +17,6 @@ function query(string|object $model): QueryBuilder return new QueryBuilder($model); } - /** - * @template T of object - * @param class-string|string|T $model - * @return ModelInspector - */ - function inspect(string|object $model): ModelInspector - { - return new ModelInspector($model); - } - /** * Provides model-related convenient query methods. * @@ -36,4 +28,17 @@ function model(string $modelClass): ModelQueryBuilder { return new ModelQueryBuilder($modelClass); } + + /** + * Inspects the given model or table name to provide database insights. + * + * @template T of object + * @param class-string|string|T $model + * @return ModelInspector + * @internal + */ + function inspect(string|object $model): ModelInspector + { + return new ModelInspector($model); + } } diff --git a/tests/Integration/Database/Builder/ModelQueryBuilderTest.php b/tests/Integration/Database/Builder/ModelQueryBuilderTest.php index 9a3340254..f5a185c2e 100644 --- a/tests/Integration/Database/Builder/ModelQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/ModelQueryBuilderTest.php @@ -20,55 +20,141 @@ public function test_select(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); + model(TestUserModel::class)->create(name: 'Frieren'); + model(TestUserModel::class)->create(name: 'Fern'); + model(TestUserModelWithoutId::class)->create(name: 'Stark'); + $builderWithId = model(TestUserModel::class)->select(); $builderWithoutId = model(TestUserModelWithoutId::class)->select(); $this->assertInstanceOf(SelectQueryBuilder::class, $builderWithId); $this->assertInstanceOf(SelectQueryBuilder::class, $builderWithoutId); + + $resultsWithId = $builderWithId->all(); + $resultsWithoutId = $builderWithoutId->all(); + + $this->assertCount(2, $resultsWithId); + $this->assertInstanceOf(TestUserModel::class, $resultsWithId[0]); + $this->assertInstanceOf(TestUserModel::class, $resultsWithId[1]); + + $this->assertCount(1, $resultsWithoutId); + $this->assertInstanceOf(TestUserModelWithoutId::class, $resultsWithoutId[0]); + $this->assertSame('Stark', $resultsWithoutId[0]->name); + + $builderWithSpecificColumns = model(TestUserModel::class)->select('name'); + $this->assertInstanceOf(SelectQueryBuilder::class, $builderWithSpecificColumns); + + $resultsWithSpecificColumns = $builderWithSpecificColumns->all(); + $this->assertCount(2, $resultsWithSpecificColumns); + $this->assertInstanceOf(TestUserModel::class, $resultsWithSpecificColumns[0]); + $this->assertNull($resultsWithSpecificColumns[0]->id); + $this->assertSame('Frieren', $resultsWithSpecificColumns[0]->name); } public function test_insert(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); - $builderWithId = model(TestUserModel::class)->insert(); - $builderWithoutId = model(TestUserModelWithoutId::class)->insert(); + $builderWithId = model(TestUserModel::class)->insert(name: 'Frieren'); + $builderWithoutId = model(TestUserModelWithoutId::class)->insert(name: 'Stark'); $this->assertInstanceOf(InsertQueryBuilder::class, $builderWithId); $this->assertInstanceOf(InsertQueryBuilder::class, $builderWithoutId); + + $insertedId = $builderWithId->execute(); + $this->assertInstanceOf(PrimaryKey::class, $insertedId); + + $insertedIdWithoutPk = $builderWithoutId->execute(); + $this->assertInstanceOf(PrimaryKey::class, $insertedIdWithoutPk); + + $retrieved = model(TestUserModel::class)->get($insertedId); + $this->assertNotNull($retrieved); + $this->assertSame('Frieren', $retrieved->name); + + $starkRecords = model(TestUserModelWithoutId::class)->select()->where('name', 'Stark')->all(); + $this->assertCount(1, $starkRecords); + $this->assertSame('Stark', $starkRecords[0]->name); } public function test_update(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); - $builderWithId = model(TestUserModel::class)->update(); - $builderWithoutId = model(TestUserModelWithoutId::class)->update(); + $createdWithId = model(TestUserModel::class)->create(name: 'Frieren'); + model(TestUserModelWithoutId::class)->create(name: 'Stark'); + + $builderWithId = model(TestUserModel::class)->update(name: 'Eisen'); + $builderWithoutId = model(TestUserModelWithoutId::class)->update(name: 'Fern'); $this->assertInstanceOf(UpdateQueryBuilder::class, $builderWithId); $this->assertInstanceOf(UpdateQueryBuilder::class, $builderWithoutId); + + $builderWithId->where('id', $createdWithId->id)->execute(); + $builderWithoutId->where('name', 'Stark')->execute(); + + $retrieved = model(TestUserModel::class)->get($createdWithId->id); + $this->assertNotNull($retrieved); + $this->assertSame('Eisen', $retrieved->name); + + $starkRecords = model(TestUserModelWithoutId::class)->select()->where('name', 'Fern')->all(); + $this->assertCount(1, $starkRecords); + $this->assertSame('Fern', $starkRecords[0]->name); } public function test_delete(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); + $createdWithId = model(TestUserModel::class)->create(name: 'Frieren'); + model(TestUserModel::class)->create(name: 'Fern'); + model(TestUserModelWithoutId::class)->create(name: 'Stark'); + model(TestUserModelWithoutId::class)->create(name: 'Eisen'); + $builderWithId = model(TestUserModel::class)->delete(); $builderWithoutId = model(TestUserModelWithoutId::class)->delete(); $this->assertInstanceOf(DeleteQueryBuilder::class, $builderWithId); $this->assertInstanceOf(DeleteQueryBuilder::class, $builderWithoutId); + + $builderWithId->where('id', $createdWithId->id)->execute(); + $builderWithoutId->where('name', 'Stark')->execute(); + + $remainingWithId = model(TestUserModel::class)->select()->all(); + $this->assertCount(1, $remainingWithId); + $this->assertSame('Fern', $remainingWithId[0]->name); + + $remainingWithoutId = model(TestUserModelWithoutId::class)->select()->all(); + $this->assertCount(1, $remainingWithoutId); + $this->assertSame('Eisen', $remainingWithoutId[0]->name); } public function test_count(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); + model(TestUserModel::class)->create(name: 'Frieren'); + model(TestUserModel::class)->create(name: 'Fern'); + model(TestUserModel::class)->create(name: 'Stark'); + model(TestUserModelWithoutId::class)->create(name: 'Eisen'); + model(TestUserModelWithoutId::class)->create(name: 'Heiter'); + $builderWithId = model(TestUserModel::class)->count(); $builderWithoutId = model(TestUserModelWithoutId::class)->count(); $this->assertInstanceOf(CountQueryBuilder::class, $builderWithId); $this->assertInstanceOf(CountQueryBuilder::class, $builderWithoutId); + + $countWithId = $builderWithId->execute(); + $countWithoutId = $builderWithoutId->execute(); + + $this->assertSame(3, $countWithId); + $this->assertSame(2, $countWithoutId); + + $countFilteredWithId = model(TestUserModel::class)->count()->where('name', 'Frieren')->execute(); + $countFilteredWithoutId = model(TestUserModelWithoutId::class)->count()->where('name', 'Eisen')->execute(); + + $this->assertSame(1, $countFilteredWithId); + $this->assertSame(1, $countFilteredWithoutId); } public function test_new(): void From c860de0da18367777731d5293e683df27e66d402 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 4 Aug 2025 07:03:32 +0200 Subject: [PATCH 13/51] feat(database): add `orderBy` with field and direction support --- .../QueryBuilders/SelectQueryBuilder.php | 15 ++++- .../Builder/SelectQueryBuilderTest.php | 64 ++++++++++++++++++- .../Database/GenericDatabaseTest.php | 2 +- tests/Integration/Database/ToRawSqlTest.php | 4 +- 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index e265255e0..d5e45c988 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -6,6 +6,7 @@ use Closure; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\Direction; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\Mappers\SelectModelMapper; use Tempest\Database\OnDatabase; @@ -160,12 +161,24 @@ public function chunk(Closure $closure, int $amountPerChunk = 200): void } while ($data !== []); } + /** + * Orders the results of the query by the given field name and direction. + * + * @return self + */ + public function orderBy(string $field, Direction $direction = Direction::ASC): self + { + $this->select->orderBy[] = new OrderByStatement("`{$field}` {$direction->value}"); + + return $this; + } + /** * Orders the results of the query by the given raw SQL statement. * * @return self */ - public function orderBy(string $statement): self + public function orderByRaw(string $statement): self { $this->select->orderBy[] = new OrderByStatement($statement); diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index 2e146e2e7..36115df7b 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -5,6 +5,7 @@ namespace Tests\Tempest\Integration\Database\Builder; use Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder; +use Tempest\Database\Direction; use Tempest\Database\Migrations\CreateMigrationsTable; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; @@ -32,7 +33,7 @@ public function test_select_query(): void ->whereRaw('`title` = ?', 'Timeline Taxi') ->andWhereRaw('`index` <> ?', '1') ->orWhereRaw('`createdAt` > ?', '2025-01-01') - ->orderBy('`index` ASC') + ->orderByRaw('`index` ASC') ->build(); $expected = 'SELECT title, index FROM chapters WHERE title = ? AND index <> ? OR createdAt > ? ORDER BY index ASC'; @@ -150,11 +151,68 @@ public function test_order_by(): void Book::new(title: 'C')->save(); Book::new(title: 'D')->save(); - $book = Book::select()->orderBy('title DESC')->first(); + $book = Book::select()->orderByRaw('title DESC')->first(); $this->assertSame('D', $book->title); } + public function test_order_by_with_field_and_direction(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + Book::new(title: 'A')->save(); + Book::new(title: 'B')->save(); + Book::new(title: 'C')->save(); + Book::new(title: 'D')->save(); + + $book = Book::select()->orderBy('title', Direction::DESC)->first(); + $this->assertSame('D', $book->title); + + $book = Book::select()->orderBy('title', Direction::ASC)->first(); + $this->assertSame('A', $book->title); + } + + public function test_order_by_with_field_defaults_to_asc(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + Book::new(title: 'A')->save(); + Book::new(title: 'B')->save(); + Book::new(title: 'C')->save(); + Book::new(title: 'D')->save(); + + $book = Book::select()->orderBy('title')->first(); + $this->assertSame('A', $book->title); + } + + public function test_order_by_sql_generation(): void + { + $this->assertSameWithoutBackticks( + expected: 'SELECT * FROM `books` ORDER BY `title` ASC', + actual: query('books')->select()->orderBy('title')->toSql(), + ); + + $this->assertSameWithoutBackticks( + expected: 'SELECT * FROM `books` ORDER BY `title` DESC', + actual: query('books')->select()->orderBy('title', Direction::DESC)->toSql(), + ); + + $this->assertSameWithoutBackticks( + expected: 'SELECT * FROM `books` ORDER BY title DESC NULLS LAST', + actual: query('books')->select()->orderByRaw('title DESC NULLS LAST')->toSql(), + ); + } + public function test_limit(): void { $this->migrate( @@ -262,7 +320,7 @@ public function test_select_query_with_conditions(): void ->andWhereRaw('`index` <> ?', '2') ->orWhereRaw('`createdAt` > ?', '2025-01-02'), ) - ->orderBy('`index` ASC') + ->orderByRaw('`index` ASC') ->build(); $expected = 'SELECT title, index FROM `chapters` WHERE `title` = ? AND `index` <> ? OR `createdAt` > ? ORDER BY `index` ASC'; diff --git a/tests/Integration/Database/GenericDatabaseTest.php b/tests/Integration/Database/GenericDatabaseTest.php index b2b83931d..61cefd8f7 100644 --- a/tests/Integration/Database/GenericDatabaseTest.php +++ b/tests/Integration/Database/GenericDatabaseTest.php @@ -72,7 +72,7 @@ public function test_query_with_semicolons(): void public function test_query_was_invalid_exception_is_thrown_on_fetch(): void { $this->assertException(QueryWasInvalid::class, function (): void { - query('books')->select()->orderBy('title DES')->first(); + query('books')->select()->orderByRaw('title DES')->first(); }); } diff --git a/tests/Integration/Database/ToRawSqlTest.php b/tests/Integration/Database/ToRawSqlTest.php index f5e104b4c..1177130d9 100644 --- a/tests/Integration/Database/ToRawSqlTest.php +++ b/tests/Integration/Database/ToRawSqlTest.php @@ -132,7 +132,7 @@ public function test_select_query_to_raw_sql_with_order_and_limit(): void $rawSql = query('books') ->select() ->where('published', true) - ->orderBy('created_at DESC') + ->orderByRaw('created_at DESC') ->limit(10) ->offset(5) ->toRawSql() @@ -376,7 +376,7 @@ public function test_raw_sql_consistency_across_database_dialects(): void ->select('title', 'author_id') ->where('published', true) ->where('rating', 4.5, WhereOperator::GREATER_THAN_OR_EQUAL) - ->orderBy('created_at DESC') + ->orderByRaw('created_at DESC') ->limit(5) ->toRawSql() ->toString(); From 1692ef1f48efb414d42223d566a43c539956c282 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 4 Aug 2025 07:03:59 +0200 Subject: [PATCH 14/51] test(database): remove redundant assertions --- .../Database/CustomPrimaryKeyRelationshipLoadingTest.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/Integration/Database/CustomPrimaryKeyRelationshipLoadingTest.php b/tests/Integration/Database/CustomPrimaryKeyRelationshipLoadingTest.php index 207794566..e82a5415b 100644 --- a/tests/Integration/Database/CustomPrimaryKeyRelationshipLoadingTest.php +++ b/tests/Integration/Database/CustomPrimaryKeyRelationshipLoadingTest.php @@ -85,7 +85,6 @@ public function test_has_many_relationship_with_uuid_primary_keys(): void $loadedMage = model(MageWithUuid::class)->get($mage->uuid); $loadedMage->load('spells'); - $this->assertIsArray($loadedMage->spells); $this->assertCount(2, $loadedMage->spells); $spellNames = array_map(fn (SpellWithUuid $spell) => $spell->name, $loadedMage->spells); @@ -161,7 +160,6 @@ public function test_nested_relationship_loading_with_uuid_primary_keys(): void $this->assertInstanceOf(GrimoireWithUuid::class, $loadedMage->grimoire); $this->assertSame('Combat Magic Fundamentals', $loadedMage->grimoire->title); - $this->assertIsArray($loadedMage->spells); $this->assertCount(1, $loadedMage->spells); $this->assertSame('Basic Attack Magic', $loadedMage->spells[0]->name); @@ -197,7 +195,6 @@ public function test_relationship_with_custom_foreign_key_naming(): void $loadedMage = model(MageWithUuid::class)->get($mage->uuid); $loadedMage->load('artifacts'); - $this->assertIsArray($loadedMage->artifacts); $this->assertCount(1, $loadedMage->artifacts); $this->assertSame('Hero Sword', $loadedMage->artifacts[0]->name); $this->assertSame('Legendary', $loadedMage->artifacts[0]->rarity); From c198a2d137443252538d4f163a235e262283e1ba Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 4 Aug 2025 07:42:02 +0200 Subject: [PATCH 15/51] test(database): move legacy orm tests in database tests --- .../Builder/InsertQueryBuilderTest.php | 21 + .../Builder}/IsDatabaseModelTest.php | 388 ++++++++++++++++-- .../Builder/UpdateQueryBuilderTest.php | 24 ++ tests/Integration/ORM/Foo.php | 17 - .../Integration/ORM/FooDatabaseMigration.php | 32 -- .../ORM/Mappers/QueryMapperTest.php | 62 --- .../ORM/Migrations/CreateATable.php | 33 -- .../ORM/Migrations/CreateBTable.php | 33 -- .../ORM/Migrations/CreateCTable.php | 30 -- .../ORM/Migrations/CreateCarbonModelTable.php | 28 -- .../ORM/Migrations/CreateCasterModelTable.php | 36 -- .../Migrations/CreateDateTimeModelTable.php | 26 -- .../Migrations/CreateHasManyChildTable.php | 26 -- .../Migrations/CreateHasManyParentTable.php | 26 -- .../Migrations/CreateHasManyThroughTable.php | 28 -- .../ORM/Models/AttributeTableNameModel.php | 15 - tests/Integration/ORM/Models/BaseModel.php | 13 - tests/Integration/ORM/Models/CarbonCaster.php | 14 - tests/Integration/ORM/Models/CarbonModel.php | 22 - .../ORM/Models/CarbonSerializer.php | 19 - tests/Integration/ORM/Models/CasterEnum.php | 9 - tests/Integration/ORM/Models/CasterModel.php | 20 - tests/Integration/ORM/Models/ChildModel.php | 28 -- .../Integration/ORM/Models/DateTimeModel.php | 16 - .../ORM/Models/ModelWithValidation.php | 21 - tests/Integration/ORM/Models/ParentModel.php | 24 -- .../ORM/Models/StaticMethodTableNameModel.php | 15 - tests/Integration/ORM/Models/ThroughModel.php | 25 -- 28 files changed, 410 insertions(+), 641 deletions(-) rename tests/Integration/{ORM => Database/Builder}/IsDatabaseModelTest.php (68%) delete mode 100644 tests/Integration/ORM/Foo.php delete mode 100644 tests/Integration/ORM/FooDatabaseMigration.php delete mode 100644 tests/Integration/ORM/Mappers/QueryMapperTest.php delete mode 100644 tests/Integration/ORM/Migrations/CreateATable.php delete mode 100644 tests/Integration/ORM/Migrations/CreateBTable.php delete mode 100644 tests/Integration/ORM/Migrations/CreateCTable.php delete mode 100644 tests/Integration/ORM/Migrations/CreateCarbonModelTable.php delete mode 100644 tests/Integration/ORM/Migrations/CreateCasterModelTable.php delete mode 100644 tests/Integration/ORM/Migrations/CreateDateTimeModelTable.php delete mode 100644 tests/Integration/ORM/Migrations/CreateHasManyChildTable.php delete mode 100644 tests/Integration/ORM/Migrations/CreateHasManyParentTable.php delete mode 100644 tests/Integration/ORM/Migrations/CreateHasManyThroughTable.php delete mode 100644 tests/Integration/ORM/Models/AttributeTableNameModel.php delete mode 100644 tests/Integration/ORM/Models/BaseModel.php delete mode 100644 tests/Integration/ORM/Models/CarbonCaster.php delete mode 100644 tests/Integration/ORM/Models/CarbonModel.php delete mode 100644 tests/Integration/ORM/Models/CarbonSerializer.php delete mode 100644 tests/Integration/ORM/Models/CasterEnum.php delete mode 100644 tests/Integration/ORM/Models/CasterModel.php delete mode 100644 tests/Integration/ORM/Models/ChildModel.php delete mode 100644 tests/Integration/ORM/Models/DateTimeModel.php delete mode 100644 tests/Integration/ORM/Models/ModelWithValidation.php delete mode 100644 tests/Integration/ORM/Models/ParentModel.php delete mode 100644 tests/Integration/ORM/Models/StaticMethodTableNameModel.php delete mode 100644 tests/Integration/ORM/Models/ThroughModel.php diff --git a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php index fa1a20f75..eba487282 100644 --- a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php @@ -213,4 +213,25 @@ private function buildExpectedInsert(string $query): string return $query; } + + public function test_insert_mapping(): void + { + $author = Author::new(name: 'test'); + + $query = query(Author::class)->insert($author)->build(); + + $dialect = $this->container->get(Database::class)->dialect; + + $expected = match ($dialect) { + DatabaseDialect::POSTGRESQL => <<<'SQL' + INSERT INTO authors (name) VALUES (?) RETURNING * + SQL, + default => <<<'SQL' + INSERT INTO `authors` (`name`) VALUES (?) + SQL, + }; + + $this->assertSame($expected, $query->toSql()->toString()); + $this->assertSame(['test'], $query->bindings); + } } diff --git a/tests/Integration/ORM/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php similarity index 68% rename from tests/Integration/ORM/IsDatabaseModelTest.php rename to tests/Integration/Database/Builder/IsDatabaseModelTest.php index fc2f70c8d..34c739bac 100644 --- a/tests/Integration/ORM/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -2,21 +2,39 @@ declare(strict_types=1); -namespace Tests\Tempest\Integration\ORM; +namespace Tests\Tempest\Integration\Database\Builder; use Carbon\Carbon; use DateTime as NativeDateTime; use DateTimeImmutable; +use Tempest\Database\BelongsTo; +use Tempest\Database\DatabaseMigration; use Tempest\Database\Exceptions\RelationWasMissing; use Tempest\Database\Exceptions\ValueWasMissing; +use Tempest\Database\HasOne; +use Tempest\Database\IsDatabaseModel; use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\PrimaryKey; +use Tempest\Database\QueryStatement; +use Tempest\Database\QueryStatements\CompoundStatement; +use Tempest\Database\QueryStatements\CreateEnumTypeStatement; +use Tempest\Database\QueryStatements\CreateTableStatement; +use Tempest\Database\QueryStatements\DropEnumTypeStatement; +use Tempest\Database\QueryStatements\DropTableStatement; +use Tempest\Database\QueryStatements\PrimaryKeyStatement; +use Tempest\Database\QueryStatements\RawStatement; +use Tempest\Database\QueryStatements\TextStatement; +use Tempest\Database\Table; use Tempest\DateTime\DateTime; +use Tempest\Mapper\Caster; use Tempest\Mapper\CasterFactory; +use Tempest\Mapper\Exceptions\ValueCouldNotBeSerialized; +use Tempest\Mapper\Serializer; use Tempest\Mapper\SerializerFactory; use Tempest\Support\Arr; use Tempest\Validation\Exceptions\ValidationFailed; use Tempest\Validation\Rules\IsBetween; +use Tempest\Validation\SkipValidation; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; use Tests\Tempest\Fixtures\Migrations\CreateChapterTable; @@ -34,28 +52,6 @@ use Tests\Tempest\Fixtures\Modules\Books\Models\Book; use Tests\Tempest\Fixtures\Modules\Books\Models\Isbn; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use Tests\Tempest\Integration\ORM\Migrations\CreateATable; -use Tests\Tempest\Integration\ORM\Migrations\CreateBTable; -use Tests\Tempest\Integration\ORM\Migrations\CreateCarbonModelTable; -use Tests\Tempest\Integration\ORM\Migrations\CreateCasterModelTable; -use Tests\Tempest\Integration\ORM\Migrations\CreateCTable; -use Tests\Tempest\Integration\ORM\Migrations\CreateDateTimeModelTable; -use Tests\Tempest\Integration\ORM\Migrations\CreateHasManyChildTable; -use Tests\Tempest\Integration\ORM\Migrations\CreateHasManyParentTable; -use Tests\Tempest\Integration\ORM\Migrations\CreateHasManyThroughTable; -use Tests\Tempest\Integration\ORM\Models\AttributeTableNameModel; -use Tests\Tempest\Integration\ORM\Models\BaseModel; -use Tests\Tempest\Integration\ORM\Models\CarbonCaster; -use Tests\Tempest\Integration\ORM\Models\CarbonModel; -use Tests\Tempest\Integration\ORM\Models\CarbonSerializer; -use Tests\Tempest\Integration\ORM\Models\CasterEnum; -use Tests\Tempest\Integration\ORM\Models\CasterModel; -use Tests\Tempest\Integration\ORM\Models\ChildModel; -use Tests\Tempest\Integration\ORM\Models\DateTimeModel; -use Tests\Tempest\Integration\ORM\Models\ModelWithValidation; -use Tests\Tempest\Integration\ORM\Models\ParentModel; -use Tests\Tempest\Integration\ORM\Models\StaticMethodTableNameModel; -use Tests\Tempest\Integration\ORM\Models\ThroughModel; use function Tempest\Database\inspect; use function Tempest\Database\query; @@ -641,3 +637,349 @@ public function test_date_field(): void $this->assertSame('2024-01-01 00:00:00', $model->tempestDateTime->format('yyyy-MM-dd HH:mm:ss')); } } + +final class Foo +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + public string $bar; +} + +final class FooDatabaseMigration implements DatabaseMigration +{ + private(set) string $name = 'foos'; + + public function up(): QueryStatement + { + return new CreateTableStatement( + tableName: 'foos', + statements: [ + new PrimaryKeyStatement(), + new TextStatement('bar'), + ], + ); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateATable implements DatabaseMigration +{ + private(set) string $name = '100-create-a'; + + public function up(): QueryStatement + { + return new CreateTableStatement( + 'a', + [ + new PrimaryKeyStatement(), + new RawStatement('b_id INTEGER'), + ], + ); + } + + public function down(): QueryStatement + { + return new DropTableStatement('a'); + } +} + +final class CreateBTable implements DatabaseMigration +{ + private(set) string $name = '100-create-b'; + + public function up(): QueryStatement + { + return new CreateTableStatement( + 'b', + [ + new PrimaryKeyStatement(), + new RawStatement('c_id INTEGER'), + ], + ); + } + + public function down(): QueryStatement + { + return new DropTableStatement('b'); + } +} + +final class CreateCTable implements DatabaseMigration +{ + private(set) string $name = '100-create-c'; + + public function up(): QueryStatement + { + return new CreateTableStatement('c', [ + new PrimaryKeyStatement(), + new TextStatement('name'), + ]); + } + + public function down(): QueryStatement + { + return new DropTableStatement('c'); + } +} + +final class CreateCarbonModelTable implements DatabaseMigration +{ + public string $name = '2024-12-17_create_users_table'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(CarbonModel::class) + ->primary() + ->datetime('createdAt'); + } + + public function down(): QueryStatement + { + return DropTableStatement::forModel(CarbonModel::class); + } +} + +final class CreateCasterModelTable implements DatabaseMigration +{ + public string $name = '0000_create_caster_model_table'; + + public function up(): QueryStatement + { + return new CompoundStatement( + new DropEnumTypeStatement(CasterEnum::class), + new CreateEnumTypeStatement(CasterEnum::class), + CreateTableStatement::forModel(CasterModel::class) + ->primary() + ->datetime('date') + ->array('array_prop') + ->enum('enum_prop', CasterEnum::class), + ); + } + + public function down(): QueryStatement + { + return DropTableStatement::forModel(CasterModel::class); + } +} + +final class CreateDateTimeModelTable implements DatabaseMigration +{ + public string $name = '0001_datetime_model_table'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(DateTimeModel::class) + ->primary() + ->datetime('phpDateTime') + ->datetime('tempestDateTime'); + } + + public function down(): null + { + return null; + } +} + +final class CreateHasManyChildTable implements DatabaseMigration +{ + private(set) string $name = '100-create-has-many-child'; + + public function up(): QueryStatement + { + return new CreateTableStatement('child') + ->primary() + ->varchar('name'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateHasManyParentTable implements DatabaseMigration +{ + private(set) string $name = '100-create-has-many-parent'; + + public function up(): QueryStatement + { + return new CreateTableStatement('parent') + ->primary() + ->varchar('name'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateHasManyThroughTable implements DatabaseMigration +{ + private(set) string $name = '100-create-has-many-through'; + + public function up(): QueryStatement + { + return new CreateTableStatement('through') + ->primary() + ->belongsTo('through.parent_id', 'parent.id') + ->belongsTo('through.child_id', 'child.id') + ->belongsTo('through.child2_id', 'child.id', nullable: true); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +#[Table('custom_attribute_table_name')] +final class AttributeTableNameModel +{ + use IsDatabaseModel; + + public PrimaryKey $id; +} + +final class BaseModel +{ + use IsDatabaseModel; + + public PrimaryKey $id; +} + +final readonly class CarbonCaster implements Caster +{ + public function cast(mixed $input): mixed + { + return new Carbon($input); + } +} + +final class CarbonModel +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + public function __construct( + public Carbon $createdAt, + ) {} +} + +final readonly class CarbonSerializer implements Serializer +{ + public function serialize(mixed $input): string + { + if (! ($input instanceof Carbon)) { + throw new ValueCouldNotBeSerialized(Carbon::class); + } + + return $input->format('Y-m-d H:i:s'); + } +} + +enum CasterEnum: string +{ + case FOO = 'foo'; + case BAR = 'bar'; +} + +final class CasterModel +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + public function __construct( + public DateTimeImmutable $date, + public array $array_prop, + public CasterEnum $enum_prop, + ) {} +} + +#[Table('child')] +final class ChildModel +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + #[HasOne] + public ThroughModel $through; + + #[HasOne(ownerJoin: 'child2_id')] + public ThroughModel $through2; + + public function __construct( + public string $name, + ) {} +} + +final class DateTimeModel +{ + use IsDatabaseModel; + + public function __construct( + public PrimaryKey $id, + public NativeDateTime $phpDateTime, + public DateTime $tempestDateTime, + ) {} +} + +final class ModelWithValidation +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + #[IsBetween(min: 1, max: 10)] + public int $index; + + #[SkipValidation] + public int $skip; +} + +#[Table('parent')] +final class ParentModel +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + public function __construct( + public string $name, + + /** @var \Tests\Tempest\Integration\Database\Builder\ThroughModel[] */ + public array $through = [], + ) {} +} + +#[Table('custom_static_method_table_name')] +final class StaticMethodTableNameModel +{ + use IsDatabaseModel; + + public PrimaryKey $id; +} + +#[Table('through')] +final class ThroughModel +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + public function __construct( + public ParentModel $parent, + public ChildModel $child, + #[BelongsTo(ownerJoin: 'child2_id')] + public ?ChildModel $child2 = null, + ) {} +} diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php index 9aab721cf..9fadee809 100644 --- a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php @@ -307,4 +307,28 @@ public function test_nested_where_with_update_query(): void $this->assertSameWithoutBackticks($expected, $query->toSql()); $this->assertSame(['archived', true, 100, '2023-01-01'], $query->bindings); } + + public function test_update_mapping(): void + { + $author = Author::new(id: new PrimaryKey(1), name: 'original'); + + $query = query($author) + ->update(name: 'other') + ->build(); + + $dialect = $this->container->get(Database::class)->dialect; + + $expected = match ($dialect) { + DatabaseDialect::POSTGRESQL => <<<'SQL' + UPDATE authors SET name = ? WHERE authors.id = ? + SQL, + default => <<<'SQL' + UPDATE `authors` SET `name` = ? WHERE `authors`.`id` = ? + SQL, + }; + + $this->assertSame($expected, $query->toSql()->toString()); + + $this->assertSame(['other', 1], $query->bindings); + } } diff --git a/tests/Integration/ORM/Foo.php b/tests/Integration/ORM/Foo.php deleted file mode 100644 index 6b7eea7d1..000000000 --- a/tests/Integration/ORM/Foo.php +++ /dev/null @@ -1,17 +0,0 @@ -insert($author)->build(); - - $dialect = $this->container->get(Database::class)->dialect; - - $expected = match ($dialect) { - DatabaseDialect::POSTGRESQL => <<<'SQL' - INSERT INTO authors (name) VALUES (?) RETURNING * - SQL, - default => <<<'SQL' - INSERT INTO `authors` (`name`) VALUES (?) - SQL, - }; - - $this->assertSame($expected, $query->toSql()->toString()); - $this->assertSame(['test'], $query->bindings); - } - - public function test_update_query(): void - { - $author = Author::new(id: new PrimaryKey(1), name: 'original'); - - $query = query($author)->update(name: 'other')->build(); - - $dialect = $this->container->get(Database::class)->dialect; - - $expected = match ($dialect) { - DatabaseDialect::POSTGRESQL => <<<'SQL' - UPDATE authors SET name = ? WHERE authors.id = ? - SQL, - default => <<<'SQL' - UPDATE `authors` SET `name` = ? WHERE `authors`.`id` = ? - SQL, - }; - - $this->assertSame($expected, $query->toSql()->toString()); - - $this->assertSame(['other', 1], $query->bindings); - } -} diff --git a/tests/Integration/ORM/Migrations/CreateATable.php b/tests/Integration/ORM/Migrations/CreateATable.php deleted file mode 100644 index 5c6869d8b..000000000 --- a/tests/Integration/ORM/Migrations/CreateATable.php +++ /dev/null @@ -1,33 +0,0 @@ -primary() - ->datetime('createdAt'); - } - - public function down(): QueryStatement - { - return DropTableStatement::forModel(CarbonModel::class); - } -} diff --git a/tests/Integration/ORM/Migrations/CreateCasterModelTable.php b/tests/Integration/ORM/Migrations/CreateCasterModelTable.php deleted file mode 100644 index b75ed8fe5..000000000 --- a/tests/Integration/ORM/Migrations/CreateCasterModelTable.php +++ /dev/null @@ -1,36 +0,0 @@ -primary() - ->datetime('date') - ->array('array_prop') - ->enum('enum_prop', CasterEnum::class), - ); - } - - public function down(): QueryStatement - { - return DropTableStatement::forModel(CasterModel::class); - } -} diff --git a/tests/Integration/ORM/Migrations/CreateDateTimeModelTable.php b/tests/Integration/ORM/Migrations/CreateDateTimeModelTable.php deleted file mode 100644 index 3dab4f381..000000000 --- a/tests/Integration/ORM/Migrations/CreateDateTimeModelTable.php +++ /dev/null @@ -1,26 +0,0 @@ -primary() - ->datetime('phpDateTime') - ->datetime('tempestDateTime'); - } - - public function down(): null - { - return null; - } -} diff --git a/tests/Integration/ORM/Migrations/CreateHasManyChildTable.php b/tests/Integration/ORM/Migrations/CreateHasManyChildTable.php deleted file mode 100644 index 1dc2a5019..000000000 --- a/tests/Integration/ORM/Migrations/CreateHasManyChildTable.php +++ /dev/null @@ -1,26 +0,0 @@ -primary() - ->varchar('name'); - } - - public function down(): ?QueryStatement - { - return null; - } -} diff --git a/tests/Integration/ORM/Migrations/CreateHasManyParentTable.php b/tests/Integration/ORM/Migrations/CreateHasManyParentTable.php deleted file mode 100644 index 6d6682f64..000000000 --- a/tests/Integration/ORM/Migrations/CreateHasManyParentTable.php +++ /dev/null @@ -1,26 +0,0 @@ -primary() - ->varchar('name'); - } - - public function down(): ?QueryStatement - { - return null; - } -} diff --git a/tests/Integration/ORM/Migrations/CreateHasManyThroughTable.php b/tests/Integration/ORM/Migrations/CreateHasManyThroughTable.php deleted file mode 100644 index 223932aad..000000000 --- a/tests/Integration/ORM/Migrations/CreateHasManyThroughTable.php +++ /dev/null @@ -1,28 +0,0 @@ -primary() - ->belongsTo('through.parent_id', 'parent.id') - ->belongsTo('through.child_id', 'child.id') - ->belongsTo('through.child2_id', 'child.id', nullable: true); - } - - public function down(): ?QueryStatement - { - return null; - } -} diff --git a/tests/Integration/ORM/Models/AttributeTableNameModel.php b/tests/Integration/ORM/Models/AttributeTableNameModel.php deleted file mode 100644 index 09509cca0..000000000 --- a/tests/Integration/ORM/Models/AttributeTableNameModel.php +++ /dev/null @@ -1,15 +0,0 @@ -format('Y-m-d H:i:s'); - } -} diff --git a/tests/Integration/ORM/Models/CasterEnum.php b/tests/Integration/ORM/Models/CasterEnum.php deleted file mode 100644 index 7918d6607..000000000 --- a/tests/Integration/ORM/Models/CasterEnum.php +++ /dev/null @@ -1,9 +0,0 @@ - Date: Mon, 4 Aug 2025 14:32:01 +0200 Subject: [PATCH 16/51] refactor(database): use consistent template naming in query builders --- .../src/Builder/QueryBuilders/BuildsQuery.php | 4 +- .../QueryBuilders/CountQueryBuilder.php | 12 +++--- .../QueryBuilders/DeleteQueryBuilder.php | 12 +++--- .../HasWhereQueryBuilderMethods.php | 20 +++++----- .../QueryBuilders/InsertQueryBuilder.php | 6 +-- .../Builder/QueryBuilders/QueryBuilder.php | 14 +++---- .../QueryBuilders/SelectQueryBuilder.php | 38 +++++++++---------- .../QueryBuilders/UpdateQueryBuilder.php | 12 +++--- packages/database/src/functions.php | 12 +++--- 9 files changed, 65 insertions(+), 65 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/BuildsQuery.php b/packages/database/src/Builder/QueryBuilders/BuildsQuery.php index 79d11b1a6..60c8c2749 100644 --- a/packages/database/src/Builder/QueryBuilders/BuildsQuery.php +++ b/packages/database/src/Builder/QueryBuilders/BuildsQuery.php @@ -5,12 +5,12 @@ use Tempest\Database\Query; /** - * @template TModelClass + * @template TModel */ interface BuildsQuery { public function build(mixed ...$bindings): Query; - /** @return self */ + /** @return self */ public function bind(mixed ...$bindings): self; } diff --git a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php index 1ecdb7225..fd06720da 100644 --- a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php @@ -16,9 +16,9 @@ use function Tempest\Database\inspect; /** - * @template T of object - * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery - * @uses \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods + * @template TModel of object + * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery + * @uses \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods */ final class CountQueryBuilder implements BuildsQuery { @@ -31,7 +31,7 @@ final class CountQueryBuilder implements BuildsQuery private ModelInspector $model; /** - * @param class-string|string|T $model + * @param class-string|string|TModel $model */ public function __construct(string|object $model, ?string $column = null) { @@ -54,7 +54,7 @@ public function execute(mixed ...$bindings): int /** * Modifies the count query to only count distinct values in the specified column. * - * @return self + * @return self */ public function distinct(): self { @@ -70,7 +70,7 @@ public function distinct(): self /** * Binds the provided values to the query, allowing for parameterized queries. * - * @return self + * @return self */ public function bind(mixed ...$bindings): self { diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index 9fd1c69fe..352fc14dd 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -13,9 +13,9 @@ use function Tempest\Database\inspect; /** - * @template TModelClass of object - * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery - * @uses \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods + * @template TModel of object + * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery + * @uses \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods */ final class DeleteQueryBuilder implements BuildsQuery { @@ -28,7 +28,7 @@ final class DeleteQueryBuilder implements BuildsQuery private ModelInspector $model; /** - * @param class-string|string|TModelClass $model + * @param class-string|string|TModel $model */ public function __construct(string|object $model) { @@ -47,7 +47,7 @@ public function execute(): void /** * Allows the delete operation to proceed without WHERE conditions, deleting all records. * - * @return self + * @return self */ public function allowAll(): self { @@ -59,7 +59,7 @@ public function allowAll(): self /** * Binds the provided values to the query, allowing for parameterized queries. * - * @return self + * @return self */ public function bind(mixed ...$bindings): self { diff --git a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php index 0a8235604..8b4d5a61a 100644 --- a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php @@ -11,7 +11,7 @@ use function Tempest\Support\str; /** - * @template TModelClass + * @template TModel * @phpstan-require-implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery */ trait HasWhereQueryBuilderMethods @@ -25,7 +25,7 @@ abstract private function getStatementForWhere(): HasWhereStatements; /** * Adds a where condition to the query. * - * @return self + * @return self */ public function where(string $field, mixed $value, string|WhereOperator $operator = WhereOperator::EQUALS): self { @@ -46,7 +46,7 @@ public function where(string $field, mixed $value, string|WhereOperator $operato /** * Adds an `AND WHERE` condition to the query. * - * @return self + * @return self */ public function andWhere(string $field, mixed $value, WhereOperator $operator = WhereOperator::EQUALS): self { @@ -63,7 +63,7 @@ public function andWhere(string $field, mixed $value, WhereOperator $operator = /** * Adds an `OR WHERE` condition to the query. * - * @return self + * @return self */ public function orWhere(string $field, mixed $value, WhereOperator $operator = WhereOperator::EQUALS): self { @@ -80,7 +80,7 @@ public function orWhere(string $field, mixed $value, WhereOperator $operator = W /** * Adds a raw SQL `WHERE` condition to the query. * - * @return self + * @return self */ public function whereRaw(string $rawCondition, mixed ...$bindings): self { @@ -97,7 +97,7 @@ public function whereRaw(string $rawCondition, mixed ...$bindings): self /** * Adds a raw SQL `AND WHERE` condition to the query. * - * @return self + * @return self */ public function andWhereRaw(string $rawCondition, mixed ...$bindings): self { @@ -110,7 +110,7 @@ public function andWhereRaw(string $rawCondition, mixed ...$bindings): self /** * Adds a raw SQL `OR WHERE` condition to the query. * - * @return self + * @return self */ public function orWhereRaw(string $rawCondition, mixed ...$bindings): self { @@ -124,7 +124,7 @@ public function orWhereRaw(string $rawCondition, mixed ...$bindings): self * Adds a grouped where statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. * * @param Closure(WhereGroupBuilder):void $callback - * @return self + * @return self */ public function whereGroup(Closure $callback): self { @@ -144,7 +144,7 @@ public function whereGroup(Closure $callback): self * Adds a grouped `AND WHERE` statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. * * @param Closure(WhereGroupBuilder):void $callback - * @return self + * @return self */ public function andWhereGroup(Closure $callback): self { @@ -159,7 +159,7 @@ public function andWhereGroup(Closure $callback): self * Adds a grouped `OR WHERE` statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. * * @param Closure(WhereGroupBuilder):void $callback - * @return self + * @return self */ public function orWhereGroup(Closure $callback): self { diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index 62db62c8c..bc803d1ba 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -19,8 +19,8 @@ use function Tempest\Database\inspect; /** - * @template T of object - * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery + * @template TModel of object + * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery */ final class InsertQueryBuilder implements BuildsQuery { @@ -35,7 +35,7 @@ final class InsertQueryBuilder implements BuildsQuery private ModelInspector $model; /** - * @param class-string|string|T $model + * @param class-string|string|TModel $model */ public function __construct( string|object $model, diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index 69c288115..e3e7be007 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -8,12 +8,12 @@ use function Tempest\Support\arr; /** - * @template T of object + * @template TModel of object */ final readonly class QueryBuilder { /** - * @param class-string|string|T $model + * @param class-string|string|TModel $model */ public function __construct( private string|object $model, @@ -22,7 +22,7 @@ public function __construct( /** * Creates a `SELECT` query builder for retrieving records from the database. * - * @return SelectQueryBuilder + * @return SelectQueryBuilder */ public function select(string ...$columns): SelectQueryBuilder { @@ -35,7 +35,7 @@ public function select(string ...$columns): SelectQueryBuilder /** * Creates an `INSERT` query builder for adding new records to the database. * - * @return InsertQueryBuilder + * @return InsertQueryBuilder */ public function insert(mixed ...$values): InsertQueryBuilder { @@ -53,7 +53,7 @@ public function insert(mixed ...$values): InsertQueryBuilder /** * Creates an `UPDATE` query builder for modifying existing records in the database. * - * @return UpdateQueryBuilder + * @return UpdateQueryBuilder */ public function update(mixed ...$values): UpdateQueryBuilder { @@ -67,7 +67,7 @@ public function update(mixed ...$values): UpdateQueryBuilder /** * Creates a `DELETE` query builder for removing records from the database. * - * @return DeleteQueryBuilder + * @return DeleteQueryBuilder */ public function delete(): DeleteQueryBuilder { @@ -77,7 +77,7 @@ public function delete(): DeleteQueryBuilder /** * Creates a `COUNT` query builder for counting records in the database. * - * @return CountQueryBuilder + * @return CountQueryBuilder */ public function count(?string $column = null): CountQueryBuilder { diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index d5e45c988..f5bf4c5a4 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -30,9 +30,9 @@ use function Tempest\map; /** - * @template T of object - * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery - * @uses \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods + * @template TModel of object + * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery + * @uses \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods */ final class SelectQueryBuilder implements BuildsQuery { @@ -49,7 +49,7 @@ final class SelectQueryBuilder implements BuildsQuery private array $bindings = []; /** - * @param class-string|string|T $model + * @param class-string|string|TModel $model */ public function __construct(string|object $model, ?ImmutableArray $fields = null) { @@ -66,7 +66,7 @@ public function __construct(string|object $model, ?ImmutableArray $fields = null /** * Returns the first record matching the query. * - * @return T|null + * @return TModel|null */ public function first(mixed ...$bindings): mixed { @@ -90,7 +90,7 @@ public function first(mixed ...$bindings): mixed /** * Returnd length-aware paginated data for the current query. * - * @return PaginatedData + * @return PaginatedData */ public function paginate(int $itemsPerPage = 20, int $currentPage = 1, int $maxLinks = 10): PaginatedData { @@ -111,7 +111,7 @@ public function paginate(int $itemsPerPage = 20, int $currentPage = 1, int $maxL /** * Returns the first record matching the given primary key. * - * @return T|null + * @return TModel|null */ public function get(PrimaryKey $id): mixed { @@ -125,7 +125,7 @@ public function get(PrimaryKey $id): mixed /** * Returns all records matching the query. * - * @return T[] + * @return TModel[] */ public function all(mixed ...$bindings): array { @@ -143,7 +143,7 @@ public function all(mixed ...$bindings): array /** * Performs multiple queries in chunks, passing each chunk to the provided closure. * - * @param Closure(T[]): void $closure + * @param Closure(TModel[]): void $closure */ public function chunk(Closure $closure, int $amountPerChunk = 200): void { @@ -164,7 +164,7 @@ public function chunk(Closure $closure, int $amountPerChunk = 200): void /** * Orders the results of the query by the given field name and direction. * - * @return self + * @return self */ public function orderBy(string $field, Direction $direction = Direction::ASC): self { @@ -176,7 +176,7 @@ public function orderBy(string $field, Direction $direction = Direction::ASC): s /** * Orders the results of the query by the given raw SQL statement. * - * @return self + * @return self */ public function orderByRaw(string $statement): self { @@ -188,7 +188,7 @@ public function orderByRaw(string $statement): self /** * Groups the results of the query by the given raw SQL statement. * - * @return self + * @return self */ public function groupBy(string $statement): self { @@ -200,7 +200,7 @@ public function groupBy(string $statement): self /** * Adds a `HAVING` clause to the query with the given raw SQL statement. * - * @return self + * @return self */ public function having(string $statement, mixed ...$bindings): self { @@ -214,7 +214,7 @@ public function having(string $statement, mixed ...$bindings): self /** * Limits the number of results returned by the query by the specified amount. * - * @return self + * @return self */ public function limit(int $limit): self { @@ -226,7 +226,7 @@ public function limit(int $limit): self /** * Sets the offset for the query, allowing you to skip a number of results. * - * @return self + * @return self */ public function offset(int $offset): self { @@ -238,7 +238,7 @@ public function offset(int $offset): self /** * Joins the specified tables to the query using raw SQL statements, allowing for complex queries across multiple tables. * - * @return self + * @return self */ public function join(string ...$joins): self { @@ -250,7 +250,7 @@ public function join(string ...$joins): self /** * Includes the specified relationships in the query, allowing for eager loading. * - * @return self + * @return self */ public function with(string ...$relations): self { @@ -262,7 +262,7 @@ public function with(string ...$relations): self /** * Adds a raw SQL statement to the query. * - * @return self + * @return self */ public function raw(string $raw): self { @@ -274,7 +274,7 @@ public function raw(string $raw): self /** * Binds the provided values to the query, allowing for parameterized queries. * - * @return self + * @return self */ public function bind(mixed ...$bindings): self { diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index 1d3aa2648..fbf8274ac 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -19,9 +19,9 @@ use function Tempest\Support\arr; /** - * @template T of object - * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery - * @uses \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods + * @template TModel of object + * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery + * @uses \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods */ final class UpdateQueryBuilder implements BuildsQuery { @@ -34,7 +34,7 @@ final class UpdateQueryBuilder implements BuildsQuery private ModelInspector $model; /** - * @param class-string|string|T $model + * @param class-string|string|TModel $model */ public function __construct( string|object $model, @@ -59,7 +59,7 @@ public function execute(mixed ...$bindings): ?PrimaryKey /** * Allows the update operation to proceed without WHERE conditions, updating all records. * - * @return self + * @return self */ public function allowAll(): self { @@ -71,7 +71,7 @@ public function allowAll(): self /** * Binds the provided values to the query, allowing for parameterized queries. * - * @return self + * @return self */ public function bind(mixed ...$bindings): self { diff --git a/packages/database/src/functions.php b/packages/database/src/functions.php index aaf68ef0c..eb3bd5539 100644 --- a/packages/database/src/functions.php +++ b/packages/database/src/functions.php @@ -8,9 +8,9 @@ /** * Creates a new query builder instance for the given model or table name. * - * @template T of object - * @param class-string|string|T $model - * @return QueryBuilder + * @template TModel of object + * @param class-string|string|TModel $model + * @return QueryBuilder */ function query(string|object $model): QueryBuilder { @@ -32,9 +32,9 @@ function model(string $modelClass): ModelQueryBuilder /** * Inspects the given model or table name to provide database insights. * - * @template T of object - * @param class-string|string|T $model - * @return ModelInspector + * @template TModel of object + * @param class-string|string|TModel $model + * @return ModelInspector * @internal */ function inspect(string|object $model): ModelInspector From fe9c9669645a346fe9fdab17b85fa6f916e3dadd Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 4 Aug 2025 14:43:43 +0200 Subject: [PATCH 17/51] feat(database): add `object` alias to `json` and `dto` --- .../QueryStatements/CreateTableStatement.php | 16 ++++- .../CreateTableStatementTest.php | 63 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/database/src/QueryStatements/CreateTableStatement.php b/packages/database/src/QueryStatements/CreateTableStatement.php index 43718cc98..44485aabd 100644 --- a/packages/database/src/QueryStatements/CreateTableStatement.php +++ b/packages/database/src/QueryStatements/CreateTableStatement.php @@ -208,7 +208,7 @@ public function json(string $name, bool $nullable = false, ?string $default = nu } /** - * Alias for `json()` method. Adds a JSON column for storing serializable objects. + * Adds a JSON column for storing serializable objects. This is an alias to the `json()` method. */ public function dto(string $name, bool $nullable = false, ?string $default = null): self { @@ -221,6 +221,20 @@ public function dto(string $name, bool $nullable = false, ?string $default = nul return $this; } + /** + * Adds a JSON column for storing serializable objects. This is an alias to the `json()` method. + */ + public function object(string $name, bool $nullable = false, ?string $default = null): self + { + $this->statements[] = new JsonStatement( + name: $name, + nullable: $nullable, + default: $default, + ); + + return $this; + } + /** * Adds a JSON column for storing arrays. The default value is automatically JSON-encoded, as opposed to `json` and `object`. */ diff --git a/tests/Integration/Database/QueryStatements/CreateTableStatementTest.php b/tests/Integration/Database/QueryStatements/CreateTableStatementTest.php index c5ccd1e31..c6114068b 100644 --- a/tests/Integration/Database/QueryStatements/CreateTableStatementTest.php +++ b/tests/Integration/Database/QueryStatements/CreateTableStatementTest.php @@ -280,6 +280,69 @@ public function test_string_method_with_custom_parameters(): void $this->assertSame($varcharStatement, $stringStatement); } + + public function test_object_field(): void + { + $migration = new class() implements DatabaseMigration { + private(set) string $name = '0'; + + public function up(): QueryStatement + { + return new CreateTableStatement('test_table') + ->object('object_data'); + } + + public function down(): ?QueryStatement + { + return null; + } + }; + + $this->migrate(CreateMigrationsTable::class, $migration); + + $this->expectNotToPerformAssertions(); + } + + public function test_object_field_with_default(): void + { + $migration = new class() implements DatabaseMigration { + private(set) string $name = '0'; + + public function up(): QueryStatement + { + return new CreateTableStatement('test_table') + ->object('object_data', default: '{"name": "Frieren", "age": 1000}'); + } + + public function down(): ?QueryStatement + { + return null; + } + }; + + $this->migrate(CreateMigrationsTable::class, $migration); + + $this->expectNotToPerformAssertions(); + } + + public function test_object_method_produces_same_sql_as_json_and_dto(): void + { + $jsonStatement = new CreateTableStatement('test_table') + ->json('data') + ->compile(DatabaseDialect::MYSQL); + + $dtoStatement = new CreateTableStatement('test_table') + ->dto('data') + ->compile(DatabaseDialect::MYSQL); + + $objectStatement = new CreateTableStatement('test_table') + ->object('data') + ->compile(DatabaseDialect::MYSQL); + + $this->assertSame($jsonStatement, $dtoStatement); + $this->assertSame($jsonStatement, $objectStatement); + $this->assertSame($dtoStatement, $objectStatement); + } } enum CreateTableStatementTestEnumForCreateTable: string From 99eb91611f00cc5e38cac3b87f9fdf60894bd79d Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 4 Aug 2025 15:05:23 +0200 Subject: [PATCH 18/51] feat(database): add a standalone `foreignKey` method when creating tables --- .../QueryStatements/CreateTableStatement.php | 43 +++++-- .../CreateTableStatementTest.php | 121 +++++++++++++----- .../BelongsToStatementTest.php | 85 ++++++++++++ 3 files changed, 212 insertions(+), 37 deletions(-) create mode 100644 tests/Integration/Database/QueryStatements/BelongsToStatementTest.php diff --git a/packages/database/src/QueryStatements/CreateTableStatement.php b/packages/database/src/QueryStatements/CreateTableStatement.php index 44485aabd..2d680eea7 100644 --- a/packages/database/src/QueryStatements/CreateTableStatement.php +++ b/packages/database/src/QueryStatements/CreateTableStatement.php @@ -43,15 +43,18 @@ public function primary(string $name = 'id'): self } /** - * Adds a foreign key relationship to another table. + * Adds an integer column with a foreign key relationship to another table. + * + * **Example** + * ```php + * $table->belongsTo('orders.customer_id', 'customers.id'); + * ``` + * + * @param string $local The local column in the format `this_table.foreign_id`. + * @param string $foreign The foreign column in the format `other_table.id`. */ - public function belongsTo( - string $local, - string $foreign, - OnDelete $onDelete = OnDelete::RESTRICT, - OnUpdate $onUpdate = OnUpdate::NO_ACTION, - bool $nullable = false, - ): self { + public function belongsTo(string $local, string $foreign, OnDelete $onDelete = OnDelete::RESTRICT, OnUpdate $onUpdate = OnUpdate::NO_ACTION, bool $nullable = false): self + { [, $localKey] = explode('.', $local); $this->integer($localKey, nullable: $nullable); @@ -66,6 +69,30 @@ public function belongsTo( return $this; } + /** + * Adds a foreign key constraint to another table. + * + * **Example** + * ```php + * $table->integer('customer_id', nullable: false); + * $table->foreignKey('orders.customer_id', 'customers.id'); + * ``` + * + * @param string $local The local column in the format `this_table.foreign_id`. + * @param string $foreign The foreign column in the format `other_table.id`. + */ + public function foreignKey(string $local, string $foreign, OnDelete $onDelete = OnDelete::RESTRICT, OnUpdate $onUpdate = OnUpdate::NO_ACTION): self + { + $this->statements[] = new BelongsToStatement( + local: $local, + foreign: $foreign, + onDelete: $onDelete, + onUpdate: $onUpdate, + ); + + return $this; + } + /** * Adds a `TEXT` column to the table. */ diff --git a/packages/database/tests/QueryStatements/CreateTableStatementTest.php b/packages/database/tests/QueryStatements/CreateTableStatementTest.php index 12f767e64..31d28e042 100644 --- a/packages/database/tests/QueryStatements/CreateTableStatementTest.php +++ b/packages/database/tests/QueryStatements/CreateTableStatementTest.php @@ -34,26 +34,32 @@ public static function provide_create_table_database_dialects(): iterable { yield 'mysql' => [ DatabaseDialect::MYSQL, - 'CREATE TABLE `migrations` ( - `id` INTEGER PRIMARY KEY AUTO_INCREMENT, - `name` VARCHAR(255) NOT NULL -);', + << [ DatabaseDialect::POSTGRESQL, - 'CREATE TABLE `migrations` ( - `id` SERIAL PRIMARY KEY, - `name` VARCHAR(255) NOT NULL -);', + << [ DatabaseDialect::SQLITE, - 'CREATE TABLE `migrations` ( - `id` INTEGER PRIMARY KEY AUTOINCREMENT, - `name` VARCHAR(255) NOT NULL -);', + << [ DatabaseDialect::MYSQL, - 'CREATE TABLE `books` ( - `id` INTEGER PRIMARY KEY AUTO_INCREMENT, - `author_id` INTEGER NOT NULL, - CONSTRAINT `fk_authors_books_author_id` FOREIGN KEY books(author_id) REFERENCES authors(id) ON DELETE CASCADE ON UPDATE NO ACTION, - `name` VARCHAR(255) NOT NULL -);', + << [ DatabaseDialect::POSTGRESQL, - 'CREATE TABLE `books` ( - `id` SERIAL PRIMARY KEY, - `author_id` INTEGER NOT NULL, - CONSTRAINT `fk_authors_books_author_id` FOREIGN KEY(author_id) REFERENCES authors(id) ON DELETE CASCADE ON UPDATE NO ACTION, - `name` VARCHAR(255) NOT NULL -);', + << [ DatabaseDialect::SQLITE, - 'CREATE TABLE `books` ( - `id` INTEGER PRIMARY KEY AUTOINCREMENT, - `author_id` INTEGER NOT NULL, - `name` VARCHAR(255) NOT NULL -);', + <<primary() + ->integer('author_id') + ->foreignKey('books.author_id', 'authors.id', OnDelete::CASCADE) + ->varchar('name') + ->compile($dialect); + + $this->assertSame($validSql, $statement); + } + + public static function provide_fk_create_table_database_drivers_explicit(): Generator + { + yield 'mysql' => [ + DatabaseDialect::MYSQL, + << [ + DatabaseDialect::POSTGRESQL, + << [ + DatabaseDialect::SQLITE, + <<primary() + ->text('order_number') + ->belongsTo('orders.customer_id', 'customers.id', OnDelete::CASCADE); + } + + public function down(): ?QueryStatement + { + return null; + } + }; + + $foreignKeyMigration = new class() implements DatabaseMigration { + private(set) string $name = '0002_test_foreign_key'; + + public function up(): QueryStatement + { + return new CreateTableStatement('invoices') + ->primary() + ->text('invoice_number') + ->integer('customer_id') // Must explicitly create the column + ->foreignKey('invoices.customer_id', 'customers.id', OnDelete::CASCADE); + } + + public function down(): ?QueryStatement + { + return null; + } + }; + + $this->migrate(CreateMigrationsTable::class, $belongsToMigration, $foreignKeyMigration); + + $this->expectNotToPerformAssertions(); + } + + public function test_foreign_key_allows_different_column_names(): void + { + $migration = new class() implements DatabaseMigration { + private(set) string $name = '0003_test_different_column_names'; + + public function up(): QueryStatement + { + return new CreateTableStatement('products') + ->primary() + ->text('name') + ->integer('category_ref') + ->foreignKey('products.category_ref', 'categories.id', OnDelete::CASCADE); + } + + public function down(): ?QueryStatement + { + return null; + } + }; + + $this->migrate(CreateMigrationsTable::class, $migration); + + $this->expectNotToPerformAssertions(); + } +} From b73511c86d7585c575cab70a9416362528892b89 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 4 Aug 2025 21:32:06 +0200 Subject: [PATCH 19/51] feat(database): support nested data transfer object casting and serialization --- .../Casters/ArrayToObjectCollectionCaster.php | 10 +- packages/mapper/src/Casters/DtoCaster.php | 33 +- .../src/Casters/NativeDateTimeCaster.php | 3 +- .../src/Mappers/ArrayToObjectMapper.php | 20 +- .../mapper/src/Serializers/DtoSerializer.php | 61 ++- .../BasicDtoSerializationTest.php | 224 +++++++++++ .../NestedDtoSerializationTest.php | 349 ++++++++++++++++++ .../TopLevelArraySerializationTest.php | 175 +++++++++ .../Database/DtoSerializationTest.php | 73 ---- .../Mapper/Serializers/DtoSerializerTest.php | 11 +- 10 files changed, 858 insertions(+), 101 deletions(-) create mode 100644 tests/Integration/Database/DtoSerialization/BasicDtoSerializationTest.php create mode 100644 tests/Integration/Database/DtoSerialization/NestedDtoSerializationTest.php create mode 100644 tests/Integration/Database/DtoSerialization/TopLevelArraySerializationTest.php delete mode 100644 tests/Integration/Database/DtoSerializationTest.php diff --git a/packages/mapper/src/Casters/ArrayToObjectCollectionCaster.php b/packages/mapper/src/Casters/ArrayToObjectCollectionCaster.php index 17e1ddcbc..e3fe307a9 100644 --- a/packages/mapper/src/Casters/ArrayToObjectCollectionCaster.php +++ b/packages/mapper/src/Casters/ArrayToObjectCollectionCaster.php @@ -17,11 +17,15 @@ public function __construct( public function cast(mixed $input): mixed { $values = []; - - $objectCaster = new ObjectCaster($this->property->getIterableType()); + $iterableType = $this->property->getIterableType(); + $objectCaster = new ObjectCaster($iterableType); foreach ($input as $key => $item) { - $values[$key] = $objectCaster->cast($item); + if (is_object($item) && $iterableType->matches($item::class)) { + $values[$key] = $item; + } else { + $values[$key] = $objectCaster->cast($item); + } } return $values; diff --git a/packages/mapper/src/Casters/DtoCaster.php b/packages/mapper/src/Casters/DtoCaster.php index 6bc6617d7..6338d6f98 100644 --- a/packages/mapper/src/Casters/DtoCaster.php +++ b/packages/mapper/src/Casters/DtoCaster.php @@ -18,14 +18,39 @@ public function __construct( public function cast(mixed $input): mixed { - if (! Json\is_valid($input)) { + if (is_string($input) && Json\is_valid($input)) { + return $this->deserializeRecursively(Json\decode($input)); + } + + if (is_array($input)) { + return $this->deserializeRecursively($input); + } + + if (is_string($input)) { throw new ValueCouldNotBeCast('json string'); } - ['type' => $type, 'data' => $data] = Json\decode($input); + return $input; + } + + private function deserializeRecursively(mixed $input): mixed + { + if (is_array($input) && isset($input['type'], $input['data'])) { + $class = Arr\find_key( + array: $this->mapperConfig->serializationMap, + value: $input['type'], + ) ?: $input['type']; - $class = Arr\find_key($this->mapperConfig->serializationMap, $type) ?: $type; + return map($this->deserializeRecursively($input['data']))->to($class); + } + + if (is_array($input)) { + return array_map( + fn (mixed $value) => $this->deserializeRecursively($value), + $input, + ); + } - return map($data)->to($class); + return $input; } } diff --git a/packages/mapper/src/Casters/NativeDateTimeCaster.php b/packages/mapper/src/Casters/NativeDateTimeCaster.php index 678784b06..f68f1b513 100644 --- a/packages/mapper/src/Casters/NativeDateTimeCaster.php +++ b/packages/mapper/src/Casters/NativeDateTimeCaster.php @@ -7,6 +7,7 @@ use DateTime; use DateTimeImmutable; use DateTimeInterface; +use InvalidArgumentException; use Tempest\Mapper\Caster; use Tempest\Reflection\PropertyReflector; use Tempest\Validation\Rules\HasDateTimeFormat; @@ -43,7 +44,7 @@ public function cast(mixed $input): ?DateTimeInterface $date = $class::createFromFormat($this->format, $input); if (! $date) { - return new $class($input); + throw new InvalidArgumentException("Must be a valid date in the format {$this->format}"); } return $date; diff --git a/packages/mapper/src/Mappers/ArrayToObjectMapper.php b/packages/mapper/src/Mappers/ArrayToObjectMapper.php index f03f79375..832f214c1 100644 --- a/packages/mapper/src/Mappers/ArrayToObjectMapper.php +++ b/packages/mapper/src/Mappers/ArrayToObjectMapper.php @@ -173,25 +173,23 @@ private function setParentRelations( public function resolveValue(PropertyReflector $property, mixed $value): mixed { - // If this isn't a property with iterable type defined, and the type accepts the value, we don't have to cast it - // We need to check the iterable type, because otherwise raw array input might incorrectly be seen as "accepted by the property's array type", - // which isn't sufficient a check. - // Oh how we long for the day that PHP gets generics… if ($property->getIterableType() === null && $property->getType()->accepts($value)) { + if (($caster = $this->casterFactory->forProperty($property)) !== null) { + if (! is_object($value) || ! $property->getType()->matches($value::class)) { + return $caster->cast($value); + } + } return $value; } - // If there is an iterable type, and it accepts the value within the array given, we don't have to cast it either - if ($property->getIterableType()?->accepts(arr($value)->first())) { - return $value; - } - - // If there's a caster, we'll cast the value if (($caster = $this->casterFactory->forProperty($property)) !== null) { return $caster->cast($value); } - // Otherwise we'll return the value as-is + if ($property->getIterableType()?->accepts(arr($value)->first())) { + return $value; + } + return $value; } } diff --git a/packages/mapper/src/Serializers/DtoSerializer.php b/packages/mapper/src/Serializers/DtoSerializer.php index 3ffcd698e..779b0012d 100644 --- a/packages/mapper/src/Serializers/DtoSerializer.php +++ b/packages/mapper/src/Serializers/DtoSerializer.php @@ -17,16 +17,63 @@ public function __construct( public function serialize(mixed $input): array|string { + if (is_array($input)) { + // Handle top-level arrays + return Json\encode($this->wrapWithTypeInfo($input)); + } + if (! is_object($input)) { - throw new ValueCouldNotBeSerialized('object'); + throw new ValueCouldNotBeSerialized('object or array'); + } + + return Json\encode($this->wrapWithTypeInfo($input)); + } + + private function wrapWithTypeInfo(mixed $input): mixed + { + if ($input instanceof \BackedEnum) { + return $input->value; + } + + if ($input instanceof \UnitEnum) { + return $input->name; } - $data = map($input)->toArray(); - $type = $this->mapperConfig->serializationMap[get_class($input)] ?? get_class($input); + if (is_object($input)) { + $data = $this->extractObjectData($input); + + foreach ($data as $key => $value) { + $data[$key] = $this->wrapWithTypeInfo($value); + } + + $type = $this->mapperConfig->serializationMap[get_class($input)] ?? get_class($input); + + return [ + 'type' => $type, + 'data' => $data, + ]; + } + + if (is_array($input)) { + return array_map([$this, 'wrapWithTypeInfo'], $input); + } + + return $input; + } + + private function extractObjectData(object $input): array + { + if ($input instanceof \JsonSerializable) { + return $input->jsonSerialize(); + } + + $data = []; + $class = new \ReflectionClass($input); + + foreach ($class->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + $data[$property->getName()] = $property->getValue($input); + } - return Json\encode([ - 'type' => $type, - 'data' => $data, - ]); + return $data; } } diff --git a/tests/Integration/Database/DtoSerialization/BasicDtoSerializationTest.php b/tests/Integration/Database/DtoSerialization/BasicDtoSerializationTest.php new file mode 100644 index 000000000..b1e6b4ddd --- /dev/null +++ b/tests/Integration/Database/DtoSerialization/BasicDtoSerializationTest.php @@ -0,0 +1,224 @@ +migrate(CreateMigrationsTable::class, new class implements DatabaseMigration { + public string $name = '001_simple_character'; + + public function up(): QueryStatement + { + return new CreateTableStatement('characters') + ->primary() + ->text('name') + ->json('stats'); + } + + public function down(): null + { + return null; + } + }); + + $stats = new CharacterStats(level: 50, health: 100, mana: 80); + $character = new Character('Frieren', $stats); + + query(Character::class) + ->insert($character) + ->execute(); + + $retrievedCharacter = query(Character::class) + ->select() + ->first(); + + $this->assertSame('Frieren', $retrievedCharacter->name); + $this->assertInstanceOf(CharacterStats::class, $retrievedCharacter->stats); + $this->assertSame(50, $retrievedCharacter->stats->level); + $this->assertSame(100, $retrievedCharacter->stats->health); + $this->assertSame(80, $retrievedCharacter->stats->mana); + } + + public function test_dto_with_enums(): void + { + $this->migrate(CreateMigrationsTable::class, new class implements DatabaseMigration { + public string $name = '002_character_class_infos'; + + public function up(): QueryStatement + { + return new CreateTableStatement('character_class_infos') + ->primary() + ->text('name') + ->json('details'); + } + + public function down(): null + { + return null; + } + }); + + $details = new ClassDetails( + type: CharacterClass::MAGE, + specialization: MageSpecialization::DESTRUCTION, + rank: ClassRank::MASTER, + ); + + $characterClass = new CharacterClassInfo('Frieren', $details); + + query(CharacterClassInfo::class) + ->insert($characterClass) + ->execute(); + + $retrieved = query(CharacterClassInfo::class) + ->select() + ->first(); + + $this->assertSame('Frieren', $retrieved->name); + $this->assertSame(CharacterClass::MAGE, $retrieved->details->type); + $this->assertSame(MageSpecialization::DESTRUCTION, $retrieved->details->specialization); + $this->assertSame(ClassRank::MASTER, $retrieved->details->rank); + } + + public function test_dto_with_custom_serialization_name(): void + { + $this->migrate(CreateMigrationsTable::class, new class implements DatabaseMigration { + public string $name = '003_settings'; + + public function up(): QueryStatement + { + return new CreateTableStatement('user_preferences') + ->primary() + ->text('username') + ->json('settings'); + } + + public function down(): null + { + return null; + } + }); + + $preferences = new UserPreferences( + username: 'frieren', + settings: new ApplicationSettings(theme: Theme::DARK, notifications: true), + ); + + query(UserPreferences::class) + ->insert($preferences) + ->execute(); + + $retrieved = query(UserPreferences::class) + ->select() + ->first(); + + $this->assertSame('frieren', $retrieved->username); + $this->assertSame(Theme::DARK, $retrieved->settings->theme); + $this->assertTrue($retrieved->settings->notifications); + } +} + +enum CharacterClass: string +{ + case MAGE = 'mage'; + case WARRIOR = 'warrior'; + case ARCHER = 'archer'; + case PRIEST = 'priest'; +} + +enum MageSpecialization: string +{ + case DESTRUCTION = 'destruction'; + case RESTORATION = 'restoration'; + case ILLUSION = 'illusion'; + case CONJURATION = 'conjuration'; +} + +enum ClassRank: string +{ + case NOVICE = 'novice'; + case ADEPT = 'adept'; + case EXPERT = 'expert'; + case MASTER = 'master'; + case LEGENDARY = 'legendary'; +} + +enum Theme: string +{ + case LIGHT = 'light'; + case DARK = 'dark'; +} + +final class Character +{ + public function __construct( + public string $name, + public CharacterStats $stats, + ) {} +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class CharacterStats +{ + public function __construct( + public int $level, + public int $health, + public int $mana, + ) {} +} + +final class CharacterClassInfo +{ + public function __construct( + public string $name, + public ClassDetails $details, + ) {} +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class ClassDetails +{ + public function __construct( + public CharacterClass $type, + public MageSpecialization $specialization, + public ClassRank $rank, + ) {} +} + +final class UserPreferences +{ + public function __construct( + public string $username, + public ApplicationSettings $settings, + ) {} +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +#[SerializeAs('app-settings')] +final class ApplicationSettings +{ + public function __construct( + public Theme $theme, + public bool $notifications, + ) {} +} diff --git a/tests/Integration/Database/DtoSerialization/NestedDtoSerializationTest.php b/tests/Integration/Database/DtoSerialization/NestedDtoSerializationTest.php new file mode 100644 index 000000000..3c19155ef --- /dev/null +++ b/tests/Integration/Database/DtoSerialization/NestedDtoSerializationTest.php @@ -0,0 +1,349 @@ +migrate(CreateMigrationsTable::class, new class implements DatabaseMigration { + public string $name = '001_spell_structure'; + + public function up(): QueryStatement + { + return new CreateTableStatement('spells') + ->primary() + ->text('name') + ->json('structure'); + } + + public function down(): null + { + return null; + } + }); + + $structure = new SpellStructure( + incantation: new Incantation( + words: 'Zoltraak', + pronunciation: new Pronunciation( + syllables: ['Zol', 'traak'], + emphasis: new EmphasisPattern( + primary: 'Zol', + secondary: 'traak', + duration: 2.5, + ), + ), + ), + components: new SpellComponents( + verbal: true, + somatic: true, + material: new MaterialComponent( + item: 'Crystal Focus', + rarity: ComponentRarity::RARE, + properties: new ItemProperties( + durability: 100, + enchantment: EnchantmentLevel::HIGH, + ), + ), + ), + ); + + $spell = new Spell('Zoltraak', $structure); + + query(Spell::class) + ->insert($spell) + ->execute(); + + $retrieved = query(Spell::class) + ->select() + ->first(); + + $this->assertSame('Zoltraak', $retrieved->name); + $this->assertSame('Zoltraak', $retrieved->structure->incantation->words); + $this->assertSame(['Zol', 'traak'], $retrieved->structure->incantation->pronunciation->syllables); + $this->assertSame('Zol', $retrieved->structure->incantation->pronunciation->emphasis->primary); + $this->assertSame(2.5, $retrieved->structure->incantation->pronunciation->emphasis->duration); + $this->assertTrue($retrieved->structure->components->verbal); + $this->assertSame('Crystal Focus', $retrieved->structure->components->material->item); + $this->assertSame(ComponentRarity::RARE, $retrieved->structure->components->material->rarity); + $this->assertSame(100, $retrieved->structure->components->material->properties->durability); + $this->assertSame(EnchantmentLevel::HIGH, $retrieved->structure->components->material->properties->enchantment); + } + + public function test_nested_dtos_with_mixed_types(): void + { + $this->migrate(CreateMigrationsTable::class, new class implements DatabaseMigration { + public string $name = '002_grimoire'; + + public function up(): QueryStatement + { + return new CreateTableStatement('grimoires') + ->primary() + ->text('title') + ->json('metadata'); + } + + public function down(): null + { + return null; + } + }); + + $metadata = new GrimoireMetadata( + author: new Author( + name: 'Serie', + era: MagicalEra::ANCIENT, + specializations: ['Destruction', 'Restoration', 'Illusion'], + ), + contents: new GrimoireContents( + spellCount: 1000, + difficulty: DifficultyLevel::LEGENDARY, + categories: ['Combat', 'Healing', 'Utility'], + indexing: new IndexingSystem( + method: IndexMethod::HIERARCHICAL, + crossReferences: true, + searchable: true, + ), + ), + preservation: new PreservationInfo( + condition: PreservationState::PRISTINE, + lastMaintenance: '1000 years ago', + protections: ['Time Stasis', 'Magic Barrier', 'Divine Ward'], + ), + ); + + $grimoire = new Grimoire('Ancient Spell Compendium', $metadata); + + query(Grimoire::class) + ->insert($grimoire) + ->execute(); + + $retrieved = query(Grimoire::class) + ->select() + ->first(); + + $this->assertSame('Ancient Spell Compendium', $retrieved->title); + $this->assertSame('Serie', $retrieved->metadata->author->name); + $this->assertSame(MagicalEra::ANCIENT, $retrieved->metadata->author->era); + $this->assertSame(['Destruction', 'Restoration', 'Illusion'], $retrieved->metadata->author->specializations); + $this->assertSame(1000, $retrieved->metadata->contents->spellCount); + $this->assertSame(DifficultyLevel::LEGENDARY, $retrieved->metadata->contents->difficulty); + $this->assertSame(IndexMethod::HIERARCHICAL, $retrieved->metadata->contents->indexing->method); + $this->assertTrue($retrieved->metadata->contents->indexing->crossReferences); + $this->assertSame(PreservationState::PRISTINE, $retrieved->metadata->preservation->condition); + $this->assertSame(['Time Stasis', 'Magic Barrier', 'Divine Ward'], $retrieved->metadata->preservation->protections); + } +} + +enum ComponentRarity: string +{ + case COMMON = 'common'; + case UNCOMMON = 'uncommon'; + case RARE = 'rare'; + case EPIC = 'epic'; + case LEGENDARY = 'legendary'; +} + +enum EnchantmentLevel: string +{ + case LOW = 'low'; + case MEDIUM = 'medium'; + case HIGH = 'high'; + case ULTIMATE = 'ultimate'; +} + +enum MagicalEra: string +{ + case ANCIENT = 'ancient'; + case CLASSICAL = 'classical'; + case MEDIEVAL = 'medieval'; + case MODERN = 'modern'; +} + +enum DifficultyLevel: string +{ + case BASIC = 'basic'; + case INTERMEDIATE = 'intermediate'; + case ADVANCED = 'advanced'; + case EXPERT = 'expert'; + case MASTER = 'master'; + case LEGENDARY = 'legendary'; +} + +enum IndexMethod: string +{ + case ALPHABETICAL = 'alphabetical'; + case HIERARCHICAL = 'hierarchical'; + case CATEGORICAL = 'categorical'; + case CHRONOLOGICAL = 'chronological'; +} + +enum PreservationState: string +{ + case PRISTINE = 'pristine'; + case EXCELLENT = 'excellent'; + case GOOD = 'good'; + case FAIR = 'fair'; + case POOR = 'poor'; + case DAMAGED = 'damaged'; +} + +final class Spell +{ + public function __construct( + public string $name, + public SpellStructure $structure, + ) {} +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class SpellStructure +{ + public function __construct( + public Incantation $incantation, + public SpellComponents $components, + ) {} +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class Incantation +{ + public function __construct( + public string $words, + public Pronunciation $pronunciation, + ) {} +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class Pronunciation +{ + public function __construct( + public array $syllables, + public EmphasisPattern $emphasis, + ) {} +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class EmphasisPattern +{ + public function __construct( + public string $primary, + public string $secondary, + public float $duration, + ) {} +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class SpellComponents +{ + public function __construct( + public bool $verbal, + public bool $somatic, + public MaterialComponent $material, + ) {} +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class MaterialComponent +{ + public function __construct( + public string $item, + public ComponentRarity $rarity, + public ItemProperties $properties, + ) {} +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class ItemProperties +{ + public function __construct( + public int $durability, + public EnchantmentLevel $enchantment, + ) {} +} + +final class Grimoire +{ + public function __construct( + public string $title, + public GrimoireMetadata $metadata, + ) {} +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class GrimoireMetadata +{ + public function __construct( + public Author $author, + public GrimoireContents $contents, + public PreservationInfo $preservation, + ) {} +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class Author +{ + public function __construct( + public string $name, + public MagicalEra $era, + public array $specializations, + ) {} +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class GrimoireContents +{ + public function __construct( + public int $spellCount, + public DifficultyLevel $difficulty, + public array $categories, + public IndexingSystem $indexing, + ) {} +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class IndexingSystem +{ + public function __construct( + public IndexMethod $method, + public bool $crossReferences, + public bool $searchable, + ) {} +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class PreservationInfo +{ + public function __construct( + public PreservationState $condition, + public string $lastMaintenance, + public array $protections, + ) {} +} diff --git a/tests/Integration/Database/DtoSerialization/TopLevelArraySerializationTest.php b/tests/Integration/Database/DtoSerialization/TopLevelArraySerializationTest.php new file mode 100644 index 000000000..fa76e0e00 --- /dev/null +++ b/tests/Integration/Database/DtoSerialization/TopLevelArraySerializationTest.php @@ -0,0 +1,175 @@ +migrate(CreateMigrationsTable::class, new class implements DatabaseMigration { + public string $name = '001_array_containers'; + + public function up(): QueryStatement + { + return new CreateTableStatement('array_container_models') + ->primary() + ->text('name') + ->json('data'); + } + + public function down(): null + { + return null; + } + }); + + $arrayData = [ + new SimpleArrayItem('First Item', 100), + new SimpleArrayItem('Second Item', 200), + new SimpleArrayItem('Third Item', 300), + ]; + + $container = new ArrayContainerModel('Test Array', $arrayData); + + query(ArrayContainerModel::class) + ->insert($container) + ->execute(); + + $retrieved = query(ArrayContainerModel::class) + ->select() + ->where('name', 'Test Array') + ->first(); + + $this->assertInstanceOf(ArrayContainerModel::class, $retrieved); + $this->assertEquals('Test Array', $retrieved->name); + $this->assertCount(3, $retrieved->data); + + foreach ($retrieved->data as $index => $item) { + $this->assertInstanceOf(SimpleArrayItem::class, $item); + $this->assertEquals($arrayData[$index]->name, $item->name); + $this->assertEquals($arrayData[$index]->value, $item->value); + } + } + + public function test_top_level_array_of_nested_dtos_serialization(): void + { + $this->migrate(CreateMigrationsTable::class, new class implements DatabaseMigration { + public string $name = '002_array_containers_nested'; + + public function up(): QueryStatement + { + return new CreateTableStatement('array_container_models') + ->primary() + ->text('name') + ->json('data'); + } + + public function down(): null + { + return null; + } + }); + + $arrayData = [ + new ItemWithNestedArray('Item A', new SimpleArrayItem('Sub A', 50)), + new ItemWithNestedArray('Item B', new SimpleArrayItem('Sub B', 75)), + ]; + + $container = new ArrayContainerModel('Nested Array', $arrayData); + + query(ArrayContainerModel::class) + ->insert($container) + ->execute(); + + $retrieved = query(ArrayContainerModel::class) + ->select() + ->where('name', 'Nested Array') + ->first(); + + $this->assertInstanceOf(ArrayContainerModel::class, $retrieved); + $this->assertEquals('Nested Array', $retrieved->name); + $this->assertCount(2, $retrieved->data); + + foreach ($retrieved->data as $index => $item) { + $this->assertInstanceOf(ItemWithNestedArray::class, $item); + $this->assertEquals($arrayData[$index]->name, $item->name); + $this->assertInstanceOf(SimpleArrayItem::class, $item->item); + $this->assertEquals($arrayData[$index]->item->name, $item->item->name); + $this->assertEquals($arrayData[$index]->item->value, $item->item->value); + } + } + + public function test_empty_top_level_array(): void + { + $this->migrate(CreateMigrationsTable::class, new class implements DatabaseMigration { + public string $name = '003_array_containers_empty'; + + public function up(): QueryStatement + { + return new CreateTableStatement('array_container_models') + ->primary() + ->text('name') + ->json('data'); + } + + public function down(): null + { + return null; + } + }); + + $container = new ArrayContainerModel('Empty Array', []); + + query(ArrayContainerModel::class) + ->insert($container) + ->execute(); + + $retrieved = query(ArrayContainerModel::class) + ->select() + ->where('name', 'Empty Array') + ->first(); + + $this->assertInstanceOf(ArrayContainerModel::class, $retrieved); + $this->assertEquals('Empty Array', $retrieved->name); + $this->assertCount(0, $retrieved->data); + } +} + +final readonly class ArrayContainerModel +{ + public function __construct( + public string $name, + #[SerializeWith(DtoSerializer::class), CastWith(DtoCaster::class)] + public array $data, + ) {} +} + +final readonly class SimpleArrayItem +{ + public function __construct( + public string $name, + public int $value, + ) {} +} + +final readonly class ItemWithNestedArray +{ + public function __construct( + public string $name, + public SimpleArrayItem $item, + ) {} +} diff --git a/tests/Integration/Database/DtoSerializationTest.php b/tests/Integration/Database/DtoSerializationTest.php deleted file mode 100644 index 9a68b53c7..000000000 --- a/tests/Integration/Database/DtoSerializationTest.php +++ /dev/null @@ -1,73 +0,0 @@ -migrate(CreateMigrationsTable::class, new class implements DatabaseMigration { - public string $name = '000_model_with_serializable_object'; - - public function up(): QueryStatement - { - return new CreateTableStatement('model_with_settings') - ->primary() - ->text('title') - ->json('settings'); - } - - public function down(): null - { - return null; - } - }); - - query(ModelWithSettings::class) - ->insert(new ModelWithSettings('model', new Settings(Theme::DARK))) - ->execute(); - - $model = query(ModelWithSettings::class) - ->select() - ->first(); - - $this->assertSame('model', $model->title); - $this->assertSame(Theme::DARK, $model->settings->theme); - } -} - -enum Theme: string -{ - case LIGHT = 'light'; - case DARK = 'dark'; -} - -final class ModelWithSettings -{ - public function __construct( - public string $title, - public Settings $settings, - ) {} -} - -#[SerializeAs('settings')] -final class Settings -{ - public function __construct( - public Theme $theme, - ) {} -} diff --git a/tests/Integration/Mapper/Serializers/DtoSerializerTest.php b/tests/Integration/Mapper/Serializers/DtoSerializerTest.php index 2f65a7e5c..842b52646 100644 --- a/tests/Integration/Mapper/Serializers/DtoSerializerTest.php +++ b/tests/Integration/Mapper/Serializers/DtoSerializerTest.php @@ -28,10 +28,17 @@ public function test_serialize_with_map(): void ); } - public function test_cannot_serialize_non_object(): void + public function test_can_serialize_empty_array(): void + { + $result = new DtoSerializer(new MapperConfig())->serialize([]); + + $this->assertSame('[]', $result); + } + + public function test_cannot_serialize_non_object_non_array(): void { $this->expectException(ValueCouldNotBeSerialized::class); - new DtoSerializer(new MapperConfig())->serialize([]); + new DtoSerializer(new MapperConfig())->serialize('string'); } } From 3c60c7dbdf331e571ca91f0b1cf2197c713b561f Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 4 Aug 2025 22:43:47 +0200 Subject: [PATCH 20/51] refactor(database): improve typings on query builders --- .../HasConvenientWhereMethods.php | 76 +++++++++++++++++++ .../HasWhereQueryBuilderMethods.php | 9 ++- .../QueryBuilders/WhereGroupBuilder.php | 22 ++++++ 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php b/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php index f377bc449..58e87409a 100644 --- a/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php @@ -11,6 +11,8 @@ use UnitEnum; /** + * @template TModel of object + * * Shared methods for building WHERE conditions and convenience WHERE methods. */ trait HasConvenientWhereMethods @@ -99,6 +101,8 @@ protected function buildCondition(string $fieldDefinition, WhereOperator $operat * Adds a `WHERE IN` condition. * * @param class-string|UnitEnum|array $values + * + * @return static */ public function whereIn(string $field, string|UnitEnum|array|ArrayAccess $values): self { @@ -109,6 +113,8 @@ public function whereIn(string $field, string|UnitEnum|array|ArrayAccess $values * Adds a `WHERE NOT IN` condition. * * @param class-string|UnitEnum|array $values + * + * @return static */ public function whereNotIn(string $field, string|UnitEnum|array|ArrayAccess $values): self { @@ -117,6 +123,8 @@ public function whereNotIn(string $field, string|UnitEnum|array|ArrayAccess $val /** * Adds a `WHERE BETWEEN` condition. + * + * @return static */ public function whereBetween(string $field, DateTimeInterface|string|float|int|Countable $min, DateTimeInterface|string|float|int|Countable $max): self { @@ -125,6 +133,8 @@ public function whereBetween(string $field, DateTimeInterface|string|float|int|C /** * Adds a `WHERE NOT BETWEEN` condition. + * + * @return static */ public function whereNotBetween(string $field, DateTimeInterface|string|float|int|Countable $min, DateTimeInterface|string|float|int|Countable $max): self { @@ -133,6 +143,8 @@ public function whereNotBetween(string $field, DateTimeInterface|string|float|in /** * Adds a `WHERE IS NULL` condition. + * + * @return static */ public function whereNull(string $field): self { @@ -141,6 +153,8 @@ public function whereNull(string $field): self /** * Adds a `WHERE IS NOT NULL` condition. + * + * @return static */ public function whereNotNull(string $field): self { @@ -149,6 +163,8 @@ public function whereNotNull(string $field): self /** * Adds a `WHERE NOT` condition (shorthand for != operator). + * + * @return static */ public function whereNot(string $field, mixed $value): self { @@ -157,6 +173,8 @@ public function whereNot(string $field, mixed $value): self /** * Adds a `WHERE LIKE` condition. + * + * @return static */ public function whereLike(string $field, string $value): self { @@ -165,6 +183,8 @@ public function whereLike(string $field, string $value): self /** * Adds a `WHERE NOT LIKE` condition. + * + * @return static */ public function whereNotLike(string $field, string $value): self { @@ -175,6 +195,8 @@ public function whereNotLike(string $field, string $value): self * Adds an `OR WHERE IN` condition. * * @param class-string|UnitEnum|array $values + * + * @return static */ public function orWhereIn(string $field, string|UnitEnum|array|ArrayAccess $values): self { @@ -185,6 +207,8 @@ public function orWhereIn(string $field, string|UnitEnum|array|ArrayAccess $valu * Adds an `OR WHERE NOT IN` condition. * * @param class-string|UnitEnum|array $values + * + * @return static */ public function orWhereNotIn(string $field, string|UnitEnum|array|ArrayAccess $values): self { @@ -193,6 +217,8 @@ public function orWhereNotIn(string $field, string|UnitEnum|array|ArrayAccess $v /** * Adds an `OR WHERE BETWEEN` condition. + * + * @return static */ public function orWhereBetween(string $field, DateTimeInterface|string|float|int|Countable $min, DateTimeInterface|string|float|int|Countable $max): self { @@ -201,6 +227,8 @@ public function orWhereBetween(string $field, DateTimeInterface|string|float|int /** * Adds an `OR WHERE NOT BETWEEN` condition. + * + * @return static */ public function orWhereNotBetween(string $field, DateTimeInterface|string|float|int|Countable $min, DateTimeInterface|string|float|int|Countable $max): self { @@ -209,6 +237,8 @@ public function orWhereNotBetween(string $field, DateTimeInterface|string|float| /** * Adds an `OR WHERE IS NULL` condition. + * + * @return static */ public function orWhereNull(string $field): self { @@ -217,6 +247,8 @@ public function orWhereNull(string $field): self /** * Adds an `OR WHERE IS NOT NULL` condition. + * + * @return static */ public function orWhereNotNull(string $field): self { @@ -225,6 +257,8 @@ public function orWhereNotNull(string $field): self /** * Adds an `OR WHERE NOT` condition (shorthand for != operator). + * + * @return static */ public function orWhereNot(string $field, mixed $value): self { @@ -233,6 +267,8 @@ public function orWhereNot(string $field, mixed $value): self /** * Adds an `OR WHERE LIKE` condition. + * + * @return static */ public function orWhereLike(string $field, string $value): self { @@ -241,6 +277,8 @@ public function orWhereLike(string $field, string $value): self /** * Adds an `OR WHERE NOT LIKE` condition. + * + * @return static */ public function orWhereNotLike(string $field, string $value): self { @@ -249,6 +287,8 @@ public function orWhereNotLike(string $field, string $value): self /** * Adds a `WHERE` condition for records from today. + * + * @return static */ public function whereToday(string $field): self { @@ -259,6 +299,8 @@ public function whereToday(string $field): self /** * Adds a `WHERE` condition for records from yesterday. + * + * @return static */ public function whereYesterday(string $field): self { @@ -269,6 +311,8 @@ public function whereYesterday(string $field): self /** * Adds a `WHERE` condition for records from this week. + * + * @return static */ public function whereThisWeek(string $field): self { @@ -279,6 +323,8 @@ public function whereThisWeek(string $field): self /** * Adds a `WHERE` condition for records from last week. + * + * @return static */ public function whereLastWeek(string $field): self { @@ -289,6 +335,8 @@ public function whereLastWeek(string $field): self /** * Adds a `WHERE` condition for records from this month. + * + * @return static */ public function whereThisMonth(string $field): self { @@ -299,6 +347,8 @@ public function whereThisMonth(string $field): self /** * Adds a `WHERE` condition for records from last month. + * + * @return static */ public function whereLastMonth(string $field): self { @@ -309,6 +359,8 @@ public function whereLastMonth(string $field): self /** * Adds a `WHERE` condition for records from this year. + * + * @return static */ public function whereThisYear(string $field): self { @@ -319,6 +371,8 @@ public function whereThisYear(string $field): self /** * Adds a `WHERE` condition for records from last year. + * + * @return static */ public function whereLastYear(string $field): self { @@ -329,6 +383,8 @@ public function whereLastYear(string $field): self /** * Adds a `WHERE` condition for records created after a specific date. + * + * @return static */ public function whereAfter(string $field, DateTimeInterface|string $date): self { @@ -337,6 +393,8 @@ public function whereAfter(string $field, DateTimeInterface|string $date): self /** * Adds a `WHERE` condition for records created before a specific date. + * + * @return static */ public function whereBefore(string $field, DateTimeInterface|string $date): self { @@ -345,6 +403,8 @@ public function whereBefore(string $field, DateTimeInterface|string $date): self /** * Adds an `OR WHERE` condition for records from today. + * + * @return static */ public function orWhereToday(string $field): self { @@ -354,6 +414,8 @@ public function orWhereToday(string $field): self /** * Adds an `OR WHERE` condition for records from yesterday. + * + * @return static */ public function orWhereYesterday(string $field): self { @@ -364,6 +426,8 @@ public function orWhereYesterday(string $field): self /** * Adds an `OR WHERE` condition for records from this week. + * + * @return static */ public function orWhereThisWeek(string $field): self { @@ -374,6 +438,8 @@ public function orWhereThisWeek(string $field): self /** * Adds an `OR WHERE` condition for records from this month. + * + * @return static */ public function orWhereThisMonth(string $field): self { @@ -384,6 +450,8 @@ public function orWhereThisMonth(string $field): self /** * Adds an `OR WHERE` condition for records from this year. + * + * @return static */ public function orWhereThisYear(string $field): self { @@ -394,6 +462,8 @@ public function orWhereThisYear(string $field): self /** * Adds an `OR WHERE` condition for records created after a specific date. + * + * @return static */ public function orWhereAfter(string $field, DateTimeInterface|string $date): self { @@ -402,6 +472,8 @@ public function orWhereAfter(string $field, DateTimeInterface|string $date): sel /** * Adds an `OR WHERE` condition for records created before a specific date. + * + * @return static */ public function orWhereBefore(string $field, DateTimeInterface|string $date): self { @@ -411,12 +483,16 @@ public function orWhereBefore(string $field, DateTimeInterface|string $date): se /** * Abstract method that must be implemented by classes using this trait. * Should add a basic WHERE condition. + * + * @return static */ abstract public function where(string $field, mixed $value, string|WhereOperator $operator = WhereOperator::EQUALS): self; /** * Abstract method that must be implemented by classes using this trait. * Should add an OR WHERE condition. + * + * @return static */ abstract public function orWhere(string $field, mixed $value, WhereOperator $operator = WhereOperator::EQUALS): self; } diff --git a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php index 8b4d5a61a..059f070b8 100644 --- a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php @@ -11,8 +11,9 @@ use function Tempest\Support\str; /** - * @template TModel + * @template TModel of object * @phpstan-require-implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery + * @uses \Tempest\Database\Builder\QueryBuilders\HasConvenientWhereMethods */ trait HasWhereQueryBuilderMethods { @@ -123,7 +124,7 @@ public function orWhereRaw(string $rawCondition, mixed ...$bindings): self /** * Adds a grouped where statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. * - * @param Closure(WhereGroupBuilder):void $callback + * @param Closure(WhereGroupBuilder):void $callback * @return self */ public function whereGroup(Closure $callback): self @@ -143,7 +144,7 @@ public function whereGroup(Closure $callback): self /** * Adds a grouped `AND WHERE` statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. * - * @param Closure(WhereGroupBuilder):void $callback + * @param Closure(WhereGroupBuilder):void $callback * @return self */ public function andWhereGroup(Closure $callback): self @@ -158,7 +159,7 @@ public function andWhereGroup(Closure $callback): self /** * Adds a grouped `OR WHERE` statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. * - * @param Closure(WhereGroupBuilder):void $callback + * @param Closure(WhereGroupBuilder):void $callback * @return self */ public function orWhereGroup(Closure $callback): self diff --git a/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php b/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php index 1618098aa..5060420db 100644 --- a/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php @@ -11,6 +11,10 @@ use function Tempest\Support\arr; use function Tempest\Support\str; +/** + * @template TModel of object + * @uses \Tempest\Database\Builder\QueryBuilders\HasConvenientWhereMethods + */ final class WhereGroupBuilder { use HasConvenientWhereMethods; @@ -27,6 +31,8 @@ public function __construct( /** * Adds a `WHERE` condition to the group. + * + * @return self */ public function where(string $field, mixed $value = null, string|WhereOperator $operator = WhereOperator::EQUALS): self { @@ -35,6 +41,8 @@ public function where(string $field, mixed $value = null, string|WhereOperator $ /** * Adds a `WHERE` condition to the group. + * + * @return self */ public function andWhere(string $field, mixed $value = null, WhereOperator $operator = WhereOperator::EQUALS): self { @@ -53,6 +61,8 @@ public function andWhere(string $field, mixed $value = null, WhereOperator $oper /** * Adds a `OR WHERE` condition to the group. + * + * @return self */ public function orWhere(string $field, mixed $value = null, string|WhereOperator $operator = WhereOperator::EQUALS): self { @@ -72,6 +82,8 @@ public function orWhere(string $field, mixed $value = null, string|WhereOperator /** * Adds a raw SQL `WHERE` condition to the group. + * + * @return self */ public function whereRaw(string $rawCondition, mixed ...$bindings): self { @@ -87,6 +99,8 @@ public function whereRaw(string $rawCondition, mixed ...$bindings): self /** * Adds a raw SQL `AND WHERE` condition to the group. + * + * @return self */ public function andWhereRaw(string $rawCondition, mixed ...$bindings): self { @@ -102,6 +116,8 @@ public function andWhereRaw(string $rawCondition, mixed ...$bindings): self /** * Adds a raw SQL `OR WHERE` condition to the group. + * + * @return self */ public function orWhereRaw(string $rawCondition, mixed ...$bindings): self { @@ -120,6 +136,8 @@ public function orWhereRaw(string $rawCondition, mixed ...$bindings): self * * @param Closure(WhereGroupBuilder):void $callback * @param 'AND'|'OR' $operator + * + * @return self */ public function whereGroup(Closure $callback, string $operator = 'AND'): self { @@ -144,6 +162,8 @@ public function whereGroup(Closure $callback, string $operator = 'AND'): self * Adds another nested `AND WHERE` statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. * * @param Closure(WhereGroupBuilder):void $callback + * + * @return self */ public function andWhereGroup(Closure $callback): self { @@ -154,6 +174,8 @@ public function andWhereGroup(Closure $callback): self * Adds another nested `OR WHERE` statement. The callback accepts a builder, which may be used to add more nested `WHERE` statements. * * @param Closure(WhereGroupBuilder):void $callback + * + * @return self */ public function orWhereGroup(Closure $callback): self { From ffb427db6de557eda8a436e509f486291e946a12 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 4 Aug 2025 22:44:11 +0200 Subject: [PATCH 21/51] refactor(mapper): clean up dto caster and serializer --- packages/mapper/src/Casters/DtoCaster.php | 13 +++---- .../mapper/src/Serializers/DtoSerializer.php | 38 +++++++++---------- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/packages/mapper/src/Casters/DtoCaster.php b/packages/mapper/src/Casters/DtoCaster.php index 6338d6f98..3b9e0e6fb 100644 --- a/packages/mapper/src/Casters/DtoCaster.php +++ b/packages/mapper/src/Casters/DtoCaster.php @@ -19,11 +19,11 @@ public function __construct( public function cast(mixed $input): mixed { if (is_string($input) && Json\is_valid($input)) { - return $this->deserializeRecursively(Json\decode($input)); + return $this->deserialize(Json\decode($input)); } if (is_array($input)) { - return $this->deserializeRecursively($input); + return $this->deserialize($input); } if (is_string($input)) { @@ -33,7 +33,7 @@ public function cast(mixed $input): mixed return $input; } - private function deserializeRecursively(mixed $input): mixed + private function deserialize(mixed $input): mixed { if (is_array($input) && isset($input['type'], $input['data'])) { $class = Arr\find_key( @@ -41,14 +41,11 @@ private function deserializeRecursively(mixed $input): mixed value: $input['type'], ) ?: $input['type']; - return map($this->deserializeRecursively($input['data']))->to($class); + return map($this->deserialize($input['data']))->to($class); } if (is_array($input)) { - return array_map( - fn (mixed $value) => $this->deserializeRecursively($value), - $input, - ); + return array_map(fn (mixed $value) => $this->deserialize($value), $input); } return $input; diff --git a/packages/mapper/src/Serializers/DtoSerializer.php b/packages/mapper/src/Serializers/DtoSerializer.php index 779b0012d..6d44bafb7 100644 --- a/packages/mapper/src/Serializers/DtoSerializer.php +++ b/packages/mapper/src/Serializers/DtoSerializer.php @@ -2,12 +2,16 @@ namespace Tempest\Mapper\Serializers; +use BackedEnum; +use JsonSerializable; use Tempest\Mapper\Exceptions\ValueCouldNotBeSerialized; use Tempest\Mapper\MapperConfig; use Tempest\Mapper\Serializer; +use Tempest\Reflection\ClassReflector; +use Tempest\Reflection\PropertyReflector; +use Tempest\Support\Arr; use Tempest\Support\Json; - -use function Tempest\map; +use UnitEnum; final readonly class DtoSerializer implements Serializer { @@ -17,25 +21,25 @@ public function __construct( public function serialize(mixed $input): array|string { + // Support top-level arrays if (is_array($input)) { - // Handle top-level arrays - return Json\encode($this->wrapWithTypeInfo($input)); + return Json\encode($this->serializeWithType($input)); } if (! is_object($input)) { throw new ValueCouldNotBeSerialized('object or array'); } - return Json\encode($this->wrapWithTypeInfo($input)); + return Json\encode($this->serializeWithType($input)); } - private function wrapWithTypeInfo(mixed $input): mixed + private function serializeWithType(mixed $input): mixed { - if ($input instanceof \BackedEnum) { + if ($input instanceof BackedEnum) { return $input->value; } - if ($input instanceof \UnitEnum) { + if ($input instanceof UnitEnum) { return $input->name; } @@ -43,7 +47,7 @@ private function wrapWithTypeInfo(mixed $input): mixed $data = $this->extractObjectData($input); foreach ($data as $key => $value) { - $data[$key] = $this->wrapWithTypeInfo($value); + $data[$key] = $this->serializeWithType($value); } $type = $this->mapperConfig->serializationMap[get_class($input)] ?? get_class($input); @@ -55,7 +59,7 @@ private function wrapWithTypeInfo(mixed $input): mixed } if (is_array($input)) { - return array_map([$this, 'wrapWithTypeInfo'], $input); + return Arr\map_iterable($input, $this->serializeWithType(...)); } return $input; @@ -63,17 +67,13 @@ private function wrapWithTypeInfo(mixed $input): mixed private function extractObjectData(object $input): array { - if ($input instanceof \JsonSerializable) { + if ($input instanceof JsonSerializable) { return $input->jsonSerialize(); } - $data = []; - $class = new \ReflectionClass($input); - - foreach ($class->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { - $data[$property->getName()] = $property->getValue($input); - } - - return $data; + return Arr\map_with_keys( + array: new ClassReflector($input)->getPublicProperties(), + map: fn (PropertyReflector $property) => yield $property->getName() => $property->getValue($input), + ); } } From a2e184e600e9af2565b498b215952e0543132f59 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 4 Aug 2025 22:44:22 +0200 Subject: [PATCH 22/51] test(database): improve dto serialization coverage --- .../DtoSerialization/SerializeAsTest.php | 308 ++++++++++++++++++ .../Mapper/Casters/DtoCasterTest.php | 159 +++++++++ .../Mapper/Serializers/DtoSerializerTest.php | 268 +++++++++++++++ 3 files changed, 735 insertions(+) create mode 100644 tests/Integration/Database/DtoSerialization/SerializeAsTest.php diff --git a/tests/Integration/Database/DtoSerialization/SerializeAsTest.php b/tests/Integration/Database/DtoSerialization/SerializeAsTest.php new file mode 100644 index 000000000..01f643f4e --- /dev/null +++ b/tests/Integration/Database/DtoSerialization/SerializeAsTest.php @@ -0,0 +1,308 @@ +container->get(MapperConfig::class); + $config->serializeAs(SimpleSpell::class, 'simple-spell'); + + $this->migrate(CreateMigrationsTable::class, new class implements DatabaseMigration { + public string $name = '001_spell_library'; + + public function up(): QueryStatement + { + return new CreateTableStatement('spell_libraries') + ->primary() + ->text('name') + ->json('spell_data'); + } + + public function down(): null + { + return null; + } + }); + + $spell = new SimpleSpell(name: 'Zoltraak', element: 'destruction'); + $library = new SpellLibrary(name: "Frieren's Collection", spell_data: $spell); + + query(SpellLibrary::class) + ->insert($library) + ->execute(); + + $retrieved = query(SpellLibrary::class) + ->select() + ->first(); + + $this->assertSame("Frieren's Collection", $retrieved->name); + $this->assertInstanceOf(SimpleSpell::class, $retrieved->spell_data); + $this->assertSame('Zoltraak', $retrieved->spell_data->name); + $this->assertSame('destruction', $retrieved->spell_data->element); + + $raw = new Query('SELECT spell_data FROM spell_libraries WHERE id = 1')->fetchFirst(); + $json = json_decode($raw['spell_data'], associative: true); + + $this->assertSame('simple-spell', $json['type']); + $this->assertArrayHasKey('data', $json); + $this->assertSame('Zoltraak', $json['data']['name']); + $this->assertSame('destruction', $json['data']['element']); + } + + public function test_serialize_as_nested_objects(): void + { + $config = $this->container->get(MapperConfig::class); + $config->serializeAs(MageProfile::class, 'mage-profile'); + $config->serializeAs(SimpleSpell::class, 'simple-spell'); + + $this->migrate(CreateMigrationsTable::class, new class implements DatabaseMigration { + public string $name = '002_mage_profiles'; + + public function up(): QueryStatement + { + return new CreateTableStatement('mages') + ->primary() + ->text('name') + ->json('profile_data'); + } + + public function down(): null + { + return null; + } + }); + + $profile = new MageProfile( + age: 1000, + favoriteSpell: new SimpleSpell(name: 'Zoltraak', element: 'destruction'), + ); + + $mage = new Mage(name: 'Frieren', profile_data: $profile); + + query(Mage::class) + ->insert($mage) + ->execute(); + + $retrieved = query(Mage::class) + ->select() + ->first(); + + $this->assertSame('Frieren', $retrieved->name); + $this->assertInstanceOf(MageProfile::class, $retrieved->profile_data); + $this->assertSame(1000, $retrieved->profile_data->age); + $this->assertInstanceOf(SimpleSpell::class, $retrieved->profile_data->favoriteSpell); + $this->assertSame('Zoltraak', $retrieved->profile_data->favoriteSpell->name); + + $raw = new Query('SELECT profile_data FROM mages WHERE id = 1')->fetchFirst(); + $json = json_decode($raw['profile_data'], associative: true); + + $this->assertSame('mage-profile', $json['type']); + $this->assertSame(1000, $json['data']['age']); + $this->assertSame('simple-spell', $json['data']['favoriteSpell']['type']); + $this->assertSame('Zoltraak', $json['data']['favoriteSpell']['data']['name']); + } + + public function test_serialize_as_with_arrays(): void + { + $config = $this->container->get(MapperConfig::class); + $config->serializeAs(SpellCollection::class, 'spell-collection'); + $config->serializeAs(SimpleSpell::class, 'simple-spell'); + + $this->migrate(CreateMigrationsTable::class, new class implements DatabaseMigration { + public string $name = '003_collections'; + + public function up(): QueryStatement + { + return new CreateTableStatement('spell_containers') + ->primary() + ->text('title') + ->json('collection_data'); + } + + public function down(): null + { + return null; + } + }); + + $collection = new SpellCollection( + count: 3, + spells: [ + new SimpleSpell(name: 'Zoltraak', element: 'destruction'), + new SimpleSpell(name: 'Shield', element: 'protection'), + new SimpleSpell(name: 'Heal', element: 'restoration'), + ], + ); + + $container = new SpellContainer(title: 'Basic Spells', collection_data: $collection); + + query(SpellContainer::class) + ->insert($container) + ->execute(); + + $retrieved = query(SpellContainer::class) + ->select() + ->first(); + + $this->assertSame('Basic Spells', $retrieved->title); + $this->assertInstanceOf(SpellCollection::class, $retrieved->collection_data); + $this->assertSame(3, $retrieved->collection_data->count); + $this->assertCount(3, $retrieved->collection_data->spells); + $this->assertInstanceOf(SimpleSpell::class, $retrieved->collection_data->spells[0]); + $this->assertSame('Zoltraak', $retrieved->collection_data->spells[0]->name); + + $raw = new Query('SELECT collection_data FROM spell_containers WHERE id = 1')->fetchFirst(); + $json = json_decode($raw['collection_data'], associative: true); + + $this->assertSame('spell-collection', $json['type']); + $this->assertSame(3, $json['data']['count']); + $this->assertCount(3, $json['data']['spells']); + $this->assertSame('simple-spell', $json['data']['spells'][0]['type']); + $this->assertSame('Zoltraak', $json['data']['spells'][0]['data']['name']); + } + + public function test_serialize_as_without_explicit_casters(): void + { + $config = $this->container->get(MapperConfig::class); + $config->serializeAs(MagicItem::class, 'magic-item'); + + $this->migrate(CreateMigrationsTable::class, new class implements DatabaseMigration { + public string $name = '004_inventory'; + + public function up(): QueryStatement + { + return new CreateTableStatement('inventories') + ->primary() + ->text('owner') + ->json('item_data'); + } + + public function down(): null + { + return null; + } + }); + + $item = new MagicItem( + name: 'Staff of Power', + type: ItemType::STAFF, + enchanted: true, + ); + + $inventory = new Inventory(owner: 'Frieren', item_data: $item); + + query(Inventory::class) + ->insert($inventory) + ->execute(); + + $retrieved = query(Inventory::class) + ->select() + ->first(); + + $this->assertSame('Frieren', $retrieved->owner); + $this->assertInstanceOf(MagicItem::class, $retrieved->item_data); + $this->assertSame('Staff of Power', $retrieved->item_data->name); + $this->assertSame(ItemType::STAFF, $retrieved->item_data->type); + $this->assertTrue($retrieved->item_data->enchanted); + + $raw = new Query('SELECT item_data FROM inventories WHERE id = 1')->fetchFirst(); + $json = json_decode($raw['item_data'], associative: true); + + $this->assertSame('magic-item', $json['type']); + $this->assertSame('Staff of Power', $json['data']['name']); + $this->assertSame('staff', $json['data']['type']); + $this->assertTrue($json['data']['enchanted']); + } +} + +enum ItemType: string +{ + case STAFF = 'staff'; + case WAND = 'wand'; + case RING = 'ring'; +} + +final class SpellLibrary +{ + public function __construct( + public string $name, + public SimpleSpell $spell_data, + ) {} +} + +#[SerializeAs('simple-spell')] +final class SimpleSpell +{ + public function __construct( + public string $name, + public string $element, + ) {} +} + +final class Mage +{ + public function __construct( + public string $name, + public MageProfile $profile_data, + ) {} +} + +#[SerializeAs('mage-profile')] +final class MageProfile +{ + public function __construct( + public int $age, + public SimpleSpell $favoriteSpell, + ) {} +} + +final class SpellContainer +{ + public function __construct( + public string $title, + public SpellCollection $collection_data, + ) {} +} + +#[SerializeAs('spell-collection')] +final class SpellCollection +{ + public function __construct( + public int $count, + /** @var \Tests\Tempest\Integration\Database\DtoSerialization\SimpleSpell[] */ + public array $spells, + ) {} +} + +final class Inventory +{ + public function __construct( + public string $owner, + public MagicItem $item_data, + ) {} +} + +#[SerializeAs('magic-item')] +final class MagicItem +{ + public function __construct( + public string $name, + public ItemType $type, + public bool $enchanted, + ) {} +} diff --git a/tests/Integration/Mapper/Casters/DtoCasterTest.php b/tests/Integration/Mapper/Casters/DtoCasterTest.php index b7ecc12aa..a741d5dae 100644 --- a/tests/Integration/Mapper/Casters/DtoCasterTest.php +++ b/tests/Integration/Mapper/Casters/DtoCasterTest.php @@ -2,11 +2,17 @@ namespace Tests\Tempest\Integration\Mapper\Casters; +use Tempest\Http\Method; use Tempest\Mapper\Casters\DtoCaster; use Tempest\Mapper\Exceptions\ValueCouldNotBeCast; use Tempest\Mapper\MapperConfig; +use Tempest\Mapper\Serializers\DtoSerializer; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use Tests\Tempest\Integration\Mapper\Fixtures\MyObject; +use Tests\Tempest\Integration\Mapper\Fixtures\NestedObjectA; +use Tests\Tempest\Integration\Mapper\Fixtures\NestedObjectB; +use Tests\Tempest\Integration\Mapper\Fixtures\ObjectWithEnum; +use Tests\Tempest\Integration\Mapper\Fixtures\ObjectWithNullableProperties; final class DtoCasterTest extends FrameworkIntegrationTestCase { @@ -40,4 +46,157 @@ public function test_cannot_cast_with_invalid_json(): void new DtoCaster(new MapperConfig())->cast($json); } + + public function test_cast_nested_objects(): void + { + $json = json_encode([ + 'type' => NestedObjectA::class, + 'data' => [ + 'items' => [ + [ + 'type' => NestedObjectB::class, + 'data' => ['name' => 'Frieren'], + ], + [ + 'type' => NestedObjectB::class, + 'data' => ['name' => 'Fern'], + ], + ], + ], + ]); + + $dto = new DtoCaster(new MapperConfig())->cast($json); + + $this->assertInstanceOf(NestedObjectA::class, $dto); + $this->assertCount(2, $dto->items); + $this->assertInstanceOf(NestedObjectB::class, $dto->items[0]); + $this->assertSame('Frieren', $dto->items[0]->name); + $this->assertInstanceOf(NestedObjectB::class, $dto->items[1]); + $this->assertSame('Fern', $dto->items[1]->name); + } + + public function test_cast_object_with_nullable_properties(): void + { + $json = json_encode([ + 'type' => ObjectWithNullableProperties::class, + 'data' => [ + 'a' => 'test', + 'b' => 3.14, + 'c' => null, + ], + ]); + + $dto = new DtoCaster(new MapperConfig())->cast($json); + + $this->assertInstanceOf(ObjectWithNullableProperties::class, $dto); + $this->assertSame('test', $dto->a); + $this->assertSame(3.14, $dto->b); + $this->assertNull($dto->c); + } + + public function test_cast_object_with_enums(): void + { + $json = json_encode([ + 'type' => ObjectWithEnum::class, + 'data' => [ + 'method' => 'GET', + ], + ]); + + $dto = new DtoCaster(new MapperConfig())->cast($json); + + $this->assertInstanceOf(ObjectWithEnum::class, $dto); + $this->assertSame(Method::GET, $dto->method); + } + + public function test_cast_array_directly(): void + { + $array = [ + 'type' => MyObject::class, + 'data' => ['name' => 'test'], + ]; + + $dto = new DtoCaster(new MapperConfig())->cast($array); + + $this->assertInstanceOf(MyObject::class, $dto); + $this->assertSame('test', $dto->name); + } + + public function test_cast_top_level_array(): void + { + $json = json_encode([ + [ + 'type' => MyObject::class, + 'data' => ['name' => 'Frieren'], + ], + [ + 'type' => MyObject::class, + 'data' => ['name' => 'Fern'], + ], + ]); + + $dto = new DtoCaster(new MapperConfig())->cast($json); + + $this->assertIsArray($dto); + $this->assertCount(2, $dto); + $this->assertInstanceOf(MyObject::class, $dto[0]); + $this->assertSame('Frieren', $dto[0]->name); + $this->assertInstanceOf(MyObject::class, $dto[1]); + $this->assertSame('Fern', $dto[1]->name); + } + + public function test_cast_with_multiple_mapped_classes(): void + { + $config = new MapperConfig() + ->serializeAs(MyObject::class, 'my-object') + ->serializeAs(NestedObjectB::class, 'nested-b'); + + $json = json_encode([ + 'type' => 'nested-b', + 'data' => ['name' => 'mapped nested'], + ]); + + $dto = new DtoCaster($config)->cast($json); + + $this->assertInstanceOf(NestedObjectB::class, $dto); + $this->assertSame('mapped nested', $dto->name); + } + + public function test_cast_preserves_non_dto_values(): void + { + $originalValue = 42; + + $result = new DtoCaster(new MapperConfig())->cast($originalValue); + + $this->assertSame($originalValue, $result); + } + + public function test_cast_malformed_json_throws_exception(): void + { + $malformedJson = '{"invalid": json}'; + + $this->expectException(ValueCouldNotBeCast::class); + + new DtoCaster(new MapperConfig())->cast($malformedJson); + } + + public function test_serialize_and_cast_roundtrip(): void + { + $original = new NestedObjectA(items: [ + new NestedObjectB(name: 'Frieren'), + new NestedObjectB(name: 'Fern'), + ]); + + $serializer = new DtoSerializer(new MapperConfig()); + $json = $serializer->serialize($original); + + $casted = new DtoCaster(new MapperConfig())->cast($json); + + $this->assertInstanceOf(NestedObjectA::class, $casted); + $this->assertCount(2, $casted->items); + $this->assertInstanceOf(NestedObjectB::class, $casted->items[0]); + $this->assertSame('Frieren', $casted->items[0]->name); + $this->assertInstanceOf(NestedObjectB::class, $casted->items[1]); + $this->assertSame('Fern', $casted->items[1]->name); + } } diff --git a/tests/Integration/Mapper/Serializers/DtoSerializerTest.php b/tests/Integration/Mapper/Serializers/DtoSerializerTest.php index 842b52646..e8105ee2e 100644 --- a/tests/Integration/Mapper/Serializers/DtoSerializerTest.php +++ b/tests/Integration/Mapper/Serializers/DtoSerializerTest.php @@ -2,11 +2,19 @@ namespace Tests\Tempest\Integration\Mapper\Serializers; +use Tempest\Http\Method; use Tempest\Mapper\Exceptions\ValueCouldNotBeSerialized; use Tempest\Mapper\MapperConfig; use Tempest\Mapper\Serializers\DtoSerializer; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; +use Tests\Tempest\Integration\Mapper\Fixtures\BackedEnumToSerialize; +use Tests\Tempest\Integration\Mapper\Fixtures\JsonSerializableObject; use Tests\Tempest\Integration\Mapper\Fixtures\MyObject; +use Tests\Tempest\Integration\Mapper\Fixtures\NestedObjectA; +use Tests\Tempest\Integration\Mapper\Fixtures\NestedObjectB; +use Tests\Tempest\Integration\Mapper\Fixtures\ObjectWithEnum; +use Tests\Tempest\Integration\Mapper\Fixtures\ObjectWithNullableProperties; +use Tests\Tempest\Integration\Mapper\Fixtures\UnitEnumToSerialize; final class DtoSerializerTest extends FrameworkIntegrationTestCase { @@ -41,4 +49,264 @@ public function test_cannot_serialize_non_object_non_array(): void new DtoSerializer(new MapperConfig())->serialize('string'); } + + public function test_serialize_nested_objects(): void + { + $nestedA = new NestedObjectA(items: [ + new NestedObjectB(name: 'Frieren'), + new NestedObjectB(name: 'Fern'), + ]); + + $expected = json_encode([ + 'type' => NestedObjectA::class, + 'data' => [ + 'items' => [ + [ + 'type' => NestedObjectB::class, + 'data' => ['name' => 'Frieren'], + ], + [ + 'type' => NestedObjectB::class, + 'data' => ['name' => 'Fern'], + ], + ], + ], + ]); + + $this->assertSame( + $expected, + new DtoSerializer(new MapperConfig())->serialize($nestedA), + ); + } + + public function test_serialize_object_with_nullable_properties(): void + { + $object = new ObjectWithNullableProperties( + a: 'test', + b: 3.14, + c: null, + ); + + $expected = json_encode([ + 'type' => ObjectWithNullableProperties::class, + 'data' => [ + 'a' => 'test', + 'b' => 3.14, + 'c' => null, + ], + ]); + + $this->assertSame( + $expected, + new DtoSerializer(new MapperConfig())->serialize($object), + ); + } + + public function test_serialize_object_with_backed_enum(): void + { + $object = new ObjectWithEnum(); + $object->method = Method::POST; + + $expected = json_encode([ + 'type' => ObjectWithEnum::class, + 'data' => [ + 'method' => 'POST', + ], + ]); + + $this->assertSame( + $expected, + new DtoSerializer(new MapperConfig())->serialize($object), + ); + } + + public function test_serialize_object_with_unit_enum(): void + { + $object = new ObjectWithEnum(); + $object->method = Method::GET; + + $expected = json_encode([ + 'type' => ObjectWithEnum::class, + 'data' => [ + 'method' => 'GET', + ], + ]); + + $this->assertSame( + $expected, + new DtoSerializer(new MapperConfig())->serialize($object), + ); + } + + public function test_serialize_complex_nested_structure(): void + { + $nestedA = new NestedObjectA(items: [ + new NestedObjectB(name: 'Frieren'), + new NestedObjectB(name: 'Fern'), + new NestedObjectB(name: 'Stark'), + ]); + + $expected = json_encode([ + 'type' => NestedObjectA::class, + 'data' => [ + 'items' => [ + [ + 'type' => NestedObjectB::class, + 'data' => ['name' => 'Frieren'], + ], + [ + 'type' => NestedObjectB::class, + 'data' => ['name' => 'Fern'], + ], + [ + 'type' => NestedObjectB::class, + 'data' => ['name' => 'Stark'], + ], + ], + ], + ]); + + $this->assertSame( + $expected, + new DtoSerializer(new MapperConfig())->serialize($nestedA), + ); + } + + public function test_serialize_top_level_array_of_objects(): void + { + $objects = [ + new MyObject(name: 'Frieren'), + new MyObject(name: 'Fern'), + ]; + + $expected = json_encode([ + [ + 'type' => MyObject::class, + 'data' => ['name' => 'Frieren'], + ], + [ + 'type' => MyObject::class, + 'data' => ['name' => 'Fern'], + ], + ]); + + $this->assertSame( + $expected, + new DtoSerializer(new MapperConfig())->serialize($objects), + ); + } + + public function test_serialize_json_serializable_object(): void + { + $object = new JsonSerializableObject(); + + $expected = json_encode([ + 'type' => JsonSerializableObject::class, + 'data' => ['a'], + ]); + + $this->assertSame( + $expected, + new DtoSerializer(new MapperConfig())->serialize($object), + ); + } + + public function test_serialize_mixed_complex_structure(): void + { + $nestedA = new NestedObjectA(items: [ + new NestedObjectB(name: 'Item 1'), + new NestedObjectB(name: 'Item 2'), + ]); + + $expected = json_encode([ + 'type' => NestedObjectA::class, + 'data' => [ + 'items' => [ + [ + 'type' => NestedObjectB::class, + 'data' => ['name' => 'Item 1'], + ], + [ + 'type' => NestedObjectB::class, + 'data' => ['name' => 'Item 2'], + ], + ], + ], + ]); + + $this->assertSame( + $expected, + new DtoSerializer(new MapperConfig())->serialize($nestedA), + ); + } + + public function test_serialize_backed_enum_directly(): void + { + $serializer = new DtoSerializer(new MapperConfig()); + + $result = $serializer->serialize([BackedEnumToSerialize::FOO]); + + $this->assertSame('["foo"]', $result); + } + + public function test_serialize_unit_enum_directly(): void + { + $serializer = new DtoSerializer(new MapperConfig()); + + $result = $serializer->serialize([UnitEnumToSerialize::BAR]); + + $this->assertSame('["BAR"]', $result); + } + + public function test_serialize_with_multiple_maps(): void + { + $config = new MapperConfig() + ->serializeAs(MyObject::class, 'my-object') + ->serializeAs(NestedObjectB::class, 'nested-b'); + + $object = new NestedObjectA(items: [ + new NestedObjectB(name: 'mapped'), + ]); + + $expected = json_encode([ + 'type' => NestedObjectA::class, + 'data' => [ + 'items' => [ + [ + 'type' => 'nested-b', + 'data' => ['name' => 'mapped'], + ], + ], + ], + ]); + + $this->assertSame( + $expected, + new DtoSerializer($config)->serialize($object), + ); + } + + public function test_serialize_array_with_mixed_types(): void + { + $objects = [ + new MyObject(name: 'test1'), + new NestedObjectB(name: 'test2'), + ]; + + $expected = json_encode([ + [ + 'type' => MyObject::class, + 'data' => ['name' => 'test1'], + ], + [ + 'type' => NestedObjectB::class, + 'data' => ['name' => 'test2'], + ], + ]); + + $this->assertSame( + $expected, + new DtoSerializer(new MapperConfig())->serialize($objects), + ); + } } From 2058ecbf1057860dbfe6e4e2dd13d5bdf4b3f0d8 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Tue, 5 Aug 2025 00:04:39 +0200 Subject: [PATCH 23/51] fix(database): implement `DatabaseException` on primary column exceptions --- .../database/src/Exceptions/ModelDidNotHavePrimaryColumn.php | 2 +- .../database/src/Exceptions/ModelHadMultiplePrimaryColumns.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/database/src/Exceptions/ModelDidNotHavePrimaryColumn.php b/packages/database/src/Exceptions/ModelDidNotHavePrimaryColumn.php index f2e8d3ce2..e60b2890a 100644 --- a/packages/database/src/Exceptions/ModelDidNotHavePrimaryColumn.php +++ b/packages/database/src/Exceptions/ModelDidNotHavePrimaryColumn.php @@ -6,7 +6,7 @@ use Exception; -final class ModelDidNotHavePrimaryColumn extends Exception +final class ModelDidNotHavePrimaryColumn extends Exception implements DatabaseException { public static function neededForMethod(string|object $model, string $method): self { diff --git a/packages/database/src/Exceptions/ModelHadMultiplePrimaryColumns.php b/packages/database/src/Exceptions/ModelHadMultiplePrimaryColumns.php index 3d07c446c..15e045f96 100644 --- a/packages/database/src/Exceptions/ModelHadMultiplePrimaryColumns.php +++ b/packages/database/src/Exceptions/ModelHadMultiplePrimaryColumns.php @@ -8,7 +8,7 @@ use function Tempest\Support\arr; -final class ModelHadMultiplePrimaryColumns extends Exception +final class ModelHadMultiplePrimaryColumns extends Exception implements DatabaseException { public static function found(string|object $model, array $properties): self { From 4ddf13b0630355c0964c08c6734cc10a3490e9bb Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Tue, 5 Aug 2025 00:30:03 +0200 Subject: [PATCH 24/51] refactor(mapper): clean up array to object mapper --- .../src/Mappers/ArrayToObjectMapper.php | 130 +++++++++--------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/packages/mapper/src/Mappers/ArrayToObjectMapper.php b/packages/mapper/src/Mappers/ArrayToObjectMapper.php index 832f214c1..18242105f 100644 --- a/packages/mapper/src/Mappers/ArrayToObjectMapper.php +++ b/packages/mapper/src/Mappers/ArrayToObjectMapper.php @@ -28,9 +28,7 @@ public function canMap(mixed $from, mixed $to): bool } try { - $class = new ClassReflector($to); - - return $class->isInstantiable(); + return new ClassReflector($to)->isInstantiable(); } catch (Throwable) { return false; } @@ -39,18 +37,13 @@ public function canMap(mixed $from, mixed $to): bool public function map(mixed $from, mixed $to): object { $class = new ClassReflector($to); - $object = $this->resolveObject($to); + $from = arr($from)->undot()->toArray(); + $isStrictClass = $class->hasAttribute(Strict::class); $missingValues = []; - - /** @var PropertyReflector[] $unsetProperties */ $unsetProperties = []; - $from = arr($from)->undot()->toArray(); - - $isStrictClass = $class->hasAttribute(Strict::class); - foreach ($class->getPublicProperties() as $property) { if ($property->isVirtual()) { continue; @@ -59,23 +52,17 @@ public function map(mixed $from, mixed $to): object $propertyName = $this->resolvePropertyName($property, $from); if (! array_key_exists($propertyName, $from)) { - $isStrictProperty = $isStrictClass || $property->hasAttribute(Strict::class); - - if ($property->hasDefaultValue()) { - continue; - } - - if ($isStrictProperty) { - $missingValues[] = $propertyName; - } else { - $unsetProperties[] = $property; - } - + $this->handleMissingProperty( + property: $property, + propertyName: $propertyName, + isStrictClass: $isStrictClass, + missingValues: $missingValues, + unsetProperties: $unsetProperties, + ); continue; } $value = $this->resolveValue($property, $from[$propertyName]); - $property->setValue($object, $value); } @@ -85,8 +72,6 @@ public function map(mixed $from, mixed $to): object $this->setParentRelations($object, $class); - // Non-strict properties that weren't passed are unset, - // which means that they can now be accessed via `__get` foreach ($unsetProperties as $property) { if ($property->isVirtual()) { continue; @@ -98,15 +83,15 @@ public function map(mixed $from, mixed $to): object return $object; } - /** - * @param array $from - */ private function resolvePropertyName(PropertyReflector $property, array $from): string { $mapFrom = $property->getAttribute(MapFrom::class); if ($mapFrom !== null) { - return arr($from)->keys()->intersect($mapFrom->names)->first() ?? $property->getName(); + return arr($from) + ->keys() + ->intersect($mapFrom->names) + ->first(default: $property->getName()); } return $property->getName(); @@ -121,16 +106,10 @@ private function resolveObject(mixed $objectOrClass): object return new ClassReflector($objectOrClass)->newInstanceWithoutConstructor(); } - private function setParentRelations( - object $parent, - ClassReflector $parentClass, - ): void { + private function setParentRelations(object $parent, ClassReflector $parentClass): void + { foreach ($parentClass->getPublicProperties() as $property) { - if (! $property->isInitialized($parent)) { - continue; - } - - if ($property->isVirtual()) { + if (! $property->isInitialized($parent) || $property->isVirtual()) { continue; } @@ -146,50 +125,71 @@ private function setParentRelations( continue; } - $childClass = $type->asClass(); + $this->setChildParentRelation($parent, $child, $type->asClass()); + } + } - foreach ($childClass->getPublicProperties() as $childProperty) { - // Determine the value to set in the child property - if ($childProperty->getType()->equals($parent::class)) { - $valueToSet = $parent; - } elseif ($childProperty->getIterableType()?->equals($parent::class)) { - $valueToSet = [$parent]; - } else { - continue; - } + private function setChildParentRelation(object $parent, mixed $child, ClassReflector $childClass): void + { + foreach ($childClass->getPublicProperties() as $childProperty) { + if ($childProperty->getType()->equals($parent::class)) { + $valueToSet = $parent; + } elseif ($childProperty->getIterableType()?->equals($parent::class)) { + $valueToSet = [$parent]; + } else { + continue; + } - if (is_array($child)) { - // Set the value for each child element if the child is an array - foreach ($child as $childItem) { - $childProperty->setValue($childItem, $valueToSet); - } - } else { - // Set the value directly on the child element if it's an object - $childProperty->setValue($child, $valueToSet); + if (is_array($child)) { + foreach ($child as $childItem) { + $childProperty->setValue($childItem, $valueToSet); } + } else { + $childProperty->setValue($child, $valueToSet); } } } public function resolveValue(PropertyReflector $property, mixed $value): mixed { - if ($property->getIterableType() === null && $property->getType()->accepts($value)) { - if (($caster = $this->casterFactory->forProperty($property)) !== null) { - if (! is_object($value) || ! $property->getType()->matches($value::class)) { - return $caster->cast($value); - } - } + $caster = $this->casterFactory->forProperty($property); + + if ($caster === null) { return $value; } - if (($caster = $this->casterFactory->forProperty($property)) !== null) { + if ($property->getIterableType() !== null) { return $caster->cast($value); } - if ($property->getIterableType()?->accepts(arr($value)->first())) { + if (! $property->getType()->accepts($value)) { + return $caster->cast($value); + } + + if (is_object($value) && $property->getType()->matches($value::class)) { return $value; } - return $value; + return $caster->cast($value); + } + + private function handleMissingProperty( + PropertyReflector $property, + string $propertyName, + bool $isStrictClass, + array &$missingValues, + array &$unsetProperties, + ): void { + if ($property->hasDefaultValue()) { + return; + } + + $isStrictProperty = $isStrictClass || $property->hasAttribute(Strict::class); + + if ($isStrictProperty) { + $missingValues[] = $propertyName; + } else { + $unsetProperties[] = $property; + } } } From 4c3ac88eea27bddd52f2bcf6835daee938083577 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Tue, 5 Aug 2025 02:01:43 +0200 Subject: [PATCH 25/51] refactor(database): handle more primary key cases --- .../QueryBuilders/InsertQueryBuilder.php | 20 ++++++--- packages/database/src/GenericDatabase.php | 16 ++++--- packages/database/src/Query.php | 9 +++- .../Database/Builder/CustomPrimaryKeyTest.php | 22 +++++----- .../Builder/ModelQueryBuilderTest.php | 3 +- .../Database/Builder/NestedWhereTest.php | 2 - .../Database/ModelsWithoutIdTest.php | 4 +- .../BelongsToStatementTest.php | 44 ++++++++++++++++--- 8 files changed, 85 insertions(+), 35 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index bc803d1ba..bf384d848 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -49,10 +49,14 @@ public function __construct( /** * Executes the insert query and returns the primary key of the inserted record. */ - public function execute(mixed ...$bindings): PrimaryKey + public function execute(mixed ...$bindings): ?PrimaryKey { $id = $this->build()->execute(...$bindings); + if ($id === null) { + return null; + } + foreach ($this->after as $after) { $query = $after($id); @@ -98,7 +102,11 @@ public function build(mixed ...$bindings): Query $this->insert->addEntry($data); } - return new Query($this->insert, [...$this->bindings, ...$bindings])->onDatabase($this->onDatabase); + return new Query( + sql: $this->insert, + bindings: [...$this->bindings, ...$bindings], + primaryKeyColumn: $this->model->getPrimaryKey(), + )->onDatabase($this->onDatabase); } /** @@ -135,9 +143,7 @@ private function resolveData(): array // The rest are model objects $definition = inspect($model); - $modelClass = new ClassReflector($model); - $entry = []; // Including all public properties @@ -152,9 +158,13 @@ private function resolveData(): array } $column = $property->getName(); - $value = $property->getValue($model); + // Skip null primary key values to allow database auto-generation + if ($property->getType()->getName() === PrimaryKey::class && $value === null) { + continue; + } + // BelongsTo and reverse HasMany relations are included if ($definition->isRelation($property)) { $relationModel = inspect($property->getType()->asClass()); diff --git a/packages/database/src/GenericDatabase.php b/packages/database/src/GenericDatabase.php index 27e8bad0b..78cb59a28 100644 --- a/packages/database/src/GenericDatabase.php +++ b/packages/database/src/GenericDatabase.php @@ -59,19 +59,25 @@ public function getLastInsertId(): ?PrimaryKey { $sql = $this->lastQuery->toSql(); - // TODO: properly determine whether a query is an insert or not if (! $sql->trim()->startsWith('INSERT')) { return null; } if ($this->dialect === DatabaseDialect::POSTGRESQL) { $data = $this->lastStatement->fetch(PDO::FETCH_ASSOC); - $lastInsertId = $data['id'] ?? null; - } else { - $lastInsertId = $this->connection->lastInsertId(); + + if (! $data) { + return null; + } + + if ($this->lastQuery->primaryKeyColumn && isset($data[$this->lastQuery->primaryKeyColumn])) { + return PrimaryKey::tryFrom($data[$this->lastQuery->primaryKeyColumn]); + } + + return null; } - return PrimaryKey::tryFrom($lastInsertId); + return PrimaryKey::tryFrom($this->connection->lastInsertId()); } public function fetch(BuildsQuery|Query $query): array diff --git a/packages/database/src/Query.php b/packages/database/src/Query.php index 1a3075852..b42b40597 100644 --- a/packages/database/src/Query.php +++ b/packages/database/src/Query.php @@ -26,6 +26,7 @@ public function __construct( public array $bindings = [], /** @var \Closure[] $executeAfter */ public array $executeAfter = [], + public ?string $primaryKeyColumn = null, ) {} public function execute(mixed ...$bindings): ?PrimaryKey @@ -40,8 +41,12 @@ public function execute(mixed ...$bindings): ?PrimaryKey // TODO: add support for "after" queries to attach hasMany relations - return isset($query->bindings['id']) - ? new PrimaryKey($query->bindings['id']) + if (! $this->primaryKeyColumn) { + return null; + } + + return isset($query->bindings[$this->primaryKeyColumn]) + ? new PrimaryKey($query->bindings[$this->primaryKeyColumn]) : $database->getLastInsertId(); } diff --git a/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php b/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php index 485bb63ba..4fa68291f 100644 --- a/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php +++ b/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php @@ -17,16 +17,16 @@ final class CustomPrimaryKeyTest extends FrameworkIntegrationTestCase { public function test_model_with_custom_primary_key_name(): void { - $this->migrate(CreateMigrationsTable::class, CreateFrierenModelMigration::class); + $this->migrate(CreateMigrationsTable::class, CreateCustomPrimaryKeyUserModelTable::class); - $frieren = model(FrierenModel::class)->create(name: 'Frieren', magic: 'Time Magic'); + $frieren = model(CustomPrimaryKeyUserModel::class)->create(name: 'Frieren', magic: 'Time Magic'); - $this->assertInstanceOf(FrierenModel::class, $frieren); + $this->assertInstanceOf(CustomPrimaryKeyUserModel::class, $frieren); $this->assertInstanceOf(PrimaryKey::class, $frieren->uuid); $this->assertSame('Frieren', $frieren->name); $this->assertSame('Time Magic', $frieren->magic); - $retrieved = model(FrierenModel::class)->get($frieren->uuid); + $retrieved = model(CustomPrimaryKeyUserModel::class)->get($frieren->uuid); $this->assertNotNull($retrieved); $this->assertSame('Frieren', $retrieved->name); $this->assertTrue($frieren->uuid->equals($retrieved->uuid)); @@ -34,11 +34,11 @@ public function test_model_with_custom_primary_key_name(): void public function test_update_or_create_with_custom_primary_key(): void { - $this->migrate(CreateMigrationsTable::class, CreateFrierenModelMigration::class); + $this->migrate(CreateMigrationsTable::class, CreateCustomPrimaryKeyUserModelTable::class); - $frieren = model(FrierenModel::class)->create(name: 'Frieren', magic: 'Time Magic'); + $frieren = model(CustomPrimaryKeyUserModel::class)->create(name: 'Frieren', magic: 'Time Magic'); - $updated = model(FrierenModel::class)->updateOrCreate( + $updated = model(CustomPrimaryKeyUserModel::class)->updateOrCreate( find: ['name' => 'Frieren'], update: ['magic' => 'Advanced Time Magic'], ); @@ -67,7 +67,7 @@ public function test_model_without_id_property_still_works(): void } } -final class FrierenModel +final class CustomPrimaryKeyUserModel { public ?PrimaryKey $uuid = null; @@ -95,13 +95,13 @@ public function __construct( ) {} } -final class CreateFrierenModelMigration implements DatabaseMigration +final class CreateCustomPrimaryKeyUserModelTable implements DatabaseMigration { - public string $name = '001_create_frieren_model'; + public string $name = '001_create_user_model'; public function up(): QueryStatement { - return CreateTableStatement::forModel(FrierenModel::class) + return CreateTableStatement::forModel(CustomPrimaryKeyUserModel::class) ->primary(name: 'uuid') ->text('name') ->text('magic'); diff --git a/tests/Integration/Database/Builder/ModelQueryBuilderTest.php b/tests/Integration/Database/Builder/ModelQueryBuilderTest.php index f5a185c2e..74bf81d19 100644 --- a/tests/Integration/Database/Builder/ModelQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/ModelQueryBuilderTest.php @@ -64,8 +64,7 @@ public function test_insert(): void $insertedId = $builderWithId->execute(); $this->assertInstanceOf(PrimaryKey::class, $insertedId); - $insertedIdWithoutPk = $builderWithoutId->execute(); - $this->assertInstanceOf(PrimaryKey::class, $insertedIdWithoutPk); + $this->assertNull($builderWithoutId->execute()); $retrieved = model(TestUserModel::class)->get($insertedId); $this->assertNotNull($retrieved); diff --git a/tests/Integration/Database/Builder/NestedWhereTest.php b/tests/Integration/Database/Builder/NestedWhereTest.php index 34552fbd7..60441bbc2 100644 --- a/tests/Integration/Database/Builder/NestedWhereTest.php +++ b/tests/Integration/Database/Builder/NestedWhereTest.php @@ -70,7 +70,6 @@ public function test_deeply_nested_where_groups(): void public function test_complex_nested_where_scenario(): void { - // Test a realistic complex query: // WHERE status = 'published' // AND ( // (category = 'fiction' AND rating > 4.0) @@ -115,7 +114,6 @@ public function test_complex_nested_where_scenario(): void public function test_where_group_without_existing_conditions(): void { - // Test starting with a group $query = query('books') ->select() ->whereGroup(function ($group): void { diff --git a/tests/Integration/Database/ModelsWithoutIdTest.php b/tests/Integration/Database/ModelsWithoutIdTest.php index 64a6f77d2..d4d51a3c7 100644 --- a/tests/Integration/Database/ModelsWithoutIdTest.php +++ b/tests/Integration/Database/ModelsWithoutIdTest.php @@ -365,8 +365,8 @@ final class CreateCacheEntryMigration implements DatabaseMigration public function up(): QueryStatement { return CreateTableStatement::forModel(CacheEntry::class) - ->text('cache_key') - ->text('cache_value') + ->string('cache_key') + ->string('cache_value') ->integer('ttl') ->unique('cache_key'); } diff --git a/tests/Integration/Database/QueryStatements/BelongsToStatementTest.php b/tests/Integration/Database/QueryStatements/BelongsToStatementTest.php index eb42189c4..8688c0354 100644 --- a/tests/Integration/Database/QueryStatements/BelongsToStatementTest.php +++ b/tests/Integration/Database/QueryStatements/BelongsToStatementTest.php @@ -18,8 +18,24 @@ final class BelongsToStatementTest extends FrameworkIntegrationTestCase { public function test_belongs_to_vs_foreign_key(): void { + $customersMigration = new class() implements DatabaseMigration { + private(set) string $name = '0001_create_customers'; + + public function up(): QueryStatement + { + return new CreateTableStatement('customers') + ->primary() + ->text('name'); + } + + public function down(): ?QueryStatement + { + return null; + } + }; + $belongsToMigration = new class() implements DatabaseMigration { - private(set) string $name = '0001_test_belongs_to'; + private(set) string $name = '0002_test_belongs_to'; public function up(): QueryStatement { @@ -36,7 +52,7 @@ public function down(): ?QueryStatement }; $foreignKeyMigration = new class() implements DatabaseMigration { - private(set) string $name = '0002_test_foreign_key'; + private(set) string $name = '0003_test_foreign_key'; public function up(): QueryStatement { @@ -53,15 +69,31 @@ public function down(): ?QueryStatement } }; - $this->migrate(CreateMigrationsTable::class, $belongsToMigration, $foreignKeyMigration); + $this->migrate(CreateMigrationsTable::class, $customersMigration, $belongsToMigration, $foreignKeyMigration); $this->expectNotToPerformAssertions(); } public function test_foreign_key_allows_different_column_names(): void { - $migration = new class() implements DatabaseMigration { - private(set) string $name = '0003_test_different_column_names'; + $categoriesMigration = new class() implements DatabaseMigration { + private(set) string $name = '0001_create_categories'; + + public function up(): QueryStatement + { + return new CreateTableStatement('categories') + ->primary() + ->text('name'); + } + + public function down(): ?QueryStatement + { + return null; + } + }; + + $productsMigration = new class() implements DatabaseMigration { + private(set) string $name = '0002_test_different_column_names'; public function up(): QueryStatement { @@ -78,7 +110,7 @@ public function down(): ?QueryStatement } }; - $this->migrate(CreateMigrationsTable::class, $migration); + $this->migrate(CreateMigrationsTable::class, $categoriesMigration, $productsMigration); $this->expectNotToPerformAssertions(); } From 810ea9d4506606f58e41539748f7697b58fcdb1d Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 6 Aug 2025 02:43:25 +0200 Subject: [PATCH 26/51] test(database): use proper relation on test model --- tests/Integration/Database/ModelsWithoutIdTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Integration/Database/ModelsWithoutIdTest.php b/tests/Integration/Database/ModelsWithoutIdTest.php index d4d51a3c7..ef5b933ba 100644 --- a/tests/Integration/Database/ModelsWithoutIdTest.php +++ b/tests/Integration/Database/ModelsWithoutIdTest.php @@ -4,6 +4,7 @@ namespace Tests\Tempest\Integration\Database; +use Tempest\Database\BelongsTo; use Tempest\Database\DatabaseMigration; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\HasOne; @@ -259,7 +260,7 @@ public function test_load_with_relation_works_for_models_with_id(): void ); model(TestProfile::class)->create( - user_id: $user->id->value, + user: $user, bio: 'Ancient elf mage who loves magic and collecting spells', age: 1000, ); @@ -330,11 +331,10 @@ final class TestProfile public ?PrimaryKey $id = null; - #[HasOne] + #[BelongsTo(ownerJoin: 'user_id')] public ?TestUser $user; public function __construct( - public int $user_id, public string $bio, public int $age, ) {} From 22dd6112b389b571da4d41a51cfe98140bb8f72a Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 6 Aug 2025 21:12:46 +0200 Subject: [PATCH 27/51] feat(database): support inserting and updating relationships --- .../QueryBuilders/InsertQueryBuilder.php | 382 ++++++++-- .../QueryBuilders/UpdateQueryBuilder.php | 476 +++++++++++-- .../database/src/Builder/TableDefinition.php | 1 - .../src/Exceptions/CouldNotUpdateRelation.php | 25 + packages/database/src/GenericDatabase.php | 2 - .../Builder/InsertQueryBuilderTest.php | 53 +- .../Database/Builder/InsertRelationsTest.php | 479 +++++++++++++ .../Builder/UpdateQueryBuilderTest.php | 201 +++++- .../Database/Builder/UpdateRelationsTest.php | 655 ++++++++++++++++++ .../GenericTransactionManagerTest.php | 2 - 10 files changed, 2089 insertions(+), 187 deletions(-) create mode 100644 packages/database/src/Exceptions/CouldNotUpdateRelation.php create mode 100644 tests/Integration/Database/Builder/InsertRelationsTest.php create mode 100644 tests/Integration/Database/Builder/UpdateRelationsTest.php diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index bf384d848..4fe405763 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -6,17 +6,22 @@ use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Exceptions\HasManyRelationCouldNotBeInsterted; use Tempest\Database\Exceptions\HasOneRelationCouldNotBeInserted; +use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; +use Tempest\Database\HasOne; use Tempest\Database\OnDatabase; use Tempest\Database\PrimaryKey; use Tempest\Database\Query; use Tempest\Database\QueryStatements\InsertStatement; +use Tempest\Intl; use Tempest\Mapper\SerializerFactory; use Tempest\Reflection\ClassReflector; -use Tempest\Support\Arr\ImmutableArray; +use Tempest\Reflection\PropertyReflector; +use Tempest\Support\Arr; use Tempest\Support\Conditions\HasConditions; use Tempest\Support\Str\ImmutableString; use function Tempest\Database\inspect; +use function Tempest\Support\str; /** * @template TModel of object @@ -87,15 +92,7 @@ public function toRawSql(): ImmutableString public function build(mixed ...$bindings): Query { foreach ($this->resolveData() as $data) { - foreach ($data as $key => $value) { - if ($this->model->getHasMany($key)) { - throw new HasManyRelationCouldNotBeInsterted($this->model->getName(), $key); - } - - if ($this->model->getHasOne($key)) { - throw new HasOneRelationCouldNotBeInserted($this->model->getName(), $key); - } - + foreach ($data as $value) { $bindings[] = $value; } @@ -129,72 +126,325 @@ public function then(Closure ...$callbacks): self return $this; } + private function removeTablePrefix(string $columnName): string + { + return str($columnName)->contains('.') + ? str($columnName)->afterLast('.')->toString() + : $columnName; + } + + private function getDefaultForeignKeyName(): string + { + return str($this->model->getName()) + ->afterLast('\\') + ->lower() + ->append('_', $this->model->getPrimaryKey()) + ->toString(); + } + + private function convertObjectToArray(object $object, array $excludeProperties = []): array + { + $reflection = new \ReflectionClass($object); + $data = []; + + foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + if (! $property->isInitialized($object)) { + continue; + } + + $propertyName = $property->getName(); + + if (! in_array($propertyName, $excludeProperties, true)) { + $data[$propertyName] = $property->getValue($object); + } + } + + return $data; + } + + private function prepareRelationItem(mixed $item, string $foreignKey, PrimaryKey $parentId): array + { + if (is_array($item)) { + $item[$foreignKey] = $parentId; + return $item; + } + + if (! is_object($item)) { + return []; + } + + $foreignKeyProperty = str($foreignKey)->before('_')->toString(); + + if (property_exists($item, $foreignKey)) { + $item->{$foreignKey} = $parentId; + return $this->convertObjectToArray($item); + } + + if (property_exists($item, $foreignKeyProperty)) { + $data = $this->convertObjectToArray($item, [$foreignKeyProperty]); + $data[$foreignKey] = $parentId; + return $data; + } + + $data = $this->convertObjectToArray($item); + $data[$foreignKey] = $parentId; + + return $data; + } + + private function addHasManyRelationCallback(string $relationName, array $relations): void + { + $hasMany = $this->model->getHasMany($relationName); + + if ($hasMany === null) { + return; + } + + if (! $this->model->hasPrimaryKey()) { + throw ModelDidNotHavePrimaryColumn::neededForRelation($this->model->getName(), 'HasMany'); + } + + $this->after[] = function (PrimaryKey $parentId) use ($hasMany, $relations) { + $foreignKey = $hasMany->ownerJoin + ? $this->removeTablePrefix($hasMany->ownerJoin) + : $this->getDefaultForeignKeyName(); + + $insert = Arr\map_iterable( + array: $relations, + map: fn ($item) => $this->prepareRelationItem($item, $foreignKey, $parentId), + ); + + if ($insert === []) { + return null; + } + + return new InsertQueryBuilder( + model: $hasMany->property->getIterableType()->asClass(), + rows: $insert, + serializerFactory: $this->serializerFactory, + ); + }; + } + + private function addHasOneRelationCallback(string $relationName, object|iterable $relation): void + { + $hasOne = $this->model->getHasOne($relationName); + + if ($hasOne === null) { + return; + } + + $this->after[] = function (PrimaryKey $parentId) use ($hasOne, $relation) { + if ($hasOne->ownerJoin) { + return $this->handleCustomHasOneRelation($hasOne, $relation, $parentId); + } + + return $this->handleStandardHasOneRelation($hasOne, $relation, $parentId); + }; + } + + private function handleCustomHasOneRelation(HasOne $hasOne, object|array $relation, PrimaryKey $parentId): null + { + $relatedModelId = new InsertQueryBuilder( + model: $hasOne->property->getType()->asClass(), + rows: [$relation], + serializerFactory: $this->serializerFactory, + )->execute(); + + $ownerModel = inspect($this->model->getName()); + $foreignKeyColumn = $hasOne->relationJoin ?? $this->removeTablePrefix($hasOne->ownerJoin); + + $updateQuery = sprintf( + 'UPDATE %s SET %s = ? WHERE %s = ?', + $ownerModel->getTableName(), + $foreignKeyColumn, + $ownerModel->getPrimaryKey(), + ); + + $query = new Query($updateQuery, [$relatedModelId->value, $parentId->value]); + $query->onDatabase($this->onDatabase)->execute(); + + return null; + } + + private function handleStandardHasOneRelation(HasOne $hasOne, object|array $relation, PrimaryKey $parentId): ?PrimaryKey + { + $ownerModel = inspect($this->model->getName()); + + if (! $ownerModel->hasPrimaryKey()) { + throw ModelDidNotHavePrimaryColumn::neededForRelation($ownerModel->getName(), 'HasOne'); + } + + // TODO: we might need to bake this into the naming strategy class + $foreignKeyColumn = Intl\singularize_last_word($ownerModel->getTableName()) . '_' . $ownerModel->getPrimaryKey(); + + $preparedData = is_array($relation) + ? [...$relation, ...[$foreignKeyColumn => $parentId->value]] + : [...$this->convertObjectToArray($relation), ...[$foreignKeyColumn => $parentId->value]]; + + $relatedModelQuery = new InsertQueryBuilder( + model: $hasOne->property->getType()->asClass(), + rows: [$preparedData], + serializerFactory: $this->serializerFactory, + ); + + return $relatedModelQuery->execute(); + } + private function resolveData(): array { - $entries = []; + return Arr\map_iterable( + array: $this->rows, + map: fn (object|iterable $model) => $this->resolveModelData($model), + ); + } - foreach ($this->rows as $model) { - // Raw entries are straight up added - if (is_array($model) || $model instanceof ImmutableArray) { - $entries[] = $model; + private function resolveModelData(object|iterable $model): array + { + return is_iterable($model) + ? $this->resolveIterableData($model) + : $this->resolveObjectData($model); + } + + private function resolveIterableData(iterable $model): array + { + $entry = []; + foreach ($model as $key => $value) { + if ($this->handleHasManyRelation($key, $value)) { continue; } - // The rest are model objects - $definition = inspect($model); - $modelClass = new ClassReflector($model); - $entry = []; - - // Including all public properties - foreach ($modelClass->getPublicProperties() as $property) { - if (! $property->isInitialized($model)) { - continue; - } - - // HasMany and HasOne relations are skipped - if ($definition->getHasMany($property->getName()) || $definition->getHasOne($property->getName())) { - continue; - } - - $column = $property->getName(); - $value = $property->getValue($model); - - // Skip null primary key values to allow database auto-generation - if ($property->getType()->getName() === PrimaryKey::class && $value === null) { - continue; - } - - // BelongsTo and reverse HasMany relations are included - if ($definition->isRelation($property)) { - $relationModel = inspect($property->getType()->asClass()); - $primaryKey = $relationModel->getPrimaryKey() ?? 'id'; - $column .= '_' . $primaryKey; - - $value = match (true) { - $value === null => null, - isset($value->{$primaryKey}) => $value->{$primaryKey}->value, - default => new InsertQueryBuilder( - $value::class, - [$value], - $this->serializerFactory, - )->build(), - }; - } - - // Check if the value needs serialization - $serializer = $this->serializerFactory->forProperty($property); - - if ($value !== null && $serializer !== null) { - $value = $serializer->serialize($value); - } - - $entry[$column] = $value; + if ($this->handleHasOneRelation($key, $value)) { + continue; } - $entries[] = $entry; + if ($this->handleBelongsToRelation($key, $value, $entry)) { + continue; + } + + $entry[$key] = $value; + } + + return $entry; + } + + private function handleHasManyRelation(string $key, mixed $relations): bool + { + $hasMany = $this->model->getHasMany($key); + + if ($hasMany === null) { + return false; + } + + if (! is_iterable($relations)) { + throw new HasManyRelationCouldNotBeInsterted($this->model->getName(), $key); + } + + $this->addHasManyRelationCallback($key, $relations); + + return true; + } + + private function handleHasOneRelation(string $key, mixed $relation): bool + { + $hasOne = $this->model->getHasOne($key); + + if ($hasOne === null) { + return false; + } + + if (! is_object($relation) && ! is_array($relation)) { + throw new HasOneRelationCouldNotBeInserted($this->model->getName(), $key); + } + + $this->addHasOneRelationCallback($key, $relation); + + return true; + } + + private function handleBelongsToRelation(string $key, mixed $value, array &$entry): bool + { + $belongsTo = $this->model->getBelongsTo($key); + + if ($belongsTo === null || ! is_object($value) && ! is_array($value)) { + return false; + } + + $relatedId = new InsertQueryBuilder( + model: $belongsTo->property->getType()->asClass(), + rows: [$value], + serializerFactory: $this->serializerFactory, + )->execute(); + + $entry[$belongsTo->getOwnerFieldName()] = $relatedId; + + return true; + } + + private function resolveObjectData(object $model): array + { + $definition = inspect($model); + $modelClass = new ClassReflector($model); + $entry = []; + + foreach ($modelClass->getPublicProperties() as $property) { + if (! $property->isInitialized($model)) { + continue; + } + + if ($definition->getHasMany($property->getName()) || $definition->getHasOne($property->getName())) { + continue; + } + + $column = $property->getName(); + $value = $property->getValue($model); + + if ($property->getType()->getName() === PrimaryKey::class && $value === null) { + continue; + } + + if ($definition->isRelation($property)) { + [$column, $value] = $this->resolveRelationProperty($definition, $property, $value); + } else { + $value = $this->serializeValue($property, $value); + } + + $entry[$column] = $value; + } + + return $entry; + } + + private function resolveRelationProperty(ModelInspector $definition, PropertyReflector $property, mixed $value): array + { + $relationModel = inspect($property->getType()->asClass()); + $primaryKey = $relationModel->getPrimaryKey(); + + if (! $primaryKey) { + throw ModelDidNotHavePrimaryColumn::neededForRelation($relationModel->getName(), 'BelongsTo'); + } + + $belongsTo = $definition->getBelongsTo($property->getName()); + $column = $belongsTo + ? $belongsTo->getOwnerFieldName() + : ($property->getName() . '_' . $primaryKey); + + $resolvedValue = match (true) { + $value === null => null, + isset($value->{$primaryKey}) => $value->{$primaryKey}->value, + default => new InsertQueryBuilder($value::class, [$value], $this->serializerFactory)->build(), + }; + + return [$column, $resolvedValue]; + } + + private function serializeValue(PropertyReflector $property, mixed $value): mixed + { + if ($value === null) { + return null; } - return $entries; + return $this->serializerFactory->forProperty($property)?->serialize($value) ?? $value; } } diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index fbf8274ac..006633d3c 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -3,20 +3,26 @@ namespace Tempest\Database\Builder\QueryBuilders; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\Builder\WhereOperator; +use Tempest\Database\Exceptions\CouldNotUpdateRelation; use Tempest\Database\Exceptions\HasManyRelationCouldNotBeUpdated; use Tempest\Database\Exceptions\HasOneRelationCouldNotBeUpdated; +use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\OnDatabase; use Tempest\Database\PrimaryKey; use Tempest\Database\Query; use Tempest\Database\QueryStatements\HasWhereStatements; use Tempest\Database\QueryStatements\UpdateStatement; +use Tempest\Database\QueryStatements\WhereStatement; +use Tempest\Intl; use Tempest\Mapper\SerializerFactory; +use Tempest\Reflection\ClassReflector; +use Tempest\Reflection\PropertyReflector; use Tempest\Support\Arr\ImmutableArray; use Tempest\Support\Conditions\HasConditions; use Tempest\Support\Str\ImmutableString; use function Tempest\Database\inspect; -use function Tempest\Support\arr; /** * @template TModel of object @@ -33,6 +39,10 @@ final class UpdateQueryBuilder implements BuildsQuery private ModelInspector $model; + private array $after = []; + + private ?PrimaryKey $primaryKeyForRelations = null; + /** * @param class-string|string|TModel $model */ @@ -53,7 +63,20 @@ public function __construct( */ public function execute(mixed ...$bindings): ?PrimaryKey { - return $this->build()->execute(...$bindings); + $result = $this->build()->execute(...$bindings); + + // Execute after callbacks for relation updates + if ($this->model->hasPrimaryKey() && $this->after !== [] && $this->primaryKeyForRelations !== null) { + foreach ($this->after as $after) { + $query = $after($this->primaryKeyForRelations); + + if ($query instanceof BuildsQuery) { + $query->build()->execute(); + } + } + } + + return $result; } /** @@ -101,78 +124,119 @@ public function build(mixed ...$bindings): Query $values = $this->resolveValues(); if ($this->model->hasPrimaryKey()) { - $primaryKey = $this->model->getPrimaryKey(); - unset($values[$primaryKey]); + unset($values[$this->model->getPrimaryKey()]); } $this->update->values = $values; - if ($this->model->isObjectModel() && is_object($this->model->instance) && $this->model->hasPrimaryKey()) { - $primaryKeyValue = $this->model->getPrimaryKeyValue(); + $this->setWhereForObjectModel(); - if ($primaryKeyValue !== null) { - $this->where($this->model->getPrimaryKey(), $primaryKeyValue->value); - } - } + $allBindings = []; foreach ($values as $value) { - $bindings[] = $value; + $allBindings[] = $value; } foreach ($this->bindings as $binding) { - $bindings[] = $binding; + $allBindings[] = $binding; + } + + foreach ($bindings as $binding) { + $allBindings[] = $binding; } - return new Query($this->update, $bindings)->onDatabase($this->onDatabase); + return new Query($this->update, $allBindings)->onDatabase($this->onDatabase); } private function resolveValues(): ImmutableArray { - if (! $this->model->isObjectModel()) { - return arr($this->values); + if ($this->hasRelationUpdates()) { + $this->validateRelationUpdateConstraints(); } - $values = arr(); + if (! $this->model->isObjectModel()) { + return new ImmutableArray($this->values); + } + $values = []; foreach ($this->values as $column => $value) { + if ($this->handleRelationUpdate($column, $value)) { + continue; + } + $property = $this->model->reflector->getProperty($column); + [$resolvedColumn, $resolvedValue] = $this->resolvePropertyValue($property, $column, $value); - if ($this->model->getHasMany($property->getName())) { - throw new HasManyRelationCouldNotBeUpdated($this->model->getName(), $property->getName()); - } + $values[$resolvedColumn] = $resolvedValue; + } - if ($this->model->getHasOne($property->getName())) { - throw new HasOneRelationCouldNotBeUpdated($this->model->getName(), $property->getName()); - } + return new ImmutableArray($values); + } - if ($this->model->isRelation($property)) { - $relationModel = inspect($property->getType()->asClass()); - $primaryKey = $relationModel->getPrimaryKey() ?? 'id'; - $column .= '_' . $primaryKey; - - $value = match (true) { - $value === null => null, - isset($value->{$primaryKey}) => $value->{$primaryKey}->value, - default => new InsertQueryBuilder( - $value::class, - [$value], - $this->serializerFactory, - )->build(), - }; - } + private function handleRelationUpdate(string $column, mixed $value): bool + { + return $this->handleHasManyRelation($column, $value) || $this->handleHasOneRelation($column, $value); + } - if (! $property->getType()->isRelation() && ! $property->getIterableType()?->isRelation()) { - $serializer = $this->serializerFactory->forProperty($property); + private function resolvePropertyValue(PropertyReflector $property, string $column, mixed $value): array + { + if ($this->model->isRelation($property)) { + return $this->resolveRelationValue($property, $column, $value); + } - if ($value !== null && $serializer !== null) { - $value = $serializer->serialize($value); - } - } + if (! $property->getType()->isRelation() && ! $property->getIterableType()?->isRelation()) { + $value = $this->serializeValue($property, $value); + } + + return [$column, $value]; + } + + private function resolveRelationValue(PropertyReflector $property, string $column, mixed $value): array + { + $belongsTo = $this->model->getBelongsTo($column); + + if ($belongsTo) { + $column = $belongsTo->getOwnerFieldName(); + $relationModel = inspect($property->getType()->asClass()); + $this->ensureModelHasPrimaryKey($relationModel, 'BelongsTo'); + $primaryKey = $relationModel->getPrimaryKey(); + } else { + $relationModel = inspect($property->getType()->asClass()); + $this->ensureModelHasPrimaryKey($relationModel, 'relation'); + $primaryKey = $relationModel->getPrimaryKey(); + $column .= '_' . $primaryKey; + } + + $resolvedValue = match (true) { + $value === null => null, + is_object($value) && isset($value->{$primaryKey}) => $value->{$primaryKey}->value, + is_object($value) || is_array($value) => new InsertQueryBuilder( + model: $property->getType()->asClass(), + rows: [$value], + serializerFactory: $this->serializerFactory, + )->build(), + default => $value, + }; + + return [$column, $resolvedValue]; + } - $values[$column] = $value; + private function serializeValue(PropertyReflector $property, mixed $value): mixed + { + $serializer = $this->serializerFactory->forProperty($property); + + if ($value !== null && $serializer !== null) { + return $serializer->serialize($value); } - return $values; + return $value; + } + + private function ensureModelHasPrimaryKey(ModelInspector $model, string $relationType): void + { + if (! $model->hasPrimaryKey()) { + throw ModelDidNotHavePrimaryColumn::neededForRelation($model->getName(), $relationType); + } } private function getStatementForWhere(): HasWhereStatements @@ -184,4 +248,326 @@ private function getModel(): ModelInspector { return $this->model; } + + private function handleHasManyRelation(string $key, mixed $relations): bool + { + $hasMany = $this->model->getHasMany($key); + + if ($hasMany === null) { + return false; + } + + if (! is_iterable($relations)) { + throw new HasManyRelationCouldNotBeUpdated($this->model->getName(), $key); + } + + $this->addHasManyRelationCallback($key, $relations); + + return true; + } + + private function handleHasOneRelation(string $key, mixed $relation): bool + { + $hasOne = $this->model->getHasOne($key); + + if ($hasOne === null) { + return false; + } + + if (! is_object($relation) && ! is_array($relation)) { + throw new HasOneRelationCouldNotBeUpdated($this->model->getName(), $key); + } + + $this->addHasOneRelationCallback($key, $relation); + + return true; + } + + private function addHasManyRelationCallback(string $relationName, iterable $relations): void + { + $hasMany = $this->model->getHasMany($relationName); + + if ($hasMany === null) { + return; + } + + $this->ensureModelHasPrimaryKey($this->model, 'HasMany'); + + $this->after[] = function (PrimaryKey $parentId) use ($hasMany, $relations) { + $this->deleteExistingHasManyRelations($hasMany, $parentId); + + $foreignKey = $hasMany->ownerJoin + ? $this->removeTablePrefix($hasMany->ownerJoin) + : $this->getDefaultForeignKeyName(); + + $insert = []; + foreach ($relations as $item) { + $insert[] = $this->prepareRelationItem($item, $foreignKey, $parentId); + } + + if ($insert === []) { + return null; + } + + return new InsertQueryBuilder( + model: $hasMany->property->getIterableType()->asClass(), + rows: $insert, + serializerFactory: $this->serializerFactory, + ); + }; + } + + private function addHasOneRelationCallback(string $relationName, object|array $relation): void + { + $hasOne = $this->model->getHasOne($relationName); + + if ($hasOne === null) { + return; + } + + $this->after[] = function (PrimaryKey $parentId) use ($hasOne, $relation) { + $this->deleteExistingHasOneRelation($hasOne, $parentId); + + if ($hasOne->ownerJoin) { + return $this->handleCustomHasOneRelation($hasOne, $relation, $parentId); + } + + return $this->handleStandardHasOneRelation($hasOne, $relation, $parentId); + }; + } + + private function deleteExistingHasManyRelations($hasMany, PrimaryKey $parentId): void + { + $relatedModel = inspect($hasMany->property->getIterableType()->asClass()); + $foreignKey = $hasMany->ownerJoin + ? $this->removeTablePrefix($hasMany->ownerJoin) + : $this->getDefaultForeignKeyName(); + + $this->executeQuery( + sql: 'DELETE FROM %s WHERE %s = ?', + params: [$relatedModel->getTableName(), $foreignKey], + bindings: [$parentId->value], + ); + } + + private function deleteExistingHasOneRelation($hasOne, PrimaryKey $parentId): void + { + if ($hasOne->ownerJoin) { + $this->deleteCustomHasOneRelation($hasOne, $parentId); + return; + } + + $this->deleteStandardHasOneRelation($hasOne, $parentId); + } + + private function deleteCustomHasOneRelation($hasOne, PrimaryKey $parentId): void + { + $ownerModel = inspect($this->model->getName()); + $relatedModel = inspect($hasOne->property->getType()->asClass()); + + $this->ensureModelHasPrimaryKey($ownerModel, 'HasOne'); + $this->ensureModelHasPrimaryKey($relatedModel, 'HasOne'); + + $foreignKeyColumn = $hasOne->relationJoin ?? $this->removeTablePrefix($hasOne->ownerJoin); + + $result = $this->executeQuery( + sql: 'SELECT %s FROM %s WHERE %s = ?', + params: [$foreignKeyColumn, $ownerModel->getTableName(), $ownerModel->getPrimaryKey()], + bindings: [$parentId->value], + ); + + if (! $result || ! isset($result[0][$foreignKeyColumn])) { + return; + } + + $relatedId = $result[0][$foreignKeyColumn]; + + $this->executeQuery( + sql: 'DELETE FROM %s WHERE %s = ?', + params: [$relatedModel->getTableName(), $relatedModel->getPrimaryKey()], + bindings: [$relatedId], + ); + + $this->executeQuery( + sql: 'UPDATE %s SET %s = NULL WHERE %s = ?', + params: [$ownerModel->getTableName(), $foreignKeyColumn, $ownerModel->getPrimaryKey()], + bindings: [$parentId->value], + ); + } + + private function deleteStandardHasOneRelation($hasOne, PrimaryKey $parentId): void + { + $ownerModel = inspect($this->model->getName()); + $relatedModel = inspect($hasOne->property->getType()->asClass()); + + $this->ensureModelHasPrimaryKey($ownerModel, 'HasOne'); + + $foreignKeyColumn = Intl\singularize($ownerModel->getTableName()) . '_' . $ownerModel->getPrimaryKey(); + + $this->executeQuery( + sql: 'DELETE FROM %s WHERE %s = ?', + params: [$relatedModel->getTableName(), $foreignKeyColumn], + bindings: [$parentId->value], + ); + } + + private function executeQuery(string $sql, array $params, array $bindings): mixed + { + $query = new Query(sprintf($sql, ...$params), $bindings); + return $query->onDatabase($this->onDatabase)->execute(); + } + + private function handleCustomHasOneRelation($hasOne, object|array $relation, PrimaryKey $parentId): null + { + $relatedModelId = new InsertQueryBuilder( + model: $hasOne->property->getType()->asClass(), + rows: [$relation], + serializerFactory: $this->serializerFactory, + )->execute(); + + $ownerModel = inspect($this->model->getName()); + $this->ensureModelHasPrimaryKey($ownerModel, 'HasOne'); + + $foreignKeyColumn = $hasOne->relationJoin ?? $this->removeTablePrefix($hasOne->ownerJoin); + + $this->executeQuery( + sql: 'UPDATE %s SET %s = ? WHERE %s = ?', + params: [$ownerModel->getTableName(), $foreignKeyColumn, $ownerModel->getPrimaryKey()], + bindings: [$relatedModelId->value, $parentId->value], + ); + + return null; + } + + private function handleStandardHasOneRelation($hasOne, object|array $relation, PrimaryKey $parentId): ?PrimaryKey + { + $ownerModel = inspect($this->model->getName()); + $this->ensureModelHasPrimaryKey($ownerModel, 'HasOne'); + + $foreignKeyColumn = Intl\singularize($ownerModel->getTableName()) . '_' . $ownerModel->getPrimaryKey(); + + $preparedData = is_array($relation) + ? [...$relation, $foreignKeyColumn => $parentId->value] + : [...$this->convertObjectToArray($relation), $foreignKeyColumn => $parentId->value]; + + return new InsertQueryBuilder( + model: $hasOne->property->getType()->asClass(), + rows: [$preparedData], + serializerFactory: $this->serializerFactory, + )->execute(); + } + + private function prepareRelationItem(object|array $item, string $foreignKey, PrimaryKey $parentId): array + { + $preparedData = is_array($item) + ? $item + : $this->convertObjectToArray($item); + + $preparedData[$foreignKey] = $parentId->value; + + return $preparedData; + } + + private function convertObjectToArray(object $object): array + { + $result = []; + $reflection = new ClassReflector($object); + + foreach ($reflection->getPublicProperties() as $property) { + if ($property->isInitialized($object)) { + $result[$property->getName()] = $property->getValue($object); + } + } + + return $result; + } + + private function getDefaultForeignKeyName(): string + { + $this->ensureModelHasPrimaryKey($this->model, 'relation'); + + return Intl\singularize($this->model->getTableName()) . '_' . $this->model->getPrimaryKey(); + } + + private function removeTablePrefix(string $column): string + { + $tableName = $this->model->getTableName(); + $prefix = $tableName . '.'; + + if (str_starts_with($column, $prefix)) { + return substr($column, strlen($prefix)); + } + + return $column; + } + + /** + * Adds a where condition to the query. + * + * @return self + */ + public function where(string $field, mixed $value, string|WhereOperator $operator = WhereOperator::EQUALS): self + { + $operator = WhereOperator::fromOperator($operator); + + if ($this->model->hasPrimaryKey() && $field === $this->model->getPrimaryKey() && $this->hasRelationUpdates()) { + if ($operator === WhereOperator::EQUALS && (is_string($value) || is_int($value) || $value instanceof PrimaryKey)) { + $this->primaryKeyForRelations = new PrimaryKey($value); + } + } + + $fieldDefinition = $this->getModel()->getFieldDefinition($field); + $condition = $this->buildCondition((string) $fieldDefinition, $operator, $value); + + if ($this->getStatementForWhere()->where->isNotEmpty()) { + return $this->andWhere($field, $value, $operator); + } + + $this->getStatementForWhere()->where[] = new WhereStatement($condition['sql']); + $this->bind(...$condition['bindings']); + + return $this; + } + + private function hasRelationUpdates(): bool + { + foreach (array_keys($this->values) as $field) { + if ($this->isRelationField($field)) { + return true; + } + } + + return false; + } + + private function isRelationField(string $field): bool + { + if (! $this->model) { + return false; + } + + return $this->model->getHasMany($field) || $this->model->getHasOne($field); + } + + private function validateRelationUpdateConstraints(): void + { + if (! $this->model->hasPrimaryKey()) { + throw CouldNotUpdateRelation::requiresPrimaryKey($this->model); + } + + if ($this->primaryKeyForRelations === null) { + throw CouldNotUpdateRelation::requiresSingleRecord($this->model); + } + } + + private function setWhereForObjectModel(): void + { + if (! $this->model->isObjectModel() || ! is_object($this->model->instance) || ! $this->model->hasPrimaryKey()) { + return; + } + + if ($primaryKeyValue = $this->model->getPrimaryKeyValue()) { + $this->where($this->model->getPrimaryKey(), $primaryKeyValue->value); + } + } } diff --git a/packages/database/src/Builder/TableDefinition.php b/packages/database/src/Builder/TableDefinition.php index 249b6a056..6395ef91a 100644 --- a/packages/database/src/Builder/TableDefinition.php +++ b/packages/database/src/Builder/TableDefinition.php @@ -5,7 +5,6 @@ namespace Tempest\Database\Builder; use Stringable; -use Tempest\Reflection\ClassReflector; final readonly class TableDefinition implements Stringable { diff --git a/packages/database/src/Exceptions/CouldNotUpdateRelation.php b/packages/database/src/Exceptions/CouldNotUpdateRelation.php new file mode 100644 index 000000000..b4e740762 --- /dev/null +++ b/packages/database/src/Exceptions/CouldNotUpdateRelation.php @@ -0,0 +1,25 @@ +getName(), + $model->getPrimaryKey(), + )); + } + + public static function requiresPrimaryKey(ModelInspector $model): self + { + return new self(sprintf('Attempted to update a relation on %s, but it does not have a primary key.', $model->getName())); + } +} diff --git a/packages/database/src/GenericDatabase.php b/packages/database/src/GenericDatabase.php index 78cb59a28..c604a0151 100644 --- a/packages/database/src/GenericDatabase.php +++ b/packages/database/src/GenericDatabase.php @@ -45,7 +45,6 @@ public function execute(BuildsQuery|Query $query): void try { $statement = $this->connection->prepare($query->toSql()->toString()); - $statement->execute($bindings); $this->lastStatement = $statement; @@ -90,7 +89,6 @@ public function fetch(BuildsQuery|Query $query): array try { $pdoQuery = $this->connection->prepare($query->toSql()->toString()); - $pdoQuery->execute($bindings); return $pdoQuery->fetchAll(PDO::FETCH_NAMED); diff --git a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php index eba487282..0e1a572ad 100644 --- a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php @@ -4,8 +4,6 @@ use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\Database; -use Tempest\Database\Exceptions\HasManyRelationCouldNotBeInsterted; -use Tempest\Database\Exceptions\HasOneRelationCouldNotBeInserted; use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\PrimaryKey; use Tempest\Database\Query; @@ -26,10 +24,7 @@ final class InsertQueryBuilderTest extends FrameworkIntegrationTestCase public function test_insert_on_plain_table(): void { $query = query('chapters') - ->insert( - title: 'Chapter 01', - index: 1, - ) + ->insert(title: 'Chapter 01', index: 1) ->build(); $expected = $this->buildExpectedInsert('INSERT INTO `chapters` (`title`, `index`) VALUES (?, ?)'); @@ -72,11 +67,7 @@ public function test_insert_with_batch(): void public function test_insert_on_model_table(): void { - $author = new Author( - name: 'brent', - type: AuthorType::A, - ); - + $author = new Author(name: 'brent', type: AuthorType::A); $query = query(Author::class) ->insert( $author, @@ -94,15 +85,11 @@ public function test_insert_on_model_table_with_new_relation(): void { $book = Book::new( title: 'Timeline Taxi', - author: Author::new( - name: 'Brent', - ), + author: Author::new(name: 'Brent'), ); $bookQuery = query(Book::class) - ->insert( - $book, - ) + ->insert($book) ->build(); $expectedBookQuery = $this->buildExpectedInsert('INSERT INTO `books` (`title`, `author_id`) VALUES (?, ?)'); @@ -130,9 +117,7 @@ public function test_insert_on_model_table_with_existing_relation(): void ); $bookQuery = query(Book::class) - ->insert( - $book, - ) + ->insert($book) ->build(); $expectedBookQuery = $this->buildExpectedInsert('INSERT INTO `books` (`title`, `author_id`) VALUES (?, ?)'); @@ -142,30 +127,6 @@ public function test_insert_on_model_table_with_existing_relation(): void $this->assertSame(10, $bookQuery->bindings[1]); } - public function test_inserting_has_many_via_parent_model_throws_exception(): void - { - $this->assertException(HasManyRelationCouldNotBeInsterted::class, function (): void { - query(Book::class) - ->insert( - title: 'Timeline Taxi', - chapters: ['title' => 'Chapter 01'], - ) - ->build(); - }); - } - - public function test_inserting_has_one_via_parent_model_throws_exception(): void - { - $this->assertException(HasOneRelationCouldNotBeInserted::class, function (): void { - query(Book::class) - ->insert( - title: 'Timeline Taxi', - isbn: ['value' => '979-8344313764'], - ) - ->build(); - }); - } - public function test_then_method(): void { $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); @@ -218,7 +179,9 @@ public function test_insert_mapping(): void { $author = Author::new(name: 'test'); - $query = query(Author::class)->insert($author)->build(); + $query = query(Author::class) + ->insert($author) + ->build(); $dialect = $this->container->get(Database::class)->dialect; diff --git a/tests/Integration/Database/Builder/InsertRelationsTest.php b/tests/Integration/Database/Builder/InsertRelationsTest.php new file mode 100644 index 000000000..838bee339 --- /dev/null +++ b/tests/Integration/Database/Builder/InsertRelationsTest.php @@ -0,0 +1,479 @@ +migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); + + $id = query(Book::class) + ->insert( + title: 'Sousou no Frieren', + chapters: [ + ['title' => 'The Journey Begins'], + ['title' => 'Meeting Fern'], + ], + ) + ->execute(); + + $book = Book::select()->with('chapters')->get($id); + + $this->assertSame('Sousou no Frieren', $book->title); + $this->assertCount(2, $book->chapters); + $this->assertSame('The Journey Begins', $book->chapters[0]->title); + $this->assertSame('Meeting Fern', $book->chapters[1]->title); + } + + public function test_inserting_has_one_with_array(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + CreateIsbnTable::class, + ); + + $id = query(Book::class) + ->insert( + title: 'Sousou no Frieren', + isbn: ['value' => '978-4091234567'], + ) + ->execute(); + + $book = Book::select()->with('isbn')->get($id); + + $this->assertSame('Sousou no Frieren', $book->title); + $this->assertNotNull($book->isbn); + $this->assertSame('978-4091234567', $book->isbn->value); + } + + public function test_inserting_has_many_with_objects(): void + { + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); + + $id = query(Book::class) + ->insert( + title: 'Sousou no Frieren', + chapters: [ + Chapter::new(title: 'Himmel the Hero'), + Chapter::new(title: 'Stark the Warrior'), + ], + ) + ->execute(); + + $book = Book::select()->with('chapters')->get($id); + + $this->assertSame('Sousou no Frieren', $book->title); + $this->assertCount(2, $book->chapters); + $this->assertSame('Himmel the Hero', $book->chapters[0]->title); + $this->assertSame('Stark the Warrior', $book->chapters[1]->title); + } + + public function test_inserting_has_one_with_object(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + CreateIsbnTable::class, + ); + + $id = query(Book::class) + ->insert( + title: 'Sousou no Frieren', + isbn: Isbn::new(value: '978-4091234567'), + ) + ->execute(); + + $book = Book::select()->with('isbn')->get($id); + + $this->assertSame('Sousou no Frieren', $book->title); + $this->assertNotNull($book->isbn); + $this->assertSame('978-4091234567', $book->isbn->value); + } + + public function test_inserting_mixed_relations(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + CreateIsbnTable::class, + ); + + $id = query(Book::class) + ->insert( + title: 'Sousou no Frieren', + chapters: [ + Chapter::new(title: 'Chapter with Object'), + ['title' => 'Chapter with Array'], + ], + isbn: Isbn::new(value: '978-4091234567'), + ) + ->execute(); + + $book = Book::select()->with('chapters', 'isbn')->get($id); + + $this->assertSame('Sousou no Frieren', $book->title); + $this->assertCount(2, $book->chapters); + $this->assertSame('Chapter with Object', $book->chapters[0]->title); + $this->assertSame('Chapter with Array', $book->chapters[1]->title); + $this->assertNotNull($book->isbn); + $this->assertSame('978-4091234567', $book->isbn->value); + } + + public function test_inserting_empty_has_many_relation(): void + { + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); + + $id = query(Book::class) + ->insert(title: 'Empty Book', chapters: []) + ->execute(); + + $book = Book::select() + ->with('chapters') + ->get($id); + + $this->assertSame('Empty Book', $book->title); + $this->assertCount(0, $book->chapters); + } + + public function test_inserting_large_batch_has_many(): void + { + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); + + $chapters = []; + for ($i = 1; $i <= 10; $i++) { + $chapters[] = ['title' => "Chapter {$i}"]; + } + + $id = query(Book::class) + ->insert(title: 'Long Story', chapters: $chapters) + ->execute(); + + $book = Book::select()->with('chapters')->get($id); + + $this->assertSame('Long Story', $book->title); + $this->assertCount(10, $book->chapters); + $this->assertSame('Chapter 1', $book->chapters[0]->title); + $this->assertSame('Chapter 10', $book->chapters[9]->title); + } + + public function test_inserting_has_many_preserves_additional_data(): void + { + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); + + $id = query(Book::class) + ->insert( + title: 'Detailed Book', + chapters: [ + ['title' => 'Chapter 1', 'contents' => 'Once upon a time...'], + ['title' => 'Chapter 2', 'contents' => 'And then...'], + ], + ) + ->execute(); + + $book = Book::select()->with('chapters')->get($id); + + $this->assertSame('Detailed Book', $book->title); + $this->assertCount(2, $book->chapters); + $this->assertSame('Chapter 1', $book->chapters[0]->title); + $this->assertSame('Once upon a time...', $book->chapters[0]->contents); + $this->assertSame('Chapter 2', $book->chapters[1]->title); + $this->assertSame('And then...', $book->chapters[1]->contents); + } + + public function test_inserting_has_many_with_invalid_array_throws_exception(): void + { + $this->expectException(HasManyRelationCouldNotBeInsterted::class); + + query(Book::class) + ->insert(title: 'Bad Book', chapters: 'not an array') + ->build(); + } + + public function test_inserting_has_one_with_invalid_type_throws_exception(): void + { + $this->expectException(HasOneRelationCouldNotBeInserted::class); + + query(Book::class) + ->insert(title: 'Bad Book', isbn: 123) + ->build(); + } + + public function test_relation_insertion_with_mixed_types(): void + { + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); + + $id = query(Book::class) + ->insert( + title: 'Mixed Content Book', + chapters: [ + Chapter::new(title: 'Object Chapter'), + ['title' => 'Array Chapter'], + Chapter::new(title: 'Another Object Chapter'), + ], + ) + ->execute(); + + $book = Book::select()->with('chapters')->get($id); + + $this->assertSame('Mixed Content Book', $book->title); + $this->assertCount(3, $book->chapters); + $this->assertSame('Object Chapter', $book->chapters[0]->title); + $this->assertSame('Array Chapter', $book->chapters[1]->title); + $this->assertSame('Another Object Chapter', $book->chapters[2]->title); + } + + public function test_insertion_with_custom_primary_key_names(): void + { + $this->migrate(CreateMigrationsTable::class, CreateMageTable::class, CreateSpellTable::class); + + $id = query(Mage::class) + ->insert( + name: 'Frieren', + element: 'Time', + spells: [ + ['name' => 'Zoltraak', 'type' => 'Offensive'], + ['name' => 'Defensive Barrier', 'type' => 'Defensive'], + ], + ) + ->execute(); + + $mage = Mage::select() + ->with('spells') + ->get($id); + + $this->assertSame('Frieren', $mage->name); + $this->assertSame('Time', $mage->element); + $this->assertCount(2, $mage->spells); + $this->assertSame('Zoltraak', $mage->spells[0]->name); + $this->assertSame('Offensive', $mage->spells[0]->type); + $this->assertSame('Defensive Barrier', $mage->spells[1]->name); + $this->assertSame('Defensive', $mage->spells[1]->type); + } + + public function test_insertion_with_non_standard_relation_names(): void + { + $this->migrate(CreateMigrationsTable::class, CreatePartyTable::class, CreateAdventurerTable::class); + + $id = query(Party::class) + ->insert( + name: 'Hero Party', + quest_type: 'Demon King Defeat', + members: [ + ['name' => 'Himmel', 'class' => 'Hero'], + ['name' => 'Heiter', 'class' => 'Priest'], + ['name' => 'Eisen', 'class' => 'Warrior'], + ['name' => 'Frieren', 'class' => 'Mage'], + ], + ) + ->execute(); + + $party = Party::select() + ->with('members') + ->get($id); + + $this->assertSame('Hero Party', $party->name); + $this->assertSame('Demon King Defeat', $party->quest_type); + $this->assertCount(4, $party->members); + $this->assertSame('Himmel', $party->members[0]->name); + $this->assertSame('Hero', $party->members[0]->class); + $this->assertSame('Heiter', $party->members[1]->name); + $this->assertSame('Priest', $party->members[1]->class); + $this->assertSame('Eisen', $party->members[2]->name); + $this->assertSame('Warrior', $party->members[2]->class); + $this->assertSame('Frieren', $party->members[3]->name); + $this->assertSame('Mage', $party->members[3]->class); + } + + public function test_insertion_with_custom_foreign_key_names(): void + { + $this->migrate(CreateMigrationsTable::class, CreateMageTable::class, CreateSpellTable::class); + + $spellId = query(Spell::class) + ->insert( + name: 'Zoltraak', + type: 'Offensive', + creator: [ + 'name' => 'Qual', + 'element' => 'Darkness', + ], + ) + ->execute(); + + $spell = Spell::select()->with('creator')->get($spellId); + + $this->assertSame('Zoltraak', $spell->name); + $this->assertSame('Offensive', $spell->type); + $this->assertNotNull($spell->creator); + $this->assertSame('Qual', $spell->creator->name); + $this->assertSame('Darkness', $spell->creator->element); + } +} + +final class Mage +{ + use IsDatabaseModel; + + public PrimaryKey $mage_uuid; + + /** @var \Tests\Tempest\Integration\Database\Builder\Spell[] */ + #[HasMany(ownerJoin: 'creator_uuid', relationJoin: 'mage_uuid')] + public array $spells = []; + + public function __construct( + public string $name, + public string $element, + ) {} +} + +final class Spell +{ + use IsDatabaseModel; + + public PrimaryKey $spell_id; + + #[BelongsTo(ownerJoin: 'creator_uuid', relationJoin: 'mage_uuid')] + public ?Mage $creator = null; + + public function __construct( + public string $name, + public string $type, + ) {} +} + +final class Party +{ + use IsDatabaseModel; + + public PrimaryKey $party_id; + + /** @var \Tests\Tempest\Integration\Database\Builder\Adventurer[] */ + #[HasMany(ownerJoin: 'party_uuid', relationJoin: 'party_id')] + public array $members = []; + + public function __construct( + public string $name, + public string $quest_type, + ) {} +} + +final class Adventurer +{ + use IsDatabaseModel; + + public PrimaryKey $adventurer_id; + + #[BelongsTo(ownerJoin: 'party_uuid', relationJoin: 'party_id')] + public ?Party $party = null; + + public function __construct( + public string $name, + public string $class, + ) {} +} + +final class CreateMageTable implements DatabaseMigration +{ + private(set) string $name = '100-create-mage'; + + public function up(): QueryStatement + { + return new CreateTableStatement('mages') + ->primary('mage_uuid') + ->varchar('name') + ->varchar('element'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateSpellTable implements DatabaseMigration +{ + private(set) string $name = '101-create-spell'; + + public function up(): QueryStatement + { + return new CreateTableStatement('spells') + ->primary('spell_id') + ->varchar('name') + ->varchar('type') + ->belongsTo('spells.creator_uuid', 'mages.mage_uuid', nullable: true); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreatePartyTable implements DatabaseMigration +{ + private(set) string $name = '102-create-party'; + + public function up(): QueryStatement + { + return new CreateTableStatement('parties') + ->primary('party_id') + ->varchar('name') + ->varchar('quest_type'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateAdventurerTable implements DatabaseMigration +{ + private(set) string $name = '103-create-adventurer'; + + public function up(): QueryStatement + { + return new CreateTableStatement('adventurers') + ->primary('adventurer_id') + ->varchar('name') + ->varchar('class') + ->belongsTo('adventurers.party_uuid', 'parties.party_id', nullable: true); + } + + public function down(): ?QueryStatement + { + return null; + } +} diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php index 9fadee809..adaf14f61 100644 --- a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php @@ -5,12 +5,14 @@ use Tempest\Database\Builder\QueryBuilders\UpdateQueryBuilder; use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\Database; -use Tempest\Database\Exceptions\HasManyRelationCouldNotBeUpdated; -use Tempest\Database\Exceptions\HasOneRelationCouldNotBeUpdated; +use Tempest\Database\Exceptions\CouldNotUpdateRelation; use Tempest\Database\Exceptions\UpdateStatementWasInvalid; +use Tempest\Database\HasMany; +use Tempest\Database\IsDatabaseModel; use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\PrimaryKey; use Tempest\Database\Query; +use Tempest\Database\Table; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; @@ -184,32 +186,17 @@ public function test_attach_existing_relation_on_update(): void $this->assertSame([5, 10], $bookQuery->bindings); } - public function test_update_has_many_relation_via_parent_model_throws_exception(): void + public function test_update_has_many_relation_without_primary_key(): void { - try { - query(Book::class) - ->update( - title: 'Timeline Taxi', - chapters: ['title' => 'Chapter 01'], - ) - ->build(); - } catch (HasManyRelationCouldNotBeUpdated $hasManyRelationCouldNotBeUpdated) { - $this->assertStringContainsString(Book::class . '::$chapters', $hasManyRelationCouldNotBeUpdated->getMessage()); - } - } + $this->expectException(CouldNotUpdateRelation::class); - public function test_update_has_one_relation_via_parent_model_throws_exception(): void - { - try { - query(Book::class) - ->update( - title: 'Timeline Taxi', - isbn: ['value' => '979-8344313764'], - ) - ->build(); - } catch (HasOneRelationCouldNotBeUpdated $hasOneRelationCouldNotBeUpdated) { - $this->assertStringContainsString(Book::class . '::$isbn', $hasOneRelationCouldNotBeUpdated->getMessage()); - } + query(Book::class) + ->update( + title: 'Timeline Taxi', + chapters: [['title' => 'Chapter 01']], + ) + ->allowAll() + ->build(); } public function test_update_on_plain_table_with_conditions(): void @@ -331,4 +318,166 @@ public function test_update_mapping(): void $this->assertSame(['other', 1], $query->bindings); } + + public function test_update_with_where_in(): void + { + $sql = query('books') + ->update(title: 'Updated Book') + ->whereIn('id', [1, 2, 3]) + ->toSql(); + + $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`id` IN (?,?,?)'; + + $this->assertSameWithoutBackticks($expected, $sql); + } + + public function test_update_with_where_not_in(): void + { + $sql = query('books') + ->update(title: 'Updated Book') + ->whereNotIn('author_id', [1, 2]) + ->toSql(); + + $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`author_id` NOT IN (?,?)'; + + $this->assertSameWithoutBackticks($expected, $sql); + } + + public function test_update_with_where_null(): void + { + $sql = query('books') + ->update(title: 'Updated Book') + ->whereNull('author_id') + ->toSql(); + + $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`author_id` IS NULL'; + + $this->assertSameWithoutBackticks($expected, $sql); + } + + public function test_update_with_where_not_null(): void + { + $sql = query('books') + ->update(title: 'Updated Book') + ->whereNotNull('author_id') + ->toSql(); + + $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`author_id` IS NOT NULL'; + + $this->assertSameWithoutBackticks($expected, $sql); + } + + public function test_update_with_where_between(): void + { + $sql = query('books') + ->update(title: 'Updated Book') + ->whereBetween('id', 1, 100) + ->toSql(); + + $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`id` BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $sql); + } + + public function test_update_with_where_not_between(): void + { + $sql = query('books') + ->update(title: 'Updated Book') + ->whereNotBetween('id', 1, 10) + ->toSql(); + + $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`id` NOT BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $sql); + } + + public function test_update_with_or_where_in(): void + { + $sql = query('books') + ->update(title: 'Updated Book') + ->whereIn('id', [1, 2]) + ->orWhereIn('author_id', [10, 20]) + ->toSql(); + + $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`id` IN (?,?) OR `books`.`author_id` IN (?,?)'; + + $this->assertSameWithoutBackticks($expected, $sql); + } + + public function test_update_captures_primary_key_for_relations_with_convenience_where_methods(): void + { + $sql = query(Book::class) + ->update(title: 'Updated Book') + ->whereIn('id', [5]) + ->toSql(); + + $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`id` IN (?)'; + + $this->assertSameWithoutBackticks($expected, $sql); + } + + public function test_throws_exception_when_relation_update_with_non_primary_key_where(): void + { + $this->expectException(CouldNotUpdateRelation::class); + + query(TestModelWithRelations::class) + ->update(items: [['name' => 'Test Item']]) + ->where('name', 'some name') + ->execute(); + } + + public function test_throws_exception_when_relation_update_with_whereIn_multiple_values(): void + { + $this->expectException(CouldNotUpdateRelation::class); + + query(TestModelWithRelations::class) + ->update(items: [['name' => 'Test Item']]) + ->whereIn('id', [1, 2]) + ->execute(); + } + + public function test_throws_exception_when_relation_update_with_whereNotIn(): void + { + $this->expectException(CouldNotUpdateRelation::class); + + query(TestModelWithRelations::class) + ->update(items: [['name' => 'Test Item']]) + ->whereNotIn('id', [999]) + ->execute(); + } + + public function test_throws_exception_when_relation_update_with_whereBetween(): void + { + $this->expectException(CouldNotUpdateRelation::class); + + query(TestModelWithRelations::class) + ->update(items: [['name' => 'Test Item']]) + ->whereBetween('id', 1, 10) + ->execute(); + } + + public function test_throws_exception_when_relation_update_with_whereNot(): void + { + $this->expectException(CouldNotUpdateRelation::class); + + query(TestModelWithRelations::class) + ->update(items: [['name' => 'Test Item']]) + ->whereNot('id', 999) + ->execute(); + } +} + +#[Table('test_models')] +final class TestModelWithRelations +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + #[HasMany] + public array $items = []; + + public function __construct( + public string $name = '', + ) {} } diff --git a/tests/Integration/Database/Builder/UpdateRelationsTest.php b/tests/Integration/Database/Builder/UpdateRelationsTest.php new file mode 100644 index 000000000..1fc99b8ad --- /dev/null +++ b/tests/Integration/Database/Builder/UpdateRelationsTest.php @@ -0,0 +1,655 @@ +migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); + + $bookId = query(Book::class) + ->insert( + title: 'Sousou no Frieren', + chapters: [ + ['title' => 'The Journey Begins'], + ['title' => 'Meeting Fern'], + ], + ) + ->execute(); + + query(Book::class) + ->update( + title: 'Sousou no Frieren - Updated', + chapters: [ + ['title' => 'The New Journey Begins'], + ['title' => 'Meeting Stark'], + ['title' => 'The Magic Academy'], + ], + ) + ->where('id', $bookId) + ->execute(); + + $book = Book::select()->with('chapters')->get($bookId); + + $this->assertSame('Sousou no Frieren - Updated', $book->title); + $this->assertCount(3, $book->chapters); + $this->assertSame('The New Journey Begins', $book->chapters[0]->title); + $this->assertSame('Meeting Stark', $book->chapters[1]->title); + $this->assertSame('The Magic Academy', $book->chapters[2]->title); + } + + public function test_updating_has_one_with_array(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + CreateIsbnTable::class, + ); + + $bookId = query(Book::class) + ->insert( + title: 'Sousou no Frieren', + isbn: ['value' => '978-4091234567'], + ) + ->execute(); + + query(Book::class) + ->update( + title: 'Sousou no Frieren - Updated', + isbn: ['value' => '978-4091234568'], + ) + ->where('id', $bookId) + ->execute(); + + $book = Book::select()->with('isbn')->get($bookId); + + $this->assertSame('Sousou no Frieren - Updated', $book->title); + $this->assertNotNull($book->isbn); + $this->assertSame('978-4091234568', $book->isbn->value); + } + + public function test_updating_has_many_with_objects(): void + { + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); + + $bookId = query(Book::class) + ->insert( + title: 'Sousou no Frieren', + chapters: [ + ['title' => 'The Journey Begins'], + ['title' => 'Meeting Fern'], + ], + ) + ->execute(); + + query(Book::class) + ->update( + title: 'Sousou no Frieren - Updated', + chapters: [ + Chapter::new(title: 'Himmel the Hero'), + Chapter::new(title: 'Stark the Warrior'), + ], + ) + ->where('id', $bookId) + ->execute(); + + $book = Book::select()->with('chapters')->get($bookId); + + $this->assertSame('Sousou no Frieren - Updated', $book->title); + $this->assertCount(2, $book->chapters); + $this->assertSame('Himmel the Hero', $book->chapters[0]->title); + $this->assertSame('Stark the Warrior', $book->chapters[1]->title); + } + + public function test_updating_has_one_with_object(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + CreateIsbnTable::class, + ); + + $bookId = query(Book::class) + ->insert( + title: 'Sousou no Frieren', + isbn: ['value' => '978-4091234567'], + ) + ->execute(); + + query(Book::class) + ->update( + title: 'Sousou no Frieren - Updated', + isbn: Isbn::new(value: '978-4091234568'), + ) + ->where('id', $bookId) + ->execute(); + + $book = Book::select()->with('isbn')->get($bookId); + + $this->assertSame('978-4091234568', $book->isbn->value); + } + + public function test_updating_mixed_relations(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + CreateIsbnTable::class, + ); + + $bookId = query(Book::class) + ->insert( + title: 'Sousou no Frieren', + chapters: [ + ['title' => 'Old Chapter 1'], + ['title' => 'Old Chapter 2'], + ], + isbn: ['value' => '978-4091234567'], + ) + ->execute(); + + query(Book::class) + ->update( + title: 'Sousou no Frieren - Updated', + chapters: [ + Chapter::new(title: 'Chapter with Object'), + ['title' => 'Chapter with Array'], + ], + isbn: Isbn::new(value: '978-4091234568'), + ) + ->where('id', $bookId) + ->execute(); + + $book = Book::select()->with('chapters', 'isbn')->get($bookId); + + $this->assertSame('Sousou no Frieren - Updated', $book->title); + $this->assertCount(2, $book->chapters); + $this->assertSame('Chapter with Object', $book->chapters[0]->title); + $this->assertSame('Chapter with Array', $book->chapters[1]->title); + $this->assertNotNull($book->isbn); + $this->assertSame('978-4091234568', $book->isbn->value); + } + + public function test_updating_empty_has_many_relation(): void + { + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); + + $bookId = query(Book::class) + ->insert( + title: 'Book with Chapters', + chapters: [ + ['title' => 'Chapter 1'], + ['title' => 'Chapter 2'], + ], + ) + ->execute(); + + query(Book::class) + ->update( + title: 'Empty Book', + chapters: [], + ) + ->where('id', $bookId) + ->execute(); + + $book = Book::select() + ->with('chapters') + ->get($bookId); + + $this->assertSame('Empty Book', $book->title); + $this->assertCount(0, $book->chapters); + } + + public function test_updating_large_batch_has_many(): void + { + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); + + $bookId = query(Book::class) + ->insert( + title: 'Short Story', + chapters: [ + ['title' => 'Chapter 1'], + ], + ) + ->execute(); + + $chapters = []; + for ($i = 1; $i <= 10; $i++) { + $chapters[] = ['title' => "Updated Chapter {$i}"]; + } + + query(Book::class) + ->update( + title: 'Long Story', + chapters: $chapters, + ) + ->where('id', $bookId) + ->execute(); + + $book = Book::select()->with('chapters')->get($bookId); + + $this->assertSame('Long Story', $book->title); + $this->assertCount(10, $book->chapters); + $this->assertSame('Updated Chapter 1', $book->chapters[0]->title); + $this->assertSame('Updated Chapter 10', $book->chapters[9]->title); + } + + public function test_updating_has_many_preserves_additional_data(): void + { + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); + + $bookId = query(Book::class) + ->insert( + title: 'Simple Book', + chapters: [ + ['title' => 'Chapter 1'], + ], + ) + ->execute(); + + query(Book::class) + ->update( + title: 'Detailed Book', + chapters: [ + ['title' => 'Chapter 1', 'contents' => 'Once upon a time...'], + ['title' => 'Chapter 2', 'contents' => 'And then...'], + ], + ) + ->where('id', $bookId) + ->execute(); + + $book = Book::select()->with('chapters')->get($bookId); + + $this->assertSame('Detailed Book', $book->title); + $this->assertCount(2, $book->chapters); + $this->assertSame('Chapter 1', $book->chapters[0]->title); + $this->assertSame('Once upon a time...', $book->chapters[0]->contents); + $this->assertSame('Chapter 2', $book->chapters[1]->title); + $this->assertSame('And then...', $book->chapters[1]->contents); + } + + public function test_updating_relation_with_mixed_types(): void + { + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); + + $bookId = query(Book::class) + ->insert( + title: 'Original Book', + chapters: [ + ['title' => 'Old Chapter'], + ], + ) + ->execute(); + + query(Book::class) + ->update( + title: 'Mixed Content Book', + chapters: [ + Chapter::new(title: 'Object Chapter'), + ['title' => 'Array Chapter'], + Chapter::new(title: 'Another Object Chapter'), + ], + ) + ->where('id', $bookId) + ->execute(); + + $book = Book::select()->with('chapters')->get($bookId); + + $this->assertSame('Mixed Content Book', $book->title); + $this->assertCount(3, $book->chapters); + $this->assertSame('Object Chapter', $book->chapters[0]->title); + $this->assertSame('Array Chapter', $book->chapters[1]->title); + $this->assertSame('Another Object Chapter', $book->chapters[2]->title); + } + + public function test_updating_with_custom_primary_key_names(): void + { + $this->migrate(CreateMigrationsTable::class, CreateUpdateMageTable::class, CreateUpdateSpellTable::class); + + $mageId = query(UpdateMage::class) + ->insert( + name: 'Frieren', + element: 'Time', + spells: [ + ['name' => 'Zoltraak', 'type' => 'Offensive'], + ], + ) + ->execute(); + + query(UpdateMage::class) + ->update( + name: 'Frieren the Slayer', + element: 'Time Magic', + spells: [ + ['name' => 'Zoltraak', 'type' => 'Offensive'], + ['name' => 'Defensive Barrier', 'type' => 'Defensive'], + ], + ) + ->where('mage_uuid', $mageId) + ->execute(); + + $mage = UpdateMage::select() + ->with('spells') + ->get($mageId); + + $this->assertSame('Frieren the Slayer', $mage->name); + $this->assertSame('Time Magic', $mage->element); + $this->assertCount(2, $mage->spells); + $this->assertSame('Zoltraak', $mage->spells[0]->name); + $this->assertSame('Offensive', $mage->spells[0]->type); + $this->assertSame('Defensive Barrier', $mage->spells[1]->name); + $this->assertSame('Defensive', $mage->spells[1]->type); + } + + public function test_updating_with_non_standard_relation_names(): void + { + $this->migrate(CreateMigrationsTable::class, CreateUpdatePartyTable::class, CreateUpdateAdventurerTable::class); + + $partyId = query(UpdateParty::class) + ->insert( + name: 'Hero Party', + quest_type: 'Demon King Defeat', + members: [ + ['name' => 'Himmel', 'class' => 'Hero'], + ['name' => 'Heiter', 'class' => 'Priest'], + ], + ) + ->execute(); + + query(UpdateParty::class) + ->update( + name: 'Legendary Hero Party', + quest_type: 'Demon King Conquest', + members: [ + ['name' => 'Himmel', 'class' => 'Hero'], + ['name' => 'Heiter', 'class' => 'Priest'], + ['name' => 'Eisen', 'class' => 'Warrior'], + ['name' => 'Frieren', 'class' => 'Mage'], + ], + ) + ->where('party_id', $partyId) + ->execute(); + + $party = UpdateParty::select() + ->with('members') + ->get($partyId); + + $this->assertSame('Legendary Hero Party', $party->name); + $this->assertSame('Demon King Conquest', $party->quest_type); + $this->assertCount(4, $party->members); + $this->assertSame('Himmel', $party->members[0]->name); + $this->assertSame('Hero', $party->members[0]->class); + $this->assertSame('Heiter', $party->members[1]->name); + $this->assertSame('Priest', $party->members[1]->class); + $this->assertSame('Eisen', $party->members[2]->name); + $this->assertSame('Warrior', $party->members[2]->class); + $this->assertSame('Frieren', $party->members[3]->name); + $this->assertSame('Mage', $party->members[3]->class); + } + + public function test_updating_with_custom_foreign_key_names(): void + { + $this->migrate(CreateMigrationsTable::class, CreateUpdateMageTable::class, CreateUpdateSpellTable::class); + + $spellId = query(UpdateSpell::class) + ->insert( + name: 'Zoltraak', + type: 'Offensive', + creator: [ + 'name' => 'Qual', + 'element' => 'Darkness', + ], + ) + ->execute(); + + query(UpdateSpell::class) + ->update( + name: 'Enhanced Zoltraak', + type: 'Advanced Offensive', + creator: [ + 'name' => 'Qual the Demon', + 'element' => 'Dark Magic', + ], + ) + ->where('spell_id', $spellId) + ->execute(); + + $spell = UpdateSpell::select()->with('creator')->get($spellId); + + $this->assertSame('Enhanced Zoltraak', $spell->name); + $this->assertSame('Advanced Offensive', $spell->type); + $this->assertNotNull($spell->creator); + $this->assertSame('Qual the Demon', $spell->creator->name); + $this->assertSame('Dark Magic', $spell->creator->element); + } + + public function test_update_throws_exception_when_model_has_no_primary_key(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + CreateSimpleModelWithoutPrimaryKeyTable::class, + ); + + query(SimpleModelWithoutPrimaryKey::class) + ->insert(name: 'initial') + ->execute(); + + $this->expectException(CouldNotUpdateRelation::class); + + query(SimpleModelWithoutPrimaryKey::class) + ->update( + name: 'updated', + chapters: [['title' => 'test chapter']], + ) + ->where('name', 'initial') + ->execute(); + } +} + +#[Table('mages')] +final class UpdateMage +{ + use IsDatabaseModel; + + public PrimaryKey $mage_uuid; + + /** @var \Tests\Tempest\Integration\Database\Builder\UpdateSpell[] */ + #[HasMany(ownerJoin: 'creator_uuid', relationJoin: 'mage_uuid')] + public array $spells = []; + + public function __construct( + public string $name, + public string $element, + ) {} +} + +#[Table('spells')] +final class UpdateSpell +{ + use IsDatabaseModel; + + public PrimaryKey $spell_id; + + #[BelongsTo(ownerJoin: 'creator_uuid', relationJoin: 'mage_uuid')] + public ?UpdateMage $creator = null; + + public function __construct( + public string $name, + public string $type, + ) {} +} + +#[Table('parties')] +final class UpdateParty +{ + use IsDatabaseModel; + + public PrimaryKey $party_id; + + /** @var \Tests\Tempest\Integration\Database\Builder\UpdateAdventurer[] */ + #[HasMany(ownerJoin: 'party_uuid', relationJoin: 'party_id')] + public array $members = []; + + public function __construct( + public string $name, + public string $quest_type, + ) {} +} + +#[Table('adventurers')] +final class UpdateAdventurer +{ + use IsDatabaseModel; + + public PrimaryKey $adventurer_id; + + #[BelongsTo(ownerJoin: 'party_uuid', relationJoin: 'party_id')] + public ?UpdateParty $party = null; + + public function __construct( + public string $name, + public string $class, + ) {} +} + +final class CreateUpdateMageTable implements DatabaseMigration +{ + private(set) string $name = '100-create-update-mage'; + + public function up(): QueryStatement + { + return new CreateTableStatement('mages') + ->primary('mage_uuid') + ->varchar('name') + ->varchar('element'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateUpdateSpellTable implements DatabaseMigration +{ + private(set) string $name = '101-create-update-spell'; + + public function up(): QueryStatement + { + return new CreateTableStatement('spells') + ->primary('spell_id') + ->varchar('name') + ->varchar('type') + ->belongsTo('spells.creator_uuid', 'mages.mage_uuid', nullable: true); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateUpdatePartyTable implements DatabaseMigration +{ + private(set) string $name = '102-create-update-party'; + + public function up(): QueryStatement + { + return new CreateTableStatement('parties') + ->primary('party_id') + ->varchar('name') + ->varchar('quest_type'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateUpdateAdventurerTable implements DatabaseMigration +{ + private(set) string $name = '103-create-update-adventurer'; + + public function up(): QueryStatement + { + return new CreateTableStatement('adventurers') + ->primary('adventurer_id') + ->varchar('name') + ->varchar('class') + ->belongsTo('adventurers.party_uuid', 'parties.party_id', nullable: true); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +#[Table('simple_no_pk')] +final class SimpleModelWithoutPrimaryKey +{ + use IsDatabaseModel; + + public function __construct( + public string $name, + /** @var \Tests\Tempest\Fixtures\Modules\Books\Models\Chapter[] */ + public array $chapters = [], + ) {} +} + +final class CreateSimpleModelWithoutPrimaryKeyTable implements DatabaseMigration +{ + private(set) string $name = '106-create-simple-no-pk-table'; + + public function up(): QueryStatement + { + return new CreateTableStatement('simple_no_pk') + ->varchar('name'); + } + + public function down(): ?QueryStatement + { + return null; + } +} diff --git a/tests/Integration/Database/GenericTransactionManagerTest.php b/tests/Integration/Database/GenericTransactionManagerTest.php index 55f06a9a5..d2d6ad71a 100644 --- a/tests/Integration/Database/GenericTransactionManagerTest.php +++ b/tests/Integration/Database/GenericTransactionManagerTest.php @@ -12,8 +12,6 @@ use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\Database\query; - /** * @internal */ From 0176f66c77504d8d6015cc8548a16d209fe4c98b Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 6 Aug 2025 22:10:30 +0200 Subject: [PATCH 28/51] fix(database): handle object serialization using `insert` --- .../QueryBuilders/InsertQueryBuilder.php | 34 ++++++++++++++++- .../Builder/InsertQueryBuilderTest.php | 2 +- .../BasicDtoSerializationTest.php | 37 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index 4fe405763..56b307781 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -323,7 +323,7 @@ private function resolveIterableData(iterable $model): array continue; } - $entry[$key] = $value; + $entry[$key] = $this->serializeIterableValue($key, $value); } return $entry; @@ -447,4 +447,36 @@ private function serializeValue(PropertyReflector $property, mixed $value): mixe return $this->serializerFactory->forProperty($property)?->serialize($value) ?? $value; } + + private function serializeIterableValue(string $key, mixed $value): mixed + { + if ($value === null) { + return null; + } + + // Booleans should be handled by the database layer, not by serializers + if (is_bool($value)) { + return $value; + } + + // Only serialize if we have an object model to work with + if (! $this->model->isObjectModel()) { + return $value; + } + + if (! $this->model?->reflector->hasProperty($key)) { + return $value; + } + + $property = $this->model->reflector->getProperty($key); + + if ($property->getType()->accepts(PrimaryKey::class)) { + return $value; + } + + return $this->serializeValue( + property: $property, + value: $value, + ); + } } diff --git a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php index 0e1a572ad..a92ea3287 100644 --- a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php @@ -71,7 +71,7 @@ public function test_insert_on_model_table(): void $query = query(Author::class) ->insert( $author, - ['name' => 'other name', 'type' => AuthorType::B->value, 'publisher_id' => null], + ['name' => 'other name', 'type' => AuthorType::B, 'publisher_id' => null], ) ->build(); diff --git a/tests/Integration/Database/DtoSerialization/BasicDtoSerializationTest.php b/tests/Integration/Database/DtoSerialization/BasicDtoSerializationTest.php index b1e6b4ddd..3cd0c223d 100644 --- a/tests/Integration/Database/DtoSerialization/BasicDtoSerializationTest.php +++ b/tests/Integration/Database/DtoSerialization/BasicDtoSerializationTest.php @@ -56,6 +56,43 @@ public function down(): null $this->assertSame(80, $retrievedCharacter->stats->mana); } + public function test_simple_dto_serialization_with_named_arguments(): void + { + $this->migrate(CreateMigrationsTable::class, new class implements DatabaseMigration { + public string $name = '001_simple_character_named_args'; + + public function up(): QueryStatement + { + return new CreateTableStatement('characters') + ->primary() + ->text('name') + ->json('stats'); + } + + public function down(): null + { + return null; + } + }); + + query(Character::class) + ->insert( + name: 'Fern', + stats: new CharacterStats(level: 25, health: 80, mana: 120), + ) + ->execute(); + + $retrievedCharacter = query(Character::class) + ->select() + ->first(); + + $this->assertSame('Fern', $retrievedCharacter->name); + $this->assertInstanceOf(CharacterStats::class, $retrievedCharacter->stats); + $this->assertSame(25, $retrievedCharacter->stats->level); + $this->assertSame(80, $retrievedCharacter->stats->health); + $this->assertSame(120, $retrievedCharacter->stats->mana); + } + public function test_dto_with_enums(): void { $this->migrate(CreateMigrationsTable::class, new class implements DatabaseMigration { From 27ff95eaeff7467aa0838e2e1adb0ea262f42a18 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 6 Aug 2025 22:34:54 +0200 Subject: [PATCH 29/51] fix(database): support insertion of relations using `create` on models --- .../src/Builder/ModelQueryBuilder.php | 1 - .../QueryBuilders/InsertQueryBuilder.php | 20 +++- .../Database/Builder/IsDatabaseModelTest.php | 94 +++++++++++++++++++ 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/packages/database/src/Builder/ModelQueryBuilder.php b/packages/database/src/Builder/ModelQueryBuilder.php index fed26c569..454bdf013 100644 --- a/packages/database/src/Builder/ModelQueryBuilder.php +++ b/packages/database/src/Builder/ModelQueryBuilder.php @@ -241,7 +241,6 @@ public function create(mixed ...$params): object $id = query($this->model) ->insert($model) - ->build() ->execute(); $inspector = inspect($this->model); diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index 56b307781..ca6d4e8d3 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -393,12 +393,26 @@ private function resolveObjectData(object $model): array continue; } - if ($definition->getHasMany($property->getName()) || $definition->getHasOne($property->getName())) { + $propertyName = $property->getName(); + $value = $property->getValue($model); + + if ($definition->getHasMany($propertyName)) { + if (is_iterable($value)) { + $this->addHasManyRelationCallback($propertyName, $value); + } + continue; } - $column = $property->getName(); - $value = $property->getValue($model); + if ($definition->getHasOne($propertyName)) { + if (is_object($value) || is_array($value)) { + $this->addHasOneRelationCallback($propertyName, $value); + } + + continue; + } + + $column = $propertyName; if ($property->getType()->getName() === PrimaryKey::class && $value === null) { continue; diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index 34c739bac..bcc766516 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -11,6 +11,7 @@ use Tempest\Database\DatabaseMigration; use Tempest\Database\Exceptions\RelationWasMissing; use Tempest\Database\Exceptions\ValueWasMissing; +use Tempest\Database\HasMany; use Tempest\Database\HasOne; use Tempest\Database\IsDatabaseModel; use Tempest\Database\Migrations\CreateMigrationsTable; @@ -636,6 +637,36 @@ public function test_date_field(): void $this->assertSame('2024-01-01 00:00:00', $model->phpDateTime->format('Y-m-d H:i:s')); $this->assertSame('2024-01-01 00:00:00', $model->tempestDateTime->format('yyyy-MM-dd HH:mm:ss')); } + + public function test_model_create_with_has_many_relations(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateTestUserMigration::class, + CreateTestPostMigration::class, + ); + + $user = TestUser::create( + name: 'Jon', + posts: [ + new TestPost('hello', 'world'), + new TestPost('foo', 'bar'), + ], + ); + + $this->assertSame('Jon', $user->name); + $this->assertInstanceOf(PrimaryKey::class, $user->id); + + $posts = TestPost::select() + ->where('testuser_id', $user->id->value) + ->all(); + + $this->assertCount(2, $posts); + $this->assertSame('hello', $posts[0]->title); + $this->assertSame('world', $posts[0]->body); + $this->assertSame('foo', $posts[1]->title); + $this->assertSame('bar', $posts[1]->body); + } } final class Foo @@ -983,3 +1014,66 @@ public function __construct( public ?ChildModel $child2 = null, ) {} } + +final class TestUser +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + /** @var \Tests\Tempest\Integration\Database\Builder\TestPost[] */ + #[HasMany] + public array $posts = []; + + public function __construct( + public string $name, + ) {} +} + +final class TestPost +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + public function __construct( + public string $title, + public string $body, + ) {} +} + +final class CreateTestUserMigration implements DatabaseMigration +{ + public string $name = '010_create_test_users'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(TestUser::class) + ->primary() + ->text('name'); + } + + public function down(): ?QueryStatement + { + return null; + } +} + +final class CreateTestPostMigration implements DatabaseMigration +{ + public string $name = '011_create_test_posts'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(TestPost::class) + ->primary() + ->belongsTo('test_posts.testuser_id', 'test_users.id') + ->string('title') + ->text('body'); + } + + public function down(): ?QueryStatement + { + return null; + } +} From bb68c86ae47e3dfc61bf9777fb3c63990ced3759 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 6 Aug 2025 22:49:48 +0200 Subject: [PATCH 30/51] fix(database): support dto serialization on model builder update --- .../QueryBuilders/UpdateQueryBuilder.php | 4 +- .../Builder/UpdateQueryBuilderDtoTest.php | 85 +++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 tests/Integration/Database/Builder/UpdateQueryBuilderDtoTest.php diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index 006633d3c..92005b59b 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -184,9 +184,7 @@ private function resolvePropertyValue(PropertyReflector $property, string $colum return $this->resolveRelationValue($property, $column, $value); } - if (! $property->getType()->isRelation() && ! $property->getIterableType()?->isRelation()) { - $value = $this->serializeValue($property, $value); - } + $value = $this->serializeValue($property, $value); return [$column, $value]; } diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderDtoTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderDtoTest.php new file mode 100644 index 000000000..88c9cc41d --- /dev/null +++ b/tests/Integration/Database/Builder/UpdateQueryBuilderDtoTest.php @@ -0,0 +1,85 @@ +migrate(CreateMigrationsTable::class, new class implements DatabaseMigration { + public string $name = '001_create_users_table_for_dto_update'; + + public function up(): QueryStatement + { + return new CreateTableStatement('users') + ->primary() + ->string('name') + ->dto('settings'); + } + + public function down(): ?QueryStatement + { + return null; + } + }); + + $user = model(UserWithDtoSettings::class) + ->create( + name: 'John', + settings: new DtoSettings(DtoTheme::LIGHT), + ); + + model(UserWithDtoSettings::class) + ->update( + name: 'Jane', + settings: new DtoSettings(DtoTheme::DARK), + ) + ->where('id', $user->id) + ->execute(); + + $updatedUser = model(UserWithDtoSettings::class)->get($user->id); + + $this->assertSame('Jane', $updatedUser->name); + $this->assertInstanceOf(DtoSettings::class, $updatedUser->settings); + $this->assertSame(DtoTheme::DARK, $updatedUser->settings->theme); + } +} + +enum DtoTheme: string +{ + case LIGHT = 'light'; + case DARK = 'dark'; +} + +#[Table('users')] +final class UserWithDtoSettings +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + public function __construct( + public string $name, + public DtoSettings $settings, + ) {} +} + +#[SerializeAs('settings')] +final class DtoSettings +{ + public function __construct( + private(set) DtoTheme $theme, + ) {} +} From 9c21f7710c9b9134af6cb3083aaf29a00ecd0563 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 6 Aug 2025 23:05:39 +0200 Subject: [PATCH 31/51] test(datetime): reduce flakiness of time sensitive test --- packages/datetime/tests/TimestampTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/datetime/tests/TimestampTest.php b/packages/datetime/tests/TimestampTest.php index 6724a9a1d..d9ca30f39 100644 --- a/packages/datetime/tests/TimestampTest.php +++ b/packages/datetime/tests/TimestampTest.php @@ -571,8 +571,8 @@ public function test_nano_precision_temporal_comparisons(): void public function test_future_past_comprehensive(): void { $now = Timestamp::monotonic(); - $future = $now->plusMilliseconds(15); - $past = $now->minusMilliseconds(15); + $future = $now->plusMilliseconds(30); + $past = $now->minusMilliseconds(30); $this->assertTrue($future->isFuture()); $this->assertFalse($future->isPast()); From 837e931e4075f568ea5cc02042604b7d5d445490 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 6 Aug 2025 23:33:48 +0200 Subject: [PATCH 32/51] feat(database): add `foreignId` alias to `belongsTo` --- .../QueryStatements/CreateTableStatement.php | 36 +++++++++++++++++-- .../CreateTableStatementTest.php | 16 +++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/database/src/QueryStatements/CreateTableStatement.php b/packages/database/src/QueryStatements/CreateTableStatement.php index 2d680eea7..1bdc22250 100644 --- a/packages/database/src/QueryStatements/CreateTableStatement.php +++ b/packages/database/src/QueryStatements/CreateTableStatement.php @@ -43,7 +43,7 @@ public function primary(string $name = 'id'): self } /** - * Adds an integer column with a foreign key relationship to another table. + * Adds an integer column with a foreign key relationship to another table. This is an alias to `foreignId`. * * **Example** * ```php @@ -69,13 +69,43 @@ public function belongsTo(string $local, string $foreign, OnDelete $onDelete = O return $this; } + /** + * Adds an integer column with a foreign key relationship to another table. + * + * **Example** + * ```php + * new CreateTableStatement('orders') + * ->foreignId('customer_id', constrainedOn: 'customers'); + * ``` + * ```php + * new CreateTableStatement('orders') + * ->foreignId('orders.customer_id', constrainedOn: 'customers.id'); + * ``` + * + * @param string $local The local column in the format `[this_table.]foreign_id`. + * @param string $constrainedOn The foreign table in the format `other_table[.id]`. + */ + public function foreignId(string $local, string $constrainedOn, OnDelete $onDelete = OnDelete::RESTRICT, OnUpdate $onUpdate = OnUpdate::NO_ACTION, bool $nullable = false): self + { + if (! str_contains($local, '.')) { + $local = $this->tableName . '.' . $local; + } + + if (! str_contains($constrainedOn, '.')) { + $constrainedOn = $constrainedOn . '.id'; + } + + return $this->belongsTo($local, $constrainedOn, $onDelete, $onUpdate, $nullable); + } + /** * Adds a foreign key constraint to another table. * * **Example** * ```php - * $table->integer('customer_id', nullable: false); - * $table->foreignKey('orders.customer_id', 'customers.id'); + * new CreateTableStatement('orders') + * ->integer('customer_id', nullable: false) + * ->foreignKey('orders.customer_id', 'customers.id'); * ``` * * @param string $local The local column in the format `this_table.foreign_id`. diff --git a/packages/database/tests/QueryStatements/CreateTableStatementTest.php b/packages/database/tests/QueryStatements/CreateTableStatementTest.php index 31d28e042..f73c91ac4 100644 --- a/packages/database/tests/QueryStatements/CreateTableStatementTest.php +++ b/packages/database/tests/QueryStatements/CreateTableStatementTest.php @@ -73,6 +73,22 @@ public function test_create_a_foreign_key_constraint(DatabaseDialect $dialect, s ->compile($dialect); $this->assertSame($validSql, $statement); + + $statement = new CreateTableStatement('books') + ->primary() + ->foreignId('author_id', constrainedOn: 'authors', onDelete: OnDelete::CASCADE) + ->varchar('name') + ->compile($dialect); + + $this->assertSame($validSql, $statement); + + $statement = new CreateTableStatement('books') + ->primary() + ->foreignId('books.author_id', constrainedOn: 'authors.id', onDelete: OnDelete::CASCADE) + ->varchar('name') + ->compile($dialect); + + $this->assertSame($validSql, $statement); } public static function provide_fk_create_table_database_drivers(): Generator From 500fc89762fd9eccd2e8f06c2e0d4f8742ad0fcc Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 7 Aug 2025 13:18:55 +0200 Subject: [PATCH 33/51] refactor(database): remove `resolve` query builder method --- .../src/Builder/ModelQueryBuilder.php | 19 --------------- .../Builder/ModelQueryBuilderTest.php | 24 ------------------- 2 files changed, 43 deletions(-) diff --git a/packages/database/src/Builder/ModelQueryBuilder.php b/packages/database/src/Builder/ModelQueryBuilder.php index 454bdf013..8c3e591b9 100644 --- a/packages/database/src/Builder/ModelQueryBuilder.php +++ b/packages/database/src/Builder/ModelQueryBuilder.php @@ -145,25 +145,6 @@ public function findById(string|int|PrimaryKey $id): object return $this->get($id); } - /** - * Finds a model instance by its ID. - * - * **Example** - * ```php - * model(User::class)->resolve(1); - * ``` - * - * @return TModel - */ - public function resolve(string|int|PrimaryKey $id): object - { - if (! inspect($this->model)->hasPrimaryKey()) { - throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'resolve'); - } - - return $this->get($id); - } - /** * Gets a model instance by its ID, optionally loading the given relationships. * diff --git a/tests/Integration/Database/Builder/ModelQueryBuilderTest.php b/tests/Integration/Database/Builder/ModelQueryBuilderTest.php index 74bf81d19..ffd471122 100644 --- a/tests/Integration/Database/Builder/ModelQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/ModelQueryBuilderTest.php @@ -168,30 +168,6 @@ public function test_new(): void $this->assertSame('Fern', $modelWithoutId->name); } - public function test_resolve_with_id_model(): void - { - $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); - - $created = model(TestUserModel::class)->create(name: 'Stark'); - $resolved = model(TestUserModel::class)->resolve($created->id); - - $this->assertInstanceOf(TestUserModel::class, $resolved); - $this->assertSame('Stark', $resolved->name); - $this->assertTrue($created->id->equals($resolved->id)); - } - - public function test_resolve_throws_for_model_without_id(): void - { - $this->migrate(CreateMigrationsTable::class, TestModelWithoutIdMigration::class); - - $this->expectException(ModelDidNotHavePrimaryColumn::class); - $this->expectExceptionMessage( - "`Tests\Tempest\Integration\Database\Builder\TestUserModelWithoutId` does not have a primary column defined, which is required for the `resolve` method.", - ); - - model(TestUserModelWithoutId::class)->resolve(1); - } - public function test_get_with_id_model(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); From 0d34a2f946bcca65f891c09c61ee66a00dbed28a Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 7 Aug 2025 13:23:03 +0200 Subject: [PATCH 34/51] refactor(database): clean up pkey resolution --- packages/database/src/Builder/ModelInspector.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index 4825b0500..bcde5e671 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -412,15 +412,15 @@ public function getPrimaryKeyProperty(): ?PropertyReflector return null; } - $idProperties = arr($this->reflector->getProperties()) - ->filter(fn (PropertyReflector $property) => $property->getType()->getName() === PrimaryKey::class); + $primaryKeys = arr($this->reflector->getProperties()) + ->filter(fn (PropertyReflector $property) => $property->getType()->matches(PrimaryKey::class)); - return match ($idProperties->count()) { + return match ($primaryKeys->count()) { 0 => null, - 1 => $idProperties->first(), + 1 => $primaryKeys->first(), default => throw ModelHadMultiplePrimaryColumns::found( model: $this->model, - properties: $idProperties->map(fn (PropertyReflector $property) => $property->getName())->toArray(), + properties: $primaryKeys->map(fn (PropertyReflector $property) => $property->getName())->toArray(), ), }; } From 9ecf8ec606bad8f953cf953b255ec0f79c061501 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 7 Aug 2025 13:49:24 +0200 Subject: [PATCH 35/51] refactor(database): remove more `resolve` calls --- packages/database/src/IsDatabaseModel.php | 8 -------- tests/Integration/Database/ModelsWithoutIdTest.php | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 98e9608bc..d1dff15c5 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -62,14 +62,6 @@ public static function findById(string|int|PrimaryKey $id): static return self::resolve($id); } - /** - * Finds a model instance by its ID. - */ - public static function resolve(string|int|PrimaryKey $id): static - { - return model(self::class)->resolve($id); - } - /** * Gets a model instance by its ID, optionally loading the given relationships. */ diff --git a/tests/Integration/Database/ModelsWithoutIdTest.php b/tests/Integration/Database/ModelsWithoutIdTest.php index ef5b933ba..7fb04cbf6 100644 --- a/tests/Integration/Database/ModelsWithoutIdTest.php +++ b/tests/Integration/Database/ModelsWithoutIdTest.php @@ -135,9 +135,9 @@ public function test_relationship_methods_throw_for_models_without_id(): void $this->migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); $this->expectException(ModelDidNotHavePrimaryColumn::class); - $this->expectExceptionMessage('does not have a primary column defined, which is required for the `resolve` method'); + $this->expectExceptionMessage('does not have a primary column defined, which is required for the `findById` method'); - model(LogEntry::class)->resolve(id: 1); + model(LogEntry::class)->findById(id: 1); } public function test_get_method_throws_for_models_without_id(): void From 7a7c07a7a6a98950949bccef0bb46a02e6625370 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 7 Aug 2025 14:02:08 +0200 Subject: [PATCH 36/51] refactor(database): rename `toSql` to compile and extract raw sql compilation --- .../QueryBuilders/CountQueryBuilder.php | 2 +- .../QueryBuilders/DeleteQueryBuilder.php | 2 +- .../QueryBuilders/InsertQueryBuilder.php | 2 +- .../QueryBuilders/SelectQueryBuilder.php | 2 +- .../QueryBuilders/UpdateQueryBuilder.php | 2 +- .../src/Exceptions/QueryWasInvalid.php | 22 +++- packages/database/src/GenericDatabase.php | 6 +- .../src/Migrations/MigrationManager.php | 2 +- packages/database/src/Query.php | 86 +------------- packages/database/src/RawSql.php | 109 ++++++++++++++++++ .../Builder/ConvenientWhereMethodsTest.php | 52 ++++----- .../Builder/CountQueryBuilderTest.php | 62 +++++----- .../Builder/DeleteQueryBuilderTest.php | 10 +- .../Builder/InsertQueryBuilderTest.php | 14 +-- .../Database/Builder/NestedWhereTest.php | 12 +- .../Builder/SelectQueryBuilderTest.php | 12 +- .../Builder/UpdateQueryBuilderTest.php | 25 ++-- .../Database/Builder/WhereOperatorTest.php | 20 ++-- 18 files changed, 240 insertions(+), 202 deletions(-) create mode 100644 packages/database/src/RawSql.php diff --git a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php index fd06720da..d2dbd500d 100644 --- a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php @@ -84,7 +84,7 @@ public function bind(mixed ...$bindings): self */ public function toSql(): ImmutableString { - return $this->build()->toSql(); + return $this->build()->compile(); } /** diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index 352fc14dd..47f652087 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -73,7 +73,7 @@ public function bind(mixed ...$bindings): self */ public function toSql(): ImmutableString { - return $this->build()->toSql(); + return $this->build()->compile(); } /** diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index ca6d4e8d3..25bba7755 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -78,7 +78,7 @@ public function execute(mixed ...$bindings): ?PrimaryKey */ public function toSql(): ImmutableString { - return $this->build()->toSql(); + return $this->build()->compile(); } /** diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index f5bf4c5a4..f612fc2f3 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -288,7 +288,7 @@ public function bind(mixed ...$bindings): self */ public function toSql(): ImmutableString { - return $this->build()->toSql(); + return $this->build()->compile(); } /** diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index 92005b59b..8e6e96f1c 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -108,7 +108,7 @@ public function bind(mixed ...$bindings): self */ public function toSql(): ImmutableString { - return $this->build()->toSql(); + return $this->build()->compile(); } /** diff --git a/packages/database/src/Exceptions/QueryWasInvalid.php b/packages/database/src/Exceptions/QueryWasInvalid.php index 92474e7af..fc173aa56 100644 --- a/packages/database/src/Exceptions/QueryWasInvalid.php +++ b/packages/database/src/Exceptions/QueryWasInvalid.php @@ -6,21 +6,24 @@ use Exception; use PDOException; +use Tempest\Core\HasContext; use Tempest\Database\Query; use Tempest\Support\Json; -final class QueryWasInvalid extends Exception +final class QueryWasInvalid extends Exception implements HasContext { public readonly PDOException $pdoException; - public function __construct(Query $query, array $bindings, PDOException $previous) - { + public function __construct( + private(set) Query $query, + private(set) array $bindings, + PDOException $previous, + ) { $this->pdoException = $previous; $message = $previous->getMessage(); - $message .= PHP_EOL . PHP_EOL . $query->toSql() . PHP_EOL; - + $message .= PHP_EOL . PHP_EOL . $query->toRawSql() . PHP_EOL; $message .= PHP_EOL . 'bindings: ' . Json\encode($bindings, pretty: true); parent::__construct( @@ -28,4 +31,13 @@ public function __construct(Query $query, array $bindings, PDOException $previou previous: $previous, ); } + + public function context(): iterable + { + return [ + 'query' => $this->query, + 'bindings' => $this->bindings, + 'raw_query' => $this->query->toRawSql(), + ]; + } } diff --git a/packages/database/src/GenericDatabase.php b/packages/database/src/GenericDatabase.php index c604a0151..4843e782b 100644 --- a/packages/database/src/GenericDatabase.php +++ b/packages/database/src/GenericDatabase.php @@ -44,7 +44,7 @@ public function execute(BuildsQuery|Query $query): void $bindings = $this->resolveBindings($query); try { - $statement = $this->connection->prepare($query->toSql()->toString()); + $statement = $this->connection->prepare($query->compile()->toString()); $statement->execute($bindings); $this->lastStatement = $statement; @@ -56,7 +56,7 @@ public function execute(BuildsQuery|Query $query): void public function getLastInsertId(): ?PrimaryKey { - $sql = $this->lastQuery->toSql(); + $sql = $this->lastQuery->compile(); if (! $sql->trim()->startsWith('INSERT')) { return null; @@ -88,7 +88,7 @@ public function fetch(BuildsQuery|Query $query): array $bindings = $this->resolveBindings($query); try { - $pdoQuery = $this->connection->prepare($query->toSql()->toString()); + $pdoQuery = $this->connection->prepare($query->compile()->toString()); $pdoQuery->execute($bindings); return $pdoQuery->fetchAll(PDO::FETCH_NAMED); diff --git a/packages/database/src/Migrations/MigrationManager.php b/packages/database/src/Migrations/MigrationManager.php index b7eb84bfd..17498cd28 100644 --- a/packages/database/src/Migrations/MigrationManager.php +++ b/packages/database/src/Migrations/MigrationManager.php @@ -323,7 +323,7 @@ private function getMinifiedSqlFromStatement(?QueryStatement $statement): string $query = new Query($statement->compile($this->dialect)); // Remove comments - $sql = preg_replace('/--.*$/m', '', $query->toSql()->toString()); // Remove SQL single-line comments + $sql = preg_replace('/--.*$/m', '', $query->compile()->toString()); // Remove SQL single-line comments $sql = preg_replace('/\/\*[\s\S]*?\*\//', '', $sql); // Remove block comments // Remove blank lines and excessive spaces diff --git a/packages/database/src/Query.php b/packages/database/src/Query.php index b42b40597..c004781bd 100644 --- a/packages/database/src/Query.php +++ b/packages/database/src/Query.php @@ -61,9 +61,9 @@ public function fetchFirst(mixed ...$bindings): ?array } /** - * Returns the SQL statement without the bindings. + * Compile the query to a SQL statement without the bindings. */ - public function toSql(): ImmutableString + public function compile(): ImmutableString { $sql = $this->sql; $dialect = $this->dialect; @@ -84,87 +84,7 @@ public function toSql(): ImmutableString */ public function toRawSql(): ImmutableString { - $sql = $this->toSql(); - $resolvedBindings = $this->resolveBindingsForDisplay(); - - if (! array_is_list($resolvedBindings)) { - return $this->replaceNamedBindings((string) $sql, $resolvedBindings); - } - - return $this->replacePositionalBindings((string) $sql, array_values($resolvedBindings)); - } - - private function replaceNamedBindings(string $sql, array $bindings): ImmutableString - { - foreach ($bindings as $key => $value) { - $placeholder = ':' . $key; - $formattedValue = $this->formatValueForSql($value); - $sql = str_replace($placeholder, $formattedValue, $sql); - } - - return new ImmutableString($sql); - } - - private function replacePositionalBindings(string $sql, array $bindings): ImmutableString - { - $bindingIndex = 0; - $result = ''; - $length = strlen($sql); - - for ($i = 0; $i < $length; $i++) { - if ($sql[$i] === '?' && $bindingIndex < count($bindings)) { - $value = $bindings[$bindingIndex]; - $result .= $this->formatValueForSql($value); - $bindingIndex++; - } else { - $result .= $sql[$i]; - } - } - - return new ImmutableString($result); - } - - private function resolveBindingsForDisplay(): array - { - $bindings = []; - - foreach ($this->bindings as $key => $value) { - if (is_bool($value)) { - $value = match ($this->dialect) { - DatabaseDialect::POSTGRESQL => $value ? 'true' : 'false', - default => $value ? '1' : '0', - }; - } - - if ($value instanceof Query) { - $value = '(' . $value->toRawSql() . ')'; - } - - $bindings[$key] = $value; - } - - return $bindings; - } - - private function formatValueForSql(mixed $value): string - { - if ($value === null) { - return 'NULL'; - } - - if (is_string($value)) { - if (str_starts_with($value, '(') && str_ends_with($value, ')')) { - return $value; - } - - return "'" . str_replace("'", "''", $value) . "'"; - } - - if (is_numeric($value)) { - return (string) $value; - } - - return "'" . str_replace("'", "''", (string) $value) . "'"; + return new RawSql($this->dialect, (string) $this->compile(), $this->bindings)->toImmutableString(); } public function append(string $append): self diff --git a/packages/database/src/RawSql.php b/packages/database/src/RawSql.php new file mode 100644 index 000000000..5d983f8c5 --- /dev/null +++ b/packages/database/src/RawSql.php @@ -0,0 +1,109 @@ +resolveBindingsForDisplay(); + + if (! array_is_list($resolvedBindings)) { + return $this->replaceNamedBindings($this->sql, $resolvedBindings); + } + + return $this->replacePositionalBindings($this->sql, array_values($resolvedBindings)); + } + + public function toImmutableString(): ImmutableString + { + return new ImmutableString($this->compile()); + } + + public function __toString(): string + { + return $this->compile(); + } + + private function replaceNamedBindings(string $sql, array $bindings): string + { + foreach ($bindings as $key => $value) { + $placeholder = ':' . $key; + $formattedValue = $this->formatValueForSql($value); + $sql = str_replace($placeholder, $formattedValue, $sql); + } + + return $sql; + } + + private function replacePositionalBindings(string $sql, array $bindings): string + { + $bindingIndex = 0; + $result = ''; + $length = strlen($sql); + + for ($i = 0; $i < $length; $i++) { + if ($sql[$i] === '?' && $bindingIndex < count($bindings)) { + $value = $bindings[$bindingIndex]; + $result .= $this->formatValueForSql($value); + $bindingIndex++; + } else { + $result .= $sql[$i]; + } + } + + return $result; + } + + private function resolveBindingsForDisplay(): array + { + $bindings = []; + + foreach ($this->bindings as $key => $value) { + if (is_bool($value)) { + $value = match ($this->dialect) { + DatabaseDialect::POSTGRESQL => $value ? 'true' : 'false', + default => $value ? '1' : '0', + }; + } + + if ($value instanceof Query) { + $value = '(' . $value->toRawSql() . ')'; + } + + $bindings[$key] = $value; + } + + return $bindings; + } + + private function formatValueForSql(mixed $value): string + { + if ($value === null) { + return 'NULL'; + } + + if (is_string($value)) { + if (str_starts_with($value, '(') && str_ends_with($value, ')')) { + return $value; + } + + return "'" . str_replace("'", "''", $value) . "'"; + } + + if (is_numeric($value)) { + return (string) $value; + } + + return "'" . str_replace("'", "''", (string) $value) . "'"; + } +} diff --git a/tests/Integration/Database/Builder/ConvenientWhereMethodsTest.php b/tests/Integration/Database/Builder/ConvenientWhereMethodsTest.php index e72b52274..2f9ac347f 100644 --- a/tests/Integration/Database/Builder/ConvenientWhereMethodsTest.php +++ b/tests/Integration/Database/Builder/ConvenientWhereMethodsTest.php @@ -23,7 +23,7 @@ public function test_select_where_in(): void $expected = 'SELECT * FROM `books` WHERE books.category IN (?,?,?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['fiction', 'mystery', 'thriller'], $query->bindings); } @@ -36,7 +36,7 @@ public function test_select_where_not_in(): void $expected = 'SELECT * FROM `books` WHERE books.status NOT IN (?,?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['draft', 'archived'], $query->bindings); } @@ -49,7 +49,7 @@ public function test_select_where_between(): void $expected = 'SELECT * FROM `books` WHERE books.publication_year BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([2020, 2024], $query->bindings); } @@ -62,7 +62,7 @@ public function test_select_where_not_between(): void $expected = 'SELECT * FROM `books` WHERE books.price NOT BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([10.0, 50.0], $query->bindings); } @@ -75,7 +75,7 @@ public function test_select_where_null(): void $expected = 'SELECT * FROM `books` WHERE books.deleted_at IS NULL'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([], $query->bindings); } @@ -88,7 +88,7 @@ public function test_select_where_not_null(): void $expected = 'SELECT * FROM `books` WHERE books.published_at IS NOT NULL'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([], $query->bindings); } @@ -101,7 +101,7 @@ public function test_select_where_not(): void $expected = 'SELECT * FROM `books` WHERE books.status != ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['draft'], $query->bindings); } @@ -114,7 +114,7 @@ public function test_select_where_like(): void $expected = 'SELECT * FROM `books` WHERE books.title LIKE ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['%fantasy%'], $query->bindings); } @@ -127,7 +127,7 @@ public function test_select_where_not_like(): void $expected = 'SELECT * FROM `books` WHERE books.title NOT LIKE ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['%test%'], $query->bindings); } @@ -140,7 +140,7 @@ public function test_update_where_in(): void $expected = 'UPDATE `books` SET title = ? WHERE books.category IN (?,?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['New Title', 'fiction', 'mystery'], $query->bindings); } @@ -153,7 +153,7 @@ public function test_update_where_between(): void $expected = 'UPDATE `books` SET status = ? WHERE books.rating BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['updated', 3.0, 5.0], $query->bindings); } @@ -166,7 +166,7 @@ public function test_update_where_null(): void $expected = 'UPDATE `books` SET status = ? WHERE books.deleted_at IS NULL'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['archived'], $query->bindings); } @@ -179,7 +179,7 @@ public function test_delete_where_in(): void $expected = 'DELETE FROM `books` WHERE books.status IN (?,?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['draft', 'archived'], $query->bindings); } @@ -192,7 +192,7 @@ public function test_delete_where_not_null(): void $expected = 'DELETE FROM `books` WHERE books.deleted_at IS NOT NULL'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([], $query->bindings); } @@ -209,7 +209,7 @@ public function test_complex_chaining_with_convenient_methods(): void $expected = 'SELECT * FROM `books` WHERE books.category IN (?,?) AND books.published_at IS NOT NULL AND books.rating BETWEEN ? AND ? OR books.status != ? OR books.title LIKE ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['fiction', 'mystery', 3.0, 5.0, 'draft', '%bestseller%'], $query->bindings); } @@ -232,7 +232,7 @@ public function test_convenient_methods_in_where_groups(): void $expected = 'SELECT * FROM `books` WHERE books.published = ? AND (books.category IN (?,?) OR books.featured_at IS NULL) OR (books.rating BETWEEN ? AND ? AND books.title NOT LIKE ?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([true, 'fiction', 'mystery', 4.0, 5.0, '%draft%'], $query->bindings); } @@ -254,7 +254,7 @@ public function test_nested_where_groups_with_convenient_methods(): void $expected = 'SELECT * FROM `books` WHERE books.status IN (?,?) AND (books.published_at IS NOT NULL OR (books.rating BETWEEN ? AND ? AND books.category NOT IN (?)))'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['published', 'featured', 4.0, 5.0, 'children'], $query->bindings); } @@ -286,7 +286,7 @@ public function test_all_convenient_methods_together(): void $expected = 'SELECT * FROM `books` WHERE books.category IN (?) AND books.status NOT IN (?) AND books.rating BETWEEN ? AND ? AND books.price NOT BETWEEN ? AND ? AND books.deleted_at IS NULL AND books.published_at IS NOT NULL AND books.featured != ? AND books.title LIKE ? AND books.description NOT LIKE ? OR books.tags IN (?) OR books.awards NOT IN (?) OR books.pages BETWEEN ? AND ? OR books.weight NOT BETWEEN ? AND ? OR books.special_edition IS NULL OR books.isbn IS NOT NULL OR books.limited_edition != ? OR books.publisher LIKE ? OR books.format NOT LIKE ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([ 'fiction', 'draft', @@ -321,7 +321,7 @@ public function test_where_between_with_tempest_datetime(): void $expected = 'SELECT * FROM `events` WHERE events.created_at BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([$startDate, $endDate], $query->bindings); } @@ -337,7 +337,7 @@ public function test_where_between_with_mixed_datetime_types(): void $expected = 'SELECT * FROM `events` WHERE events.created_at BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([$startDate, $endDate], $query->bindings); } @@ -353,7 +353,7 @@ public function test_where_not_between_with_tempest_datetime(): void $expected = 'SELECT * FROM `events` WHERE events.created_at NOT BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([$startDate, $endDate], $query->bindings); } @@ -370,7 +370,7 @@ public function test_or_where_between_with_tempest_datetime(): void $expected = 'SELECT * FROM `events` WHERE events.status = ? OR events.created_at BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['active', $startDate, $endDate], $query->bindings); } @@ -387,7 +387,7 @@ public function test_or_where_not_between_with_tempest_datetime(): void $expected = 'SELECT * FROM `events` WHERE events.priority = ? OR events.created_at NOT BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['high', $startDate, $endDate], $query->bindings); } @@ -404,7 +404,7 @@ public function test_where_between_with_datetime_convenience_methods(): void $expected = 'SELECT * FROM `events` WHERE events.created_at BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([$startDate, $endDate], $query->bindings); } @@ -421,7 +421,7 @@ public function test_where_between_with_datetime_start_and_end_of_month(): void $expected = 'SELECT * FROM `events` WHERE events.created_at BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([$startDate, $endDate], $query->bindings); } @@ -439,7 +439,7 @@ public function test_where_between_with_datetime_start_and_end_of_week(): void $expected = 'SELECT * FROM `events` WHERE events.created_at BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([$startDate, $endDate], $query->bindings); } } diff --git a/tests/Integration/Database/Builder/CountQueryBuilderTest.php b/tests/Integration/Database/Builder/CountQueryBuilderTest.php index 5d4069600..374a3c68c 100644 --- a/tests/Integration/Database/Builder/CountQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/CountQueryBuilderTest.php @@ -30,7 +30,7 @@ public function test_simple_count_query(): void $expected = 'SELECT COUNT(*) AS count FROM chapters WHERE title = ? AND index <> ? OR createdAt > ?'; - $sql = $query->toSql(); + $sql = $query->compile(); $bindings = $query->bindings; $this->assertSameWithoutBackticks($expected, $sql); @@ -43,7 +43,7 @@ public function test_count_query_with_specified_asterisk(): void ->count('*') ->build(); - $sql = $query->toSql(); + $sql = $query->compile(); $expected = 'SELECT COUNT(*) AS `count` FROM `chapters`'; @@ -54,7 +54,7 @@ public function test_count_query_with_specified_field(): void { $query = query('chapters')->count('title')->build(); - $sql = $query->toSql(); + $sql = $query->compile(); $expected = 'SELECT COUNT(`title`) AS `count` FROM `chapters`'; @@ -88,7 +88,7 @@ public function test_count_query_with_distinct_specified_field(): void ->distinct() ->build(); - $sql = $query->toSql(); + $sql = $query->compile(); $expected = 'SELECT COUNT(DISTINCT `title`) AS `count` FROM `chapters`'; @@ -99,7 +99,7 @@ public function test_count_from_model(): void { $query = query(Author::class)->count()->build(); - $sql = $query->toSql(); + $sql = $query->compile(); $expected = 'SELECT COUNT(*) AS `count` FROM `authors`'; @@ -128,7 +128,7 @@ public function test_count_query_with_conditions(): void $expected = 'SELECT COUNT(*) AS `count` FROM `chapters` WHERE `title` = ? AND `index` <> ? OR `createdAt` > ?'; - $sql = $query->toSql(); + $sql = $query->compile(); $bindings = $query->bindings; $this->assertSameWithoutBackticks($expected, $sql); @@ -186,7 +186,7 @@ public function test_where_in(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.category IN (?,?,?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['fiction', 'mystery', 'thriller'], $query->bindings); } @@ -199,7 +199,7 @@ public function test_where_in_with_enum_class(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.status IN (?,?,?,?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['draft', 'published', 'archived', 'featured'], $query->bindings); } @@ -212,7 +212,7 @@ public function test_where_in_with_enums(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.status IN (?,?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['published', 'featured'], $query->bindings); } @@ -225,7 +225,7 @@ public function test_where_not_in(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.status NOT IN (?,?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['draft', 'archived'], $query->bindings); } @@ -238,7 +238,7 @@ public function test_where_not_in_with_enums(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.status NOT IN (?,?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['draft', 'archived'], $query->bindings); } @@ -251,7 +251,7 @@ public function test_where_between(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.publication_year BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([2020, 2024], $query->bindings); } @@ -264,7 +264,7 @@ public function test_where_not_between(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.price NOT BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([10.0, 50.0], $query->bindings); } @@ -277,7 +277,7 @@ public function test_where_null(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.deleted_at IS NULL'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([], $query->bindings); } @@ -290,7 +290,7 @@ public function test_where_not_null(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published_at IS NOT NULL'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([], $query->bindings); } @@ -303,7 +303,7 @@ public function test_where_not(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.status != ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['draft'], $query->bindings); } @@ -316,7 +316,7 @@ public function test_where_like(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.title LIKE ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['%fantasy%'], $query->bindings); } @@ -329,7 +329,7 @@ public function test_where_not_like(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.title NOT LIKE ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['%test%'], $query->bindings); } @@ -343,7 +343,7 @@ public function test_or_where_in(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.category IN (?,?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([true, 'fiction', 'mystery'], $query->bindings); } @@ -357,7 +357,7 @@ public function test_or_where_not_in(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.status NOT IN (?,?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([true, 'draft', 'archived'], $query->bindings); } @@ -371,7 +371,7 @@ public function test_or_where_between(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.rating BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([true, 4.0, 5.0], $query->bindings); } @@ -385,7 +385,7 @@ public function test_or_where_not_between(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.price NOT BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([true, 20.0, 80.0], $query->bindings); } @@ -399,7 +399,7 @@ public function test_or_where_null(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.deleted_at IS NULL'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([true], $query->bindings); } @@ -413,7 +413,7 @@ public function test_or_where_not_null(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.featured_at IS NOT NULL'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([false], $query->bindings); } @@ -427,7 +427,7 @@ public function test_or_where_not(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.status != ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([true, 'archived'], $query->bindings); } @@ -441,7 +441,7 @@ public function test_or_where_like(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.description LIKE ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([true, '%adventure%'], $query->bindings); } @@ -455,7 +455,7 @@ public function test_or_where_not_like(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.published = ? OR books.title NOT LIKE ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([true, '%boring%'], $query->bindings); } @@ -471,7 +471,7 @@ public function test_chained_convenient_where_methods(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.category IN (?,?) AND books.published_at IS NOT NULL AND books.rating BETWEEN ? AND ? AND books.status != ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['fiction', 'mystery', 3.0, 5.0, 'draft'], $query->bindings); } @@ -486,7 +486,7 @@ public function test_mixed_convenient_and_or_where_methods(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.category IN (?) OR books.featured_at IS NULL OR books.price NOT BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['fiction', 100.0, 200.0], $query->bindings); } @@ -504,7 +504,7 @@ public function test_convenient_where_methods_in_groups(): void $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.status IN (?,?) AND (books.published_at IS NOT NULL OR books.rating BETWEEN ? AND ?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['published', 'featured', 4.0, 5.0], $query->bindings); } @@ -522,7 +522,7 @@ public function test_nested_where_with_count_query(): void $expected = 'SELECT COUNT(*) AS count FROM books WHERE published = ? OR (status = ? AND rating >= ?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([true, 'featured', 4.5], $query->bindings); } } diff --git a/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php b/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php index a8bc86ee3..9b91aaf2e 100644 --- a/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php @@ -23,7 +23,7 @@ public function test_delete_on_plain_table(): void $this->assertSameWithoutBackticks( 'DELETE FROM `foo` WHERE `bar` = ?', - $query->toSql(), + $query->compile(), ); $this->assertSameWithoutBackticks( @@ -41,7 +41,7 @@ public function test_delete_on_model_table(): void $this->assertSameWithoutBackticks( 'DELETE FROM `authors`', - $query->toSql(), + $query->compile(), ); } @@ -56,7 +56,7 @@ public function test_delete_on_model_object(): void $this->assertSameWithoutBackticks( 'DELETE FROM `authors` WHERE `authors`.`id` = ?', - $query->toSql(), + $query->compile(), ); $this->assertSame( @@ -81,7 +81,7 @@ public function test_delete_on_plain_table_with_conditions(): void $this->assertSameWithoutBackticks( 'DELETE FROM `foo` WHERE `bar` = ?', - $query->toSql(), + $query->compile(), ); $this->assertSame( @@ -148,7 +148,7 @@ public function test_nested_where_with_delete_query(): void $expected = 'DELETE FROM books WHERE status = ? AND (created_at < ? AND author_id IS NULL)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['draft', '2022-01-01'], $query->bindings); } } diff --git a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php index a92ea3287..579e9e64a 100644 --- a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php @@ -31,7 +31,7 @@ public function test_insert_on_plain_table(): void $this->assertSameWithoutBackticks( $expected, - $query->toSql(), + $query->compile(), ); $this->assertSame( @@ -56,7 +56,7 @@ public function test_insert_with_batch(): void $this->assertSameWithoutBackticks( $expected, - $query->toSql(), + $query->compile(), ); $this->assertSame( @@ -77,7 +77,7 @@ public function test_insert_on_model_table(): void $expected = $this->buildExpectedInsert('INSERT INTO `authors` (`name`, `type`, `publisher_id`) VALUES (?, ?, ?), (?, ?, ?)'); - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['brent', 'a', null, 'other name', 'b', null], $query->bindings); } @@ -94,7 +94,7 @@ public function test_insert_on_model_table_with_new_relation(): void $expectedBookQuery = $this->buildExpectedInsert('INSERT INTO `books` (`title`, `author_id`) VALUES (?, ?)'); - $this->assertSameWithoutBackticks($expectedBookQuery, $bookQuery->toSql()); + $this->assertSameWithoutBackticks($expectedBookQuery, $bookQuery->compile()); $this->assertSame('Timeline Taxi', $bookQuery->bindings[0]); $this->assertInstanceOf(Query::class, $bookQuery->bindings[1]); @@ -102,7 +102,7 @@ public function test_insert_on_model_table_with_new_relation(): void $expectedAuthorQuery = $this->buildExpectedInsert('INSERT INTO `authors` (`name`) VALUES (?)'); - $this->assertSameWithoutBackticks($expectedAuthorQuery, $authorQuery->toSql()); + $this->assertSameWithoutBackticks($expectedAuthorQuery, $authorQuery->compile()); $this->assertSame('Brent', $authorQuery->bindings[0]); } @@ -122,7 +122,7 @@ public function test_insert_on_model_table_with_existing_relation(): void $expectedBookQuery = $this->buildExpectedInsert('INSERT INTO `books` (`title`, `author_id`) VALUES (?, ?)'); - $this->assertSameWithoutBackticks($expectedBookQuery, $bookQuery->toSql()); + $this->assertSameWithoutBackticks($expectedBookQuery, $bookQuery->compile()); $this->assertSame('Timeline Taxi', $bookQuery->bindings[0]); $this->assertSame(10, $bookQuery->bindings[1]); } @@ -194,7 +194,7 @@ public function test_insert_mapping(): void SQL, }; - $this->assertSame($expected, $query->toSql()->toString()); + $this->assertSame($expected, $query->compile()->toString()); $this->assertSame(['test'], $query->bindings); } } diff --git a/tests/Integration/Database/Builder/NestedWhereTest.php b/tests/Integration/Database/Builder/NestedWhereTest.php index 60441bbc2..0d7709bcf 100644 --- a/tests/Integration/Database/Builder/NestedWhereTest.php +++ b/tests/Integration/Database/Builder/NestedWhereTest.php @@ -24,7 +24,7 @@ public function test_nested_where_with_and_group(): void $expected = 'SELECT * FROM books WHERE title = ? AND (author_id = ? OR author_id = ?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['test', 1, 2], $query->bindings); } @@ -42,7 +42,7 @@ public function test_nested_where_with_or_group(): void $expected = 'SELECT * FROM books WHERE status = ? OR (priority = ? AND urgent = ?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['active', 'high', true], $query->bindings); } @@ -64,7 +64,7 @@ public function test_deeply_nested_where_groups(): void $expected = 'SELECT * FROM books WHERE published = ? AND (category = ? OR (author_name = ? AND rating > ?))'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([true, 'fiction', 'Tolkien', 4.5], $query->bindings); } @@ -99,7 +99,7 @@ public function test_complex_nested_where_scenario(): void $expected = 'SELECT * FROM books WHERE status = ? AND ((category = ? AND rating > ?) OR (category = ? AND author_id IN (?, ?, ?))) AND created_at > ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([ 'published', 'fiction', @@ -125,7 +125,7 @@ public function test_where_group_without_existing_conditions(): void $expected = 'SELECT * FROM books WHERE (title LIKE ? OR description LIKE ?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['%test%', '%test%'], $query->bindings); } @@ -143,7 +143,7 @@ public function test_nested_where_with_where(): void $expected = 'SELECT * FROM books WHERE books.published = ? AND (category = ? OR priority = ?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([true, 'fiction', 'high'], $query->bindings); } } diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index 36115df7b..aeccaf843 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -38,7 +38,7 @@ public function test_select_query(): void $expected = 'SELECT title, index FROM chapters WHERE title = ? AND index <> ? OR createdAt > ? ORDER BY index ASC'; - $sql = $query->toSql(); + $sql = $query->compile(); $bindings = $query->bindings; $this->assertSameWithoutBackticks($expected, $sql); @@ -49,7 +49,7 @@ public function test_select_without_any_fields_specified(): void { $query = query('chapters')->select()->build(); - $sql = $query->toSql(); + $sql = $query->compile(); $expected = 'SELECT * FROM `chapters`'; @@ -60,7 +60,7 @@ public function test_select_from_model(): void { $query = query(Author::class)->select()->build(); - $sql = $query->toSql(); + $sql = $query->compile(); $expected = 'SELECT authors.id AS `authors.id`, authors.name AS `authors.name`, authors.type AS `authors.type`, authors.publisher_id AS `authors.publisher_id` FROM `authors`'; @@ -325,7 +325,7 @@ public function test_select_query_with_conditions(): void $expected = 'SELECT title, index FROM `chapters` WHERE `title` = ? AND `index` <> ? OR `createdAt` > ? ORDER BY `index` ASC'; - $sql = $query->toSql(); + $sql = $query->compile(); $bindings = $query->bindings; $this->assertSameWithoutBackticks($expected, $sql); @@ -376,7 +376,7 @@ public function test_select_includes_belongs_to(): void $this->assertSameWithoutBackticks( 'SELECT books.id AS `books.id`, books.title AS `books.title`, books.author_id AS `books.author_id` FROM `books`', - $query->build()->toSql(), + $query->build()->compile(), ); } @@ -389,7 +389,7 @@ public function test_with_belongs_to_relation(): void $this->assertSameWithoutBackticks( 'SELECT books.id AS `books.id`, books.title AS `books.title`, books.author_id AS `books.author_id`, authors.id AS `author.id`, authors.name AS `author.name`, authors.type AS `author.type`, authors.publisher_id AS `author.publisher_id`, chapters.id AS `chapters.id`, chapters.title AS `chapters.title`, chapters.contents AS `chapters.contents`, chapters.book_id AS `chapters.book_id`, isbns.id AS `isbn.id`, isbns.value AS `isbn.value`, isbns.book_id AS `isbn.book_id` FROM `books` LEFT JOIN authors ON authors.id = books.author_id LEFT JOIN chapters ON chapters.book_id = books.id LEFT JOIN isbns ON isbns.book_id = books.id', - $query->toSql(), + $query->compile(), ); } diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php index adaf14f61..0de3e3844 100644 --- a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php @@ -36,7 +36,7 @@ public function test_update_on_plain_table(): void $this->assertSameWithoutBackticks( 'UPDATE `chapters` SET `title` = ?, `index` = ? WHERE `id` = ?', - $query->toSql(), + $query->compile(), ); $this->assertSame( @@ -54,7 +54,7 @@ public function test_global_update(): void $this->assertSameWithoutBackticks( 'UPDATE `chapters` SET `index` = ?', - $query->toSql(), + $query->compile(), ); $this->assertSame( @@ -70,7 +70,7 @@ public function test_global_update_fails_without_allow_all(): void query('chapters') ->update(index: 0) ->build() - ->toSql(); + ->compile(); } public function test_model_update_with_values(): void @@ -84,7 +84,7 @@ public function test_model_update_with_values(): void $this->assertSameWithoutBackticks( 'UPDATE `books` SET `title` = ? WHERE `id` = ?', - $query->toSql(), + $query->compile(), ); $this->assertSame( @@ -108,7 +108,7 @@ public function test_model_update_with_object(): void $this->assertSameWithoutBackticks( 'UPDATE `books` SET `title` = ? WHERE `books`.`id` = ?', - $query->toSql(), + $query->compile(), ); $this->assertSame( @@ -147,7 +147,7 @@ public function test_insert_new_relation_on_update(): void $this->assertSameWithoutBackticks( 'UPDATE `books` SET `author_id` = ? WHERE `books`.`id` = ?', - $bookQuery->toSql(), + $bookQuery->compile(), ); $this->assertInstanceOf(Query::class, $bookQuery->bindings[0]); @@ -160,10 +160,7 @@ public function test_insert_new_relation_on_update(): void $expected .= ' RETURNING *'; } - $this->assertSameWithoutBackticks( - $expected, - $authorQuery->toSql(), - ); + $this->assertSameWithoutBackticks($expected, $authorQuery->compile()); $this->assertSame(['Brent'], $authorQuery->bindings); } @@ -180,7 +177,7 @@ public function test_attach_existing_relation_on_update(): void $this->assertSameWithoutBackticks( 'UPDATE `books` SET `author_id` = ? WHERE `books`.`id` = ?', - $bookQuery->toSql(), + $bookQuery->compile(), ); $this->assertSame([5, 10], $bookQuery->bindings); @@ -218,7 +215,7 @@ public function test_update_on_plain_table_with_conditions(): void $this->assertSameWithoutBackticks( 'UPDATE `chapters` SET `title` = ?, `index` = ? WHERE `id` = ?', - $query->toSql(), + $query->compile(), ); $this->assertSame( @@ -291,7 +288,7 @@ public function test_nested_where_with_update_query(): void $expected = 'UPDATE books SET status = ? WHERE published = ? AND (views < ? OR last_accessed < ?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['archived', true, 100, '2023-01-01'], $query->bindings); } @@ -314,7 +311,7 @@ public function test_update_mapping(): void SQL, }; - $this->assertSame($expected, $query->toSql()->toString()); + $this->assertSame($expected, $query->compile()->toString()); $this->assertSame(['other', 1], $query->bindings); } diff --git a/tests/Integration/Database/Builder/WhereOperatorTest.php b/tests/Integration/Database/Builder/WhereOperatorTest.php index 82ddaeaf0..5fa44e140 100644 --- a/tests/Integration/Database/Builder/WhereOperatorTest.php +++ b/tests/Integration/Database/Builder/WhereOperatorTest.php @@ -21,7 +21,7 @@ public function test_basic_where_with_field_and_value(): void $expected = 'SELECT * FROM books WHERE books.title = ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['Test Book'], $query->bindings); } @@ -34,7 +34,7 @@ public function test_where_with_explicit_operator(): void $expected = 'SELECT * FROM books WHERE books.rating > ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([4.0], $query->bindings); } @@ -47,7 +47,7 @@ public function test_where_with_string_operator(): void $expected = 'SELECT * FROM books WHERE books.title LIKE ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['%fantasy%'], $query->bindings); } @@ -60,7 +60,7 @@ public function test_where_in_operator(): void $expected = 'SELECT * FROM books WHERE books.category IN (?,?,?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['fiction', 'mystery', 'thriller'], $query->bindings); } @@ -73,7 +73,7 @@ public function test_where_between_operator(): void $expected = 'SELECT * FROM books WHERE books.publication_year BETWEEN ? AND ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([2020, 2024], $query->bindings); } @@ -86,7 +86,7 @@ public function test_where_is_null_operator(): void $expected = 'SELECT * FROM books WHERE books.deleted_at IS NULL'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([], $query->bindings); } @@ -101,7 +101,7 @@ public function test_multiple_where_conditions(): void $expected = 'SELECT * FROM books WHERE books.published = ? AND books.rating >= ? OR books.category = ?'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([true, 4.0, 'bestseller'], $query->bindings); } @@ -115,7 +115,7 @@ public function test_where_raw_for_complex_conditions(): void $expected = 'SELECT * FROM books WHERE books.published = ? AND (title LIKE ? OR description LIKE ?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([true, '%test%', '%test%'], $query->bindings); } @@ -133,7 +133,7 @@ public function test_nested_where_groups_with_new_api(): void $expected = 'SELECT * FROM books WHERE books.published = ? AND (books.category = ? OR books.rating > ?)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([true, 'fiction', 4.5], $query->bindings); } @@ -151,7 +151,7 @@ public function test_mixed_raw_and_typed_conditions_in_groups(): void $expected = 'SELECT * FROM books WHERE books.status = ? AND (books.category IN (?,?) OR custom_field IS NOT NULL)'; - $this->assertSameWithoutBackticks($expected, $query->toSql()); + $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame(['published', 'fiction', 'mystery'], $query->bindings); } From 63bd1b7b1d2c9769e666fa43a7300a3625c650a2 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 7 Aug 2025 14:02:26 +0200 Subject: [PATCH 37/51] refactor(database): remove setter from `HasWhereStatements` --- packages/database/src/QueryStatements/HasWhereStatements.php | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/database/src/QueryStatements/HasWhereStatements.php b/packages/database/src/QueryStatements/HasWhereStatements.php index 489ccdfbf..ba77fa4dc 100644 --- a/packages/database/src/QueryStatements/HasWhereStatements.php +++ b/packages/database/src/QueryStatements/HasWhereStatements.php @@ -9,6 +9,5 @@ interface HasWhereStatements /** @var ImmutableArray */ public ImmutableArray $where { get; - set; } } From f97a9f83159f95f5184614a53cec2a30dd757e91 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 7 Aug 2025 14:02:45 +0200 Subject: [PATCH 38/51] refactor(database): add back `resolve` to models --- .../src/Builder/ModelQueryBuilder.php | 19 +++++++++++++++++++ packages/database/src/IsDatabaseModel.php | 10 +++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/database/src/Builder/ModelQueryBuilder.php b/packages/database/src/Builder/ModelQueryBuilder.php index 8c3e591b9..454bdf013 100644 --- a/packages/database/src/Builder/ModelQueryBuilder.php +++ b/packages/database/src/Builder/ModelQueryBuilder.php @@ -145,6 +145,25 @@ public function findById(string|int|PrimaryKey $id): object return $this->get($id); } + /** + * Finds a model instance by its ID. + * + * **Example** + * ```php + * model(User::class)->resolve(1); + * ``` + * + * @return TModel + */ + public function resolve(string|int|PrimaryKey $id): object + { + if (! inspect($this->model)->hasPrimaryKey()) { + throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'resolve'); + } + + return $this->get($id); + } + /** * Gets a model instance by its ID, optionally loading the given relationships. * diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index d1dff15c5..6a204a249 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -59,7 +59,15 @@ public static function new(mixed ...$params): self */ public static function findById(string|int|PrimaryKey $id): static { - return self::resolve($id); + return self::get($id); + } + + /** + * Finds a model instance by its ID. + */ + public static function resolve(string|int|PrimaryKey $id): static + { + return model(self::class)->resolve($id); } /** From c4eb32c1969fabc5323aac560a278b19a85f3e3c Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 7 Aug 2025 14:31:40 +0200 Subject: [PATCH 39/51] refactor(database): merge query builder and model query builder --- .../src/Builder/ModelQueryBuilder.php | 331 ------------------ .../Builder/QueryBuilders/QueryBuilder.php | 258 ++++++++++++++ .../src/Exceptions/QueryWasInvalid.php | 4 +- packages/database/src/IsDatabaseModel.php | 24 +- packages/database/src/functions.php | 13 - .../Database/Builder/CustomPrimaryKeyTest.php | 12 +- ...ryBuilderTest.php => QueryBuilderTest.php} | 150 ++++---- .../Builder/UpdateQueryBuilderDtoTest.php | 8 +- ...ustomPrimaryKeyRelationshipLoadingTest.php | 60 ++-- .../Database/ModelsWithoutIdTest.php | 37 +- 10 files changed, 404 insertions(+), 493 deletions(-) delete mode 100644 packages/database/src/Builder/ModelQueryBuilder.php rename tests/Integration/Database/Builder/{ModelQueryBuilderTest.php => QueryBuilderTest.php} (76%) diff --git a/packages/database/src/Builder/ModelQueryBuilder.php b/packages/database/src/Builder/ModelQueryBuilder.php deleted file mode 100644 index 454bdf013..000000000 --- a/packages/database/src/Builder/ModelQueryBuilder.php +++ /dev/null @@ -1,331 +0,0 @@ - */ - private string $model, - ) {} - - /** - * Returns a builder for selecting records using this model's table. - * - * **Example** - * ```php - * model(User::class) - * ->select('id', 'username', 'email') - * ->execute(); - * ``` - * - * @return SelectQueryBuilder - */ - public function select(string ...$columns): SelectQueryBuilder - { - return query($this->model)->select(...$columns); - } - - /** - * Returns a builder for inserting records using this model's table. - * - * **Example** - * ```php - * model(User::class) - * ->insert(username: 'Frieren') - * ->execute(); - * ``` - * - * @return InsertQueryBuilder - */ - public function insert(mixed ...$values): InsertQueryBuilder - { - return query($this->model)->insert(...$values); - } - - /** - * Returns a builder for updating records using this model's table. - * - * **Example** - * ```php - * model(User::class) - * ->update(is_admin: true) - * ->whereIn('id', [1, 2, 3]) - * ->execute(); - * ``` - * - * @return UpdateQueryBuilder - */ - public function update(mixed ...$values): UpdateQueryBuilder - { - return query($this->model)->update(...$values); - } - - /** - * Returns a builder for deleting records using this model's table. - * - * **Example** - * ```php - * model(User::class) - * ->delete() - * ->where(name: 'Frieren') - * ->execute(); - * ``` - * - * @return DeleteQueryBuilder - */ - public function delete(): DeleteQueryBuilder - { - return query($this->model)->delete(); - } - - /** - * Returns a builder for counting records using this model's table. - * - * **Example** - * ```php - * model(User::class)->count()->execute(); - * ``` - * - * @return CountQueryBuilder - */ - public function count(): CountQueryBuilder - { - return query($this->model)->count(); - } - - /** - * Creates a new instance of this model without persisting it to the database. - * - * **Example** - * ```php - * model(User::class)->new(name: 'Frieren'); - * ``` - * - * @return TModel - */ - public function new(mixed ...$params): object - { - return make($this->model)->from($params); - } - - /** - * Finds a model instance by its ID. - * - * **Example** - * ```php - * model(User::class)->findById(1); - * ``` - * - * @return TModel - */ - public function findById(string|int|PrimaryKey $id): object - { - if (! inspect($this->model)->hasPrimaryKey()) { - throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'findById'); - } - - return $this->get($id); - } - - /** - * Finds a model instance by its ID. - * - * **Example** - * ```php - * model(User::class)->resolve(1); - * ``` - * - * @return TModel - */ - public function resolve(string|int|PrimaryKey $id): object - { - if (! inspect($this->model)->hasPrimaryKey()) { - throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'resolve'); - } - - return $this->get($id); - } - - /** - * Gets a model instance by its ID, optionally loading the given relationships. - * - * **Example** - * ```php - * model(User::class)->get(1); - * ``` - * - * @return TModel|null - */ - public function get(string|int|PrimaryKey $id, array $relations = []): ?object - { - if (! inspect($this->model)->hasPrimaryKey()) { - throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'get'); - } - - $id = match (true) { - $id instanceof PrimaryKey => $id, - default => new PrimaryKey($id), - }; - - return $this->select() - ->with(...$relations) - ->get($id); - } - - /** - * Gets all records from the model's table. - * - * @return TModel[] - */ - public function all(array $relations = []): array - { - return $this->select() - ->with(...$relations) - ->all(); - } - - /** - * Finds records based on their columns. - * - * **Example** - * ```php - * model(User::class)->find(name: 'Frieren'); - * ``` - * - * @return SelectQueryBuilder - */ - public function find(mixed ...$conditions): SelectQueryBuilder - { - $query = $this->select(); - - foreach ($conditions as $field => $value) { - $query->where($field, $value); - } - - return $query; - } - - /** - * Creates a new model instance and persists it to the database. - * - * **Example** - * ```php - * model(User::class)->create(name: 'Frieren', kind: Kind::ELF); - * ``` - * - * @return TModel - */ - public function create(mixed ...$params): object - { - inspect($this->model)->validate(...$params); - - $model = $this->new(...$params); - - $id = query($this->model) - ->insert($model) - ->execute(); - - $inspector = inspect($this->model); - $primaryKeyProperty = $inspector->getPrimaryKeyProperty(); - - if ($id !== null && $primaryKeyProperty !== null) { - $primaryKeyName = $primaryKeyProperty->getName(); - $model->{$primaryKeyName} = new PrimaryKey($id); - } - - return $model; - } - - /** - * Finds an existing model instance or creates a new one if it doesn't exist, without persisting it to the database. - * - * **Example** - * ```php - * $model = model(User::class)->findOrNew( - * find: ['name' => 'Frieren'], - * update: ['kind' => Kind::ELF], - * ); - * ``` - * - * @param array $find Properties to search for in the existing model. - * @param array $update Properties to update or set on the model if it is found or created. - * @return TModel - */ - public function findOrNew(array $find, array $update): object - { - $existing = $this->select(); - - foreach ($find as $key => $value) { - $existing = $existing->where($key, $value); - } - - $model = $existing->first() ?? $this->new(...$find); - - foreach ($update as $key => $value) { - $model->{$key} = $value; - } - - return $model; - } - - /** - * Finds an existing model instance or creates a new one if it doesn't exist, and persists it to the database. - * - * **Example** - * ```php - * $model = model(User::class)->updateOrCreate( - * find: ['name' => 'Frieren'], - * update: ['kind' => Kind::ELF], - * ); - * ``` - * - * @param array $find Properties to search for in the existing model. - * @param array $update Properties to update or set on the model if it is found or created. - * @return TModel - */ - public function updateOrCreate(array $find, array $update): object - { - $inspector = inspect($this->model); - - if (! $inspector->hasPrimaryKey()) { - throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'updateOrCreate'); - } - - $model = $this->findOrNew($find, $update); - - $primaryKeyProperty = $inspector->getPrimaryKeyProperty(); - $primaryKeyName = $primaryKeyProperty->getName(); - - if (! isset($model->{$primaryKeyName})) { - return $this->create(...$update); - } - - query($model) - ->update(...$update) - ->execute(); - - foreach ($update as $key => $value) { - $model->{$key} = $value; - } - - return $model; - } -} diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index e3e7be007..96750fbe0 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -2,9 +2,14 @@ namespace Tempest\Database\Builder\QueryBuilders; +use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; +use Tempest\Database\PrimaryKey; use Tempest\Mapper\SerializerFactory; +use function Tempest\Database\inspect; +use function Tempest\Database\query; use function Tempest\get; +use function Tempest\make; use function Tempest\Support\arr; /** @@ -22,6 +27,13 @@ public function __construct( /** * Creates a `SELECT` query builder for retrieving records from the database. * + * **Example** + * ```php + * query(User::class) + * ->select('id', 'username', 'email') + * ->execute(); + * ``` + * * @return SelectQueryBuilder */ public function select(string ...$columns): SelectQueryBuilder @@ -35,6 +47,13 @@ public function select(string ...$columns): SelectQueryBuilder /** * Creates an `INSERT` query builder for adding new records to the database. * + * **Example** + * ```php + * query(User::class) + * ->insert(username: 'Frieren') + * ->execute(); + * ``` + * * @return InsertQueryBuilder */ public function insert(mixed ...$values): InsertQueryBuilder @@ -53,6 +72,14 @@ public function insert(mixed ...$values): InsertQueryBuilder /** * Creates an `UPDATE` query builder for modifying existing records in the database. * + * **Example** + * ```php + * query(User::class) + * ->update(is_admin: true) + * ->whereIn('id', [1, 2, 3]) + * ->execute(); + * ``` + * * @return UpdateQueryBuilder */ public function update(mixed ...$values): UpdateQueryBuilder @@ -67,6 +94,14 @@ public function update(mixed ...$values): UpdateQueryBuilder /** * Creates a `DELETE` query builder for removing records from the database. * + * **Example** + * ```php + * query(User::class) + * ->delete() + * ->where(name: 'Frieren') + * ->execute(); + * ``` + * * @return DeleteQueryBuilder */ public function delete(): DeleteQueryBuilder @@ -77,6 +112,11 @@ public function delete(): DeleteQueryBuilder /** * Creates a `COUNT` query builder for counting records in the database. * + * **Example** + * ```php + * query(User::class)->count()->execute(); + * ``` + * * @return CountQueryBuilder */ public function count(?string $column = null): CountQueryBuilder @@ -86,4 +126,222 @@ public function count(?string $column = null): CountQueryBuilder column: $column, ); } + + /** + * Creates a new instance of this model without persisting it to the database. + * + * **Example** + * ```php + * query(User::class)->new(name: 'Frieren'); + * ``` + * + * @return TModel + */ + public function new(mixed ...$params): object + { + return make($this->model)->from($params); + } + + /** + * Finds a model instance by its ID. + * + * **Example** + * ```php + * query(User::class)->findById(1); + * ``` + * + * @return TModel + */ + public function findById(string|int|PrimaryKey $id): object + { + if (! inspect($this->model)->hasPrimaryKey()) { + throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'findById'); + } + + return $this->get($id); + } + + /** + * Finds a model instance by its ID. + * + * **Example** + * ```php + * query(User::class)->resolve(1); + * ``` + * + * @return TModel + */ + public function resolve(string|int|PrimaryKey $id): object + { + if (! inspect($this->model)->hasPrimaryKey()) { + throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'resolve'); + } + + return $this->get($id); + } + + /** + * Gets a model instance by its ID, optionally loading the given relationships. + * + * **Example** + * ```php + * query(User::class)->get(1); + * ``` + * + * @return TModel|null + */ + public function get(string|int|PrimaryKey $id, array $relations = []): ?object + { + if (! inspect($this->model)->hasPrimaryKey()) { + throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'get'); + } + + $id = match (true) { + $id instanceof PrimaryKey => $id, + default => new PrimaryKey($id), + }; + + return $this->select() + ->with(...$relations) + ->get($id); + } + + /** + * Gets all records from the model's table. + * + * @return TModel[] + */ + public function all(array $relations = []): array + { + return $this->select() + ->with(...$relations) + ->all(); + } + + /** + * Finds records based on their columns. + * + * **Example** + * ```php + * query(User::class)->find(name: 'Frieren'); + * ``` + * + * @return SelectQueryBuilder + */ + public function find(mixed ...$conditions): SelectQueryBuilder + { + $query = $this->select(); + + foreach ($conditions as $field => $value) { + $query->where($field, $value); + } + + return $query; + } + + /** + * Creates a new model instance and persists it to the database. + * + * **Example** + * ```php + * query(User::class)->create(name: 'Frieren', kind: Kind::ELF); + * ``` + * + * @return TModel + */ + public function create(mixed ...$params): object + { + inspect($this->model)->validate(...$params); + + $model = $this->new(...$params); + + $id = query($this->model) + ->insert($model) + ->execute(); + + $inspector = inspect($this->model); + $primaryKeyProperty = $inspector->getPrimaryKeyProperty(); + + if ($id !== null && $primaryKeyProperty !== null) { + $primaryKeyName = $primaryKeyProperty->getName(); + $model->{$primaryKeyName} = new PrimaryKey($id); + } + + return $model; + } + + /** + * Finds an existing model instance or creates a new one if it doesn't exist, without persisting it to the database. + * + * **Example** + * ```php + * $model = query(User::class)->findOrNew( + * find: ['name' => 'Frieren'], + * update: ['kind' => Kind::ELF], + * ); + * ``` + * + * @param array $find Properties to search for in the existing model. + * @param array $update Properties to update or set on the model if it is found or created. + * @return TModel + */ + public function findOrNew(array $find, array $update): object + { + $existing = $this->select(); + + foreach ($find as $key => $value) { + $existing = $existing->where($key, $value); + } + + $model = $existing->first() ?? $this->new(...$find); + + foreach ($update as $key => $value) { + $model->{$key} = $value; + } + + return $model; + } + + /** + * Finds an existing model instance or creates a new one if it doesn't exist, and persists it to the database. + * + * **Example** + * ```php + * $model = query(User::class)->updateOrCreate( + * find: ['name' => 'Frieren'], + * update: ['kind' => Kind::ELF], + * ); + * ``` + * + * @param array $find Properties to search for in the existing model. + * @param array $update Properties to update or set on the model if it is found or created. + * @return TModel + */ + public function updateOrCreate(array $find, array $update): object + { + $inspector = inspect($this->model); + + if (! $inspector->hasPrimaryKey()) { + throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'updateOrCreate'); + } + + $model = $this->findOrNew($find, $update); + + $primaryKeyProperty = $inspector->getPrimaryKeyProperty(); + $primaryKeyName = $primaryKeyProperty->getName(); + + if (! isset($model->{$primaryKeyName})) { + return $this->create(...$update); + } + + query($model) + ->update(...$update) + ->execute(); + + foreach ($update as $key => $value) { + $model->{$key} = $value; + } + + return $model; + } } diff --git a/packages/database/src/Exceptions/QueryWasInvalid.php b/packages/database/src/Exceptions/QueryWasInvalid.php index fc173aa56..f205ce48b 100644 --- a/packages/database/src/Exceptions/QueryWasInvalid.php +++ b/packages/database/src/Exceptions/QueryWasInvalid.php @@ -22,9 +22,7 @@ public function __construct( $this->pdoException = $previous; $message = $previous->getMessage(); - - $message .= PHP_EOL . PHP_EOL . $query->toRawSql() . PHP_EOL; - $message .= PHP_EOL . 'bindings: ' . Json\encode($bindings, pretty: true); + $message .= PHP_EOL . PHP_EOL . $query->toRawSql(); parent::__construct( message: $message, diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 6a204a249..5c8c69c8e 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -12,7 +12,7 @@ use Tempest\Reflection\ClassReflector; use Tempest\Reflection\PropertyReflector; -use function Tempest\Database\model; +use function Tempest\Database\query; trait IsDatabaseModel { @@ -23,7 +23,7 @@ trait IsDatabaseModel */ public static function select(): SelectQueryBuilder { - return model(self::class)->select(); + return query(self::class)->select(); } /** @@ -33,7 +33,7 @@ public static function select(): SelectQueryBuilder */ public static function insert(): InsertQueryBuilder { - return model(self::class)->insert(); + return query(self::class)->insert(); } /** @@ -43,7 +43,7 @@ public static function insert(): InsertQueryBuilder */ public static function count(): CountQueryBuilder { - return model(self::class)->count(); + return query(self::class)->count(); } /** @@ -51,7 +51,7 @@ public static function count(): CountQueryBuilder */ public static function new(mixed ...$params): self { - return model(self::class)->new(...$params); + return query(self::class)->new(...$params); } /** @@ -67,7 +67,7 @@ public static function findById(string|int|PrimaryKey $id): static */ public static function resolve(string|int|PrimaryKey $id): static { - return model(self::class)->resolve($id); + return query(self::class)->resolve($id); } /** @@ -75,7 +75,7 @@ public static function resolve(string|int|PrimaryKey $id): static */ public static function get(string|int|PrimaryKey $id, array $relations = []): ?self { - return model(self::class)->get($id, $relations); + return query(self::class)->get($id, $relations); } /** @@ -85,7 +85,7 @@ public static function get(string|int|PrimaryKey $id, array $relations = []): ?s */ public static function all(array $relations = []): array { - return model(self::class)->all($relations); + return query(self::class)->all($relations); } /** @@ -100,7 +100,7 @@ public static function all(array $relations = []): array */ public static function find(mixed ...$conditions): SelectQueryBuilder { - return model(self::class)->find(...$conditions); + return query(self::class)->find(...$conditions); } /** @@ -115,7 +115,7 @@ public static function find(mixed ...$conditions): SelectQueryBuilder */ public static function create(mixed ...$params): self { - return model(self::class)->create(...$params); + return query(self::class)->create(...$params); } /** @@ -135,7 +135,7 @@ public static function create(mixed ...$params): self */ public static function findOrNew(array $find, array $update): self { - return model(self::class)->findOrNew($find, $update); + return query(self::class)->findOrNew($find, $update); } /** @@ -155,7 +155,7 @@ public static function findOrNew(array $find, array $update): self */ public static function updateOrCreate(array $find, array $update): self { - return model(self::class)->updateOrCreate($find, $update); + return query(self::class)->updateOrCreate($find, $update); } /** diff --git a/packages/database/src/functions.php b/packages/database/src/functions.php index eb3bd5539..c635c96ef 100644 --- a/packages/database/src/functions.php +++ b/packages/database/src/functions.php @@ -2,7 +2,6 @@ namespace Tempest\Database { use Tempest\Database\Builder\ModelInspector; - use Tempest\Database\Builder\ModelQueryBuilder; use Tempest\Database\Builder\QueryBuilders\QueryBuilder; /** @@ -17,18 +16,6 @@ function query(string|object $model): QueryBuilder return new QueryBuilder($model); } - /** - * Provides model-related convenient query methods. - * - * @template TModel of object - * @param class-string $modelClass - * @return ModelQueryBuilder - */ - function model(string $modelClass): ModelQueryBuilder - { - return new ModelQueryBuilder($modelClass); - } - /** * Inspects the given model or table name to provide database insights. * diff --git a/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php b/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php index 4fa68291f..4c8e0e251 100644 --- a/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php +++ b/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php @@ -11,7 +11,7 @@ use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use function Tempest\Database\inspect; -use function Tempest\Database\model; +use function Tempest\Database\query; final class CustomPrimaryKeyTest extends FrameworkIntegrationTestCase { @@ -19,14 +19,14 @@ public function test_model_with_custom_primary_key_name(): void { $this->migrate(CreateMigrationsTable::class, CreateCustomPrimaryKeyUserModelTable::class); - $frieren = model(CustomPrimaryKeyUserModel::class)->create(name: 'Frieren', magic: 'Time Magic'); + $frieren = query(CustomPrimaryKeyUserModel::class)->create(name: 'Frieren', magic: 'Time Magic'); $this->assertInstanceOf(CustomPrimaryKeyUserModel::class, $frieren); $this->assertInstanceOf(PrimaryKey::class, $frieren->uuid); $this->assertSame('Frieren', $frieren->name); $this->assertSame('Time Magic', $frieren->magic); - $retrieved = model(CustomPrimaryKeyUserModel::class)->get($frieren->uuid); + $retrieved = query(CustomPrimaryKeyUserModel::class)->get($frieren->uuid); $this->assertNotNull($retrieved); $this->assertSame('Frieren', $retrieved->name); $this->assertTrue($frieren->uuid->equals($retrieved->uuid)); @@ -36,9 +36,9 @@ public function test_update_or_create_with_custom_primary_key(): void { $this->migrate(CreateMigrationsTable::class, CreateCustomPrimaryKeyUserModelTable::class); - $frieren = model(CustomPrimaryKeyUserModel::class)->create(name: 'Frieren', magic: 'Time Magic'); + $frieren = query(CustomPrimaryKeyUserModel::class)->create(name: 'Frieren', magic: 'Time Magic'); - $updated = model(CustomPrimaryKeyUserModel::class)->updateOrCreate( + $updated = query(CustomPrimaryKeyUserModel::class)->updateOrCreate( find: ['name' => 'Frieren'], update: ['magic' => 'Advanced Time Magic'], ); @@ -61,7 +61,7 @@ public function test_model_without_id_property_still_works(): void { $this->migrate(CreateMigrationsTable::class, CreateModelWithoutIdMigration::class); - $model = model(ModelWithoutId::class)->new(name: 'Test'); + $model = query(ModelWithoutId::class)->new(name: 'Test'); $this->assertInstanceOf(ModelWithoutId::class, $model); $this->assertSame('Test', $model->name); } diff --git a/tests/Integration/Database/Builder/ModelQueryBuilderTest.php b/tests/Integration/Database/Builder/QueryBuilderTest.php similarity index 76% rename from tests/Integration/Database/Builder/ModelQueryBuilderTest.php rename to tests/Integration/Database/Builder/QueryBuilderTest.php index ffd471122..848272579 100644 --- a/tests/Integration/Database/Builder/ModelQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/QueryBuilderTest.php @@ -12,20 +12,20 @@ use Tempest\Database\PrimaryKey; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\Database\model; +use function Tempest\Database\query; -final class ModelQueryBuilderTest extends FrameworkIntegrationTestCase +final class QueryBuilderTest extends FrameworkIntegrationTestCase { public function test_select(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); - model(TestUserModel::class)->create(name: 'Frieren'); - model(TestUserModel::class)->create(name: 'Fern'); - model(TestUserModelWithoutId::class)->create(name: 'Stark'); + query(TestUserModel::class)->create(name: 'Frieren'); + query(TestUserModel::class)->create(name: 'Fern'); + query(TestUserModelWithoutId::class)->create(name: 'Stark'); - $builderWithId = model(TestUserModel::class)->select(); - $builderWithoutId = model(TestUserModelWithoutId::class)->select(); + $builderWithId = query(TestUserModel::class)->select(); + $builderWithoutId = query(TestUserModelWithoutId::class)->select(); $this->assertInstanceOf(SelectQueryBuilder::class, $builderWithId); $this->assertInstanceOf(SelectQueryBuilder::class, $builderWithoutId); @@ -41,7 +41,7 @@ public function test_select(): void $this->assertInstanceOf(TestUserModelWithoutId::class, $resultsWithoutId[0]); $this->assertSame('Stark', $resultsWithoutId[0]->name); - $builderWithSpecificColumns = model(TestUserModel::class)->select('name'); + $builderWithSpecificColumns = query(TestUserModel::class)->select('name'); $this->assertInstanceOf(SelectQueryBuilder::class, $builderWithSpecificColumns); $resultsWithSpecificColumns = $builderWithSpecificColumns->all(); @@ -55,8 +55,8 @@ public function test_insert(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); - $builderWithId = model(TestUserModel::class)->insert(name: 'Frieren'); - $builderWithoutId = model(TestUserModelWithoutId::class)->insert(name: 'Stark'); + $builderWithId = query(TestUserModel::class)->insert(name: 'Frieren'); + $builderWithoutId = query(TestUserModelWithoutId::class)->insert(name: 'Stark'); $this->assertInstanceOf(InsertQueryBuilder::class, $builderWithId); $this->assertInstanceOf(InsertQueryBuilder::class, $builderWithoutId); @@ -66,11 +66,11 @@ public function test_insert(): void $this->assertNull($builderWithoutId->execute()); - $retrieved = model(TestUserModel::class)->get($insertedId); + $retrieved = query(TestUserModel::class)->get($insertedId); $this->assertNotNull($retrieved); $this->assertSame('Frieren', $retrieved->name); - $starkRecords = model(TestUserModelWithoutId::class)->select()->where('name', 'Stark')->all(); + $starkRecords = query(TestUserModelWithoutId::class)->select()->where('name', 'Stark')->all(); $this->assertCount(1, $starkRecords); $this->assertSame('Stark', $starkRecords[0]->name); } @@ -79,11 +79,11 @@ public function test_update(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); - $createdWithId = model(TestUserModel::class)->create(name: 'Frieren'); - model(TestUserModelWithoutId::class)->create(name: 'Stark'); + $createdWithId = query(TestUserModel::class)->create(name: 'Frieren'); + query(TestUserModelWithoutId::class)->create(name: 'Stark'); - $builderWithId = model(TestUserModel::class)->update(name: 'Eisen'); - $builderWithoutId = model(TestUserModelWithoutId::class)->update(name: 'Fern'); + $builderWithId = query(TestUserModel::class)->update(name: 'Eisen'); + $builderWithoutId = query(TestUserModelWithoutId::class)->update(name: 'Fern'); $this->assertInstanceOf(UpdateQueryBuilder::class, $builderWithId); $this->assertInstanceOf(UpdateQueryBuilder::class, $builderWithoutId); @@ -91,11 +91,11 @@ public function test_update(): void $builderWithId->where('id', $createdWithId->id)->execute(); $builderWithoutId->where('name', 'Stark')->execute(); - $retrieved = model(TestUserModel::class)->get($createdWithId->id); + $retrieved = query(TestUserModel::class)->get($createdWithId->id); $this->assertNotNull($retrieved); $this->assertSame('Eisen', $retrieved->name); - $starkRecords = model(TestUserModelWithoutId::class)->select()->where('name', 'Fern')->all(); + $starkRecords = query(TestUserModelWithoutId::class)->select()->where('name', 'Fern')->all(); $this->assertCount(1, $starkRecords); $this->assertSame('Fern', $starkRecords[0]->name); } @@ -104,13 +104,13 @@ public function test_delete(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); - $createdWithId = model(TestUserModel::class)->create(name: 'Frieren'); - model(TestUserModel::class)->create(name: 'Fern'); - model(TestUserModelWithoutId::class)->create(name: 'Stark'); - model(TestUserModelWithoutId::class)->create(name: 'Eisen'); + $createdWithId = query(TestUserModel::class)->create(name: 'Frieren'); + query(TestUserModel::class)->create(name: 'Fern'); + query(TestUserModelWithoutId::class)->create(name: 'Stark'); + query(TestUserModelWithoutId::class)->create(name: 'Eisen'); - $builderWithId = model(TestUserModel::class)->delete(); - $builderWithoutId = model(TestUserModelWithoutId::class)->delete(); + $builderWithId = query(TestUserModel::class)->delete(); + $builderWithoutId = query(TestUserModelWithoutId::class)->delete(); $this->assertInstanceOf(DeleteQueryBuilder::class, $builderWithId); $this->assertInstanceOf(DeleteQueryBuilder::class, $builderWithoutId); @@ -118,11 +118,11 @@ public function test_delete(): void $builderWithId->where('id', $createdWithId->id)->execute(); $builderWithoutId->where('name', 'Stark')->execute(); - $remainingWithId = model(TestUserModel::class)->select()->all(); + $remainingWithId = query(TestUserModel::class)->select()->all(); $this->assertCount(1, $remainingWithId); $this->assertSame('Fern', $remainingWithId[0]->name); - $remainingWithoutId = model(TestUserModelWithoutId::class)->select()->all(); + $remainingWithoutId = query(TestUserModelWithoutId::class)->select()->all(); $this->assertCount(1, $remainingWithoutId); $this->assertSame('Eisen', $remainingWithoutId[0]->name); } @@ -131,14 +131,14 @@ public function test_count(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); - model(TestUserModel::class)->create(name: 'Frieren'); - model(TestUserModel::class)->create(name: 'Fern'); - model(TestUserModel::class)->create(name: 'Stark'); - model(TestUserModelWithoutId::class)->create(name: 'Eisen'); - model(TestUserModelWithoutId::class)->create(name: 'Heiter'); + query(TestUserModel::class)->create(name: 'Frieren'); + query(TestUserModel::class)->create(name: 'Fern'); + query(TestUserModel::class)->create(name: 'Stark'); + query(TestUserModelWithoutId::class)->create(name: 'Eisen'); + query(TestUserModelWithoutId::class)->create(name: 'Heiter'); - $builderWithId = model(TestUserModel::class)->count(); - $builderWithoutId = model(TestUserModelWithoutId::class)->count(); + $builderWithId = query(TestUserModel::class)->count(); + $builderWithoutId = query(TestUserModelWithoutId::class)->count(); $this->assertInstanceOf(CountQueryBuilder::class, $builderWithId); $this->assertInstanceOf(CountQueryBuilder::class, $builderWithoutId); @@ -149,8 +149,8 @@ public function test_count(): void $this->assertSame(3, $countWithId); $this->assertSame(2, $countWithoutId); - $countFilteredWithId = model(TestUserModel::class)->count()->where('name', 'Frieren')->execute(); - $countFilteredWithoutId = model(TestUserModelWithoutId::class)->count()->where('name', 'Eisen')->execute(); + $countFilteredWithId = query(TestUserModel::class)->count()->where('name', 'Frieren')->execute(); + $countFilteredWithoutId = query(TestUserModelWithoutId::class)->count()->where('name', 'Eisen')->execute(); $this->assertSame(1, $countFilteredWithId); $this->assertSame(1, $countFilteredWithoutId); @@ -158,8 +158,8 @@ public function test_count(): void public function test_new(): void { - $modelWithId = model(TestUserModel::class)->new(name: 'Frieren'); - $modelWithoutId = model(TestUserModelWithoutId::class)->new(name: 'Fern'); + $modelWithId = query(TestUserModel::class)->new(name: 'Frieren'); + $modelWithoutId = query(TestUserModelWithoutId::class)->new(name: 'Fern'); $this->assertInstanceOf(TestUserModel::class, $modelWithId); $this->assertSame('Frieren', $modelWithId->name); @@ -168,12 +168,12 @@ public function test_new(): void $this->assertSame('Fern', $modelWithoutId->name); } - public function test_get_with_id_model(): void + public function test_get_with_id_query(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); - $created = model(TestUserModel::class)->create(name: 'Himmel'); - $retrieved = model(TestUserModel::class)->get($created->id); + $created = query(TestUserModel::class)->create(name: 'Himmel'); + $retrieved = query(TestUserModel::class)->get($created->id); $this->assertNotNull($retrieved); $this->assertSame('Himmel', $retrieved->name); @@ -189,21 +189,21 @@ public function test_get_throws_for_model_without_id(): void "`Tests\Tempest\Integration\Database\Builder\TestUserModelWithoutId` does not have a primary column defined, which is required for the `get` method.", ); - model(TestUserModelWithoutId::class)->get(1); + query(TestUserModelWithoutId::class)->get(1); } public function test_all(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); - model(TestUserModel::class)->create(name: 'Fern'); - model(TestUserModel::class)->create(name: 'Stark'); + query(TestUserModel::class)->create(name: 'Fern'); + query(TestUserModel::class)->create(name: 'Stark'); - model(TestUserModelWithoutId::class)->create(name: 'Eisen'); - model(TestUserModelWithoutId::class)->create(name: 'Heiter'); + query(TestUserModelWithoutId::class)->create(name: 'Eisen'); + query(TestUserModelWithoutId::class)->create(name: 'Heiter'); - $allWithId = model(TestUserModel::class)->all(); - $allWithoutId = model(TestUserModelWithoutId::class)->all(); + $allWithId = query(TestUserModel::class)->all(); + $allWithoutId = query(TestUserModelWithoutId::class)->all(); $this->assertCount(2, $allWithId); $this->assertInstanceOf(TestUserModel::class, $allWithId[0]); @@ -218,14 +218,14 @@ public function test_find(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); - model(TestUserModel::class)->create(name: 'Frieren'); - model(TestUserModel::class)->create(name: 'Fern'); + query(TestUserModel::class)->create(name: 'Frieren'); + query(TestUserModel::class)->create(name: 'Fern'); - model(TestUserModelWithoutId::class)->create(name: 'Ubel'); - model(TestUserModelWithoutId::class)->create(name: 'Land'); + query(TestUserModelWithoutId::class)->create(name: 'Ubel'); + query(TestUserModelWithoutId::class)->create(name: 'Land'); - $builderWithId = model(TestUserModel::class)->find(name: 'Frieren'); - $builderWithoutId = model(TestUserModelWithoutId::class)->find(name: 'Ubel'); + $builderWithId = query(TestUserModel::class)->find(name: 'Frieren'); + $builderWithoutId = query(TestUserModelWithoutId::class)->find(name: 'Ubel'); $this->assertInstanceOf(SelectQueryBuilder::class, $builderWithId); $this->assertInstanceOf(SelectQueryBuilder::class, $builderWithoutId); @@ -244,8 +244,8 @@ public function test_create(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); - $createdWithId = model(TestUserModel::class)->create(name: 'Ubel'); - $createdWithoutId = model(TestUserModelWithoutId::class)->create(name: 'Serie'); + $createdWithId = query(TestUserModel::class)->create(name: 'Ubel'); + $createdWithoutId = query(TestUserModelWithoutId::class)->create(name: 'Serie'); $this->assertInstanceOf(TestUserModel::class, $createdWithId); $this->assertInstanceOf(PrimaryKey::class, $createdWithId->id); @@ -259,15 +259,15 @@ public function test_find_or_new_finds_existing(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); - $existingWithId = model(TestUserModel::class)->create(name: 'Serie'); - $existingWithoutId = model(TestUserModelWithoutId::class)->create(name: 'Macht'); + $existingWithId = query(TestUserModel::class)->create(name: 'Serie'); + $existingWithoutId = query(TestUserModelWithoutId::class)->create(name: 'Macht'); - $resultWithId = model(TestUserModel::class)->findOrNew( + $resultWithId = query(TestUserModel::class)->findOrNew( find: ['name' => 'Serie'], update: ['name' => 'Updated Serie'], ); - $resultWithoutId = model(TestUserModelWithoutId::class)->findOrNew( + $resultWithoutId = query(TestUserModelWithoutId::class)->findOrNew( find: ['name' => 'Macht'], update: ['name' => 'Updated Macht'], ); @@ -284,12 +284,12 @@ public function test_find_or_new_creates_new(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); - $resultWithId = model(TestUserModel::class)->findOrNew( + $resultWithId = query(TestUserModel::class)->findOrNew( find: ['name' => 'NonExistent'], update: ['name' => 'Updated Name'], ); - $resultWithoutId = model(TestUserModelWithoutId::class)->findOrNew( + $resultWithoutId = query(TestUserModelWithoutId::class)->findOrNew( find: ['name' => 'NonExistent'], update: ['name' => 'Updated Name'], ); @@ -306,9 +306,9 @@ public function test_update_or_create_updates_existing(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); - $existingWithId = model(TestUserModel::class)->create(name: 'Qual'); + $existingWithId = query(TestUserModel::class)->create(name: 'Qual'); - $resultWithId = model(TestUserModel::class)->updateOrCreate( + $resultWithId = query(TestUserModel::class)->updateOrCreate( find: ['name' => 'Qual'], update: ['name' => 'Updated Qual'], ); @@ -322,7 +322,7 @@ public function test_update_or_create_creates_new(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); - $resultWithId = model(TestUserModel::class)->updateOrCreate( + $resultWithId = query(TestUserModel::class)->updateOrCreate( find: ['name' => 'NonExistent'], update: ['name' => 'Aura'], ); @@ -336,8 +336,8 @@ public function test_get_with_string_id(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); - $created = model(TestUserModel::class)->create(name: 'Heiter'); - $retrieved = model(TestUserModel::class)->get((string) $created->id->value); + $created = query(TestUserModel::class)->create(name: 'Heiter'); + $retrieved = query(TestUserModel::class)->get((string) $created->id->value); $this->assertNotNull($retrieved); $this->assertSame('Heiter', $retrieved->name); @@ -348,8 +348,8 @@ public function test_get_with_int_id(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); - $created = model(TestUserModel::class)->create(name: 'Eisen'); - $retrieved = model(TestUserModel::class)->get($created->id->value); + $created = query(TestUserModel::class)->create(name: 'Eisen'); + $retrieved = query(TestUserModel::class)->get($created->id->value); $this->assertNotNull($retrieved); $this->assertSame('Eisen', $retrieved->name); @@ -360,7 +360,7 @@ public function test_get_returns_null_for_non_existent_id(): void { $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); - $result = model(TestUserModel::class)->get(new PrimaryKey(999)); + $result = query(TestUserModel::class)->get(new PrimaryKey(999)); $this->assertNull($result); } @@ -374,7 +374,7 @@ public function test_find_by_id_throws_for_model_without_id(): void "`Tests\Tempest\Integration\Database\Builder\TestUserModelWithoutId` does not have a primary column defined, which is required for the `findById` method.", ); - model(TestUserModelWithoutId::class)->findById(1); + query(TestUserModelWithoutId::class)->findById(1); } public function test_update_or_create_throws_for_model_without_id(): void @@ -386,7 +386,7 @@ public function test_update_or_create_throws_for_model_without_id(): void "`Tests\Tempest\Integration\Database\Builder\TestUserModelWithoutId` does not have a primary column defined, which is required for the `updateOrCreate` method.", ); - model(TestUserModelWithoutId::class)->updateOrCreate( + query(TestUserModelWithoutId::class)->updateOrCreate( find: ['name' => 'Denken'], update: ['name' => 'Updated Denken'], ); @@ -396,13 +396,13 @@ public function test_custom_primary_key_name(): void { $this->migrate(CreateMigrationsTable::class, TestModelWithCustomPrimaryKeyMigration::class); - $created = model(TestUserModelWithCustomPrimaryKey::class)->create(name: 'Fern'); + $created = query(TestUserModelWithCustomPrimaryKey::class)->create(name: 'Fern'); $this->assertInstanceOf(TestUserModelWithCustomPrimaryKey::class, $created); $this->assertInstanceOf(PrimaryKey::class, $created->uuid); $this->assertSame('Fern', $created->name); - $retrieved = model(TestUserModelWithCustomPrimaryKey::class)->get($created->uuid); + $retrieved = query(TestUserModelWithCustomPrimaryKey::class)->get($created->uuid); $this->assertNotNull($retrieved); $this->assertSame('Fern', $retrieved->name); $this->assertTrue($created->uuid->equals($retrieved->uuid)); @@ -412,9 +412,9 @@ public function test_custom_primary_key_update_or_create(): void { $this->migrate(CreateMigrationsTable::class, TestModelWithCustomPrimaryKeyMigration::class); - $original = model(TestUserModelWithCustomPrimaryKey::class)->create(name: 'Stark'); + $original = query(TestUserModelWithCustomPrimaryKey::class)->create(name: 'Stark'); - $updated = model(TestUserModelWithCustomPrimaryKey::class)->updateOrCreate( + $updated = query(TestUserModelWithCustomPrimaryKey::class)->updateOrCreate( find: ['name' => 'Stark'], update: ['name' => 'Stark the Strong'], ); diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderDtoTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderDtoTest.php index 88c9cc41d..c4d8c3973 100644 --- a/tests/Integration/Database/Builder/UpdateQueryBuilderDtoTest.php +++ b/tests/Integration/Database/Builder/UpdateQueryBuilderDtoTest.php @@ -12,7 +12,7 @@ use Tempest\Mapper\SerializeAs; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\Database\model; +use function Tempest\Database\query; final class UpdateQueryBuilderDtoTest extends FrameworkIntegrationTestCase { @@ -35,13 +35,13 @@ public function down(): ?QueryStatement } }); - $user = model(UserWithDtoSettings::class) + $user = query(UserWithDtoSettings::class) ->create( name: 'John', settings: new DtoSettings(DtoTheme::LIGHT), ); - model(UserWithDtoSettings::class) + query(UserWithDtoSettings::class) ->update( name: 'Jane', settings: new DtoSettings(DtoTheme::DARK), @@ -49,7 +49,7 @@ public function down(): ?QueryStatement ->where('id', $user->id) ->execute(); - $updatedUser = model(UserWithDtoSettings::class)->get($user->id); + $updatedUser = query(UserWithDtoSettings::class)->get($user->id); $this->assertSame('Jane', $updatedUser->name); $this->assertInstanceOf(DtoSettings::class, $updatedUser->settings); diff --git a/tests/Integration/Database/CustomPrimaryKeyRelationshipLoadingTest.php b/tests/Integration/Database/CustomPrimaryKeyRelationshipLoadingTest.php index e82a5415b..33eed93fc 100644 --- a/tests/Integration/Database/CustomPrimaryKeyRelationshipLoadingTest.php +++ b/tests/Integration/Database/CustomPrimaryKeyRelationshipLoadingTest.php @@ -16,7 +16,7 @@ use Tempest\Database\Table; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\Database\model; +use function Tempest\Database\query; /** * @internal @@ -31,12 +31,12 @@ public function test_has_one_relationship_with_uuid_primary_keys(): void CreateGrimoireWithUuidMigration::class, ); - $mage = model(MageWithUuid::class)->create( + $mage = query(MageWithUuid::class)->create( name: 'Frieren', element: 'Time', ); - $grimoire = model(GrimoireWithUuid::class)->create( + $grimoire = query(GrimoireWithUuid::class)->create( mage_uuid: $mage->uuid->value, title: 'Ancient Time Magic Compendium', spells_count: 847, @@ -45,7 +45,7 @@ public function test_has_one_relationship_with_uuid_primary_keys(): void $this->assertInstanceOf(PrimaryKey::class, $mage->uuid); $this->assertInstanceOf(PrimaryKey::class, $grimoire->uuid); - $loadedMage = model(MageWithUuid::class)->get($mage->uuid); + $loadedMage = query(MageWithUuid::class)->get($mage->uuid); $loadedMage->load('grimoire'); $this->assertInstanceOf(GrimoireWithUuid::class, $loadedMage->grimoire); @@ -63,26 +63,26 @@ public function test_has_many_relationship_with_uuid_primary_keys(): void CreateSpellWithUuidMigration::class, ); - $mage = model(MageWithUuid::class)->create( + $mage = query(MageWithUuid::class)->create( name: 'Flamme', element: 'Fire', ); - $spell1 = model(SpellWithUuid::class)->create( + $spell1 = query(SpellWithUuid::class)->create( mage_uuid: $mage->uuid->value, name: 'Zoltraak', power_level: 95, mana_cost: 150, ); - $spell2 = model(SpellWithUuid::class)->create( + $spell2 = query(SpellWithUuid::class)->create( mage_uuid: $mage->uuid->value, name: 'Volzandia', power_level: 87, mana_cost: 120, ); - $loadedMage = model(MageWithUuid::class)->get($mage->uuid); + $loadedMage = query(MageWithUuid::class)->get($mage->uuid); $loadedMage->load('spells'); $this->assertCount(2, $loadedMage->spells); @@ -106,19 +106,19 @@ public function test_belongs_to_relationship_with_uuid_primary_keys(): void CreateSpellWithUuidMigration::class, ); - $mage = model(MageWithUuid::class)->create( + $mage = query(MageWithUuid::class)->create( name: 'Serie', element: 'Ancient', ); - $spell = model(SpellWithUuid::class)->create( + $spell = query(SpellWithUuid::class)->create( mage_uuid: $mage->uuid->value, name: 'Goddess Magic', power_level: 100, mana_cost: 999, ); - $loadedSpell = model(SpellWithUuid::class)->get($spell->uuid); + $loadedSpell = query(SpellWithUuid::class)->get($spell->uuid); $loadedSpell->load('mage'); $this->assertInstanceOf(MageWithUuid::class, $loadedSpell->mage); @@ -136,25 +136,25 @@ public function test_nested_relationship_loading_with_uuid_primary_keys(): void CreateSpellWithUuidMigration::class, ); - $mage = model(MageWithUuid::class)->create( + $mage = query(MageWithUuid::class)->create( name: 'Fern', element: 'Combat', ); - $grimoire = model(GrimoireWithUuid::class)->create( + $grimoire = query(GrimoireWithUuid::class)->create( mage_uuid: $mage->uuid->value, title: 'Combat Magic Fundamentals', spells_count: 42, ); - $spell = model(SpellWithUuid::class)->create( + $spell = query(SpellWithUuid::class)->create( mage_uuid: $mage->uuid->value, name: 'Basic Attack Magic', power_level: 75, mana_cost: 50, ); - $loadedMage = model(MageWithUuid::class)->get($mage->uuid); + $loadedMage = query(MageWithUuid::class)->get($mage->uuid); $loadedMage->load('grimoire', 'spells'); $this->assertInstanceOf(GrimoireWithUuid::class, $loadedMage->grimoire); @@ -163,7 +163,7 @@ public function test_nested_relationship_loading_with_uuid_primary_keys(): void $this->assertCount(1, $loadedMage->spells); $this->assertSame('Basic Attack Magic', $loadedMage->spells[0]->name); - $loadedSpell = model(SpellWithUuid::class)->get($spell->uuid); + $loadedSpell = query(SpellWithUuid::class)->get($spell->uuid); $loadedSpell->load('mage.grimoire'); $this->assertInstanceOf(MageWithUuid::class, $loadedSpell->mage); @@ -180,26 +180,26 @@ public function test_relationship_with_custom_foreign_key_naming(): void CreateArtifactWithUuidMigration::class, ); - $mage = model(MageWithUuid::class)->create( + $mage = query(MageWithUuid::class)->create( name: 'Himmel', element: 'Hero', ); - $artifact = model(ArtifactWithUuid::class)->create( + $artifact = query(ArtifactWithUuid::class)->create( owner_uuid: $mage->uuid->value, name: 'Hero Sword', rarity: 'Legendary', enchantment_level: 10, ); - $loadedMage = model(MageWithUuid::class)->get($mage->uuid); + $loadedMage = query(MageWithUuid::class)->get($mage->uuid); $loadedMage->load('artifacts'); $this->assertCount(1, $loadedMage->artifacts); $this->assertSame('Hero Sword', $loadedMage->artifacts[0]->name); $this->assertSame('Legendary', $loadedMage->artifacts[0]->rarity); - $loadedArtifact = model(ArtifactWithUuid::class)->get($artifact->uuid); + $loadedArtifact = query(ArtifactWithUuid::class)->get($artifact->uuid); $loadedArtifact->load('owner'); $this->assertInstanceOf(MageWithUuid::class, $loadedArtifact->owner); @@ -215,27 +215,27 @@ public function test_relationship_loading_preserves_uuid_integrity(): void CreateSpellWithUuidMigration::class, ); - $mage1 = model(MageWithUuid::class)->create(name: 'Stark', element: 'Axe'); - $mage2 = model(MageWithUuid::class)->create(name: 'Eisen', element: 'Monk'); + $mage1 = query(MageWithUuid::class)->create(name: 'Stark', element: 'Axe'); + $mage2 = query(MageWithUuid::class)->create(name: 'Eisen', element: 'Monk'); - $spell1 = model(SpellWithUuid::class)->create( + $spell1 = query(SpellWithUuid::class)->create( mage_uuid: $mage1->uuid->value, name: 'Axe Technique', power_level: 80, mana_cost: 30, ); - $spell2 = model(SpellWithUuid::class)->create( + $spell2 = query(SpellWithUuid::class)->create( mage_uuid: $mage2->uuid->value, name: 'Warrior Meditation', power_level: 60, mana_cost: 20, ); - $loadedMage1 = model(MageWithUuid::class)->get($mage1->uuid); + $loadedMage1 = query(MageWithUuid::class)->get($mage1->uuid); $loadedMage1->load('spells'); - $loadedMage2 = model(MageWithUuid::class)->get($mage2->uuid); + $loadedMage2 = query(MageWithUuid::class)->get($mage2->uuid); $loadedMage2->load('spells'); $this->assertCount(1, $loadedMage1->spells); @@ -259,24 +259,24 @@ public function test_automatic_uuid_primary_key_detection(): void CreateSpellSimpleMigration::class, ); - $mage = model(MageSimple::class)->create( + $mage = query(MageSimple::class)->create( name: 'Fern', element: 'Combat', ); - $spell = model(SpellSimple::class)->create( + $spell = query(SpellSimple::class)->create( mage_uuid: $mage->uuid->value, name: 'Cutting Magic', power_level: 90, ); - $loadedMage = model(MageSimple::class)->get($mage->uuid); + $loadedMage = query(MageSimple::class)->get($mage->uuid); $loadedMage->load('spells'); $this->assertCount(1, $loadedMage->spells); $this->assertSame('Cutting Magic', $loadedMage->spells[0]->name); - $loadedSpell = model(SpellSimple::class)->get($spell->uuid); + $loadedSpell = query(SpellSimple::class)->get($spell->uuid); $loadedSpell->load('mage'); $this->assertInstanceOf(MageSimple::class, $loadedSpell->mage); diff --git a/tests/Integration/Database/ModelsWithoutIdTest.php b/tests/Integration/Database/ModelsWithoutIdTest.php index 7fb04cbf6..f7ffa5257 100644 --- a/tests/Integration/Database/ModelsWithoutIdTest.php +++ b/tests/Integration/Database/ModelsWithoutIdTest.php @@ -16,7 +16,6 @@ use Tempest\Database\Table; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\Database\model; use function Tempest\Database\query; /** @@ -35,7 +34,7 @@ public function test_save_creates_new_record_for_model_without_id(): void $this->assertSame('INFO', $savedLog->level); $this->assertSame('Frieren discovered ancient magic', $savedLog->message); - $allLogs = model(LogEntry::class)->all(); + $allLogs = query(LogEntry::class)->all(); $this->assertCount(1, $allLogs); $this->assertSame('INFO', $allLogs[0]->level); } @@ -51,7 +50,7 @@ public function test_save_always_inserts_for_models_without_id(): void $log->message = 'Modified message'; $log->save(); - $allLogs = model(LogEntry::class)->all(); + $allLogs = query(LogEntry::class)->all(); $this->assertCount(2, $allLogs); $this->assertSame('Original message', $allLogs[0]->message); $this->assertSame('Modified message', $allLogs[1]->message); @@ -61,7 +60,7 @@ public function test_update_model_without_id_with_specific_conditions(): void { $this->migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); - model(LogEntry::class)->create( + query(LogEntry::class)->create( level: 'INFO', message: 'Himmel was here', context: 'memory', @@ -72,7 +71,7 @@ public function test_update_model_without_id_with_specific_conditions(): void ->where('context', 'memory') ->execute(); - $updatedLog = model(LogEntry::class) + $updatedLog = query(LogEntry::class) ->find(context: 'memory') ->first(); @@ -84,26 +83,26 @@ public function test_delete_operations_on_models_without_id(): void { $this->migrate(CreateMigrationsTable::class, CreateLogEntryMigration::class); - model(LogEntry::class)->create( + query(LogEntry::class)->create( level: 'TEMP', message: 'Temporary debug info', context: 'debug', ); - model(LogEntry::class)->create( + query(LogEntry::class)->create( level: 'IMPORTANT', message: 'Frieren awakens', context: 'story', ); - $this->assertCount(2, model(LogEntry::class)->all()); + $this->assertCount(2, query(LogEntry::class)->all()); query(LogEntry::class) ->delete() ->where('level', 'TEMP') ->execute(); - $remaining = model(LogEntry::class)->all(); + $remaining = query(LogEntry::class)->all(); $this->assertCount(1, $remaining); $this->assertSame('IMPORTANT', $remaining[0]->level); } @@ -112,7 +111,7 @@ public function test_model_without_id_with_unique_constraints(): void { $this->migrate(CreateMigrationsTable::class, CreateCacheEntryMigration::class); - model(CacheEntry::class)->create( + query(CacheEntry::class)->create( cache_key: 'spell_fire', cache_value: 'flame_magic_data', ttl: 3600, @@ -123,7 +122,7 @@ public function test_model_without_id_with_unique_constraints(): void ->where('cache_key', 'spell_fire') ->execute(); - $updatedData = model(CacheEntry::class) + $updatedData = query(CacheEntry::class) ->find(cache_key: 'spell_fire') ->first(); @@ -137,7 +136,7 @@ public function test_relationship_methods_throw_for_models_without_id(): void $this->expectException(ModelDidNotHavePrimaryColumn::class); $this->expectExceptionMessage('does not have a primary column defined, which is required for the `findById` method'); - model(LogEntry::class)->findById(id: 1); + query(LogEntry::class)->findById(id: 1); } public function test_get_method_throws_for_models_without_id(): void @@ -147,7 +146,7 @@ public function test_get_method_throws_for_models_without_id(): void $this->expectException(ModelDidNotHavePrimaryColumn::class); $this->expectExceptionMessage('does not have a primary column defined, which is required for the `get` method'); - model(LogEntry::class)->get(id: 1); + query(LogEntry::class)->get(id: 1); } public function test_update_or_create_throws_for_models_without_id(): void @@ -157,7 +156,7 @@ public function test_update_or_create_throws_for_models_without_id(): void $this->expectException(ModelDidNotHavePrimaryColumn::class); $this->expectExceptionMessage('does not have a primary column defined, which is required for the `updateOrCreate` method'); - model(LogEntry::class)->updateOrCreate( + query(LogEntry::class)->updateOrCreate( find: ['level' => 'INFO'], update: ['message' => 'test'], ); @@ -177,7 +176,7 @@ public function test_model_with_mixed_id_and_non_id_properties(): void $this->assertInstanceOf(PrimaryKey::class, $mixed->id); $this->assertSame('test', $mixed->regular_field); - $all = model(MixedModel::class)->all(); + $all = query(MixedModel::class)->all(); $this->assertCount(1, $all); $this->assertInstanceOf(PrimaryKey::class, $all[0]->id); $this->assertSame('test', $all[0]->regular_field); @@ -219,7 +218,7 @@ public function test_refresh_works_for_models_with_id(): void { $this->migrate(CreateMigrationsTable::class, CreateMixedModelMigration::class); - $mixed = model(MixedModel::class)->create( + $mixed = query(MixedModel::class)->create( regular_field: 'original', another_field: 'data', ); @@ -239,7 +238,7 @@ public function test_load_works_for_models_with_id(): void { $this->migrate(CreateMigrationsTable::class, CreateMixedModelMigration::class); - $mixed = model(MixedModel::class)->create(regular_field: 'test', another_field: 'data'); + $mixed = query(MixedModel::class)->create(regular_field: 'test', another_field: 'data'); $result = $mixed->load(); $this->assertSame($mixed, $result); @@ -254,12 +253,12 @@ public function test_load_with_relation_works_for_models_with_id(): void CreateTestProfileMigration::class, ); - $user = model(TestUser::class)->create( + $user = query(TestUser::class)->create( name: 'Frieren', email: 'frieren@magic.elf', ); - model(TestProfile::class)->create( + query(TestProfile::class)->create( user: $user, bio: 'Ancient elf mage who loves magic and collecting spells', age: 1000, From 0922233e1d2fd5ebdfba0e95c74a782935071018 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 7 Aug 2025 15:38:08 +0200 Subject: [PATCH 40/51] fix(database): fix default foreign key names --- .../src/Builder/QueryBuilders/InsertQueryBuilder.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index 25bba7755..f15ef74bd 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -135,11 +135,7 @@ private function removeTablePrefix(string $columnName): string private function getDefaultForeignKeyName(): string { - return str($this->model->getName()) - ->afterLast('\\') - ->lower() - ->append('_', $this->model->getPrimaryKey()) - ->toString(); + return Intl\singularize($this->model->getTableName()) . '_' . $this->model->getPrimaryKey(); } private function convertObjectToArray(object $object, array $excludeProperties = []): array From ba4b5d5efc53d121b43029e242b7c628d9838e09 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 7 Aug 2025 16:03:19 +0200 Subject: [PATCH 41/51] refactor(database): support relation-only updates --- .../QueryBuilders/UpdateQueryBuilder.php | 28 +++++++++++++++-- packages/database/src/IsDatabaseModel.php | 16 +++++++--- .../Database/Builder/IsDatabaseModelTest.php | 31 ++++++++++++++++--- 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index 8e6e96f1c..51b13ef0d 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -63,7 +63,14 @@ public function __construct( */ public function execute(mixed ...$bindings): ?PrimaryKey { - $result = $this->build()->execute(...$bindings); + // For relation-only updates, we need to resolve values to set up + // callbacks but we won't actually execute an UPDATE statement. + if ($this->hasOnlyRelationUpdates()) { + $this->resolveValuesToUpdate(); + $result = $this->primaryKeyForRelations; + } else { + $result = $this->build()->execute(...$bindings); + } // Execute after callbacks for relation updates if ($this->model->hasPrimaryKey() && $this->after !== [] && $this->primaryKeyForRelations !== null) { @@ -121,7 +128,7 @@ public function toRawSql(): ImmutableString public function build(mixed ...$bindings): Query { - $values = $this->resolveValues(); + $values = $this->resolveValuesToUpdate(); if ($this->model->hasPrimaryKey()) { unset($values[$this->model->getPrimaryKey()]); @@ -148,7 +155,7 @@ public function build(mixed ...$bindings): Query return new Query($this->update, $allBindings)->onDatabase($this->onDatabase); } - private function resolveValues(): ImmutableArray + private function resolveValuesToUpdate(): ImmutableArray { if ($this->hasRelationUpdates()) { $this->validateRelationUpdateConstraints(); @@ -527,6 +534,21 @@ public function where(string $field, mixed $value, string|WhereOperator $operato return $this; } + private function hasOnlyRelationUpdates(): bool + { + if (! $this->hasRelationUpdates()) { + return false; + } + + foreach (array_keys($this->values) as $field) { + if (! $this->isRelationField($field)) { + return false; + } + } + + return true; + } + private function hasRelationUpdates(): bool { foreach (array_keys($this->values) as $field) { diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 5c8c69c8e..ef05ee472 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -205,7 +205,7 @@ public function load(string ...$relations): self } /** - * Saves the model to the database. + * Saves the model to the database. If the model has no primary key, this method always inserts. */ public function save(): self { @@ -250,17 +250,23 @@ public function save(): self */ public function update(mixed ...$params): self { - inspect(self::class)->validate(...$params); + $model = inspect($this); - foreach ($params as $key => $value) { - $this->{$key} = $value; + if (! $model->hasPrimaryKey()) { + throw Exceptions\ModelDidNotHavePrimaryColumn::neededForMethod($this, 'update'); } + $model->validate(...$params); + query($this) ->update(...$params) - ->build() + ->where($model->getPrimaryKey(), $model->getPrimaryKeyValue()) ->execute(); + foreach ($params as $key => $value) { + $this->{$key} = $value; + } + return $this; } diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index bcc766516..67d4a1bf0 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -658,7 +658,7 @@ public function test_model_create_with_has_many_relations(): void $this->assertInstanceOf(PrimaryKey::class, $user->id); $posts = TestPost::select() - ->where('testuser_id', $user->id->value) + ->where('test_user_id', $user->id->value) ->all(); $this->assertCount(2, $posts); @@ -667,6 +667,29 @@ public function test_model_create_with_has_many_relations(): void $this->assertSame('foo', $posts[1]->title); $this->assertSame('bar', $posts[1]->body); } + + public function test_model_update_with_only_relations(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateTestUserMigration::class, + CreateTestPostMigration::class, + ); + + $user = TestUser::create(name: 'Frieren'); + $user->update(posts: [ + new TestPost('hello', 'world'), + ]); + + $posts = TestPost::select() + ->where('test_user_id', $user->id->value) + ->all(); + + $this->assertCount(1, $posts); + $this->assertSame('hello', $posts[0]->title); + $this->assertSame('world', $posts[0]->body); + $this->assertSame('Frieren', $user->name); // Ensure name wasn't changed + } } final class Foo @@ -1048,7 +1071,7 @@ final class CreateTestUserMigration implements DatabaseMigration public function up(): QueryStatement { - return CreateTableStatement::forModel(TestUser::class) + return new CreateTableStatement('test_users') ->primary() ->text('name'); } @@ -1065,9 +1088,9 @@ final class CreateTestPostMigration implements DatabaseMigration public function up(): QueryStatement { - return CreateTableStatement::forModel(TestPost::class) + return new CreateTableStatement('test_posts') ->primary() - ->belongsTo('test_posts.testuser_id', 'test_users.id') + ->foreignId('test_user_id', constrainedOn: 'test_users') ->string('title') ->text('body'); } From 7b066d21cde8eaae351b22a299eb801bc4ee69fd Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 7 Aug 2025 16:03:44 +0200 Subject: [PATCH 42/51] fix(database): handle uninitialized pkey edge case --- .../database/src/Builder/ModelInspector.php | 10 +- .../Database/Builder/IsDatabaseModelTest.php | 225 +++++++----------- 2 files changed, 93 insertions(+), 142 deletions(-) diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index bcde5e671..eb5c44681 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -435,12 +435,16 @@ public function getPrimaryKeyValue(): ?PrimaryKey return null; } - $primaryKey = $this->getPrimaryKey(); + $primaryKeyProperty = $this->getPrimaryKeyProperty(); + + if ($primaryKeyProperty === null) { + return null; + } - if ($primaryKey === null) { + if (! $primaryKeyProperty->isInitialized($this->instance)) { return null; } - return $this->instance->{$primaryKey}; + return $primaryKeyProperty->getValue($this->instance); } } diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index 67d4a1bf0..fe4aa356f 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -9,6 +9,7 @@ use DateTimeImmutable; use Tempest\Database\BelongsTo; use Tempest\Database\DatabaseMigration; +use Tempest\Database\Exceptions\DeleteStatementWasInvalid; use Tempest\Database\Exceptions\RelationWasMissing; use Tempest\Database\Exceptions\ValueWasMissing; use Tempest\Database\HasMany; @@ -499,196 +500,115 @@ public function test_delete(): void $this->assertNotNull(Foo::get($bar->id)); } - public function test_property_with_carbon_type(): void + public function test_delete_via_model_class_with_where_conditions(): void { $this->migrate( CreateMigrationsTable::class, - CreateCarbonModelTable::class, + FooDatabaseMigration::class, ); - $this->container->get(CasterFactory::class)->addCaster(Carbon::class, CarbonCaster::class); - $this->container->get(SerializerFactory::class)->addSerializer(Carbon::class, CarbonSerializer::class); - - new CarbonModel(createdAt: new Carbon('2024-01-01'))->save(); + $foo1 = Foo::create(bar: 'delete_me'); + $foo2 = Foo::create(bar: 'keep_me'); + $foo3 = Foo::create(bar: 'delete_me'); - $model = CarbonModel::select()->first(); + query(Foo::class) + ->delete() + ->where('bar', 'delete_me') + ->execute(); - $this->assertTrue($model->createdAt->equalTo(new Carbon('2024-01-01'))); + $this->assertNull(Foo::get($foo1->id)); + $this->assertNotNull(Foo::get($foo2->id)); + $this->assertNull(Foo::get($foo3->id)); } - public function test_two_way_casters_on_models(): void + public function test_delete_via_model_instance_with_primary_key(): void { $this->migrate( CreateMigrationsTable::class, - CreateCasterModelTable::class, + FooDatabaseMigration::class, ); - new CasterModel( - date: new DateTimeImmutable('2025-01-01 00:00:00'), - array_prop: ['a', 'b', 'c'], - enum_prop: CasterEnum::BAR, - )->save(); + $foo1 = Foo::create(bar: 'first'); + $foo2 = Foo::create(bar: 'second'); + $foo1->delete(); - $model = CasterModel::select()->first(); - - $this->assertSame(new DateTimeImmutable('2025-01-01 00:00:00')->format('c'), $model->date->format('c')); - $this->assertSame(['a', 'b', 'c'], $model->array_prop); - $this->assertSame(CasterEnum::BAR, $model->enum_prop); + $this->assertNull(Foo::get($foo1->id)); + $this->assertNotNull(Foo::get($foo2->id)); + $this->assertSame('second', Foo::get($foo2->id)->bar); } - public function test_find(): void + public function test_delete_via_model_instance_without_primary_key(): void { $this->migrate( CreateMigrationsTable::class, - CreateATable::class, - CreateBTable::class, - CreateCTable::class, - ); - - new C(name: 'one')->save(); - new C(name: 'two')->save(); - - /** @var C[] */ - $valid = C::find(name: 'one')->all(); - - $this->assertCount(1, $valid); - $this->assertSame($valid[0]->name, 'one'); - - $invalid = C::find(name: 'three')->all(); - - $this->assertCount(0, $invalid); - } - - public function test_table_name_overrides(): void - { - $this->assertEquals('base_models', inspect(BaseModel::class)->getTableDefinition()->name); - $this->assertEquals('custom_attribute_table_name', inspect(AttributeTableNameModel::class)->getTableDefinition()->name); - $this->assertEquals('custom_static_method_table_name', inspect(StaticMethodTableNameModel::class)->getTableDefinition()->name); - } - - public function test_validation_on_create(): void - { - $this->expectException(ValidationFailed::class); - - ModelWithValidation::create( - index: -1, - ); - } - - public function test_validation_on_update(): void - { - $model = ModelWithValidation::new( - id: new PrimaryKey(1), - index: 1, - ); - - $this->expectException(ValidationFailed::class); - - $model->update( - index: -1, + CreateModelWithoutPrimaryKeyMigration::class, ); - } - - public function test_validation_on_new(): void - { - $model = ModelWithValidation::new( - index: 1, - ); - - $model->index = -1; - - $this->expectException(ValidationFailed::class); + $model = new ModelWithoutPrimaryKey(name: 'Frieren', description: 'Elf mage'); $model->save(); - } - public function test_skipped_validation(): void - { - try { - inspect(ModelWithValidation::class)->validate( - index: -1, - skip: -1, - ); - } catch (ValidationFailed $validationFailed) { - $this->assertStringContainsString(ModelWithValidation::class, $validationFailed->getMessage()); - $this->assertStringContainsString(ModelWithValidation::class, $validationFailed->subject); - $this->assertStringContainsString('index', array_key_first($validationFailed->failingRules)); - $this->assertInstanceOf(IsBetween::class, Arr\first($validationFailed->failingRules)[0]); - } + $this->expectException(DeleteStatementWasInvalid::class); + $model->delete(); } - public function test_date_field(): void + public function test_delete_via_model_class_without_primary_key(): void { $this->migrate( CreateMigrationsTable::class, - CreateDateTimeModelTable::class, + CreateModelWithoutPrimaryKeyMigration::class, ); - $id = query(DateTimeModel::class) - ->insert([ - 'phpDateTime' => new NativeDateTime('2024-01-01 00:00:00'), - 'tempestDateTime' => DateTime::parse('2024-01-01 00:00:00'), - ]) + query(ModelWithoutPrimaryKey::class)->create(name: 'Himmel', description: 'Hero'); + query(ModelWithoutPrimaryKey::class)->create(name: 'Heiter', description: 'Priest'); + query(ModelWithoutPrimaryKey::class)->create(name: 'Eisen', description: 'Warrior'); + + $this->assertCount(3, query(ModelWithoutPrimaryKey::class)->select()->all()); + + query(ModelWithoutPrimaryKey::class) + ->delete() + ->where('name', 'Himmel') ->execute(); - /** @var DateTimeModel $model */ - $model = query(DateTimeModel::class)->select()->where('id', $id)->first(); + $remaining = query(ModelWithoutPrimaryKey::class)->select()->all(); + $this->assertCount(2, $remaining); - $this->assertSame('2024-01-01 00:00:00', $model->phpDateTime->format('Y-m-d H:i:s')); - $this->assertSame('2024-01-01 00:00:00', $model->tempestDateTime->format('yyyy-MM-dd HH:mm:ss')); + $names = array_map(fn (ModelWithoutPrimaryKey $model) => $model->name, $remaining); + $this->assertContains('Heiter', $names); + $this->assertContains('Eisen', $names); + $this->assertNotContains('Himmel', $names); } - public function test_model_create_with_has_many_relations(): void + public function test_delete_with_uninitialized_primary_key(): void { $this->migrate( CreateMigrationsTable::class, - CreateTestUserMigration::class, - CreateTestPostMigration::class, - ); - - $user = TestUser::create( - name: 'Jon', - posts: [ - new TestPost('hello', 'world'), - new TestPost('foo', 'bar'), - ], + FooDatabaseMigration::class, ); - $this->assertSame('Jon', $user->name); - $this->assertInstanceOf(PrimaryKey::class, $user->id); - - $posts = TestPost::select() - ->where('test_user_id', $user->id->value) - ->all(); + $foo = new Foo(); + $foo->bar = 'unsaved'; - $this->assertCount(2, $posts); - $this->assertSame('hello', $posts[0]->title); - $this->assertSame('world', $posts[0]->body); - $this->assertSame('foo', $posts[1]->title); - $this->assertSame('bar', $posts[1]->body); + $this->expectException(DeleteStatementWasInvalid::class); + $foo->delete(); } - public function test_model_update_with_only_relations(): void + public function test_delete_nonexistent_record(): void { $this->migrate( CreateMigrationsTable::class, - CreateTestUserMigration::class, - CreateTestPostMigration::class, + FooDatabaseMigration::class, ); - $user = TestUser::create(name: 'Frieren'); - $user->update(posts: [ - new TestPost('hello', 'world'), - ]); + $foo = Foo::create(bar: 'test'); + $fooId = $foo->id; - $posts = TestPost::select() - ->where('test_user_id', $user->id->value) - ->all(); + // Delete the record + $foo->delete(); - $this->assertCount(1, $posts); - $this->assertSame('hello', $posts[0]->title); - $this->assertSame('world', $posts[0]->body); - $this->assertSame('Frieren', $user->name); // Ensure name wasn't changed + // Delete again + $foo->delete(); + + $this->assertNull(Foo::get($fooId)); } } @@ -1100,3 +1020,30 @@ public function down(): ?QueryStatement return null; } } + +final class ModelWithoutPrimaryKey +{ + use IsDatabaseModel; + + public function __construct( + public string $name, + public string $description, + ) {} +} + +final class CreateModelWithoutPrimaryKeyMigration implements DatabaseMigration +{ + private(set) string $name = '100-create-model-without-primary-key'; + + public function up(): QueryStatement + { + return new CreateTableStatement('model_without_primary_keys') + ->text('name') + ->text('description'); + } + + public function down(): ?QueryStatement + { + return null; + } +} From f814d796f303b4ee3e2d3b720d6f75c44ff3c6b0 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 7 Aug 2025 17:12:31 +0200 Subject: [PATCH 43/51] refactor(database): rename `toSql` to `compile` in query builders --- .../QueryBuilders/CountQueryBuilder.php | 4 ++-- .../QueryBuilders/DeleteQueryBuilder.php | 4 ++-- .../QueryBuilders/InsertQueryBuilder.php | 4 ++-- .../QueryBuilders/SelectQueryBuilder.php | 4 ++-- .../QueryBuilders/UpdateQueryBuilder.php | 4 ++-- .../Builder/CountQueryBuilderTest.php | 4 ++-- .../Builder/DeleteQueryBuilderTest.php | 4 ++-- .../Builder/SelectQueryBuilderTest.php | 16 +++++++-------- .../Builder/UpdateQueryBuilderTest.php | 20 +++++++++---------- 9 files changed, 32 insertions(+), 32 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php index d2dbd500d..48d440cef 100644 --- a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php @@ -80,9 +80,9 @@ public function bind(mixed ...$bindings): self } /** - * Returns the SQL statement without the bindings. + * Compile the query to a SQL statement without the bindings. */ - public function toSql(): ImmutableString + public function compile(): ImmutableString { return $this->build()->compile(); } diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index 47f652087..364b51c63 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -69,9 +69,9 @@ public function bind(mixed ...$bindings): self } /** - * Returns the SQL statement without the bindings. + * Compile the query to a SQL statement without the bindings. */ - public function toSql(): ImmutableString + public function compile(): ImmutableString { return $this->build()->compile(); } diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index f15ef74bd..fb3bf8768 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -74,9 +74,9 @@ public function execute(mixed ...$bindings): ?PrimaryKey } /** - * Returns the SQL statement without the bindings. + * Compile the query to a SQL statement without the bindings. */ - public function toSql(): ImmutableString + public function compile(): ImmutableString { return $this->build()->compile(); } diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index f612fc2f3..7152054ce 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -284,9 +284,9 @@ public function bind(mixed ...$bindings): self } /** - * Returns the SQL statement without the bindings. + * Compile the query to a SQL statement without the bindings. */ - public function toSql(): ImmutableString + public function compile(): ImmutableString { return $this->build()->compile(); } diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index 51b13ef0d..409293e84 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -111,9 +111,9 @@ public function bind(mixed ...$bindings): self } /** - * Returns the SQL statement without the bindings. + * Compile the query to a SQL statement without the bindings. */ - public function toSql(): ImmutableString + public function compile(): ImmutableString { return $this->build()->compile(); } diff --git a/tests/Integration/Database/Builder/CountQueryBuilderTest.php b/tests/Integration/Database/Builder/CountQueryBuilderTest.php index 374a3c68c..a88eb9dc4 100644 --- a/tests/Integration/Database/Builder/CountQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/CountQueryBuilderTest.php @@ -157,7 +157,7 @@ public function test_multiple_where_raw(): void ->whereRaw('author_id = ?', 1) ->whereRaw('OR author_id = ?', 2) ->whereRaw('AND author_id <> NULL') - ->toSql(); + ->compile(); $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE title = ? AND author_id = ? OR author_id = ? AND author_id <> NULL'; @@ -170,7 +170,7 @@ public function test_multiple_where(): void ->count() ->where('title', 'a') ->where('author_id', 1) - ->toSql(); + ->compile(); $expected = 'SELECT COUNT(*) AS `count` FROM `books` WHERE books.title = ? AND books.author_id = ?'; diff --git a/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php b/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php index 9b91aaf2e..6d54517b7 100644 --- a/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php @@ -114,7 +114,7 @@ public function test_multiple_where_raw(): void ->whereRaw('author_id = ?', 1) ->whereRaw('OR author_id = ?', 2) ->whereRaw('AND author_id <> NULL') - ->toSql(); + ->compile(); $expected = 'DELETE FROM `books` WHERE title = ? AND author_id = ? OR author_id = ? AND author_id <> NULL'; @@ -127,7 +127,7 @@ public function test_multiple_where(): void ->delete() ->where('title', 'a') ->where('author_id', 1) - ->toSql(); + ->compile(); $expected = 'DELETE FROM `books` WHERE books.title = ? AND books.author_id = ?'; diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index aeccaf843..5715193f5 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -75,7 +75,7 @@ public function test_multiple_where(): void ->whereRaw('author_id = ?', 1) ->whereRaw('OR author_id = ?', 2) ->whereRaw('AND author_id <> NULL') - ->toSql(); + ->compile(); $expected = 'SELECT * FROM `books` WHERE title = ? AND author_id = ? OR author_id = ? AND author_id <> NULL'; @@ -88,7 +88,7 @@ public function test_multiple_where_field(): void ->select() ->where('title', 'a') ->where('author_id', 1) - ->toSql(); + ->compile(); $expected = 'SELECT * FROM `books` WHERE books.title = ? AND books.author_id = ?'; @@ -199,17 +199,17 @@ public function test_order_by_sql_generation(): void { $this->assertSameWithoutBackticks( expected: 'SELECT * FROM `books` ORDER BY `title` ASC', - actual: query('books')->select()->orderBy('title')->toSql(), + actual: query('books')->select()->orderBy('title')->compile(), ); $this->assertSameWithoutBackticks( expected: 'SELECT * FROM `books` ORDER BY `title` DESC', - actual: query('books')->select()->orderBy('title', Direction::DESC)->toSql(), + actual: query('books')->select()->orderBy('title', Direction::DESC)->compile(), ); $this->assertSameWithoutBackticks( expected: 'SELECT * FROM `books` ORDER BY title DESC NULLS LAST', - actual: query('books')->select()->orderByRaw('title DESC NULLS LAST')->toSql(), + actual: query('books')->select()->orderByRaw('title DESC NULLS LAST')->compile(), ); } @@ -421,7 +421,7 @@ public function test_select_query_execute_with_relations(): void public function test_eager_loads_combined_with_manual_loads(): void { - $query = AWithEager::select()->with('b.c')->toSql(); + $query = AWithEager::select()->with('b.c')->compile(); $this->assertSameWithoutBackticks( 'SELECT a.id AS `a.id`, a.b_id AS `a.b_id`, b.id AS `b.id`, b.c_id AS `b.c_id`, c.id AS `b.c.id`, c.name AS `b.c.name` FROM `a` LEFT JOIN b ON b.id = a.b_id LEFT JOIN c ON c.id = b.c_id', @@ -434,7 +434,7 @@ public function test_group_by(): void $sql = query('authors') ->select() ->groupBy('name') - ->toSql(); + ->compile(); $expected = 'SELECT * FROM authors GROUP BY name'; @@ -446,7 +446,7 @@ public function test_having(): void $sql = query('authors') ->select() ->having('name = ?', 'Brent') - ->toSql(); + ->compile(); $expected = 'SELECT * FROM authors HAVING name = ?'; diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php index 0de3e3844..0a83551d4 100644 --- a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php @@ -252,7 +252,7 @@ public function test_multiple_where(): void ->whereRaw('author_id = ?', 1) ->whereRaw('OR author_id = ?', 2) ->whereRaw('AND author_id <> NULL') - ->toSql(); + ->compile(); $expected = 'UPDATE `books` SET title = ? WHERE title = ? AND author_id = ? OR author_id = ? AND author_id <> NULL'; @@ -267,7 +267,7 @@ public function test_multiple_where_field(): void ) ->where('title', 'a') ->where('author_id', 1) - ->toSql(); + ->compile(); $expected = 'UPDATE `books` SET title = ? WHERE books.title = ? AND books.author_id = ?'; @@ -321,7 +321,7 @@ public function test_update_with_where_in(): void $sql = query('books') ->update(title: 'Updated Book') ->whereIn('id', [1, 2, 3]) - ->toSql(); + ->compile(); $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`id` IN (?,?,?)'; @@ -333,7 +333,7 @@ public function test_update_with_where_not_in(): void $sql = query('books') ->update(title: 'Updated Book') ->whereNotIn('author_id', [1, 2]) - ->toSql(); + ->compile(); $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`author_id` NOT IN (?,?)'; @@ -345,7 +345,7 @@ public function test_update_with_where_null(): void $sql = query('books') ->update(title: 'Updated Book') ->whereNull('author_id') - ->toSql(); + ->compile(); $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`author_id` IS NULL'; @@ -357,7 +357,7 @@ public function test_update_with_where_not_null(): void $sql = query('books') ->update(title: 'Updated Book') ->whereNotNull('author_id') - ->toSql(); + ->compile(); $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`author_id` IS NOT NULL'; @@ -369,7 +369,7 @@ public function test_update_with_where_between(): void $sql = query('books') ->update(title: 'Updated Book') ->whereBetween('id', 1, 100) - ->toSql(); + ->compile(); $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`id` BETWEEN ? AND ?'; @@ -381,7 +381,7 @@ public function test_update_with_where_not_between(): void $sql = query('books') ->update(title: 'Updated Book') ->whereNotBetween('id', 1, 10) - ->toSql(); + ->compile(); $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`id` NOT BETWEEN ? AND ?'; @@ -394,7 +394,7 @@ public function test_update_with_or_where_in(): void ->update(title: 'Updated Book') ->whereIn('id', [1, 2]) ->orWhereIn('author_id', [10, 20]) - ->toSql(); + ->compile(); $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`id` IN (?,?) OR `books`.`author_id` IN (?,?)'; @@ -406,7 +406,7 @@ public function test_update_captures_primary_key_for_relations_with_convenience_ $sql = query(Book::class) ->update(title: 'Updated Book') ->whereIn('id', [5]) - ->toSql(); + ->compile(); $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`id` IN (?)'; From 437f82d27002947c0dae0f72b99e31454460ef8b Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 7 Aug 2025 17:13:35 +0200 Subject: [PATCH 44/51] refactor(database): use Tempest reflection instead of built-in one --- .../src/Builder/QueryBuilders/InsertQueryBuilder.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index fb3bf8768..8af4f4a33 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -140,17 +140,17 @@ private function getDefaultForeignKeyName(): string private function convertObjectToArray(object $object, array $excludeProperties = []): array { - $reflection = new \ReflectionClass($object); + $reflection = new ClassReflector($object); $data = []; - foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + foreach ($reflection->getPublicProperties() as $property) { if (! $property->isInitialized($object)) { continue; } $propertyName = $property->getName(); - if (! in_array($propertyName, $excludeProperties, true)) { + if (! in_array($propertyName, $excludeProperties, strict: true)) { $data[$propertyName] = $property->getValue($object); } } From 708ef964cca95d2e47c9f2ebf803995b010eb90c Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 7 Aug 2025 17:25:33 +0200 Subject: [PATCH 45/51] refactor(database): clean up update query builder --- .../QueryBuilders/UpdateQueryBuilder.php | 70 +++++++++---------- 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index 409293e84..214e20973 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -348,11 +348,11 @@ private function deleteExistingHasManyRelations($hasMany, PrimaryKey $parentId): ? $this->removeTablePrefix($hasMany->ownerJoin) : $this->getDefaultForeignKeyName(); - $this->executeQuery( - sql: 'DELETE FROM %s WHERE %s = ?', - params: [$relatedModel->getTableName(), $foreignKey], - bindings: [$parentId->value], - ); + new DeleteQueryBuilder($relatedModel->getName()) + ->where($foreignKey, $parentId->value) + ->build() + ->onDatabase($this->onDatabase) + ->execute(); } private function deleteExistingHasOneRelation($hasOne, PrimaryKey $parentId): void @@ -375,29 +375,29 @@ private function deleteCustomHasOneRelation($hasOne, PrimaryKey $parentId): void $foreignKeyColumn = $hasOne->relationJoin ?? $this->removeTablePrefix($hasOne->ownerJoin); - $result = $this->executeQuery( - sql: 'SELECT %s FROM %s WHERE %s = ?', - params: [$foreignKeyColumn, $ownerModel->getTableName(), $ownerModel->getPrimaryKey()], - bindings: [$parentId->value], - ); + $result = new SelectQueryBuilder($ownerModel->getName(), new ImmutableArray([$foreignKeyColumn])) + ->where($ownerModel->getPrimaryKey(), $parentId->value) + ->build() + ->onDatabase($this->onDatabase) + ->fetchFirst(); - if (! $result || ! isset($result[0][$foreignKeyColumn])) { + if (! $result || ! isset($result[$foreignKeyColumn])) { return; } - $relatedId = $result[0][$foreignKeyColumn]; + $relatedId = $result[$foreignKeyColumn]; - $this->executeQuery( - sql: 'DELETE FROM %s WHERE %s = ?', - params: [$relatedModel->getTableName(), $relatedModel->getPrimaryKey()], - bindings: [$relatedId], - ); + new DeleteQueryBuilder($relatedModel->getName()) + ->where($relatedModel->getPrimaryKey(), $relatedId) + ->build() + ->onDatabase($this->onDatabase) + ->execute(); - $this->executeQuery( - sql: 'UPDATE %s SET %s = NULL WHERE %s = ?', - params: [$ownerModel->getTableName(), $foreignKeyColumn, $ownerModel->getPrimaryKey()], - bindings: [$parentId->value], - ); + new UpdateQueryBuilder($ownerModel->getName(), [$foreignKeyColumn => null], $this->serializerFactory) + ->where($ownerModel->getPrimaryKey(), $parentId->value) + ->build() + ->onDatabase($this->onDatabase) + ->execute(); } private function deleteStandardHasOneRelation($hasOne, PrimaryKey $parentId): void @@ -409,17 +409,11 @@ private function deleteStandardHasOneRelation($hasOne, PrimaryKey $parentId): vo $foreignKeyColumn = Intl\singularize($ownerModel->getTableName()) . '_' . $ownerModel->getPrimaryKey(); - $this->executeQuery( - sql: 'DELETE FROM %s WHERE %s = ?', - params: [$relatedModel->getTableName(), $foreignKeyColumn], - bindings: [$parentId->value], - ); - } - - private function executeQuery(string $sql, array $params, array $bindings): mixed - { - $query = new Query(sprintf($sql, ...$params), $bindings); - return $query->onDatabase($this->onDatabase)->execute(); + new DeleteQueryBuilder($relatedModel->getName()) + ->where($foreignKeyColumn, $parentId->value) + ->build() + ->onDatabase($this->onDatabase) + ->execute(); } private function handleCustomHasOneRelation($hasOne, object|array $relation, PrimaryKey $parentId): null @@ -435,11 +429,11 @@ private function handleCustomHasOneRelation($hasOne, object|array $relation, Pri $foreignKeyColumn = $hasOne->relationJoin ?? $this->removeTablePrefix($hasOne->ownerJoin); - $this->executeQuery( - sql: 'UPDATE %s SET %s = ? WHERE %s = ?', - params: [$ownerModel->getTableName(), $foreignKeyColumn, $ownerModel->getPrimaryKey()], - bindings: [$relatedModelId->value, $parentId->value], - ); + new UpdateQueryBuilder($ownerModel->getName(), [$foreignKeyColumn => $relatedModelId->value], $this->serializerFactory) + ->where($ownerModel->getPrimaryKey(), $parentId->value) + ->build() + ->onDatabase($this->onDatabase) + ->execute(); return null; } From b5d146d4a7cb5032ff23639b855aa2d3ce2d08f0 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 7 Aug 2025 18:22:25 +0200 Subject: [PATCH 46/51] feat(database): add `transform` to query builders --- .../QueryBuilders/CountQueryBuilder.php | 2 +- .../QueryBuilders/DeleteQueryBuilder.php | 2 +- .../QueryBuilders/InsertQueryBuilder.php | 2 +- .../QueryBuilders/SelectQueryBuilder.php | 2 +- .../QueryBuilders/TransformsQueryBuilder.php | 20 +++++ .../QueryBuilders/UpdateQueryBuilder.php | 2 +- packages/support/src/functions.php | 4 +- .../Builder/TransformsQueryBuilderTest.php | 80 +++++++++++++++++++ 8 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 packages/database/src/Builder/QueryBuilders/TransformsQueryBuilder.php create mode 100644 tests/Integration/Database/Builder/TransformsQueryBuilderTest.php diff --git a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php index 48d440cef..73ad6d674 100644 --- a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php @@ -22,7 +22,7 @@ */ final class CountQueryBuilder implements BuildsQuery { - use HasConditions, OnDatabase, HasWhereQueryBuilderMethods; + use HasConditions, OnDatabase, HasWhereQueryBuilderMethods, TransformsQueryBuilder; private CountStatement $count; diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index 364b51c63..346b9ded4 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -19,7 +19,7 @@ */ final class DeleteQueryBuilder implements BuildsQuery { - use HasConditions, OnDatabase, HasWhereQueryBuilderMethods; + use HasConditions, OnDatabase, HasWhereQueryBuilderMethods, TransformsQueryBuilder; private DeleteStatement $delete; diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index 8af4f4a33..401fb2531 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -29,7 +29,7 @@ */ final class InsertQueryBuilder implements BuildsQuery { - use HasConditions, OnDatabase; + use HasConditions, OnDatabase, TransformsQueryBuilder; private InsertStatement $insert; diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 7152054ce..ef3629e3d 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -36,7 +36,7 @@ */ final class SelectQueryBuilder implements BuildsQuery { - use HasConditions, OnDatabase, HasWhereQueryBuilderMethods; + use HasConditions, OnDatabase, HasWhereQueryBuilderMethods, TransformsQueryBuilder; private ModelInspector $model; diff --git a/packages/database/src/Builder/QueryBuilders/TransformsQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/TransformsQueryBuilder.php new file mode 100644 index 000000000..d8e89b05e --- /dev/null +++ b/packages/database/src/Builder/QueryBuilders/TransformsQueryBuilder.php @@ -0,0 +1,20 @@ +select() + ->transform(fn ($builder) => $builder->where('name', 'Frieren')); + + $bindings = $query->build()->bindings; + + $this->assertSame(['Frieren'], $bindings); + } + + public function test_count_query_builder_transform(): void + { + $query = query(Author::class) + ->count() + ->transform(fn ($builder) => $builder->where('name', 'Himmel')); + + $bindings = $query->build()->bindings; + + $this->assertSame(['Himmel'], $bindings); + } + + public function test_update_query_builder_transform(): void + { + $query = query(Author::class) + ->update(name: 'Heiter') + ->transform(fn ($builder) => $builder->where('id', 1)); + + $bindings = $query->build()->bindings; + + $this->assertSame(['Heiter', 1], $bindings); + } + + public function test_delete_query_builder_transform(): void + { + $query = query(Author::class) + ->delete() + ->transform(fn ($builder) => $builder->where('name', 'Eisen')); + + $bindings = $query->build()->bindings; + + $this->assertSame(['Eisen'], $bindings); + } + + public function test_insert_query_builder_transform(): void + { + $query = query(Author::class) + ->insert(['name' => 'Stark']) + ->transform(fn ($builder) => $builder->then(fn () => null)); + + $bindings = $query->build()->bindings; + + $this->assertSame(['Stark'], $bindings); + } + + public function test_transform_returns_new_instance(): void + { + $original = query(Author::class)->select(); + + $transformed = $original->transform(fn ($builder) => $builder); + + $this->assertNotSame($original, $transformed); + } +} From 218760d1d2330260ab1f3af9bf10c9dc96acdc6a Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 7 Aug 2025 18:28:52 +0200 Subject: [PATCH 47/51] fix(database): properly pass through generics to query builder traits --- .../QueryBuilders/CountQueryBuilder.php | 2 +- .../QueryBuilders/DeleteQueryBuilder.php | 2 +- .../HasConvenientWhereMethods.php | 74 +++++++++---------- .../HasWhereQueryBuilderMethods.php | 2 +- .../QueryBuilders/SelectQueryBuilder.php | 2 +- .../QueryBuilders/UpdateQueryBuilder.php | 2 +- 6 files changed, 42 insertions(+), 42 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php index 73ad6d674..1115811a5 100644 --- a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php @@ -18,7 +18,7 @@ /** * @template TModel of object * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery - * @uses \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods + * @use \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods */ final class CountQueryBuilder implements BuildsQuery { diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index 346b9ded4..6ecd72fa1 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -15,7 +15,7 @@ /** * @template TModel of object * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery - * @uses \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods + * @use \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods */ final class DeleteQueryBuilder implements BuildsQuery { diff --git a/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php b/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php index 58e87409a..a2e7f327a 100644 --- a/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php @@ -102,7 +102,7 @@ protected function buildCondition(string $fieldDefinition, WhereOperator $operat * * @param class-string|UnitEnum|array $values * - * @return static + * @return self */ public function whereIn(string $field, string|UnitEnum|array|ArrayAccess $values): self { @@ -114,7 +114,7 @@ public function whereIn(string $field, string|UnitEnum|array|ArrayAccess $values * * @param class-string|UnitEnum|array $values * - * @return static + * @return self */ public function whereNotIn(string $field, string|UnitEnum|array|ArrayAccess $values): self { @@ -124,7 +124,7 @@ public function whereNotIn(string $field, string|UnitEnum|array|ArrayAccess $val /** * Adds a `WHERE BETWEEN` condition. * - * @return static + * @return self */ public function whereBetween(string $field, DateTimeInterface|string|float|int|Countable $min, DateTimeInterface|string|float|int|Countable $max): self { @@ -134,7 +134,7 @@ public function whereBetween(string $field, DateTimeInterface|string|float|int|C /** * Adds a `WHERE NOT BETWEEN` condition. * - * @return static + * @return self */ public function whereNotBetween(string $field, DateTimeInterface|string|float|int|Countable $min, DateTimeInterface|string|float|int|Countable $max): self { @@ -144,7 +144,7 @@ public function whereNotBetween(string $field, DateTimeInterface|string|float|in /** * Adds a `WHERE IS NULL` condition. * - * @return static + * @return self */ public function whereNull(string $field): self { @@ -154,7 +154,7 @@ public function whereNull(string $field): self /** * Adds a `WHERE IS NOT NULL` condition. * - * @return static + * @return self */ public function whereNotNull(string $field): self { @@ -164,7 +164,7 @@ public function whereNotNull(string $field): self /** * Adds a `WHERE NOT` condition (shorthand for != operator). * - * @return static + * @return self */ public function whereNot(string $field, mixed $value): self { @@ -174,7 +174,7 @@ public function whereNot(string $field, mixed $value): self /** * Adds a `WHERE LIKE` condition. * - * @return static + * @return self */ public function whereLike(string $field, string $value): self { @@ -184,7 +184,7 @@ public function whereLike(string $field, string $value): self /** * Adds a `WHERE NOT LIKE` condition. * - * @return static + * @return self */ public function whereNotLike(string $field, string $value): self { @@ -196,7 +196,7 @@ public function whereNotLike(string $field, string $value): self * * @param class-string|UnitEnum|array $values * - * @return static + * @return self */ public function orWhereIn(string $field, string|UnitEnum|array|ArrayAccess $values): self { @@ -208,7 +208,7 @@ public function orWhereIn(string $field, string|UnitEnum|array|ArrayAccess $valu * * @param class-string|UnitEnum|array $values * - * @return static + * @return self */ public function orWhereNotIn(string $field, string|UnitEnum|array|ArrayAccess $values): self { @@ -218,7 +218,7 @@ public function orWhereNotIn(string $field, string|UnitEnum|array|ArrayAccess $v /** * Adds an `OR WHERE BETWEEN` condition. * - * @return static + * @return self */ public function orWhereBetween(string $field, DateTimeInterface|string|float|int|Countable $min, DateTimeInterface|string|float|int|Countable $max): self { @@ -228,7 +228,7 @@ public function orWhereBetween(string $field, DateTimeInterface|string|float|int /** * Adds an `OR WHERE NOT BETWEEN` condition. * - * @return static + * @return self */ public function orWhereNotBetween(string $field, DateTimeInterface|string|float|int|Countable $min, DateTimeInterface|string|float|int|Countable $max): self { @@ -238,7 +238,7 @@ public function orWhereNotBetween(string $field, DateTimeInterface|string|float| /** * Adds an `OR WHERE IS NULL` condition. * - * @return static + * @return self */ public function orWhereNull(string $field): self { @@ -248,7 +248,7 @@ public function orWhereNull(string $field): self /** * Adds an `OR WHERE IS NOT NULL` condition. * - * @return static + * @return self */ public function orWhereNotNull(string $field): self { @@ -258,7 +258,7 @@ public function orWhereNotNull(string $field): self /** * Adds an `OR WHERE NOT` condition (shorthand for != operator). * - * @return static + * @return self */ public function orWhereNot(string $field, mixed $value): self { @@ -268,7 +268,7 @@ public function orWhereNot(string $field, mixed $value): self /** * Adds an `OR WHERE LIKE` condition. * - * @return static + * @return self */ public function orWhereLike(string $field, string $value): self { @@ -278,7 +278,7 @@ public function orWhereLike(string $field, string $value): self /** * Adds an `OR WHERE NOT LIKE` condition. * - * @return static + * @return self */ public function orWhereNotLike(string $field, string $value): self { @@ -288,7 +288,7 @@ public function orWhereNotLike(string $field, string $value): self /** * Adds a `WHERE` condition for records from today. * - * @return static + * @return self */ public function whereToday(string $field): self { @@ -300,7 +300,7 @@ public function whereToday(string $field): self /** * Adds a `WHERE` condition for records from yesterday. * - * @return static + * @return self */ public function whereYesterday(string $field): self { @@ -312,7 +312,7 @@ public function whereYesterday(string $field): self /** * Adds a `WHERE` condition for records from this week. * - * @return static + * @return self */ public function whereThisWeek(string $field): self { @@ -324,7 +324,7 @@ public function whereThisWeek(string $field): self /** * Adds a `WHERE` condition for records from last week. * - * @return static + * @return self */ public function whereLastWeek(string $field): self { @@ -336,7 +336,7 @@ public function whereLastWeek(string $field): self /** * Adds a `WHERE` condition for records from this month. * - * @return static + * @return self */ public function whereThisMonth(string $field): self { @@ -348,7 +348,7 @@ public function whereThisMonth(string $field): self /** * Adds a `WHERE` condition for records from last month. * - * @return static + * @return self */ public function whereLastMonth(string $field): self { @@ -360,7 +360,7 @@ public function whereLastMonth(string $field): self /** * Adds a `WHERE` condition for records from this year. * - * @return static + * @return self */ public function whereThisYear(string $field): self { @@ -372,7 +372,7 @@ public function whereThisYear(string $field): self /** * Adds a `WHERE` condition for records from last year. * - * @return static + * @return self */ public function whereLastYear(string $field): self { @@ -384,7 +384,7 @@ public function whereLastYear(string $field): self /** * Adds a `WHERE` condition for records created after a specific date. * - * @return static + * @return self */ public function whereAfter(string $field, DateTimeInterface|string $date): self { @@ -394,7 +394,7 @@ public function whereAfter(string $field, DateTimeInterface|string $date): self /** * Adds a `WHERE` condition for records created before a specific date. * - * @return static + * @return self */ public function whereBefore(string $field, DateTimeInterface|string $date): self { @@ -404,7 +404,7 @@ public function whereBefore(string $field, DateTimeInterface|string $date): self /** * Adds an `OR WHERE` condition for records from today. * - * @return static + * @return self */ public function orWhereToday(string $field): self { @@ -415,7 +415,7 @@ public function orWhereToday(string $field): self /** * Adds an `OR WHERE` condition for records from yesterday. * - * @return static + * @return self */ public function orWhereYesterday(string $field): self { @@ -427,7 +427,7 @@ public function orWhereYesterday(string $field): self /** * Adds an `OR WHERE` condition for records from this week. * - * @return static + * @return self */ public function orWhereThisWeek(string $field): self { @@ -439,7 +439,7 @@ public function orWhereThisWeek(string $field): self /** * Adds an `OR WHERE` condition for records from this month. * - * @return static + * @return self */ public function orWhereThisMonth(string $field): self { @@ -451,7 +451,7 @@ public function orWhereThisMonth(string $field): self /** * Adds an `OR WHERE` condition for records from this year. * - * @return static + * @return self */ public function orWhereThisYear(string $field): self { @@ -463,7 +463,7 @@ public function orWhereThisYear(string $field): self /** * Adds an `OR WHERE` condition for records created after a specific date. * - * @return static + * @return self */ public function orWhereAfter(string $field, DateTimeInterface|string $date): self { @@ -473,7 +473,7 @@ public function orWhereAfter(string $field, DateTimeInterface|string $date): sel /** * Adds an `OR WHERE` condition for records created before a specific date. * - * @return static + * @return self */ public function orWhereBefore(string $field, DateTimeInterface|string $date): self { @@ -484,7 +484,7 @@ public function orWhereBefore(string $field, DateTimeInterface|string $date): se * Abstract method that must be implemented by classes using this trait. * Should add a basic WHERE condition. * - * @return static + * @return self */ abstract public function where(string $field, mixed $value, string|WhereOperator $operator = WhereOperator::EQUALS): self; @@ -492,7 +492,7 @@ abstract public function where(string $field, mixed $value, string|WhereOperator * Abstract method that must be implemented by classes using this trait. * Should add an OR WHERE condition. * - * @return static + * @return self */ abstract public function orWhere(string $field, mixed $value, WhereOperator $operator = WhereOperator::EQUALS): self; } diff --git a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php index 059f070b8..733534769 100644 --- a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php @@ -13,7 +13,7 @@ /** * @template TModel of object * @phpstan-require-implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery - * @uses \Tempest\Database\Builder\QueryBuilders\HasConvenientWhereMethods + * @use \Tempest\Database\Builder\QueryBuilders\HasConvenientWhereMethods */ trait HasWhereQueryBuilderMethods { diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index ef3629e3d..e61cfb75d 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -32,7 +32,7 @@ /** * @template TModel of object * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery - * @uses \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods + * @use \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods */ final class SelectQueryBuilder implements BuildsQuery { diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index 0ae2296ec..795b6916f 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -27,7 +27,7 @@ /** * @template TModel of object * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery - * @uses \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods + * @use \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods */ final class UpdateQueryBuilder implements BuildsQuery { From 2a34424d0425bc0955a8a24819a6a9d20e59c3fe Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 10 Aug 2025 15:27:51 +0200 Subject: [PATCH 48/51] fix(database): support virtual properties Closes #1474 --- .../QueryBuilders/InsertQueryBuilder.php | 6 +++ .../QueryBuilders/UpdateQueryBuilder.php | 5 ++ packages/database/src/IsDatabaseModel.php | 9 ++++ packages/database/src/Virtual.php | 3 ++ .../Database/Builder/IsDatabaseModelTest.php | 50 ++++++++++++++++++- 5 files changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index 401fb2531..fcf835a9b 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -12,6 +12,7 @@ use Tempest\Database\PrimaryKey; use Tempest\Database\Query; use Tempest\Database\QueryStatements\InsertStatement; +use Tempest\Database\Virtual; use Tempest\Intl; use Tempest\Mapper\SerializerFactory; use Tempest\Reflection\ClassReflector; @@ -390,6 +391,11 @@ private function resolveObjectData(object $model): array } $propertyName = $property->getName(); + + if ($property->hasAttribute(Virtual::class)) { + continue; + } + $value = $property->getValue($model); if ($definition->getHasMany($propertyName)) { diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index 795b6916f..a96263d18 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -14,6 +14,7 @@ use Tempest\Database\QueryStatements\HasWhereStatements; use Tempest\Database\QueryStatements\UpdateStatement; use Tempest\Database\QueryStatements\WhereStatement; +use Tempest\Database\Virtual; use Tempest\Intl; use Tempest\Mapper\SerializerFactory; use Tempest\Reflection\ClassReflector; @@ -473,6 +474,10 @@ private function convertObjectToArray(object $object): array $reflection = new ClassReflector($object); foreach ($reflection->getPublicProperties() as $property) { + if ($property->hasAttribute(Virtual::class)) { + continue; + } + if ($property->isInitialized($object)) { $result[$property->getName()] = $property->getValue($object); } diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index ef05ee472..d0fd2f87c 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -9,6 +9,7 @@ use Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder; use Tempest\Database\Exceptions\RelationWasMissing; use Tempest\Database\Exceptions\ValueWasMissing; +use Tempest\Database\Virtual; use Tempest\Reflection\ClassReflector; use Tempest\Reflection\PropertyReflector; @@ -175,6 +176,10 @@ public function refresh(): self $refreshed = self::find(id: $primaryKeyValue)->first(); foreach (new ClassReflector($refreshed)->getPublicProperties() as $property) { + if ($property->hasAttribute(Virtual::class)) { + continue; + } + $property->setValue($this, $property->getValue($refreshed)); } @@ -198,6 +203,10 @@ public function load(string ...$relations): self $new = self::get($primaryKeyValue, $relations); foreach (new ClassReflector($new)->getPublicProperties() as $property) { + if ($property->hasAttribute(Virtual::class)) { + continue; + } + $property->setValue($this, $property->getValue($new)); } diff --git a/packages/database/src/Virtual.php b/packages/database/src/Virtual.php index a805a48d2..6d44eb7b6 100644 --- a/packages/database/src/Virtual.php +++ b/packages/database/src/Virtual.php @@ -6,6 +6,9 @@ use Attribute; +/** + * Virtual properties are ignored by the database mapper. + */ #[Attribute(Attribute::TARGET_PROPERTY)] final readonly class Virtual { diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index fe4aa356f..755d642f2 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -433,7 +433,25 @@ public function test_no_result(): void $this->assertNull(A::select()->first()); } - public function test_virtual_property(): void + public function test_create_with_virtual_property(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateATable::class, + CreateBTable::class, + CreateCTable::class, + ); + + $a = AWithVirtual::create( + b: new B( + c: new C(name: 'test'), + ), + ); + + $this->assertSame(-$a->id->value, $a->fake); + } + + public function test_select_virtual_property(): void { $this->migrate( CreateMigrationsTable::class, @@ -453,6 +471,36 @@ public function test_virtual_property(): void $this->assertSame(-$a->id->value, $a->fake); } + public function test_update_with_virtual_property(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateATable::class, + CreateBTable::class, + CreateCTable::class, + ); + + $a = AWithVirtual::create( + b: new B( + c: new C(name: 'test'), + ), + ); + + $a->update( + b: new B( + c: new C(name: 'updated'), + ), + ); + + $updatedA = AWithVirtual::select() + ->with('b.c') + ->where('id', $a->id) + ->first(); + + $this->assertSame(-$updatedA->id->value, $updatedA->fake); + $this->assertSame('updated', $updatedA->b->c->name); + } + public function test_update_or_create(): void { $this->migrate( From ba68bafafcb960de87d70a2fa4fc5b82d7ca4b7a Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 11 Aug 2025 06:52:47 +0200 Subject: [PATCH 49/51] refactor(database): support hybrid `where` --- .../HasConvenientWhereMethods.php | 38 +++++--- .../HasWhereQueryBuilderMethods.php | 30 +++++-- .../Builder/QueryBuilders/QueryBuilder.php | 4 +- .../QueryBuilders/SelectQueryBuilder.php | 2 +- .../QueryBuilders/UpdateQueryBuilder.php | 16 ++-- .../QueryBuilders/WhereGroupBuilder.php | 22 ++++- packages/database/src/IsDatabaseModel.php | 2 +- .../Database/Builder/UpdateRelationsTest.php | 1 - .../Database/Builder/WhereOperatorTest.php | 89 ++++++++++++++++--- .../Database/ConvenientWhereMethodsTest.php | 4 +- .../Database/GroupedWhereMethodsTest.php | 8 +- tests/Integration/Database/ToRawSqlTest.php | 6 +- .../Commands/DatabaseSeedCommandTest.php | 4 +- 13 files changed, 172 insertions(+), 54 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php b/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php index a2e7f327a..03ca3c256 100644 --- a/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php @@ -8,6 +8,7 @@ use Tempest\Database\Builder\WhereOperator; use Tempest\DateTime\DateTime; use Tempest\DateTime\DateTimeInterface; +use Tempest\Support\Str; use UnitEnum; /** @@ -97,6 +98,19 @@ protected function buildCondition(string $fieldDefinition, WhereOperator $operat ]; } + private function looksLikeWhereRawStatement(string $statement, array $bindings): bool + { + if (count($bindings) === 2 && $bindings[1] instanceof WhereOperator) { + return false; + } + + if (! Str\contains($statement, [' ', ...array_map(fn (WhereOperator $op) => $op->value, WhereOperator::cases())])) { + return false; + } + + return true; + } + /** * Adds a `WHERE IN` condition. * @@ -106,7 +120,7 @@ protected function buildCondition(string $fieldDefinition, WhereOperator $operat */ public function whereIn(string $field, string|UnitEnum|array|ArrayAccess $values): self { - return $this->where($field, $values, WhereOperator::IN); + return $this->whereField($field, $values, WhereOperator::IN); } /** @@ -118,7 +132,7 @@ public function whereIn(string $field, string|UnitEnum|array|ArrayAccess $values */ public function whereNotIn(string $field, string|UnitEnum|array|ArrayAccess $values): self { - return $this->where($field, $values, WhereOperator::NOT_IN); + return $this->whereField($field, $values, WhereOperator::NOT_IN); } /** @@ -128,7 +142,7 @@ public function whereNotIn(string $field, string|UnitEnum|array|ArrayAccess $val */ public function whereBetween(string $field, DateTimeInterface|string|float|int|Countable $min, DateTimeInterface|string|float|int|Countable $max): self { - return $this->where($field, [$min, $max], WhereOperator::BETWEEN); + return $this->whereField($field, [$min, $max], WhereOperator::BETWEEN); } /** @@ -138,7 +152,7 @@ public function whereBetween(string $field, DateTimeInterface|string|float|int|C */ public function whereNotBetween(string $field, DateTimeInterface|string|float|int|Countable $min, DateTimeInterface|string|float|int|Countable $max): self { - return $this->where($field, [$min, $max], WhereOperator::NOT_BETWEEN); + return $this->whereField($field, [$min, $max], WhereOperator::NOT_BETWEEN); } /** @@ -148,7 +162,7 @@ public function whereNotBetween(string $field, DateTimeInterface|string|float|in */ public function whereNull(string $field): self { - return $this->where($field, null, WhereOperator::IS_NULL); + return $this->whereField($field, null, WhereOperator::IS_NULL); } /** @@ -158,7 +172,7 @@ public function whereNull(string $field): self */ public function whereNotNull(string $field): self { - return $this->where($field, null, WhereOperator::IS_NOT_NULL); + return $this->whereField($field, null, WhereOperator::IS_NOT_NULL); } /** @@ -168,7 +182,7 @@ public function whereNotNull(string $field): self */ public function whereNot(string $field, mixed $value): self { - return $this->where($field, $value, WhereOperator::NOT_EQUALS); + return $this->whereField($field, $value, WhereOperator::NOT_EQUALS); } /** @@ -178,7 +192,7 @@ public function whereNot(string $field, mixed $value): self */ public function whereLike(string $field, string $value): self { - return $this->where($field, $value, WhereOperator::LIKE); + return $this->whereField($field, $value, WhereOperator::LIKE); } /** @@ -188,7 +202,7 @@ public function whereLike(string $field, string $value): self */ public function whereNotLike(string $field, string $value): self { - return $this->where($field, $value, WhereOperator::NOT_LIKE); + return $this->whereField($field, $value, WhereOperator::NOT_LIKE); } /** @@ -388,7 +402,7 @@ public function whereLastYear(string $field): self */ public function whereAfter(string $field, DateTimeInterface|string $date): self { - return $this->where($field, DateTime::parse($date), WhereOperator::GREATER_THAN); + return $this->whereField($field, DateTime::parse($date), WhereOperator::GREATER_THAN); } /** @@ -398,7 +412,7 @@ public function whereAfter(string $field, DateTimeInterface|string $date): self */ public function whereBefore(string $field, DateTimeInterface|string $date): self { - return $this->where($field, DateTime::parse($date), WhereOperator::LESS_THAN); + return $this->whereField($field, DateTime::parse($date), WhereOperator::LESS_THAN); } /** @@ -486,7 +500,7 @@ public function orWhereBefore(string $field, DateTimeInterface|string $date): se * * @return self */ - abstract public function where(string $field, mixed $value, string|WhereOperator $operator = WhereOperator::EQUALS): self; + abstract public function whereField(string $field, mixed $value, string|WhereOperator $operator = WhereOperator::EQUALS): self; /** * Abstract method that must be implemented by classes using this trait. diff --git a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php index 733534769..d34dcef9f 100644 --- a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php @@ -7,6 +7,7 @@ use Tempest\Database\Builder\WhereOperator; use Tempest\Database\QueryStatements\HasWhereStatements; use Tempest\Database\QueryStatements\WhereStatement; +use Tempest\Support\Str; use function Tempest\Support\str; @@ -23,12 +24,31 @@ abstract private function getModel(): ModelInspector; abstract private function getStatementForWhere(): HasWhereStatements; + /** + * Adds a SQL `WHERE` condition to the query. If the `$statement` looks like raw SQL, the method will assume it is and call `whereRaw`. Otherwise, `whereField` will be called. + * + * **Example** + * ```php + * ->where('price > ?', $value); // calls `whereRaw` + * ->where('price', $value); // calls `whereField` + * ``` + * @return self + */ + public function where(string $statement, mixed ...$bindings): self + { + if ($this->looksLikeWhereRawStatement($statement, $bindings)) { + return $this->whereRaw($statement, ...$bindings); + } + + return $this->whereField($statement, value: $bindings[0], operator: $bindings[1] ?? WhereOperator::EQUALS); + } + /** * Adds a where condition to the query. * * @return self */ - public function where(string $field, mixed $value, string|WhereOperator $operator = WhereOperator::EQUALS): self + public function whereField(string $field, mixed $value, string|WhereOperator $operator = WhereOperator::EQUALS): self { $operator = WhereOperator::fromOperator($operator); $fieldDefinition = $this->getModel()->getFieldDefinition($field); @@ -83,13 +103,13 @@ public function orWhere(string $field, mixed $value, WhereOperator $operator = W * * @return self */ - public function whereRaw(string $rawCondition, mixed ...$bindings): self + public function whereRaw(string $statement, mixed ...$bindings): self { - if ($this->getStatementForWhere()->where->isNotEmpty() && ! str($rawCondition)->trim()->startsWith(['AND', 'OR'])) { - return $this->andWhereRaw($rawCondition, ...$bindings); + if ($this->getStatementForWhere()->where->isNotEmpty() && ! str($statement)->trim()->startsWith(['AND', 'OR'])) { + return $this->andWhereRaw($statement, ...$bindings); } - $this->getStatementForWhere()->where[] = new WhereStatement($rawCondition); + $this->getStatementForWhere()->where[] = new WhereStatement($statement); $this->bind(...$bindings); return $this; diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index 96750fbe0..a770e9f2d 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -233,7 +233,7 @@ public function find(mixed ...$conditions): SelectQueryBuilder $query = $this->select(); foreach ($conditions as $field => $value) { - $query->where($field, $value); + $query->whereField($field, $value); } return $query; @@ -290,7 +290,7 @@ public function findOrNew(array $find, array $update): object $existing = $this->select(); foreach ($find as $key => $value) { - $existing = $existing->where($key, $value); + $existing = $existing->whereField($key, $value); } $model = $existing->first() ?? $this->new(...$find); diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index e61cfb75d..f0d36d08a 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -119,7 +119,7 @@ public function get(PrimaryKey $id): mixed throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model->getName(), 'get'); } - return $this->where($this->model->getPrimaryKey(), $id)->first(); + return $this->whereField($this->model->getPrimaryKey(), $id)->first(); } /** diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index a96263d18..73f0d4bd2 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -350,7 +350,7 @@ private function deleteExistingHasManyRelations($hasMany, PrimaryKey $parentId): : $this->getDefaultForeignKeyName(); new DeleteQueryBuilder($relatedModel->getName()) - ->where($foreignKey, $parentId->value) + ->whereField($foreignKey, $parentId->value) ->build() ->onDatabase($this->onDatabase) ->execute(); @@ -377,7 +377,7 @@ private function deleteCustomHasOneRelation($hasOne, PrimaryKey $parentId): void $foreignKeyColumn = $hasOne->relationJoin ?? $this->removeTablePrefix($hasOne->ownerJoin); $result = new SelectQueryBuilder($ownerModel->getName(), new ImmutableArray([$foreignKeyColumn])) - ->where($ownerModel->getPrimaryKey(), $parentId->value) + ->whereField($ownerModel->getPrimaryKey(), $parentId->value) ->build() ->onDatabase($this->onDatabase) ->fetchFirst(); @@ -389,13 +389,13 @@ private function deleteCustomHasOneRelation($hasOne, PrimaryKey $parentId): void $relatedId = $result[$foreignKeyColumn]; new DeleteQueryBuilder($relatedModel->getName()) - ->where($relatedModel->getPrimaryKey(), $relatedId) + ->whereField($relatedModel->getPrimaryKey(), $relatedId) ->build() ->onDatabase($this->onDatabase) ->execute(); new UpdateQueryBuilder($ownerModel->getName(), [$foreignKeyColumn => null], $this->serializerFactory) - ->where($ownerModel->getPrimaryKey(), $parentId->value) + ->whereField($ownerModel->getPrimaryKey(), $parentId->value) ->build() ->onDatabase($this->onDatabase) ->execute(); @@ -411,7 +411,7 @@ private function deleteStandardHasOneRelation($hasOne, PrimaryKey $parentId): vo $foreignKeyColumn = Intl\singularize($ownerModel->getTableName()) . '_' . $ownerModel->getPrimaryKey(); new DeleteQueryBuilder($relatedModel->getName()) - ->where($foreignKeyColumn, $parentId->value) + ->whereField($foreignKeyColumn, $parentId->value) ->build() ->onDatabase($this->onDatabase) ->execute(); @@ -431,7 +431,7 @@ private function handleCustomHasOneRelation($hasOne, object|array $relation, Pri $foreignKeyColumn = $hasOne->relationJoin ?? $this->removeTablePrefix($hasOne->ownerJoin); new UpdateQueryBuilder($ownerModel->getName(), [$foreignKeyColumn => $relatedModelId->value], $this->serializerFactory) - ->where($ownerModel->getPrimaryKey(), $parentId->value) + ->whereField($ownerModel->getPrimaryKey(), $parentId->value) ->build() ->onDatabase($this->onDatabase) ->execute(); @@ -510,7 +510,7 @@ private function removeTablePrefix(string $column): string * * @return self */ - public function where(string $field, mixed $value, string|WhereOperator $operator = WhereOperator::EQUALS): self + public function whereField(string $field, mixed $value, string|WhereOperator $operator = WhereOperator::EQUALS): self { $operator = WhereOperator::fromOperator($operator); @@ -586,7 +586,7 @@ private function setWhereForObjectModel(): void } if ($primaryKeyValue = $this->model->getPrimaryKeyValue()) { - $this->where($this->model->getPrimaryKey(), $primaryKeyValue->value); + $this->whereField($this->model->getPrimaryKey(), $primaryKeyValue->value); } } } diff --git a/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php b/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php index 5060420db..6fac0afee 100644 --- a/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php @@ -7,6 +7,7 @@ use Tempest\Database\Builder\WhereOperator; use Tempest\Database\QueryStatements\WhereGroupStatement; use Tempest\Database\QueryStatements\WhereStatement; +use Tempest\Support\Str; use function Tempest\Support\arr; use function Tempest\Support\str; @@ -29,12 +30,31 @@ public function __construct( private readonly ModelInspector $model, ) {} + /** + * Adds a SQL `WHERE` condition to the query. If the `$statement` looks like raw SQL, the method will assume it is and call `whereRaw`. Otherwise, `whereField` will be called. + * + * **Example** + * ```php + * ->where('price > ?', $value); // calls `whereRaw` + * ->where('price', $value); // calls `whereField` + * ``` + * @return self + */ + public function where(string $statement, mixed ...$bindings): self + { + if ($this->looksLikeWhereRawStatement($statement, $bindings)) { + return $this->whereRaw($statement, ...$bindings); + } + + return $this->whereField($statement, value: $bindings[0], operator: $bindings[1] ?? WhereOperator::EQUALS); + } + /** * Adds a `WHERE` condition to the group. * * @return self */ - public function where(string $field, mixed $value = null, string|WhereOperator $operator = WhereOperator::EQUALS): self + public function whereField(string $field, mixed $value = null, string|WhereOperator $operator = WhereOperator::EQUALS): self { return $this->andWhere($field, $value, WhereOperator::fromOperator($operator)); } diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index d0fd2f87c..66978bb32 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -269,7 +269,7 @@ public function update(mixed ...$params): self query($this) ->update(...$params) - ->where($model->getPrimaryKey(), $model->getPrimaryKeyValue()) + ->whereField($model->getPrimaryKey(), $model->getPrimaryKeyValue()) ->execute(); foreach ($params as $key => $value) { diff --git a/tests/Integration/Database/Builder/UpdateRelationsTest.php b/tests/Integration/Database/Builder/UpdateRelationsTest.php index 1fc99b8ad..ca5ac052d 100644 --- a/tests/Integration/Database/Builder/UpdateRelationsTest.php +++ b/tests/Integration/Database/Builder/UpdateRelationsTest.php @@ -25,7 +25,6 @@ use Tests\Tempest\Fixtures\Modules\Books\Models\Isbn; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; -use function Tempest\Database\inspect; use function Tempest\Database\query; final class UpdateRelationsTest extends FrameworkIntegrationTestCase diff --git a/tests/Integration/Database/Builder/WhereOperatorTest.php b/tests/Integration/Database/Builder/WhereOperatorTest.php index 5fa44e140..3389effd9 100644 --- a/tests/Integration/Database/Builder/WhereOperatorTest.php +++ b/tests/Integration/Database/Builder/WhereOperatorTest.php @@ -12,37 +12,102 @@ final class WhereOperatorTest extends FrameworkIntegrationTestCase { - public function test_basic_where_with_field_and_value(): void + public function test_hybrid_where_equals(): void { $query = query('books') ->select() - ->where('title', 'Test Book') + ->where('`title` = ?', 'Timeline Taxi') ->build(); - $expected = 'SELECT * FROM books WHERE books.title = ?'; + $expected = 'SELECT * FROM `books` WHERE `title` = ?'; $this->assertSameWithoutBackticks($expected, $query->compile()); - $this->assertSame(['Test Book'], $query->bindings); + $this->assertSame(['Timeline Taxi'], $query->bindings); + } + + public function test_hybrid_where_superior(): void + { + $query = query('books') + ->select() + ->where('`price` > ?', 20) + ->build(); + + $expected = 'SELECT * FROM `books` WHERE `price` > ?'; + + $this->assertSameWithoutBackticks($expected, $query->compile()); + $this->assertSame([20], $query->bindings); } - public function test_where_with_explicit_operator(): void + public function test_hybrid_where_field(): void + { + $query = query('books') + ->select() + ->where('title', 'Timeline Taxi') + ->build(); + + $expected = 'SELECT * FROM `books` WHERE `books.title` = ?'; + + $this->assertSameWithoutBackticks($expected, $query->compile()); + $this->assertSame(['Timeline Taxi'], $query->bindings); + } + + public function test_hybrid_where_with_operator(): void { $query = query('books') ->select() ->where('rating', 4.0, WhereOperator::GREATER_THAN) ->build(); + $expected = 'SELECT * FROM `books` WHERE `books.rating` > ?'; + + $this->assertSameWithoutBackticks($expected, $query->compile()); + $this->assertSame([4.0], $query->bindings); + } + + public function test_where_field(): void + { + $query = query('books') + ->select() + ->whereField('title', 'Timeline Taxi') + ->build(); + + $expected = 'SELECT * FROM `books` WHERE `books.title` = ?'; + + $this->assertSameWithoutBackticks($expected, $query->compile()); + $this->assertSame(['Timeline Taxi'], $query->bindings); + } + + public function test_where_field_explicit_operator(): void + { + $query = query('books') + ->select() + ->whereField('rating', 4.0, WhereOperator::GREATER_THAN) + ->build(); + $expected = 'SELECT * FROM books WHERE books.rating > ?'; $this->assertSameWithoutBackticks($expected, $query->compile()); $this->assertSame([4.0], $query->bindings); } + public function test_basic_where_with_field_and_value(): void + { + $query = query('books') + ->select() + ->where('title', 'Test Book') + ->build(); + + $expected = 'SELECT * FROM books WHERE books.title = ?'; + + $this->assertSameWithoutBackticks($expected, $query->compile()); + $this->assertSame(['Test Book'], $query->bindings); + } + public function test_where_with_string_operator(): void { $query = query('books') ->select() - ->where('title', '%fantasy%', 'like') + ->whereField('title', '%fantasy%', 'like') ->build(); $expected = 'SELECT * FROM books WHERE books.title LIKE ?'; @@ -55,7 +120,7 @@ public function test_where_in_operator(): void { $query = query('books') ->select() - ->where('category', ['fiction', 'mystery', 'thriller'], WhereOperator::IN) + ->whereField('category', ['fiction', 'mystery', 'thriller'], WhereOperator::IN) ->build(); $expected = 'SELECT * FROM books WHERE books.category IN (?,?,?)'; @@ -68,7 +133,7 @@ public function test_where_between_operator(): void { $query = query('books') ->select() - ->where('publication_year', [2020, 2024], WhereOperator::BETWEEN) + ->whereField('publication_year', [2020, 2024], WhereOperator::BETWEEN) ->build(); $expected = 'SELECT * FROM books WHERE books.publication_year BETWEEN ? AND ?'; @@ -81,7 +146,7 @@ public function test_where_is_null_operator(): void { $query = query('books') ->select() - ->where('deleted_at', null, WhereOperator::IS_NULL) + ->whereField('deleted_at', null, WhereOperator::IS_NULL) ->build(); $expected = 'SELECT * FROM books WHERE books.deleted_at IS NULL'; @@ -144,7 +209,7 @@ public function test_mixed_raw_and_typed_conditions_in_groups(): void ->where('status', 'published') ->andWhereGroup(function ($group): void { $group - ->where('category', ['fiction', 'mystery'], WhereOperator::IN) + ->whereField('category', ['fiction', 'mystery'], WhereOperator::IN) ->orWhereRaw('custom_field IS NOT NULL'); }) ->build(); @@ -162,7 +227,7 @@ public function test_error_handling_for_in_operator_without_array(): void query('books') ->select() - ->where('category', 'fiction', WhereOperator::IN) + ->whereField('category', 'fiction', WhereOperator::IN) ->build(); } @@ -173,7 +238,7 @@ public function test_error_handling_for_between_operator_with_wrong_array_size() query('books') ->select() - ->where('year', [2020, 2021, 2022], WhereOperator::BETWEEN) + ->whereField('year', [2020, 2021, 2022], WhereOperator::BETWEEN) ->build(); } } diff --git a/tests/Integration/Database/ConvenientWhereMethodsTest.php b/tests/Integration/Database/ConvenientWhereMethodsTest.php index eeb4a2ec3..39aaf9878 100644 --- a/tests/Integration/Database/ConvenientWhereMethodsTest.php +++ b/tests/Integration/Database/ConvenientWhereMethodsTest.php @@ -379,7 +379,7 @@ public function test_where_between_throws_exception_for_invalid_array(): void query(User::class) ->select() - ->where('age', [25], WhereOperator::BETWEEN) + ->whereField('age', [25], WhereOperator::BETWEEN) ->all(); } @@ -390,7 +390,7 @@ public function test_where_between_throws_exception_for_too_many_values(): void query(User::class) ->select() - ->where('age', [25, 30, 35], WhereOperator::BETWEEN) + ->whereField('age', [25, 30, 35], WhereOperator::BETWEEN) ->all(); } } diff --git a/tests/Integration/Database/GroupedWhereMethodsTest.php b/tests/Integration/Database/GroupedWhereMethodsTest.php index 6839ca438..66ba60e80 100644 --- a/tests/Integration/Database/GroupedWhereMethodsTest.php +++ b/tests/Integration/Database/GroupedWhereMethodsTest.php @@ -80,7 +80,7 @@ public function test_and_where_group(): void ->where('category', 'electronics') ->andWhereGroup(function ($query): void { $query - ->where('price', 100.0, WhereOperator::GREATER_THAN) + ->whereField('price', 100.0, WhereOperator::GREATER_THAN) ->where('in_stock', true); }) ->all(); @@ -99,7 +99,7 @@ public function test_or_where_group(): void ->where('category', 'furniture') ->orWhereGroup(function ($query): void { $query - ->where('price', 500.0, WhereOperator::GREATER_THAN) + ->whereField('price', 500.0, WhereOperator::GREATER_THAN) ->where('brand', 'TechCorp'); }) ->all(); @@ -131,7 +131,7 @@ public function test_nested_where_groups(): void ->orWhereGroup(function ($subQuery): void { $subQuery ->where('category', 'furniture') - ->where('price', 200.0, WhereOperator::LESS_THAN); + ->whereField('price', 200.0, WhereOperator::LESS_THAN); }); }) ->where('in_stock', true) @@ -309,7 +309,7 @@ public function test_deeply_nested_where_groups(): void ->where('category', 'furniture') ->andWhereGroup(function ($deepQuery): void { $deepQuery - ->where('price', 150.0, WhereOperator::GREATER_THAN) + ->whereField('price', 150.0, WhereOperator::GREATER_THAN) ->orWhere('brand', 'LightUp'); }); }); diff --git a/tests/Integration/Database/ToRawSqlTest.php b/tests/Integration/Database/ToRawSqlTest.php index 1177130d9..f54cdb9c6 100644 --- a/tests/Integration/Database/ToRawSqlTest.php +++ b/tests/Integration/Database/ToRawSqlTest.php @@ -57,8 +57,8 @@ public function test_select_query_to_raw_sql_with_raw_where(): void expected: "SELECT * FROM books WHERE published_date > '2020-01-01' AND rating >= 4.5", actual: query('books') ->select() - ->whereRaw('published_date > ?', '2020-01-01') - ->whereRaw('rating >= ?', 4.5) + ->where('published_date > ?', '2020-01-01') + ->where('rating >= ?', 4.5) ->toRawSql() ->toString(), ); @@ -330,7 +330,7 @@ public function test_raw_sql_with_raw_subquery_in_where(): void { $rawSql = query('books') ->select() - ->whereRaw('author_id IN (SELECT id FROM authors WHERE type = ?)', 'a') + ->where('author_id IN (SELECT id FROM authors WHERE type = ?)', 'a') ->toRawSql() ->toString(); diff --git a/tests/Integration/Framework/Commands/DatabaseSeedCommandTest.php b/tests/Integration/Framework/Commands/DatabaseSeedCommandTest.php index 626c640b2..c2816e717 100644 --- a/tests/Integration/Framework/Commands/DatabaseSeedCommandTest.php +++ b/tests/Integration/Framework/Commands/DatabaseSeedCommandTest.php @@ -120,10 +120,10 @@ public function test_seed_via_migrate_fresh(): void $this->assertSame(2, query(Book::class)->count()->execute()); - $book = Book::select()->where('title', 'Timeline Taxi')->first(); + $book = Book::select()->whereField('title', 'Timeline Taxi')->first(); $this->assertNotNull($book); - $book = Book::select()->where('title', 'Timeline Taxi 2')->first(); + $book = Book::select()->whereField('title', 'Timeline Taxi 2')->first(); $this->assertNotNull($book); } From fcaa7a86af532b0c070c4f99cc3dc6560ed54fa2 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 13 Aug 2025 18:05:26 +0200 Subject: [PATCH 50/51] fix(database): reload loaded relations when using `refresh` Closes #1491 --- packages/database/src/IsDatabaseModel.php | 15 ++-- .../Database/ModelsWithoutIdTest.php | 86 +++++++++++++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 66978bb32..3467f8375 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -170,19 +170,20 @@ public function refresh(): self throw Exceptions\ModelDidNotHavePrimaryColumn::neededForMethod($this, 'refresh'); } - $primaryKeyProperty = $model->getPrimaryKeyProperty(); - $primaryKeyValue = $primaryKeyProperty->getValue($this); - - $refreshed = self::find(id: $primaryKeyValue)->first(); + $relations = []; - foreach (new ClassReflector($refreshed)->getPublicProperties() as $property) { - if ($property->hasAttribute(Virtual::class)) { + foreach (new ClassReflector($this)->getPublicProperties() as $property) { + if (! $property->getValue($this)) { continue; } - $property->setValue($this, $property->getValue($refreshed)); + if ($model->isRelation($property->getName())) { + $relations[] = $property->getName(); + } } + $this->load(...$relations); + return $this; } diff --git a/tests/Integration/Database/ModelsWithoutIdTest.php b/tests/Integration/Database/ModelsWithoutIdTest.php index f7ffa5257..7b8d58a3a 100644 --- a/tests/Integration/Database/ModelsWithoutIdTest.php +++ b/tests/Integration/Database/ModelsWithoutIdTest.php @@ -234,6 +234,55 @@ public function test_refresh_works_for_models_with_id(): void $this->assertSame('data', $mixed->another_field); } + public function test_refresh_works_for_models_with_unloaded_relation(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateTestUserMigration::class, + CreateTestProfileMigration::class, + ); + + $user = query(TestUser::class)->create( + name: 'Frieren', + email: 'frieren@magic.elf', + ); + + query(TestProfile::class)->create( + user: $user, + bio: 'Ancient elf mage', + age: 1000, + ); + + // Get user without loading the profile relation + $userWithoutProfile = query(TestUser::class)->findById($user->id); + + $this->assertNull($userWithoutProfile->profile); + + // Update the user's name in the database + query(TestUser::class) + ->update(name: 'Frieren the Mage') + ->where('id', $user->id->value) + ->execute(); + + // Refresh should work even with unloaded relations + $userWithoutProfile->refresh(); + + $this->assertSame('Frieren the Mage', $userWithoutProfile->name); + $this->assertSame('frieren@magic.elf', $userWithoutProfile->email); + $this->assertNull($userWithoutProfile->profile); // Relation should still be unloaded + + // Load the relation + $userWithoutProfile->load('profile'); + + $this->assertInstanceOf(TestProfile::class, $userWithoutProfile->profile); + $this->assertSame('Ancient elf mage', $userWithoutProfile->profile->bio); + $this->assertSame(1000, $userWithoutProfile->profile->age); + + $userWithoutProfile->refresh(); + + $this->assertInstanceOf(TestProfile::class, $userWithoutProfile->profile); + } + public function test_load_works_for_models_with_id(): void { $this->migrate(CreateMigrationsTable::class, CreateMixedModelMigration::class); @@ -272,6 +321,43 @@ public function test_load_with_relation_works_for_models_with_id(): void $this->assertSame('Ancient elf mage who loves magic and collecting spells', $user->profile->bio); $this->assertSame(1000, $user->profile->age); } + + // this may be a bug, but I'm adding a test just to be sure we don't break the behavior by mistake. + // I believe ->load should just load the specified relations, but it also reloads all properties + public function test_load_method_refreshes_all_properties_not_just_relations(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateTestUserMigration::class, + CreateTestProfileMigration::class, + ); + + $user = query(TestUser::class)->create( + name: 'Frieren', + email: 'frieren@magic.elf', + ); + + query(TestProfile::class)->create( + user: $user, + bio: 'Ancient elf mage', + age: 1000, + ); + + $userInstance = query(TestUser::class)->findById($user->id); + $userInstance->name = 'Fern'; + + query(TestUser::class) + ->update(email: 'updated@magic.elf') + ->where('id', $user->id->value) + ->execute(); + + $userInstance->load('profile'); + + $this->assertSame('Frieren', $userInstance->name); // "Fern" was discarded here + $this->assertSame('updated@magic.elf', $userInstance->email); + $this->assertInstanceOf(TestProfile::class, $userInstance->profile); + $this->assertNotNull($userInstance->profile->bio); + } } final class LogEntry From 8d9a53ef6ed6cb3413efc47854ff3c08f5ce7e89 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 13 Aug 2025 18:14:18 +0200 Subject: [PATCH 51/51] test: remove redundant assertion --- tests/Integration/Database/ModelsWithoutIdTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Integration/Database/ModelsWithoutIdTest.php b/tests/Integration/Database/ModelsWithoutIdTest.php index 7b8d58a3a..dc3064288 100644 --- a/tests/Integration/Database/ModelsWithoutIdTest.php +++ b/tests/Integration/Database/ModelsWithoutIdTest.php @@ -356,7 +356,6 @@ public function test_load_method_refreshes_all_properties_not_just_relations(): $this->assertSame('Frieren', $userInstance->name); // "Fern" was discarded here $this->assertSame('updated@magic.elf', $userInstance->email); $this->assertInstanceOf(TestProfile::class, $userInstance->profile); - $this->assertNotNull($userInstance->profile->bio); } }