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 36d457201..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( @@ -78,7 +81,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/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/BelongsTo.php b/packages/database/src/BelongsTo.php index 8722ad539..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; @@ -46,14 +47,19 @@ public function getOwnerFieldName(): string } } - $relationModel = model($this->property->getType()->asClass()); + $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 { - $relationModel = model($this->property->getType()->asClass()); + $relationModel = inspect($this->property->getType()->asClass()); return $relationModel ->getSelectFields() @@ -70,8 +76,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); @@ -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 8fd3f9803..eb5c44681 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -6,9 +6,11 @@ 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; +use Tempest\Database\PrimaryKey; use Tempest\Database\Relation; use Tempest\Database\Table; use Tempest\Database\Virtual; @@ -21,7 +23,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 +294,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 +336,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; } } @@ -357,6 +359,10 @@ public function validate(mixed ...$data): void continue; } + if ($property->getType()->getName() === PrimaryKey::class) { + continue; + } + $failingRulesForProperty = $this->validator->validateValueForProperty( $property, $value, @@ -381,17 +387,45 @@ 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 + public function getPrimaryKey(): ?string { - return 'id'; + return $this->getPrimaryKeyProperty()?->getName(); + } + + public function hasPrimaryKey(): bool + { + return $this->getPrimaryKeyProperty() !== null; + } + + public function getPrimaryKeyProperty(): ?PropertyReflector + { + if (! $this->isObjectModel()) { + return null; + } + + $primaryKeys = arr($this->reflector->getProperties()) + ->filter(fn (PropertyReflector $property) => $property->getType()->matches(PrimaryKey::class)); + + return match ($primaryKeys->count()) { + 0 => null, + 1 => $primaryKeys->first(), + default => throw ModelHadMultiplePrimaryColumns::found( + model: $this->model, + properties: $primaryKeys->map(fn (PropertyReflector $property) => $property->getName())->toArray(), + ), + }; } - public function getPrimaryKeyValue(): ?Id + public function getPrimaryKeyValue(): ?PrimaryKey { if (! $this->isObjectModel()) { return null; @@ -401,6 +435,16 @@ public function getPrimaryKeyValue(): ?Id return null; } - return $this->instance->{$this->getPrimaryKey()}; + $primaryKeyProperty = $this->getPrimaryKeyProperty(); + + if ($primaryKeyProperty === null) { + return null; + } + + if (! $primaryKeyProperty->isInitialized($this->instance)) { + return null; + } + + return $primaryKeyProperty->getValue($this->instance); } } 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 edda35f1b..1115811a5 100644 --- a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php @@ -11,17 +11,18 @@ 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; +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 + * @use \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods */ final class CountQueryBuilder implements BuildsQuery { - use HasConditions, OnDatabase, HasWhereQueryBuilderMethods; + use HasConditions, OnDatabase, HasWhereQueryBuilderMethods, TransformsQueryBuilder; private CountStatement $count; @@ -30,11 +31,11 @@ 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) { - $this->model = model($model); + $this->model = inspect($model); $this->count = new CountStatement( table: $this->model->getTableDefinition(), @@ -42,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 === '*') { @@ -59,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]; @@ -67,9 +79,20 @@ public function bind(mixed ...$bindings): self return $this; } - public function toSql(): string + /** + * Compile the query to a SQL statement without the bindings. + */ + public function compile(): ImmutableString + { + return $this->build()->compile(); + } + + /** + * 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()->toSql(); + return $this->build()->toRawSql(); } public function build(mixed ...$bindings): Query diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index f67ff0355..6ecd72fa1 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -8,17 +8,18 @@ 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; +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 + * @use \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods */ final class DeleteQueryBuilder implements BuildsQuery { - use HasConditions, OnDatabase, HasWhereQueryBuilderMethods; + use HasConditions, OnDatabase, HasWhereQueryBuilderMethods, TransformsQueryBuilder; private DeleteStatement $delete; @@ -27,20 +28,27 @@ 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) { - $this->model = model($model); + $this->model = inspect($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; @@ -48,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]; @@ -56,18 +68,30 @@ public function bind(mixed ...$bindings): self return $this; } - public function toSql(): string + /** + * Compile the query to a SQL statement without the bindings. + */ + public function compile(): ImmutableString + { + return $this->build()->compile(); + } + + /** + * 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()->toSql(); + return $this->build()->toRawSql(); } public function build(mixed ...$bindings): Query { - if ($this->model->isObjectModel() && is_object($this->model->instance)) { - $this->whereField( - $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->value); + } } return new Query($this->delete, [...$this->bindings, ...$bindings])->onDatabase($this->onDatabase); diff --git a/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php b/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php new file mode 100644 index 000000000..03ca3c256 --- /dev/null +++ b/packages/database/src/Builder/QueryBuilders/HasConvenientWhereMethods.php @@ -0,0 +1,512 @@ + } + */ + 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 (DateTimeInterface|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, + ]; + } + + 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. + * + * @param class-string|UnitEnum|array $values + * + * @return self + */ + public function whereIn(string $field, string|UnitEnum|array|ArrayAccess $values): self + { + return $this->whereField($field, $values, WhereOperator::IN); + } + + /** + * Adds a `WHERE NOT IN` condition. + * + * @param class-string|UnitEnum|array $values + * + * @return self + */ + public function whereNotIn(string $field, string|UnitEnum|array|ArrayAccess $values): self + { + return $this->whereField($field, $values, WhereOperator::NOT_IN); + } + + /** + * Adds a `WHERE BETWEEN` condition. + * + * @return self + */ + public function whereBetween(string $field, DateTimeInterface|string|float|int|Countable $min, DateTimeInterface|string|float|int|Countable $max): self + { + return $this->whereField($field, [$min, $max], WhereOperator::BETWEEN); + } + + /** + * Adds a `WHERE NOT BETWEEN` condition. + * + * @return self + */ + public function whereNotBetween(string $field, DateTimeInterface|string|float|int|Countable $min, DateTimeInterface|string|float|int|Countable $max): self + { + return $this->whereField($field, [$min, $max], WhereOperator::NOT_BETWEEN); + } + + /** + * Adds a `WHERE IS NULL` condition. + * + * @return self + */ + public function whereNull(string $field): self + { + return $this->whereField($field, null, WhereOperator::IS_NULL); + } + + /** + * Adds a `WHERE IS NOT NULL` condition. + * + * @return self + */ + public function whereNotNull(string $field): self + { + return $this->whereField($field, null, WhereOperator::IS_NOT_NULL); + } + + /** + * Adds a `WHERE NOT` condition (shorthand for != operator). + * + * @return self + */ + public function whereNot(string $field, mixed $value): self + { + return $this->whereField($field, $value, WhereOperator::NOT_EQUALS); + } + + /** + * Adds a `WHERE LIKE` condition. + * + * @return self + */ + public function whereLike(string $field, string $value): self + { + return $this->whereField($field, $value, WhereOperator::LIKE); + } + + /** + * Adds a `WHERE NOT LIKE` condition. + * + * @return self + */ + public function whereNotLike(string $field, string $value): self + { + return $this->whereField($field, $value, WhereOperator::NOT_LIKE); + } + + /** + * Adds an `OR WHERE IN` condition. + * + * @param class-string|UnitEnum|array $values + * + * @return self + */ + 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 + * + * @return self + */ + 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. + * + * @return 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); + } + + /** + * Adds an `OR WHERE NOT BETWEEN` condition. + * + * @return 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); + } + + /** + * Adds an `OR WHERE IS NULL` condition. + * + * @return self + */ + public function orWhereNull(string $field): self + { + return $this->orWhere($field, null, WhereOperator::IS_NULL); + } + + /** + * Adds an `OR WHERE IS NOT NULL` condition. + * + * @return self + */ + public function orWhereNotNull(string $field): self + { + return $this->orWhere($field, null, WhereOperator::IS_NOT_NULL); + } + + /** + * Adds an `OR WHERE NOT` condition (shorthand for != operator). + * + * @return self + */ + public function orWhereNot(string $field, mixed $value): self + { + return $this->orWhere($field, $value, WhereOperator::NOT_EQUALS); + } + + /** + * Adds an `OR WHERE LIKE` condition. + * + * @return self + */ + public function orWhereLike(string $field, string $value): self + { + return $this->orWhere($field, $value, WhereOperator::LIKE); + } + + /** + * Adds an `OR WHERE NOT LIKE` condition. + * + * @return self + */ + public function orWhereNotLike(string $field, string $value): self + { + return $this->orWhere($field, $value, WhereOperator::NOT_LIKE); + } + + /** + * Adds a `WHERE` condition for records from today. + * + * @return self + */ + 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. + * + * @return self + */ + 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. + * + * @return self + */ + 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. + * + * @return self + */ + 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. + * + * @return self + */ + 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. + * + * @return self + */ + 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. + * + * @return self + */ + 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. + * + * @return self + */ + 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. + * + * @return self + */ + public function whereAfter(string $field, DateTimeInterface|string $date): self + { + return $this->whereField($field, DateTime::parse($date), WhereOperator::GREATER_THAN); + } + + /** + * Adds a `WHERE` condition for records created before a specific date. + * + * @return self + */ + public function whereBefore(string $field, DateTimeInterface|string $date): self + { + return $this->whereField($field, DateTime::parse($date), WhereOperator::LESS_THAN); + } + + /** + * Adds an `OR WHERE` condition for records from today. + * + * @return self + */ + 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. + * + * @return self + */ + 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. + * + * @return self + */ + 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. + * + * @return self + */ + 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. + * + * @return self + */ + 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. + * + * @return self + */ + public function orWhereAfter(string $field, DateTimeInterface|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. + * + * @return self + */ + public function orWhereBefore(string $field, DateTimeInterface|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. + * + * @return 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. + * Should add an OR WHERE condition. + * + * @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 a58594e54..d34dcef9f 100644 --- a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php @@ -2,53 +2,192 @@ namespace Tempest\Database\Builder\QueryBuilders; +use Closure; use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\Builder\WhereOperator; use Tempest\Database\QueryStatements\HasWhereStatements; use Tempest\Database\QueryStatements\WhereStatement; +use Tempest\Support\Str; use function Tempest\Support\str; /** - * @template TModelClass + * @template TModel of object * @phpstan-require-implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery + * @use \Tempest\Database\Builder\QueryBuilders\HasConvenientWhereMethods */ 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 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 whereField(string $field, mixed $value, string|WhereOperator $operator = WhereOperator::EQUALS): self + { + $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($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 $statement, mixed ...$bindings): self { - if ($this->getStatementForWhere()->where->isNotEmpty() && ! str($where)->trim()->startsWith(['AND', 'OR'])) { - return $this->andWhere($where, ...$bindings); + if ($this->getStatementForWhere()->where->isNotEmpty() && ! str($statement)->trim()->startsWith(['AND', 'OR'])) { + return $this->andWhereRaw($statement, ...$bindings); } - $this->getStatementForWhere()->where[] = new WhereStatement($where); + $this->getStatementForWhere()->where[] = new WhereStatement($statement); + $this->bind(...$bindings); + + return $this; + } + + /** + * Adds a raw SQL `AND WHERE` condition to the query. + * + * @return self + */ + public function andWhereRaw(string $rawCondition, mixed ...$bindings): self + { + $this->getStatementForWhere()->where[] = new WhereStatement("AND {$rawCondition}"); + $this->bind(...$bindings); + + return $this; + } + /** + * Adds a raw SQL `OR WHERE` condition to the query. + * + * @return self + */ + public function orWhereRaw(string $rawCondition, mixed ...$bindings): self + { + $this->getStatementForWhere()->where[] = new WhereStatement("OR {$rawCondition}"); $this->bind(...$bindings); return $this; } - /** @return self */ - public function andWhere(string $where, 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 + */ + public function whereGroup(Closure $callback): self { - return $this->where("AND {$where}", ...$bindings); + $groupBuilder = new WhereGroupBuilder($this->getModel()); + $callback($groupBuilder); + $group = $groupBuilder->build(); + + if (! $group->conditions->isEmpty()) { + $this->getStatementForWhere()->where[] = $group; + $this->bind(...$groupBuilder->getBindings()); + } + + return $this; } - /** @return self */ - public function orWhere(string $where, mixed ...$bindings): 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 + */ + public function andWhereGroup(Closure $callback): self { - return $this->where("OR {$where}", ...$bindings); + if ($this->getStatementForWhere()->where->isNotEmpty()) { + $this->getStatementForWhere()->where[] = new WhereStatement('AND'); + } + + return $this->whereGroup($callback); } - /** @return self */ - public function whereField(string $field, mixed $value): 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 + */ + public function orWhereGroup(Closure $callback): self { - $field = $this->getModel()->getFieldDefinition($field); + 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/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index 0ed9f5114..fcf835a9b 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -6,24 +6,31 @@ use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Exceptions\HasManyRelationCouldNotBeInsterted; use Tempest\Database\Exceptions\HasOneRelationCouldNotBeInserted; -use Tempest\Database\Id; +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\Database\Virtual; +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\model; +use function Tempest\Database\inspect; +use function Tempest\Support\str; /** - * @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 { - use HasConditions, OnDatabase; + use HasConditions, OnDatabase, TransformsQueryBuilder; private InsertStatement $insert; @@ -34,21 +41,28 @@ 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, private readonly array $rows, private readonly SerializerFactory $serializerFactory, ) { - $this->model = model($model); + $this->model = inspect($model); $this->insert = new InsertStatement($this->model->getTableDefinition()); } - public function execute(mixed ...$bindings): Id + /** + * Executes the insert query and returns the primary key of the inserted record. + */ + public function execute(mixed ...$bindings): ?PrimaryKey { $id = $this->build()->execute(...$bindings); + if ($id === null) { + return null; + } + foreach ($this->after as $after) { $query = $after($id); @@ -60,32 +74,42 @@ public function execute(mixed ...$bindings): Id return $id; } - public function toSql(): string + /** + * Compile the query to a SQL statement without the bindings. + */ + public function compile(): ImmutableString + { + return $this->build()->compile(); + } + + /** + * 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()->toSql(); + return $this->build()->toRawSql(); } 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; } $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); } + /** + * Binds the provided values to the query, allowing for parameterized queries. + */ public function bind(mixed ...$bindings): self { $this->bindings = [...$this->bindings, ...$bindings]; @@ -93,6 +117,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]; @@ -100,68 +127,372 @@ 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 Intl\singularize($this->model->getTableName()) . '_' . $this->model->getPrimaryKey(); + } + + private function convertObjectToArray(object $object, array $excludeProperties = []): array + { + $reflection = new ClassReflector($object); + $data = []; + + foreach ($reflection->getPublicProperties() as $property) { + if (! $property->isInitialized($object)) { + continue; + } + + $propertyName = $property->getName(); + + if (! in_array($propertyName, $excludeProperties, strict: 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), + ); + } + + private function resolveModelData(object|iterable $model): array + { + return is_iterable($model) + ? $this->resolveIterableData($model) + : $this->resolveObjectData($model); + } - foreach ($this->rows as $model) { - // Raw entries are straight up added - if (is_array($model) || $model instanceof ImmutableArray) { - $entries[] = $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 = model($model); + if ($this->handleHasOneRelation($key, $value)) { + continue; + } - $modelClass = new ClassReflector($model); + if ($this->handleBelongsToRelation($key, $value, $entry)) { + continue; + } - $entry = []; + $entry[$key] = $this->serializeIterableValue($key, $value); + } - // Including all public properties - foreach ($modelClass->getPublicProperties() as $property) { - if (! $property->isInitialized($model)) { - continue; - } + return $entry; + } - // HasMany and HasOne relations are skipped - if ($definition->getHasMany($property->getName()) || $definition->getHasOne($property->getName())) { - continue; - } + private function handleHasManyRelation(string $key, mixed $relations): bool + { + $hasMany = $this->model->getHasMany($key); + + if ($hasMany === null) { + return false; + } - $column = $property->getName(); + if (! is_iterable($relations)) { + throw new HasManyRelationCouldNotBeInsterted($this->model->getName(), $key); + } + + $this->addHasManyRelationCallback($key, $relations); - $value = $property->getValue($model); + 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; + } - // BelongsTo and reverse HasMany relations are included - if ($definition->isRelation($property)) { - $column .= '_id'; + $propertyName = $property->getName(); - $value = match (true) { - $value === null => null, - isset($value->id) => $value->id->id, - default => new InsertQueryBuilder( - $value::class, - [$value], - $this->serializerFactory, - )->build(), - }; + if ($property->hasAttribute(Virtual::class)) { + continue; + } + + $value = $property->getValue($model); + + if ($definition->getHasMany($propertyName)) { + if (is_iterable($value)) { + $this->addHasManyRelationCallback($propertyName, $value); } - // Check if the value needs serialization - $serializer = $this->serializerFactory->forProperty($property); + continue; + } - if ($value !== null && $serializer !== null) { - $value = $serializer->serialize($value); + if ($definition->getHasOne($propertyName)) { + if (is_object($value) || is_array($value)) { + $this->addHasOneRelationCallback($propertyName, $value); } - $entry[$column] = $value; + continue; + } + + $column = $propertyName; + + 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); } - $entries[] = $entry; + $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 $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 $entries; + return $this->serializeValue( + property: $property, + value: $value, + ); } } diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index 62890a9e1..a770e9f2d 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -2,25 +2,39 @@ 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; /** - * @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, ) {} /** - * @return SelectQueryBuilder + * 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 { @@ -31,7 +45,16 @@ public function select(string ...$columns): SelectQueryBuilder } /** - * @return InsertQueryBuilder + * 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 { @@ -47,7 +70,17 @@ public function insert(mixed ...$values): InsertQueryBuilder } /** - * @return UpdateQueryBuilder + * 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 { @@ -59,7 +92,17 @@ public function update(mixed ...$values): UpdateQueryBuilder } /** - * @return DeleteQueryBuilder + * 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 { @@ -67,7 +110,14 @@ public function delete(): DeleteQueryBuilder } /** - * @return CountQueryBuilder + * 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 { @@ -76,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->whereField($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->whereField($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/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index e9a726e3f..f0d36d08a 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -6,9 +6,11 @@ use Closure; use Tempest\Database\Builder\ModelInspector; -use Tempest\Database\Id; +use Tempest\Database\Direction; +use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; 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; @@ -22,18 +24,19 @@ 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\Database\inspect; 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 + * @use \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods */ final class SelectQueryBuilder implements BuildsQuery { - use HasConditions, OnDatabase, HasWhereQueryBuilderMethods; + use HasConditions, OnDatabase, HasWhereQueryBuilderMethods, TransformsQueryBuilder; private ModelInspector $model; @@ -46,11 +49,11 @@ 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) { - $this->model = model($model); + $this->model = inspect($model); $this->select = new SelectStatement( table: $this->model->getTableDefinition(), @@ -60,7 +63,11 @@ public function __construct(string|object $model, ?ImmutableArray $fields = null ); } - /** @return T|null */ + /** + * Returns the first record matching the query. + * + * @return TModel|null + */ public function first(mixed ...$bindings): mixed { $query = $this->build(...$bindings); @@ -80,7 +87,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(); @@ -97,13 +108,25 @@ public function paginate(int $itemsPerPage = 20, int $currentPage = 1, int $maxL ); } - /** @return T|null */ - public function get(Id $id): mixed + /** + * Returns the first record matching the given primary key. + * + * @return TModel|null + */ + public function get(PrimaryKey $id): mixed { - return $this->whereField('id', $id)->first(); + if (! $this->model->hasPrimaryKey()) { + throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model->getName(), 'get'); + } + + return $this->whereField($this->model->getPrimaryKey(), $id)->first(); } - /** @return T[] */ + /** + * Returns all records matching the query. + * + * @return TModel[] + */ public function all(mixed ...$bindings): array { $query = $this->build(...$bindings); @@ -118,7 +141,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(TModel[]): void $closure */ public function chunk(Closure $closure, int $amountPerChunk = 200): void { @@ -136,15 +161,35 @@ public function chunk(Closure $closure, int $amountPerChunk = 200): void } while ($data !== []); } - /** @return self */ - public function orderBy(string $statement): self + /** + * 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 orderByRaw(string $statement): self { $this->select->orderBy[] = new OrderByStatement($statement); 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); @@ -152,7 +197,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); @@ -162,7 +211,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; @@ -170,7 +223,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; @@ -178,7 +235,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]; @@ -186,7 +247,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]; @@ -194,7 +259,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); @@ -202,7 +271,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]; @@ -210,9 +283,20 @@ public function bind(mixed ...$bindings): self return $this; } - public function toSql(): string + /** + * Compile the query to a SQL statement without the bindings. + */ + public function compile(): ImmutableString + { + return $this->build()->compile(); + } + + /** + * 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()->toSql(); + return $this->build()->toRawSql(); } public function build(mixed ...$bindings): Query @@ -240,7 +324,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/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 @@ + - * @uses \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods + * @template TModel of object + * @implements \Tempest\Database\Builder\QueryBuilders\BuildsQuery + * @use \Tempest\Database\Builder\QueryBuilders\HasWhereQueryBuilderMethods */ final class UpdateQueryBuilder implements BuildsQuery { - use HasConditions, OnDatabase, HasWhereQueryBuilderMethods; + use HasConditions, OnDatabase, HasWhereQueryBuilderMethods, TransformsQueryBuilder; private UpdateStatement $update; @@ -32,27 +40,58 @@ final class UpdateQueryBuilder implements BuildsQuery private ModelInspector $model; + private array $after = []; + + private ?PrimaryKey $primaryKeyForRelations = null; + /** - * @param class-string|string|T $model + * @param class-string|string|TModel $model */ public function __construct( string|object $model, 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(), ); } - public function execute(mixed ...$bindings): ?Id + /** + * Executes the update query and returns the primary key of the updated record. + */ + public function execute(mixed ...$bindings): ?PrimaryKey { - return $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) { + foreach ($this->after as $after) { + $query = $after($this->primaryKeyForRelations); + + if ($query instanceof BuildsQuery) { + $query->build()->execute(); + } + } + } + + return $result; } - /** @return self */ + /** + * Allows the update operation to proceed without WHERE conditions, updating all records. + * + * @return self + */ public function allowAll(): self { $this->update->allowAll = true; @@ -60,7 +99,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]; @@ -68,82 +111,138 @@ public function bind(mixed ...$bindings): self return $this; } - public function toSql(): string + /** + * Compile the query to a SQL statement without the bindings. + */ + public function compile(): ImmutableString { - return $this->build()->toSql(); + return $this->build()->compile(); + } + + /** + * 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(); } public function build(mixed ...$bindings): Query { - $values = $this->resolveValues(); + $values = $this->resolveValuesToUpdate(); - unset($values['id']); + if ($this->model->hasPrimaryKey()) { + unset($values[$this->model->getPrimaryKey()]); + } $this->update->values = $values; - if ($this->model->isObjectModel() && is_object($this->model->instance)) { - $this->whereField( - $this->model->getPrimaryKey(), - $this->model->getPrimaryKeyValue()->id, - ); - } + $this->setWhereForObjectModel(); + + $allBindings = []; foreach ($values as $value) { - $bindings[] = $value; + $allBindings[] = $value; } foreach ($this->bindings as $binding) { - $bindings[] = $binding; + $allBindings[] = $binding; } - return new Query($this->update, $bindings)->onDatabase($this->onDatabase); + foreach ($bindings as $binding) { + $allBindings[] = $binding; + } + + return new Query($this->update, $allBindings)->onDatabase($this->onDatabase); } - private function resolveValues(): ImmutableArray + private function resolveValuesToUpdate(): 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)) { - $column .= '_id'; - - $value = match (true) { - $value === null => null, - isset($value->id) => $value->id->id, - 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); - } - } + $value = $this->serializeValue($property, $value); + + return [$column, $value]; + } - $values[$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; } - return $values; + $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]; + } + + private function serializeValue(PropertyReflector $property, mixed $value): mixed + { + $serializer = $this->serializerFactory->forProperty($property); + + if ($value !== null && $serializer !== null) { + return $serializer->serialize($value); + } + + return $value; + } + + private function ensureModelHasPrimaryKey(ModelInspector $model, string $relationType): void + { + if (! $model->hasPrimaryKey()) { + throw ModelDidNotHavePrimaryColumn::neededForRelation($model->getName(), $relationType); + } } private function getStatementForWhere(): HasWhereStatements @@ -155,4 +254,339 @@ 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(); + + new DeleteQueryBuilder($relatedModel->getName()) + ->whereField($foreignKey, $parentId->value) + ->build() + ->onDatabase($this->onDatabase) + ->execute(); + } + + 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 = new SelectQueryBuilder($ownerModel->getName(), new ImmutableArray([$foreignKeyColumn])) + ->whereField($ownerModel->getPrimaryKey(), $parentId->value) + ->build() + ->onDatabase($this->onDatabase) + ->fetchFirst(); + + if (! $result || ! isset($result[$foreignKeyColumn])) { + return; + } + + $relatedId = $result[$foreignKeyColumn]; + + new DeleteQueryBuilder($relatedModel->getName()) + ->whereField($relatedModel->getPrimaryKey(), $relatedId) + ->build() + ->onDatabase($this->onDatabase) + ->execute(); + + new UpdateQueryBuilder($ownerModel->getName(), [$foreignKeyColumn => null], $this->serializerFactory) + ->whereField($ownerModel->getPrimaryKey(), $parentId->value) + ->build() + ->onDatabase($this->onDatabase) + ->execute(); + } + + 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(); + + new DeleteQueryBuilder($relatedModel->getName()) + ->whereField($foreignKeyColumn, $parentId->value) + ->build() + ->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); + + new UpdateQueryBuilder($ownerModel->getName(), [$foreignKeyColumn => $relatedModelId->value], $this->serializerFactory) + ->whereField($ownerModel->getPrimaryKey(), $parentId->value) + ->build() + ->onDatabase($this->onDatabase) + ->execute(); + + 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->hasAttribute(Virtual::class)) { + continue; + } + + 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 whereField(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 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) { + 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->whereField($this->model->getPrimaryKey(), $primaryKeyValue->value); + } + } } diff --git a/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php b/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php new file mode 100644 index 000000000..6fac0afee --- /dev/null +++ b/packages/database/src/Builder/QueryBuilders/WhereGroupBuilder.php @@ -0,0 +1,216 @@ + + */ +final class WhereGroupBuilder +{ + use HasConvenientWhereMethods; + + /** @var array */ + private array $conditions = []; + + /** @var array */ + private array $bindings = []; + + 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 whereField(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. + * + * @return self + */ + 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. + * + * @return self + */ + 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. + * + * @return self + */ + 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. + * + * @return self + */ + 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. + * + * @return self + */ + 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 + * + * @return self + */ + 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 + * + * @return self + */ + 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 + * + * @return self + */ + 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/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/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/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 @@ -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/Exceptions/ModelDidNotHavePrimaryColumn.php b/packages/database/src/Exceptions/ModelDidNotHavePrimaryColumn.php new file mode 100644 index 000000000..e60b2890a --- /dev/null +++ b/packages/database/src/Exceptions/ModelDidNotHavePrimaryColumn.php @@ -0,0 +1,28 @@ +join(); + + return new self("`{$model}` has multiple `Id` properties ({$propertyNames}). Only one `Id` property is allowed per model."); + } +} diff --git a/packages/database/src/Exceptions/QueryWasInvalid.php b/packages/database/src/Exceptions/QueryWasInvalid.php index 92474e7af..f205ce48b 100644 --- a/packages/database/src/Exceptions/QueryWasInvalid.php +++ b/packages/database/src/Exceptions/QueryWasInvalid.php @@ -6,26 +6,36 @@ 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 . 'bindings: ' . Json\encode($bindings, pretty: true); + $message .= PHP_EOL . PHP_EOL . $query->toRawSql(); parent::__construct( message: $message, 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 2cda2b581..4843e782b 100644 --- a/packages/database/src/GenericDatabase.php +++ b/packages/database/src/GenericDatabase.php @@ -44,8 +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->compile()->toString()); $statement->execute($bindings); $this->lastStatement = $statement; @@ -55,23 +54,29 @@ public function execute(BuildsQuery|Query $query): void } } - public function getLastInsertId(): ?Id + public function getLastInsertId(): ?PrimaryKey { - $sql = $this->lastQuery->toSql(); + $sql = $this->lastQuery->compile(); - // TODO: properly determine whether a query is an insert or not - if (! str_starts_with($sql, 'INSERT')) { + 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 Id::tryFrom($lastInsertId); + return PrimaryKey::tryFrom($this->connection->lastInsertId()); } public function fetch(BuildsQuery|Query $query): array @@ -83,8 +88,7 @@ public function fetch(BuildsQuery|Query $query): array $bindings = $this->resolveBindings($query); try { - $pdoQuery = $this->connection->prepare($query->toSql()); - + $pdoQuery = $this->connection->prepare($query->compile()->toString()); $pdoQuery->execute($bindings); return $pdoQuery->fetchAll(PDO::FETCH_NAMED); @@ -124,6 +128,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)) { diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index b04fe96ad..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; @@ -38,7 +39,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,24 +54,36 @@ public function getSelectFields(): ImmutableArray public function primaryKey(): string { - return model($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 = model($this->property->getIterableType()->asClass()); + $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, ); } 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); @@ -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 878452587..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; @@ -38,7 +39,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 +54,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); @@ -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/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 27a98c1eb..3467f8375 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -5,196 +5,284 @@ namespace Tempest\Database; use Tempest\Database\Builder\QueryBuilders\CountQueryBuilder; +use Tempest\Database\Builder\QueryBuilders\InsertQueryBuilder; 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; -use Tempest\Validation\SkipValidation; -use function Tempest\make; +use function Tempest\Database\query; trait IsDatabaseModel { - #[SkipValidation] - public Id $id; - - public static function new(mixed ...$params): self + /** + * Returns a builder for selecting records using this model's table. + * + * @return SelectQueryBuilder + */ + public static function select(): SelectQueryBuilder { - return make(self::class)->from($params); + return query(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 query(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 query(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 query(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|PrimaryKey $id): static { - if (! ($id instanceof Id)) { - $id = new Id($id); - } - - return self::select() - ->with(...$relations) - ->get($id); + return self::get($id); } - public static function find(mixed ...$conditions): SelectQueryBuilder + /** + * Finds a model instance by its ID. + */ + public static function resolve(string|int|PrimaryKey $id): static { - $query = self::select(); + return query(self::class)->resolve($id); + } - array_walk($conditions, fn ($value, $column) => $query->whereField($column, $value)); + /** + * Gets a model instance by its ID, optionally loading the given relationships. + */ + public static function get(string|int|PrimaryKey $id, array $relations = []): ?self + { + return query(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 query(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 query(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 { - model(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 query(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->where("{$key} = :{$key}"); - } - - $model = $existing->first() ?? self::new(...$find); - - foreach ($update as $key => $value) { - $model->{$key} = $value; - } - - return $model; + return query(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 query(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)); - } - - return $this; - } + $model = inspect($this); - public function __get(string $name): mixed - { - $property = PropertyReflector::fromParts($this, $name); - - if ($property->hasAttribute(Lazy::class)) { - $this->load($name); - - return $property->getValue($this); + if (! $model->hasPrimaryKey()) { + throw Exceptions\ModelDidNotHavePrimaryColumn::neededForMethod($this, 'refresh'); } - $type = $property->getType(); + $relations = []; - if ($type->isRelation()) { - throw new RelationWasMissing($this, $name); - } + foreach (new ClassReflector($this)->getPublicProperties() as $property) { + if (! $property->getValue($this)) { + continue; + } - if ($type->isBuiltIn()) { - throw new ValueWasMissing($this, $name); + if ($model->isRelation($property->getName())) { + $relations[] = $property->getName(); + } } - throw new RelationWasMissing($this, $name); + $this->load(...$relations); + + return $this; } + /** + * Loads the specified relations on the model instance. + */ 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) { + if ($property->hasAttribute(Virtual::class)) { + continue; + } + $property->setValue($this, $property->getValue($new)); } return $this; } + /** + * Saves the model to the database. If the model has no primary key, this method always inserts. + */ public function save(): self { - $model = model($this); + $model = inspect($this); + $model->validate(...inspect($this)->getPropertyValues()); + + // Models without primary keys always insert + if (! $model->hasPrimaryKey()) { + query($this::class) + ->insert($this) + ->execute(); - $model->validate(...model($this)->getPropertyValues()); + return $this; + } + + $primaryKeyProperty = $model->getPrimaryKeyProperty(); + $isInitialized = $primaryKeyProperty->isInitialized($this); + $primaryKeyValue = $isInitialized ? $primaryKeyProperty->getValue($this) : null; - if (! isset($this->id)) { - $query = query($this::class)->insert($this); + // 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(); - $this->id = $query->execute(); - } else { - query($this)->update( - ...model($this)->getPropertyValues(), - )->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; } + /** + * Updates the specified columns and persist the model to the database. + */ public function update(mixed ...$params): self { - model(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() + ->whereField($model->getPrimaryKey(), $model->getPrimaryKeyValue()) ->execute(); + foreach ($params as $key => $value) { + $this->{$key} = $value; + } + return $this; } + /** + * Deletes this model from the database. + */ public function delete(): void { query($this) @@ -202,4 +290,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/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php index f1be7877e..0a09ee9cf 100644 --- a/packages/database/src/Mappers/SelectModelMapper.php +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -5,13 +5,14 @@ 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; 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,12 +26,12 @@ 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(); + $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(); @@ -58,7 +59,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 +116,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/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/Migrations/MigrationManager.php b/packages/database/src/Migrations/MigrationManager.php index 4b68ced00..17498cd28 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(), )); @@ -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->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/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 24a45de1e..c004781bd 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; @@ -25,9 +26,10 @@ public function __construct( public array $bindings = [], /** @var \Closure[] $executeAfter */ public array $executeAfter = [], + public ?string $primaryKeyColumn = null, ) {} - public function execute(mixed ...$bindings): ?Id + public function execute(mixed ...$bindings): ?PrimaryKey { $this->bindings = [...$this->bindings, ...$bindings]; @@ -39,8 +41,12 @@ 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']) + if (! $this->primaryKeyColumn) { + return null; + } + + return isset($query->bindings[$this->primaryKeyColumn]) + ? new PrimaryKey($query->bindings[$this->primaryKeyColumn]) : $database->getLastInsertId(); } @@ -54,10 +60,12 @@ public function fetchFirst(mixed ...$bindings): ?array return $this->database->fetchFirst($this->withBindings($bindings)); } - public function toSql(): string + /** + * Compile the query to a SQL statement without the bindings. + */ + public function compile(): ImmutableString { $sql = $this->sql; - $dialect = $this->dialect; if ($sql instanceof QueryStatement) { @@ -68,7 +76,15 @@ 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 + { + return new RawSql($this->dialect, (string) $this->compile(), $this->bindings)->toImmutableString(); } public function append(string $append): self 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/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/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/CreateTableStatement.php b/packages/database/src/QueryStatements/CreateTableStatement.php index f642923dc..1bdc22250 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); } /** @@ -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. This is an alias to `foreignId`. + * + * **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,60 @@ public function belongsTo( 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 + * 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`. + * @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. */ @@ -208,7 +265,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 +278,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/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/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/QueryStatements/HasWhereStatements.php b/packages/database/src/QueryStatements/HasWhereStatements.php index f836e7e85..ba77fa4dc 100644 --- a/packages/database/src/QueryStatements/HasWhereStatements.php +++ b/packages/database/src/QueryStatements/HasWhereStatements.php @@ -6,6 +6,7 @@ interface HasWhereStatements { + /** @var ImmutableArray */ public ImmutableArray $where { get; } 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/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/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/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/packages/database/src/functions.php b/packages/database/src/functions.php index 5fe92c674..c635c96ef 100644 --- a/packages/database/src/functions.php +++ b/packages/database/src/functions.php @@ -5,9 +5,11 @@ use Tempest\Database\Builder\QueryBuilders\QueryBuilder; /** - * @template T of object - * @param class-string|string|T $model - * @return QueryBuilder + * Creates a new query builder instance for the given model or table name. + * + * @template TModel of object + * @param class-string|string|TModel $model + * @return QueryBuilder */ function query(string|object $model): QueryBuilder { @@ -15,11 +17,14 @@ function query(string|object $model): QueryBuilder } /** - * @template T of object - * @param class-string|string|T $model - * @return ModelInspector + * Inspects the given model or table name to provide database insights. + * + * @template TModel of object + * @param class-string|string|TModel $model + * @return ModelInspector + * @internal */ - function model(string|object $model): ModelInspector + function inspect(string|object $model): ModelInspector { return new ModelInspector($model); } 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/CreateTableStatementTest.php b/packages/database/tests/QueryStatements/CreateTableStatementTest.php index 12f767e64..f73c91ac4 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 -);', + <<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 { yield 'mysql' => [ 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, + << [ + DatabaseDialect::SQLITE, + <<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, - '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 -);', + <<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/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/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/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()); 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/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..3b9e0e6fb 100644 --- a/packages/mapper/src/Casters/DtoCaster.php +++ b/packages/mapper/src/Casters/DtoCaster.php @@ -18,14 +18,36 @@ public function __construct( public function cast(mixed $input): mixed { - if (! Json\is_valid($input)) { + if (is_string($input) && Json\is_valid($input)) { + return $this->deserialize(Json\decode($input)); + } + + if (is_array($input)) { + return $this->deserialize($input); + } + + if (is_string($input)) { throw new ValueCouldNotBeCast('json string'); } - ['type' => $type, 'data' => $data] = Json\decode($input); + return $input; + } + + private function deserialize(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->deserialize($input['data']))->to($class); + } + + if (is_array($input)) { + return array_map(fn (mixed $value) => $this->deserialize($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..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,52 +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 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)) { + $caster = $this->casterFactory->forProperty($property); + + if ($caster === null) { 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 ($property->getIterableType() !== null) { + return $caster->cast($value); } - // If there's a caster, we'll cast the value - if (($caster = $this->casterFactory->forProperty($property)) !== null) { + if (! $property->getType()->accepts($value)) { return $caster->cast($value); } - // Otherwise we'll return the value as-is - return $value; + if (is_object($value) && $property->getType()->matches($value::class)) { + 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; + } } } 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/packages/mapper/src/Serializers/DtoSerializer.php b/packages/mapper/src/Serializers/DtoSerializer.php index 3ffcd698e..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,16 +21,59 @@ public function __construct( public function serialize(mixed $input): array|string { + // Support top-level arrays + if (is_array($input)) { + return Json\encode($this->serializeWithType($input)); + } + if (! is_object($input)) { - throw new ValueCouldNotBeSerialized('object'); + throw new ValueCouldNotBeSerialized('object or array'); } - $data = map($input)->toArray(); - $type = $this->mapperConfig->serializationMap[get_class($input)] ?? get_class($input); + return Json\encode($this->serializeWithType($input)); + } + + private function serializeWithType(mixed $input): mixed + { + if ($input instanceof BackedEnum) { + return $input->value; + } + + if ($input instanceof UnitEnum) { + return $input->name; + } + + if (is_object($input)) { + $data = $this->extractObjectData($input); + + foreach ($data as $key => $value) { + $data[$key] = $this->serializeWithType($value); + } + + $type = $this->mapperConfig->serializationMap[get_class($input)] ?? get_class($input); + + return [ + 'type' => $type, + 'data' => $data, + ]; + } + + if (is_array($input)) { + return Arr\map_iterable($input, $this->serializeWithType(...)); + } + + return $input; + } + + private function extractObjectData(object $input): array + { + if ($input instanceof JsonSerializable) { + return $input->jsonSerialize(); + } - return Json\encode([ - 'type' => $type, - 'data' => $data, - ]); + return Arr\map_with_keys( + array: new ClassReflector($input)->getPublicProperties(), + map: fn (PropertyReflector $property) => yield $property->getName() => $property->getValue($input), + ); } } diff --git a/packages/support/src/functions.php b/packages/support/src/functions.php index 2111811e4..eb250f45b 100644 --- a/packages/support/src/functions.php +++ b/packages/support/src/functions.php @@ -39,11 +39,11 @@ function path(Stringable|string ...$parts): Path * @template T * * @param T $value - * @param (Closure(T): void) $callback + * @param (callable(T): void) $callback * * @return T */ - function tap(mixed $value, Closure $callback): mixed + function tap(mixed $value, callable $callback): mixed { $callback($value); 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/.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/BelongsToTest.php b/tests/Integration/Database/BelongsToTest.php deleted file mode 100644 index e238a08e7..000000000 --- a/tests/Integration/Database/BelongsToTest.php +++ /dev/null @@ -1,89 +0,0 @@ -getRelation('relation'); - - $this->assertInstanceOf(BelongsTo::class, $relation); - - $this->assertEquals( - 'LEFT JOIN relation ON relation.id = owner.relation_id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), - ); - } - - public function test_belongs_to_with_relation_join_field(): void - { - $model = model(OwnerModel::class); - $relation = $model->getRelation('relationJoinField'); - - $this->assertInstanceOf(BelongsTo::class, $relation); - - $this->assertEquals( - 'LEFT JOIN relation ON relation.overwritten_id = owner.relation_id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), - ); - } - - public function test_belongs_to_with_relation_join_field_and_table(): void - { - $model = model(OwnerModel::class); - $relation = $model->getRelation('relationJoinFieldAndTable'); - - $this->assertInstanceOf(BelongsTo::class, $relation); - - $this->assertEquals( - 'LEFT JOIN relation ON overwritten.overwritten_id = owner.relation_id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), - ); - } - - public function test_belongs_to_with_owner_join_field(): void - { - $model = model(OwnerModel::class); - $relation = $model->getRelation('ownerJoinField'); - - $this->assertInstanceOf(BelongsTo::class, $relation); - - $this->assertEquals( - 'LEFT JOIN relation ON relation.id = owner.overwritten_id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), - ); - } - - public function test_belongs_to_with_owner_join_field_and_table(): void - { - $model = model(OwnerModel::class); - $relation = $model->getRelation('ownerJoinFieldAndTable'); - - $this->assertInstanceOf(BelongsTo::class, $relation); - - $this->assertEquals( - 'LEFT JOIN relation ON relation.id = overwritten.overwritten_id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), - ); - } - - public function test_belongs_to_with_parent(): void - { - $model = model(OwnerModel::class); - $relation = $model->getRelation('relation')->setParent('parent'); - - $this->assertSame( - 'relation.name AS `parent.relation.name`', - $relation->getSelectFields()[0]->compile(DatabaseDialect::SQLITE), - ); - } -} diff --git a/tests/Integration/Database/Builder/ConvenientWhereMethodsTest.php b/tests/Integration/Database/Builder/ConvenientWhereMethodsTest.php new file mode 100644 index 000000000..2f9ac347f --- /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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $this->assertSame([$startDate, $endDate], $query->bindings); + } +} diff --git a/tests/Integration/Database/Builder/CountQueryBuilderTest.php b/tests/Integration/Database/Builder/CountQueryBuilderTest.php index 6e8011cac..a88eb9dc4 100644 --- a/tests/Integration/Database/Builder/CountQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/CountQueryBuilderTest.php @@ -23,20 +23,14 @@ 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(); + $sql = $query->compile(); $bindings = $query->bindings; $this->assertSameWithoutBackticks($expected, $sql); @@ -49,12 +43,9 @@ public function test_count_query_with_specified_asterisk(): void ->count('*') ->build(); - $sql = $query->toSql(); + $sql = $query->compile(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } @@ -63,12 +54,9 @@ public function test_count_query_with_specified_field(): void { $query = query('chapters')->count('title')->build(); - $sql = $query->toSql(); + $sql = $query->compile(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } @@ -100,12 +88,9 @@ public function test_count_query_with_distinct_specified_field(): void ->distinct() ->build(); - $sql = $query->toSql(); + $sql = $query->compile(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } @@ -114,12 +99,9 @@ public function test_count_from_model(): void { $query = query(Author::class)->count()->build(); - $sql = $query->toSql(); + $sql = $query->compile(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } @@ -131,28 +113,22 @@ 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(); + $sql = $query->compile(); $bindings = $query->bindings; $this->assertSameWithoutBackticks($expected, $sql); @@ -173,43 +149,388 @@ 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') - ->toSql(); - - $expected = << NULL - SQL; + ->whereRaw('title = ?', 'a') + ->whereRaw('author_id = ?', 1) + ->whereRaw('OR author_id = ?', 2) + ->whereRaw('AND author_id <> NULL') + ->compile(); + + $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) - ->toSql(); + ->where('title', 'a') + ->where('author_id', 1) + ->compile(); - $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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/Builder/CustomPrimaryKeyTest.php b/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php new file mode 100644 index 000000000..4c8e0e251 --- /dev/null +++ b/tests/Integration/Database/Builder/CustomPrimaryKeyTest.php @@ -0,0 +1,130 @@ +migrate(CreateMigrationsTable::class, CreateCustomPrimaryKeyUserModelTable::class); + + $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 = query(CustomPrimaryKeyUserModel::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, CreateCustomPrimaryKeyUserModelTable::class); + + $frieren = query(CustomPrimaryKeyUserModel::class)->create(name: 'Frieren', magic: 'Time Magic'); + + $updated = query(CustomPrimaryKeyUserModel::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 = query(ModelWithoutId::class)->new(name: 'Test'); + $this->assertInstanceOf(ModelWithoutId::class, $model); + $this->assertSame('Test', $model->name); + } +} + +final class CustomPrimaryKeyUserModel +{ + public ?PrimaryKey $uuid = null; + + public function __construct( + public string $name, + public string $magic, + ) {} +} + +final class ModelWithMultipleIds +{ + public ?PrimaryKey $uuid = null; + + public ?PrimaryKey $external_id = null; + + public function __construct( + public string $name = 'test', + ) {} +} + +final class ModelWithoutId +{ + public function __construct( + public string $name, + ) {} +} + +final class CreateCustomPrimaryKeyUserModelTable implements DatabaseMigration +{ + public string $name = '001_create_user_model'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(CustomPrimaryKeyUserModel::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/DeleteQueryBuilderTest.php b/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php index 70ddfa36f..6d54517b7 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; @@ -18,15 +18,12 @@ public function test_delete_on_plain_table(): void { $query = query('foo') ->delete() - ->where('`bar` = ?', 'boo') + ->whereRaw('`bar` = ?', 'boo') ->build(); $this->assertSameWithoutBackticks( - <<toSql(), + 'DELETE FROM `foo` WHERE `bar` = ?', + $query->compile(), ); $this->assertSameWithoutBackticks( @@ -43,28 +40,23 @@ public function test_delete_on_model_table(): void ->build(); $this->assertSameWithoutBackticks( - <<toSql(), + 'DELETE FROM `authors`', + $query->compile(), ); } 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() ->build(); $this->assertSameWithoutBackticks( - <<toSql(), + 'DELETE FROM `authors` WHERE `authors`.`id` = ?', + $query->compile(), ); $this->assertSame( @@ -79,20 +71,17 @@ 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(), + 'DELETE FROM `foo` WHERE `bar` = ?', + $query->compile(), ); $this->assertSame( @@ -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') - ->toSql(); - - $expected = << NULL - SQL; + ->whereRaw('title = ?', 'a') + ->whereRaw('author_id = ?', 1) + ->whereRaw('OR author_id = ?', 2) + ->whereRaw('AND author_id <> NULL') + ->compile(); + + $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) - ->toSql(); + ->where('title', 'a') + ->where('author_id', 1) + ->compile(); - $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->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 5d40f38d9..579e9e64a 100644 --- a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php @@ -4,10 +4,8 @@ use Tempest\Database\Config\DatabaseDialect; 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; @@ -26,20 +24,14 @@ 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(<<buildExpectedInsert('INSERT INTO `chapters` (`title`, `index`) VALUES (?, ?)'); $this->assertSameWithoutBackticks( $expected, - $query->toSql(), + $query->compile(), ); $this->assertSame( @@ -60,14 +52,11 @@ public function test_insert_with_batch(): void ->insert(...$arrayOfStuff) ->build(); - $expected = $this->buildExpectedInsert(<<buildExpectedInsert('INSERT INTO `chapters` (`chapter`, `index`) VALUES (?, ?), (?, ?), (?, ?)'); $this->assertSameWithoutBackticks( $expected, - $query->toSql(), + $query->compile(), ); $this->assertSame( @@ -78,24 +67,17 @@ 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, - ['name' => 'other name', 'type' => AuthorType::B->value, 'publisher_id' => null], + ['name' => 'other name', 'type' => AuthorType::B, 'publisher_id' => null], ) ->build(); - $expected = $this->buildExpectedInsert(<<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); } @@ -103,34 +85,24 @@ 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(<<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]); $authorQuery = $bookQuery->bindings[1]; - $expectedAuthorQuery = $this->buildExpectedInsert(<<buildExpectedInsert('INSERT INTO `authors` (`name`) VALUES (?)'); - $this->assertSameWithoutBackticks($expectedAuthorQuery, $authorQuery->toSql()); + $this->assertSameWithoutBackticks($expectedAuthorQuery, $authorQuery->compile()); $this->assertSame('Brent', $authorQuery->bindings[0]); } @@ -139,51 +111,22 @@ 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', ), ); $bookQuery = query(Book::class) - ->insert( - $book, - ) + ->insert($book) ->build(); - $expectedBookQuery = $this->buildExpectedInsert(<<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]); } - 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); @@ -191,11 +134,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], ), ) @@ -231,4 +174,27 @@ 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->compile()->toString()); + $this->assertSame(['test'], $query->bindings); + } } 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/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php new file mode 100644 index 000000000..755d642f2 --- /dev/null +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -0,0 +1,1097 @@ +migrate( + CreateMigrationsTable::class, + FooDatabaseMigration::class, + ); + + $foo = Foo::create( + bar: 'baz', + ); + + $this->assertSame('baz', $foo->bar); + $this->assertInstanceOf(PrimaryKey::class, $foo->id); + + $foo = Foo::get($foo->id); + + $this->assertSame('baz', $foo->bar); + $this->assertInstanceOf(PrimaryKey::class, $foo->id); + + $foo->update( + bar: 'boo', + ); + + $foo = Foo::get($foo->id); + + $this->assertSame('boo', $foo->bar); + } + + public function test_get_with_non_id_object(): void + { + $this->migrate( + CreateMigrationsTable::class, + FooDatabaseMigration::class, + ); + + Foo::create( + bar: 'baz', + ); + + $foo = Foo::get(1); + + $this->assertSame(1, $foo->id->value); + } + + public function test_creating_many_and_saving_preserves_model_id(): void + { + $this->migrate( + CreateMigrationsTable::class, + FooDatabaseMigration::class, + ); + + $a = Foo::create( + bar: 'a', + ); + $b = Foo::create( + bar: 'b', + ); + + $this->assertEquals(1, $a->id->value); + $a->save(); + $this->assertEquals(1, $a->id->value); + } + + public function test_complex_query(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $book = Book::new( + title: 'Book Title', + author: new Author( + name: 'Author Name', + type: AuthorType::B, + ), + ); + + $book = $book->save(); + + $book = Book::get($book->id, relations: ['author']); + + $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->value); + } + + public function test_all_with_relations(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + Book::new( + title: 'Book Title', + author: new Author( + name: 'Author Name', + type: AuthorType::B, + ), + )->save(); + + $books = Book::all(relations: [ + 'author', + ]); + + $this->assertCount(1, $books); + + $book = $books[0]; + + $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->value); + } + + public function test_missing_relation_exception(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateATable::class, + CreateBTable::class, + CreateCTable::class, + ); + + new A( + b: new B( + c: new C(name: 'test'), + ), + )->save(); + + $a = A::select()->first(); + + $this->expectException(RelationWasMissing::class); + + $b = $a->b; + } + + public function test_missing_value_exception(): void + { + $a = map([])->to(AWithValue::class); + + $this->expectException(ValueWasMissing::class); + + $name = $a->name; + } + + public function test_nested_relations(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateATable::class, + CreateBTable::class, + CreateCTable::class, + ); + + new A( + b: new B( + c: new C(name: 'test'), + ), + )->save(); + + $a = A::select()->with('b.c')->first(); + $this->assertSame('test', $a->b->c->name); + + $a = A::select()->with('b.c')->all()[0]; + $this->assertSame('test', $a->b->c->name); + } + + public function test_load_belongs_to(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateATable::class, + CreateBTable::class, + CreateCTable::class, + ); + + new A( + b: new B( + c: new C(name: 'test'), + ), + )->save(); + + $a = A::select()->first(); + $this->assertFalse(isset($a->b)); + + $a->load('b.c'); + $this->assertTrue(isset($a->b)); + $this->assertTrue(isset($a->b->c)); + } + + public function test_has_many_relations(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $author = Author::create( + name: 'Author Name', + type: AuthorType::B, + ); + + Book::create( + title: 'Book Title', + author: $author, + ); + + Book::create( + title: 'Timeline Taxi', + author: $author, + ); + + $author = Author::select()->with('books')->first(); + + $this->assertCount(2, $author->books); + } + + public function test_has_many_through_relation(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateHasManyParentTable::class, + CreateHasManyChildTable::class, + CreateHasManyThroughTable::class, + ); + + $parent = new ParentModel(name: 'parent')->save(); + + $childA = new ChildModel(name: 'A')->save(); + $childB = new ChildModel(name: 'B')->save(); + + new ThroughModel(parent: $parent, child: $childA)->save(); + new ThroughModel(parent: $parent, child: $childB)->save(); + + $parent = ParentModel::get($parent->id, ['through.child']); + + $this->assertSame('A', $parent->through[0]->child->name); + $this->assertSame('B', $parent->through[1]->child->name); + } + + public function test_empty_has_many_relation(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + CreateHasManyChildTable::class, + ); + + Book::new(title: 'Timeline Taxi')->save(); + $book = Book::select()->with('chapters')->first(); + $this->assertEmpty($book->chapters); + } + + public function test_has_one_relation(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + CreateHasManyChildTable::class, + CreateIsbnTable::class, + ); + + $book = Book::new(title: 'Timeline Taxi')->save(); + $isbn = Isbn::new(value: 'tt-1', book: $book)->save(); + + $isbn = Isbn::select()->with('book')->get($isbn->id); + + $this->assertSame('Timeline Taxi', $isbn->book->title); + } + + public function test_invalid_has_one_relation(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateHasManyParentTable::class, + CreateHasManyChildTable::class, + CreateHasManyThroughTable::class, + ); + + $parent = new ParentModel(name: 'parent')->save(); + + $childA = new ChildModel(name: 'A')->save(); + $childB = new ChildModel(name: 'B')->save(); + + new ThroughModel(parent: $parent, child: $childA, child2: $childB)->save(); + + $child = ChildModel::get($childA->id, ['through.parent']); + $this->assertSame('parent', $child->through->parent->name); + + $child2 = ChildModel::select()->with('through2.parent')->get($childB->id); + $this->assertSame('parent', $child2->through2->parent->name); + } + + public function test_lazy_load(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateATable::class, + CreateBTable::class, + CreateCTable::class, + ); + + new AWithLazy( + b: new B( + c: new C(name: 'test'), + ), + )->save(); + + $a = AWithLazy::select()->first(); + + $this->assertFalse(isset($a->b)); + + /** @phpstan-ignore expr.resultUnused */ + $a->b; // The side effect from accessing ->b will cause it to load + + $this->assertTrue(isset($a->b)); + } + + public function test_eager_load(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateATable::class, + CreateBTable::class, + CreateCTable::class, + ); + + new AWithLazy( + b: new B( + c: new C(name: 'test'), + ), + )->save(); + + $a = AWithEager::select()->first(); + $this->assertTrue(isset($a->b)); + $this->assertTrue(isset($a->b->c)); + } + + public function test_no_result(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateATable::class, + CreateBTable::class, + CreateCTable::class, + ); + + $this->assertNull(A::select()->first()); + } + + 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, + CreateATable::class, + CreateBTable::class, + CreateCTable::class, + ); + + new A( + b: new B( + c: new C(name: 'test'), + ), + )->save(); + + $a = AWithVirtual::select()->first(); + + $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( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + Book::new( + title: 'A', + author: new Author( + name: 'Author Name', + type: AuthorType::B, + ), + )->save(); + + Book::updateOrCreate( + ['title' => 'A'], + ['title' => 'B'], + ); + + $this->assertNull(Book::select()->where('title', 'A')->first()); + $this->assertNotNull(Book::select()->where('title', 'B')->first()); + } + + public function test_delete(): void + { + $this->migrate( + CreateMigrationsTable::class, + FooDatabaseMigration::class, + ); + + $foo = Foo::create( + bar: 'baz', + ); + + $bar = Foo::create( + bar: 'baz', + ); + + $foo->delete(); + + $this->assertNull(Foo::get($foo->id)); + $this->assertNotNull(Foo::get($bar->id)); + } + + public function test_delete_via_model_class_with_where_conditions(): void + { + $this->migrate( + CreateMigrationsTable::class, + FooDatabaseMigration::class, + ); + + $foo1 = Foo::create(bar: 'delete_me'); + $foo2 = Foo::create(bar: 'keep_me'); + $foo3 = Foo::create(bar: 'delete_me'); + + query(Foo::class) + ->delete() + ->where('bar', 'delete_me') + ->execute(); + + $this->assertNull(Foo::get($foo1->id)); + $this->assertNotNull(Foo::get($foo2->id)); + $this->assertNull(Foo::get($foo3->id)); + } + + public function test_delete_via_model_instance_with_primary_key(): void + { + $this->migrate( + CreateMigrationsTable::class, + FooDatabaseMigration::class, + ); + + $foo1 = Foo::create(bar: 'first'); + $foo2 = Foo::create(bar: 'second'); + $foo1->delete(); + + $this->assertNull(Foo::get($foo1->id)); + $this->assertNotNull(Foo::get($foo2->id)); + $this->assertSame('second', Foo::get($foo2->id)->bar); + } + + public function test_delete_via_model_instance_without_primary_key(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateModelWithoutPrimaryKeyMigration::class, + ); + + $model = new ModelWithoutPrimaryKey(name: 'Frieren', description: 'Elf mage'); + $model->save(); + + $this->expectException(DeleteStatementWasInvalid::class); + $model->delete(); + } + + public function test_delete_via_model_class_without_primary_key(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreateModelWithoutPrimaryKeyMigration::class, + ); + + 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(); + + $remaining = query(ModelWithoutPrimaryKey::class)->select()->all(); + $this->assertCount(2, $remaining); + + $names = array_map(fn (ModelWithoutPrimaryKey $model) => $model->name, $remaining); + $this->assertContains('Heiter', $names); + $this->assertContains('Eisen', $names); + $this->assertNotContains('Himmel', $names); + } + + public function test_delete_with_uninitialized_primary_key(): void + { + $this->migrate( + CreateMigrationsTable::class, + FooDatabaseMigration::class, + ); + + $foo = new Foo(); + $foo->bar = 'unsaved'; + + $this->expectException(DeleteStatementWasInvalid::class); + $foo->delete(); + } + + public function test_delete_nonexistent_record(): void + { + $this->migrate( + CreateMigrationsTable::class, + FooDatabaseMigration::class, + ); + + $foo = Foo::create(bar: 'test'); + $fooId = $foo->id; + + // Delete the record + $foo->delete(); + + // Delete again + $foo->delete(); + + $this->assertNull(Foo::get($fooId)); + } +} + +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, + ) {} +} + +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 new CreateTableStatement('test_users') + ->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 new CreateTableStatement('test_posts') + ->primary() + ->foreignId('test_user_id', constrainedOn: 'test_users') + ->string('title') + ->text('body'); + } + + 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; + } +} diff --git a/tests/Integration/Database/Builder/NestedWhereTest.php b/tests/Integration/Database/Builder/NestedWhereTest.php new file mode 100644 index 000000000..0d7709bcf --- /dev/null +++ b/tests/Integration/Database/Builder/NestedWhereTest.php @@ -0,0 +1,149 @@ +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->compile()); + $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->compile()); + $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->compile()); + $this->assertSame([true, 'fiction', 'Tolkien', 4.5], $query->bindings); + } + + public function test_complex_nested_where_scenario(): void + { + // 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->compile()); + $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 + { + $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->compile()); + $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->compile()); + $this->assertSame([true, 'fiction', 'high'], $query->bindings); + } +} diff --git a/tests/Integration/Database/Builder/QueryBuilderTest.php b/tests/Integration/Database/Builder/QueryBuilderTest.php new file mode 100644 index 000000000..848272579 --- /dev/null +++ b/tests/Integration/Database/Builder/QueryBuilderTest.php @@ -0,0 +1,504 @@ +migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); + + query(TestUserModel::class)->create(name: 'Frieren'); + query(TestUserModel::class)->create(name: 'Fern'); + query(TestUserModelWithoutId::class)->create(name: 'Stark'); + + $builderWithId = query(TestUserModel::class)->select(); + $builderWithoutId = query(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 = query(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 = query(TestUserModel::class)->insert(name: 'Frieren'); + $builderWithoutId = query(TestUserModelWithoutId::class)->insert(name: 'Stark'); + + $this->assertInstanceOf(InsertQueryBuilder::class, $builderWithId); + $this->assertInstanceOf(InsertQueryBuilder::class, $builderWithoutId); + + $insertedId = $builderWithId->execute(); + $this->assertInstanceOf(PrimaryKey::class, $insertedId); + + $this->assertNull($builderWithoutId->execute()); + + $retrieved = query(TestUserModel::class)->get($insertedId); + $this->assertNotNull($retrieved); + $this->assertSame('Frieren', $retrieved->name); + + $starkRecords = query(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); + + $createdWithId = query(TestUserModel::class)->create(name: 'Frieren'); + query(TestUserModelWithoutId::class)->create(name: 'Stark'); + + $builderWithId = query(TestUserModel::class)->update(name: 'Eisen'); + $builderWithoutId = query(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 = query(TestUserModel::class)->get($createdWithId->id); + $this->assertNotNull($retrieved); + $this->assertSame('Eisen', $retrieved->name); + + $starkRecords = query(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 = 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 = query(TestUserModel::class)->delete(); + $builderWithoutId = query(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 = query(TestUserModel::class)->select()->all(); + $this->assertCount(1, $remainingWithId); + $this->assertSame('Fern', $remainingWithId[0]->name); + + $remainingWithoutId = query(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); + + 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 = query(TestUserModel::class)->count(); + $builderWithoutId = query(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 = 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); + } + + public function test_new(): void + { + $modelWithId = query(TestUserModel::class)->new(name: 'Frieren'); + $modelWithoutId = query(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_get_with_id_query(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); + + $created = query(TestUserModel::class)->create(name: 'Himmel'); + $retrieved = query(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.", + ); + + query(TestUserModelWithoutId::class)->get(1); + } + + public function test_all(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class, TestModelWithoutIdMigration::class); + + query(TestUserModel::class)->create(name: 'Fern'); + query(TestUserModel::class)->create(name: 'Stark'); + + query(TestUserModelWithoutId::class)->create(name: 'Eisen'); + query(TestUserModelWithoutId::class)->create(name: 'Heiter'); + + $allWithId = query(TestUserModel::class)->all(); + $allWithoutId = query(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); + + query(TestUserModel::class)->create(name: 'Frieren'); + query(TestUserModel::class)->create(name: 'Fern'); + + query(TestUserModelWithoutId::class)->create(name: 'Ubel'); + query(TestUserModelWithoutId::class)->create(name: 'Land'); + + $builderWithId = query(TestUserModel::class)->find(name: 'Frieren'); + $builderWithoutId = query(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 = query(TestUserModel::class)->create(name: 'Ubel'); + $createdWithoutId = query(TestUserModelWithoutId::class)->create(name: 'Serie'); + + $this->assertInstanceOf(TestUserModel::class, $createdWithId); + $this->assertInstanceOf(PrimaryKey::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 = query(TestUserModel::class)->create(name: 'Serie'); + $existingWithoutId = query(TestUserModelWithoutId::class)->create(name: 'Macht'); + + $resultWithId = query(TestUserModel::class)->findOrNew( + find: ['name' => 'Serie'], + update: ['name' => 'Updated Serie'], + ); + + $resultWithoutId = query(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 = query(TestUserModel::class)->findOrNew( + find: ['name' => 'NonExistent'], + update: ['name' => 'Updated Name'], + ); + + $resultWithoutId = query(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 = query(TestUserModel::class)->create(name: 'Qual'); + + $resultWithId = query(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 = query(TestUserModel::class)->updateOrCreate( + find: ['name' => 'NonExistent'], + update: ['name' => 'Aura'], + ); + + $this->assertInstanceOf(TestUserModel::class, $resultWithId); + $this->assertInstanceOf(PrimaryKey::class, $resultWithId->id); + $this->assertSame('Aura', $resultWithId->name); + } + + public function test_get_with_string_id(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWrapperMigration::class); + + $created = query(TestUserModel::class)->create(name: 'Heiter'); + $retrieved = query(TestUserModel::class)->get((string) $created->id->value); + + $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 = query(TestUserModel::class)->create(name: 'Eisen'); + $retrieved = query(TestUserModel::class)->get($created->id->value); + + $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 = query(TestUserModel::class)->get(new PrimaryKey(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.", + ); + + query(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.", + ); + + query(TestUserModelWithoutId::class)->updateOrCreate( + find: ['name' => 'Denken'], + update: ['name' => 'Updated Denken'], + ); + } + + public function test_custom_primary_key_name(): void + { + $this->migrate(CreateMigrationsTable::class, TestModelWithCustomPrimaryKeyMigration::class); + + $created = query(TestUserModelWithCustomPrimaryKey::class)->create(name: 'Fern'); + + $this->assertInstanceOf(TestUserModelWithCustomPrimaryKey::class, $created); + $this->assertInstanceOf(PrimaryKey::class, $created->uuid); + $this->assertSame('Fern', $created->name); + + $retrieved = query(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 = query(TestUserModelWithCustomPrimaryKey::class)->create(name: 'Stark'); + + $updated = query(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 +{ + public ?PrimaryKey $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; + } +} + +final class TestUserModelWithCustomPrimaryKey +{ + public ?PrimaryKey $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/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index 565a3480d..5715193f5 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; @@ -29,22 +30,15 @@ public function test_select_query(): void { $query = query('chapters') ->select('title', 'index') - ->where('`title` = ?', 'Timeline Taxi') - ->andWhere('`index` <> ?', '1') - ->orWhere('`createdAt` > ?', '2025-01-01') - ->orderBy('`index` ASC') + ->whereRaw('`title` = ?', 'Timeline Taxi') + ->andWhereRaw('`index` <> ?', '1') + ->orWhereRaw('`createdAt` > ?', '2025-01-01') + ->orderByRaw('`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(); + $sql = $query->compile(); $bindings = $query->bindings; $this->assertSameWithoutBackticks($expected, $sql); @@ -55,12 +49,9 @@ public function test_select_without_any_fields_specified(): void { $query = query('chapters')->select()->build(); - $sql = $query->toSql(); + $sql = $query->compile(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } @@ -69,12 +60,9 @@ public function test_select_from_model(): void { $query = query(Author::class)->select()->build(); - $sql = $query->toSql(); + $sql = $query->compile(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } @@ -83,20 +71,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') - ->toSql(); - - $expected = << NULL - SQL; + ->whereRaw('title = ?', 'a') + ->whereRaw('author_id = ?', 1) + ->whereRaw('OR author_id = ?', 2) + ->whereRaw('AND author_id <> NULL') + ->compile(); + + $expected = 'SELECT * FROM `books` WHERE title = ? AND author_id = ? OR author_id = ? AND author_id <> NULL'; $this->assertSameWithoutBackticks($expected, $sql); } @@ -105,16 +86,11 @@ public function test_multiple_where_field(): void { $sql = query('books') ->select() - ->whereField('title', 'a') - ->whereField('author_id', 1) - ->toSql(); + ->where('title', 'a') + ->where('author_id', 1) + ->compile(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } @@ -133,7 +109,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); } @@ -175,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')->compile(), + ); + + $this->assertSameWithoutBackticks( + expected: 'SELECT * FROM `books` ORDER BY `title` DESC', + 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')->compile(), + ); + } + public function test_limit(): void { $this->migrate( @@ -246,7 +279,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,30 +309,23 @@ 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') + ->orderByRaw('`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(); + $sql = $query->compile(); $bindings = $query->bindings; $this->assertSameWithoutBackticks($expected, $sql); @@ -317,7 +343,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 +361,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 +374,10 @@ public function test_select_includes_belongs_to(): void { $query = query(Book::class)->select(); - $this->assertSameWithoutBackticks(<<build()->toSql()); + $this->assertSameWithoutBackticks( + 'SELECT books.id AS `books.id`, books.title AS `books.title`, books.author_id AS `books.author_id` FROM `books`', + $query->build()->compile(), + ); } public function test_with_belongs_to_relation(): void @@ -361,13 +387,10 @@ public function test_with_belongs_to_relation(): void ->with('author', 'chapters', 'isbn') ->build(); - $this->assertSameWithoutBackticks(<<toSql()); + $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->compile(), + ); } public function test_select_query_execute_with_relations(): void @@ -398,14 +421,12 @@ 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(); - - $this->assertSameWithoutBackticks(<<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', + $query, + ); } public function test_group_by(): void @@ -413,13 +434,9 @@ public function test_group_by(): void $sql = query('authors') ->select() ->groupBy('name') - ->toSql(); + ->compile(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } @@ -429,13 +446,9 @@ public function test_having(): void $sql = query('authors') ->select() ->having('name = ?', 'Brent') - ->toSql(); + ->compile(); - $expected = <<assertSameWithoutBackticks($expected, $sql); } diff --git a/tests/Integration/Database/Builder/TransformsQueryBuilderTest.php b/tests/Integration/Database/Builder/TransformsQueryBuilderTest.php new file mode 100644 index 000000000..8ee939d87 --- /dev/null +++ b/tests/Integration/Database/Builder/TransformsQueryBuilderTest.php @@ -0,0 +1,80 @@ +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); + } +} diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderDtoTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderDtoTest.php new file mode 100644 index 000000000..c4d8c3973 --- /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 = query(UserWithDtoSettings::class) + ->create( + name: 'John', + settings: new DtoSettings(DtoTheme::LIGHT), + ); + + query(UserWithDtoSettings::class) + ->update( + name: 'Jane', + settings: new DtoSettings(DtoTheme::DARK), + ) + ->where('id', $user->id) + ->execute(); + + $updatedUser = query(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, + ) {} +} diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php index d5cb9edfc..0a83551d4 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\Id; +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; @@ -29,16 +31,12 @@ public function test_update_on_plain_table(): void title: 'Chapter 01', index: 1, ) - ->where('`id` = ?', 10) + ->whereRaw('`id` = ?', 10) ->build(); $this->assertSameWithoutBackticks( - <<toSql(), + 'UPDATE `chapters` SET `title` = ?, `index` = ? WHERE `id` = ?', + $query->compile(), ); $this->assertSame( @@ -55,11 +53,8 @@ public function test_global_update(): void ->build(); $this->assertSameWithoutBackticks( - <<toSql(), + 'UPDATE `chapters` SET `index` = ?', + $query->compile(), ); $this->assertSame( @@ -75,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,16 +79,12 @@ public function test_model_update_with_values(): void ->update( title: 'Chapter 02', ) - ->where('`id` = ?', 10) + ->whereRaw('`id` = ?', 10) ->build(); $this->assertSameWithoutBackticks( - <<toSql(), + 'UPDATE `books` SET `title` = ? WHERE `id` = ?', + $query->compile(), ); $this->assertSame( @@ -105,7 +96,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', ); @@ -116,12 +107,8 @@ public function test_model_update_with_object(): void ->build(); $this->assertSameWithoutBackticks( - <<toSql(), + 'UPDATE `books` SET `title` = ? WHERE `books`.`id` = ?', + $query->compile(), ); $this->assertSame( @@ -133,7 +120,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) @@ -151,7 +138,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) @@ -159,31 +146,21 @@ public function test_insert_new_relation_on_update(): void ->build(); $this->assertSameWithoutBackticks( - <<toSql(), + 'UPDATE `books` SET `author_id` = ? WHERE `books`.`id` = ?', + $bookQuery->compile(), ); $this->assertInstanceOf(Query::class, $bookQuery->bindings[0]); $authorQuery = $bookQuery->bindings[0]; - $expected = <<container->get(Database::class)->dialect === DatabaseDialect::POSTGRESQL) { $expected .= ' RETURNING *'; } - $this->assertSameWithoutBackticks( - $expected, - $authorQuery->toSql(), - ); + $this->assertSameWithoutBackticks($expected, $authorQuery->compile()); $this->assertSame(['Brent'], $authorQuery->bindings); } @@ -191,51 +168,32 @@ 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( - <<toSql(), + 'UPDATE `books` SET `author_id` = ? WHERE `books`.`id` = ?', + $bookQuery->compile(), ); $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 @@ -247,21 +205,17 @@ 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(), + 'UPDATE `chapters` SET `title` = ?, `index` = ? WHERE `id` = ?', + $query->compile(), ); $this->assertSame( @@ -281,9 +235,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 +248,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') - ->toSql(); - - $expected = << NULL - SQL; + ->whereRaw('title = ?', 'a') + ->whereRaw('author_id = ?', 1) + ->whereRaw('OR author_id = ?', 2) + ->whereRaw('AND author_id <> NULL') + ->compile(); + + $expected = 'UPDATE `books` SET title = ? WHERE title = ? AND author_id = ? OR author_id = ? AND author_id <> NULL'; $this->assertSameWithoutBackticks($expected, $sql); } @@ -318,17 +265,216 @@ public function test_multiple_where_field(): void ->update( title: 'Timeline Taxi', ) - ->whereField('title', 'a') - ->whereField('author_id', 1) - ->toSql(); + ->where('title', 'a') + ->where('author_id', 1) + ->compile(); + + $expected = 'UPDATE `books` SET title = ? WHERE books.title = ? AND books.author_id = ?'; + + $this->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->compile()); + $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->compile()->toString()); + + $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]) + ->compile(); + + $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]) + ->compile(); + + $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') + ->compile(); + + $expected = 'UPDATE `books` SET `title` = ? WHERE `books`.`author_id` IS NULL'; - $expected = <<assertSameWithoutBackticks($expected, $sql); + } + + public function test_update_with_where_not_null(): void + { + $sql = query('books') + ->update(title: 'Updated Book') + ->whereNotNull('author_id') + ->compile(); + + $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) + ->compile(); + + $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) + ->compile(); + + $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]) + ->compile(); + + $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]) + ->compile(); + + $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..ca5ac052d --- /dev/null +++ b/tests/Integration/Database/Builder/UpdateRelationsTest.php @@ -0,0 +1,654 @@ +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/Builder/WhereOperatorTest.php b/tests/Integration/Database/Builder/WhereOperatorTest.php new file mode 100644 index 000000000..3389effd9 --- /dev/null +++ b/tests/Integration/Database/Builder/WhereOperatorTest.php @@ -0,0 +1,244 @@ +select() + ->where('`title` = ?', 'Timeline Taxi') + ->build(); + + $expected = 'SELECT * FROM `books` WHERE `title` = ?'; + + $this->assertSameWithoutBackticks($expected, $query->compile()); + $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_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() + ->whereField('title', '%fantasy%', 'like') + ->build(); + + $expected = 'SELECT * FROM books WHERE books.title LIKE ?'; + + $this->assertSameWithoutBackticks($expected, $query->compile()); + $this->assertSame(['%fantasy%'], $query->bindings); + } + + public function test_where_in_operator(): void + { + $query = query('books') + ->select() + ->whereField('category', ['fiction', 'mystery', 'thriller'], WhereOperator::IN) + ->build(); + + $expected = 'SELECT * FROM books WHERE books.category IN (?,?,?)'; + + $this->assertSameWithoutBackticks($expected, $query->compile()); + $this->assertSame(['fiction', 'mystery', 'thriller'], $query->bindings); + } + + public function test_where_between_operator(): void + { + $query = query('books') + ->select() + ->whereField('publication_year', [2020, 2024], WhereOperator::BETWEEN) + ->build(); + + $expected = 'SELECT * FROM books WHERE books.publication_year BETWEEN ? AND ?'; + + $this->assertSameWithoutBackticks($expected, $query->compile()); + $this->assertSame([2020, 2024], $query->bindings); + } + + public function test_where_is_null_operator(): void + { + $query = query('books') + ->select() + ->whereField('deleted_at', null, WhereOperator::IS_NULL) + ->build(); + + $expected = 'SELECT * FROM books WHERE books.deleted_at IS NULL'; + + $this->assertSameWithoutBackticks($expected, $query->compile()); + $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->compile()); + $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->compile()); + $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->compile()); + $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 + ->whereField('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->compile()); + $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() + ->whereField('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() + ->whereField('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..482c7097d --- /dev/null +++ b/tests/Integration/Database/ConvenientDateWhereMethodsTest.php @@ -0,0 +1,397 @@ +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 PrimaryKey $id; + + 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..39aaf9878 --- /dev/null +++ b/tests/Integration/Database/ConvenientWhereMethodsTest.php @@ -0,0 +1,450 @@ +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') // @phpstan-ignore argument.type + ->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() + ->whereField('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() + ->whereField('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 PrimaryKey $id; + + 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/CustomPrimaryKeyRelationshipLoadingTest.php b/tests/Integration/Database/CustomPrimaryKeyRelationshipLoadingTest.php new file mode 100644 index 000000000..33eed93fc --- /dev/null +++ b/tests/Integration/Database/CustomPrimaryKeyRelationshipLoadingTest.php @@ -0,0 +1,510 @@ +migrate( + CreateMigrationsTable::class, + CreateMageWithUuidMigration::class, + CreateGrimoireWithUuidMigration::class, + ); + + $mage = query(MageWithUuid::class)->create( + name: 'Frieren', + element: 'Time', + ); + + $grimoire = query(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 = query(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 = query(MageWithUuid::class)->create( + name: 'Flamme', + element: 'Fire', + ); + + $spell1 = query(SpellWithUuid::class)->create( + mage_uuid: $mage->uuid->value, + name: 'Zoltraak', + power_level: 95, + mana_cost: 150, + ); + + $spell2 = query(SpellWithUuid::class)->create( + mage_uuid: $mage->uuid->value, + name: 'Volzandia', + power_level: 87, + mana_cost: 120, + ); + + $loadedMage = query(MageWithUuid::class)->get($mage->uuid); + $loadedMage->load('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 = query(MageWithUuid::class)->create( + name: 'Serie', + element: 'Ancient', + ); + + $spell = query(SpellWithUuid::class)->create( + mage_uuid: $mage->uuid->value, + name: 'Goddess Magic', + power_level: 100, + mana_cost: 999, + ); + + $loadedSpell = query(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 = query(MageWithUuid::class)->create( + name: 'Fern', + element: 'Combat', + ); + + $grimoire = query(GrimoireWithUuid::class)->create( + mage_uuid: $mage->uuid->value, + title: 'Combat Magic Fundamentals', + spells_count: 42, + ); + + $spell = query(SpellWithUuid::class)->create( + mage_uuid: $mage->uuid->value, + name: 'Basic Attack Magic', + power_level: 75, + mana_cost: 50, + ); + + $loadedMage = query(MageWithUuid::class)->get($mage->uuid); + $loadedMage->load('grimoire', 'spells'); + + $this->assertInstanceOf(GrimoireWithUuid::class, $loadedMage->grimoire); + $this->assertSame('Combat Magic Fundamentals', $loadedMage->grimoire->title); + + $this->assertCount(1, $loadedMage->spells); + $this->assertSame('Basic Attack Magic', $loadedMage->spells[0]->name); + + $loadedSpell = query(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 = query(MageWithUuid::class)->create( + name: 'Himmel', + element: 'Hero', + ); + + $artifact = query(ArtifactWithUuid::class)->create( + owner_uuid: $mage->uuid->value, + name: 'Hero Sword', + rarity: 'Legendary', + enchantment_level: 10, + ); + + $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 = query(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 = query(MageWithUuid::class)->create(name: 'Stark', element: 'Axe'); + $mage2 = query(MageWithUuid::class)->create(name: 'Eisen', element: 'Monk'); + + $spell1 = query(SpellWithUuid::class)->create( + mage_uuid: $mage1->uuid->value, + name: 'Axe Technique', + power_level: 80, + mana_cost: 30, + ); + + $spell2 = query(SpellWithUuid::class)->create( + mage_uuid: $mage2->uuid->value, + name: 'Warrior Meditation', + power_level: 60, + mana_cost: 20, + ); + + $loadedMage1 = query(MageWithUuid::class)->get($mage1->uuid); + $loadedMage1->load('spells'); + + $loadedMage2 = query(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 = query(MageSimple::class)->create( + name: 'Fern', + element: 'Combat', + ); + + $spell = query(SpellSimple::class)->create( + mage_uuid: $mage->uuid->value, + name: 'Cutting Magic', + power_level: 90, + ); + + $loadedMage = query(MageSimple::class)->get($mage->uuid); + $loadedMage->load('spells'); + + $this->assertCount(1, $loadedMage->spells); + $this->assertSame('Cutting Magic', $loadedMage->spells[0]->name); + + $loadedSpell = query(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; + } +} 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/DtoSerialization/BasicDtoSerializationTest.php b/tests/Integration/Database/DtoSerialization/BasicDtoSerializationTest.php new file mode 100644 index 000000000..3cd0c223d --- /dev/null +++ b/tests/Integration/Database/DtoSerialization/BasicDtoSerializationTest.php @@ -0,0 +1,261 @@ +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_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 { + 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/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/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/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/DtoForModelWithSerializer.php b/tests/Integration/Database/Fixtures/DtoForModelWithSerializer.php deleted file mode 100644 index 0e0924090..000000000 --- a/tests/Integration/Database/Fixtures/DtoForModelWithSerializer.php +++ /dev/null @@ -1,17 +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 @@ -assertException(QueryWasInvalid::class, function (): void { - query('books')->select()->orderBy('title DES')->first(); + query('books')->select()->orderByRaw('title DES')->first(); }); } public function test_query_was_invalid_exception_is_thrown_on_execute(): void { $this->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/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 */ diff --git a/tests/Integration/Database/GroupedWhereMethodsTest.php b/tests/Integration/Database/GroupedWhereMethodsTest.php new file mode 100644 index 000000000..66ba60e80 --- /dev/null +++ b/tests/Integration/Database/GroupedWhereMethodsTest.php @@ -0,0 +1,366 @@ +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 + ->whereField('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 + ->whereField('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') + ->whereField('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 + ->whereField('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 PrimaryKey $id; + + 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/HasManyTest.php b/tests/Integration/Database/HasManyTest.php deleted file mode 100644 index b48bf3d26..000000000 --- a/tests/Integration/Database/HasManyTest.php +++ /dev/null @@ -1,85 +0,0 @@ -getRelation('owners'); - - $this->assertInstanceOf(HasMany::class, $relation); - $this->assertSame( - 'LEFT JOIN owner ON owner.relation_id = relation.id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), - ); - } - - public function test_has_many_with_overwritten_owner_join_field(): void - { - $model = model(RelationModel::class); - $relation = $model->getRelation('ownerJoinField'); - - $this->assertInstanceOf(HasMany::class, $relation); - $this->assertSame( - 'LEFT JOIN owner ON owner.overwritten_id = relation.id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), - ); - } - - public function test_has_many_with_overwritten_owner_join_field_and_table(): void - { - $model = model(RelationModel::class); - $relation = $model->getRelation('ownerJoinFieldAndTable'); - - $this->assertInstanceOf(HasMany::class, $relation); - $this->assertSame( - 'LEFT JOIN owner ON overwritten.overwritten_id = relation.id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), - ); - } - - public function test_has_many_with_overwritten_relation_join_field(): void - { - $model = model(RelationModel::class); - $relation = $model->getRelation('relationJoinField'); - - $this->assertInstanceOf(HasMany::class, $relation); - $this->assertSame( - 'LEFT JOIN owner ON owner.relation_id = relation.overwritten_id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), - ); - } - - public function test_has_many_with_overwritten_relation_join_field_and_table(): void - { - $model = model(RelationModel::class); - $relation = $model->getRelation('relationJoinFieldAndTable'); - - $this->assertInstanceOf(HasMany::class, $relation); - $this->assertSame( - 'LEFT JOIN owner ON owner.relation_id = overwritten.overwritten_id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), - ); - } - - public function test_has_many_with_parent(): void - { - $model = model(RelationModel::class); - $relation = $model->getRelation('owners')->setParent('parent'); - - $this->assertSame( - 'owner.relation_id AS `parent.owners.relation_id`', - $relation->getSelectFields()[0]->compile(DatabaseDialect::SQLITE), - ); - } -} diff --git a/tests/Integration/Database/HasOneTest.php b/tests/Integration/Database/HasOneTest.php deleted file mode 100644 index a7bb3543f..000000000 --- a/tests/Integration/Database/HasOneTest.php +++ /dev/null @@ -1,85 +0,0 @@ -getRelation('owner'); - - $this->assertInstanceOf(HasOne::class, $relation); - $this->assertSame( - 'LEFT JOIN owner ON owner.relation_id = relation.id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), - ); - } - - public function test_has_one_with_overwritten_owner_join_field(): void - { - $model = model(HasOneRelationModel::class); - $relation = $model->getRelation('ownerJoinField'); - - $this->assertInstanceOf(HasOne::class, $relation); - $this->assertSame( - 'LEFT JOIN owner ON owner.overwritten_id = relation.id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), - ); - } - - public function test_has_one_with_overwritten_owner_join_field_and_table(): void - { - $model = model(HasOneRelationModel::class); - $relation = $model->getRelation('ownerJoinFieldAndTable'); - - $this->assertInstanceOf(HasOne::class, $relation); - $this->assertSame( - 'LEFT JOIN owner ON overwritten.overwritten_id = relation.id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), - ); - } - - public function test_has_one_with_overwritten_relation_join_field(): void - { - $model = model(HasOneRelationModel::class); - $relation = $model->getRelation('relationJoinField'); - - $this->assertInstanceOf(HasOne::class, $relation); - $this->assertSame( - 'LEFT JOIN owner ON owner.relation_id = relation.overwritten_id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), - ); - } - - public function test_has_one_with_overwritten_relation_join_field_and_table(): void - { - $model = model(HasOneRelationModel::class); - $relation = $model->getRelation('relationJoinFieldAndTable'); - - $this->assertInstanceOf(HasOne::class, $relation); - $this->assertSame( - 'LEFT JOIN owner ON owner.relation_id = overwritten.overwritten_id', - $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), - ); - } - - public function test_has_one_with_parent(): void - { - $model = model(HasOneRelationModel::class); - $relation = $model->getRelation('owner')->setParent('parent'); - - $this->assertSame( - 'owner.relation_id AS `parent.owner.relation_id`', - $relation->getSelectFields()[0]->compile(DatabaseDialect::SQLITE), - ); - } -} diff --git a/tests/Integration/Database/ModelInspector/BelongsToTest.php b/tests/Integration/Database/ModelInspector/BelongsToTest.php new file mode 100644 index 000000000..7b54859b1 --- /dev/null +++ b/tests/Integration/Database/ModelInspector/BelongsToTest.php @@ -0,0 +1,170 @@ +getRelation('relation'); + + $this->assertInstanceOf(BelongsTo::class, $relation); + + $this->assertEquals( + 'LEFT JOIN relation ON relation.id = owner.relation_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_belongs_to_with_relation_join_field(): void + { + $model = inspect(BelongsToTestOwnerModel::class); + $relation = $model->getRelation('relationJoinField'); + + $this->assertInstanceOf(BelongsTo::class, $relation); + + $this->assertEquals( + 'LEFT JOIN relation ON relation.overwritten_id = owner.relation_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_belongs_to_with_relation_join_field_and_table(): void + { + $model = inspect(BelongsToTestOwnerModel::class); + $relation = $model->getRelation('relationJoinFieldAndTable'); + + $this->assertInstanceOf(BelongsTo::class, $relation); + + $this->assertEquals( + 'LEFT JOIN relation ON overwritten.overwritten_id = owner.relation_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_belongs_to_with_owner_join_field(): void + { + $model = inspect(BelongsToTestOwnerModel::class); + $relation = $model->getRelation('ownerJoinField'); + + $this->assertInstanceOf(BelongsTo::class, $relation); + + $this->assertEquals( + 'LEFT JOIN relation ON relation.id = owner.overwritten_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_belongs_to_with_owner_join_field_and_table(): void + { + $model = inspect(BelongsToTestOwnerModel::class); + $relation = $model->getRelation('ownerJoinFieldAndTable'); + + $this->assertInstanceOf(BelongsTo::class, $relation); + + $this->assertEquals( + 'LEFT JOIN relation ON relation.id = overwritten.overwritten_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_belongs_to_with_parent(): void + { + $model = inspect(BelongsToTestOwnerModel::class); + $relation = $model->getRelation('relation')->setParent('parent'); + + $this->assertSame( + 'relation.name AS `parent.relation.name`', + $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 PrimaryKey $id; + + /** @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 PrimaryKey $id; + + 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; + + 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 new file mode 100644 index 000000000..abcf14d89 --- /dev/null +++ b/tests/Integration/Database/ModelInspector/HasManyTest.php @@ -0,0 +1,166 @@ +getRelation('owners'); + + $this->assertInstanceOf(HasMany::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON owner.relation_id = relation.id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_has_many_with_overwritten_owner_join_field(): void + { + $model = inspect(HasManyTestRelationModel::class); + $relation = $model->getRelation('ownerJoinField'); + + $this->assertInstanceOf(HasMany::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON owner.overwritten_id = relation.id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_has_many_with_overwritten_owner_join_field_and_table(): void + { + $model = inspect(HasManyTestRelationModel::class); + $relation = $model->getRelation('ownerJoinFieldAndTable'); + + $this->assertInstanceOf(HasMany::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON overwritten.overwritten_id = relation.id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_has_many_with_overwritten_relation_join_field(): void + { + $model = inspect(HasManyTestRelationModel::class); + $relation = $model->getRelation('relationJoinField'); + + $this->assertInstanceOf(HasMany::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON owner.relation_id = relation.overwritten_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_has_many_with_overwritten_relation_join_field_and_table(): void + { + $model = inspect(HasManyTestRelationModel::class); + $relation = $model->getRelation('relationJoinFieldAndTable'); + + $this->assertInstanceOf(HasMany::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON owner.relation_id = overwritten.overwritten_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_has_many_with_parent(): void + { + $model = inspect(HasManyTestRelationModel::class); + $relation = $model->getRelation('owners')->setParent('parent'); + + $this->assertSame( + 'owner.relation_id AS `parent.owners.relation_id`', + $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 PrimaryKey $id; + + /** @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('relation')] +final class HasManyTestRelationWithoutIdModel +{ + /** @var \Tests\Tempest\Integration\Database\ModelInspector\HasManyTestOwnerWithoutIdModel[] */ + public array $owners = []; + + public string $name; +} + +#[Table('owner')] +final class HasManyTestOwnerModel +{ + public PrimaryKey $id; + + 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; +} + +#[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 new file mode 100644 index 000000000..93349c6bc --- /dev/null +++ b/tests/Integration/Database/ModelInspector/HasOneTest.php @@ -0,0 +1,163 @@ +getRelation('owner'); + + $this->assertInstanceOf(HasOne::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON owner.relation_id = relation.id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_has_one_with_overwritten_owner_join_field(): void + { + $model = inspect(HasOneTestRelationModel::class); + $relation = $model->getRelation('ownerJoinField'); + + $this->assertInstanceOf(HasOne::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON owner.overwritten_id = relation.id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_has_one_with_overwritten_owner_join_field_and_table(): void + { + $model = inspect(HasOneTestRelationModel::class); + $relation = $model->getRelation('ownerJoinFieldAndTable'); + + $this->assertInstanceOf(HasOne::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON overwritten.overwritten_id = relation.id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_has_one_with_overwritten_relation_join_field(): void + { + $model = inspect(HasOneTestRelationModel::class); + $relation = $model->getRelation('relationJoinField'); + + $this->assertInstanceOf(HasOne::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON owner.relation_id = relation.overwritten_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_has_one_with_overwritten_relation_join_field_and_table(): void + { + $model = inspect(HasOneTestRelationModel::class); + $relation = $model->getRelation('relationJoinFieldAndTable'); + + $this->assertInstanceOf(HasOne::class, $relation); + $this->assertSame( + 'LEFT JOIN owner ON owner.relation_id = overwritten.overwritten_id', + $relation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_has_one_with_parent(): void + { + $model = inspect(HasOneTestRelationModel::class); + $relation = $model->getRelation('owner')->setParent('parent'); + + $this->assertSame( + 'owner.relation_id AS `parent.owner.relation_id`', + $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 PrimaryKey $id; + + #[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 PrimaryKey $id; + + 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; +} + +#[Table('relation')] +final class HasOneTestRelationNoIdModel +{ + #[HasOne] + public HasOneTestOwnerNoIdModel $owner; + + public string $name; +} + +#[Table('owner')] +final class HasOneTestOwnerNoIdModel +{ + public HasOneTestRelationNoIdModel $relation; + + 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..c9582b52f --- /dev/null +++ b/tests/Integration/Database/ModelInspector/ModelInspectorTest.php @@ -0,0 +1,100 @@ +assertFalse(inspect(ModelInspectorTestModelWithVirtualHasMany::class)->isRelation('dtos')); + } + + public function test_virtual_property_is_never_a_relation(): void + { + $this->assertFalse(inspect(ModelInspectorTestModelWithVirtualDto::class)->isRelation('dto')); + } + + public function test_serialized_property_type_is_never_a_relation(): void + { + $this->assertFalse(inspect(ModelInspectorTestModelWithSerializedDto::class)->isRelation('dto')); + } + + public function test_serialized_property_is_never_a_relation(): void + { + $this->assertFalse(inspect(ModelInspectorTestModelWithSerializedDtoProperty::class)->isRelation('dto')); + } +} + +final class ModelInspectorTestDtoForModelWithVirtual +{ + public function __construct( + public string $data, + ) {} +} + +final class ModelInspectorTestModelWithVirtualHasMany +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + #[Virtual] + /** @var \Tests\Tempest\Integration\Database\ModelInspector\ModelInspectorTestDtoForModelWithVirtual[] $dto */ + public array $dtos; +} + +final class ModelInspectorTestModelWithVirtualDto +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + #[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 PrimaryKey $id; + + public ModelInspectorTestDtoForModelWithSerializer $dto; +} + +final class ModelInspectorTestDtoForModelWithSerializerOnProperty +{ + public function __construct( + public string $data, + ) {} +} + +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 new file mode 100644 index 000000000..19b180618 --- /dev/null +++ b/tests/Integration/Database/ModelInspector/ModelWithDtoTest.php @@ -0,0 +1,71 @@ +assertFalse($definition->isRelation('dto')); + } + + public function test_dto_is_skipped_as_relation(): void + { + $migration = new class implements DatabaseMigration { + public string $name = '000_model_with_dto'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(ModelWithDtoTestModelWithSerializedDto::class) + ->primary() + ->dto('dto'); + } + + public function down(): null + { + return null; + } + }; + + $this->migrate(CreateMigrationsTable::class, $migration); + + ModelWithDtoTestModelWithSerializedDto::new(dto: new ModelWithDtoTestDtoForModelWithSerializer('test'))->save(); + + $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 PrimaryKey $id; + + 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/ModelWithDtoTest.php b/tests/Integration/Database/ModelWithDtoTest.php deleted file mode 100644 index c4084158b..000000000 --- a/tests/Integration/Database/ModelWithDtoTest.php +++ /dev/null @@ -1,49 +0,0 @@ -assertFalse($definition->isRelation('dto')); - } - - public function test_dto_is_skipped_as_relation(): void - { - $migration = new class implements DatabaseMigration { - public string $name = '000_model_with_dto'; - - public function up(): QueryStatement - { - return CreateTableStatement::forModel(ModelWithSerializedDto::class) - ->primary() - ->dto('dto'); - } - - public function down(): null - { - return null; - } - }; - - $this->migrate(CreateMigrationsTable::class, $migration); - - ModelWithSerializedDto::new(dto: new DtoForModelWithSerializer('test'))->save(); - - $model = ModelWithSerializedDto::get(1); - - $this->assertSame('test', $model->dto->data); - } -} diff --git a/tests/Integration/Database/ModelsWithoutIdTest.php b/tests/Integration/Database/ModelsWithoutIdTest.php new file mode 100644 index 000000000..dc3064288 --- /dev/null +++ b/tests/Integration/Database/ModelsWithoutIdTest.php @@ -0,0 +1,517 @@ +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 = query(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 = query(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); + + query(LogEntry::class)->create( + level: 'INFO', + message: 'Himmel was here', + context: 'memory', + ); + + query(LogEntry::class) + ->update(level: 'NOSTALGIC') + ->where('context', 'memory') + ->execute(); + + $updatedLog = query(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); + + query(LogEntry::class)->create( + level: 'TEMP', + message: 'Temporary debug info', + context: 'debug', + ); + + query(LogEntry::class)->create( + level: 'IMPORTANT', + message: 'Frieren awakens', + context: 'story', + ); + + $this->assertCount(2, query(LogEntry::class)->all()); + + query(LogEntry::class) + ->delete() + ->where('level', 'TEMP') + ->execute(); + + $remaining = query(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); + + query(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 = query(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 `findById` method'); + + query(LogEntry::class)->findById(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'); + + query(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'); + + query(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 = query(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 = query(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_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); + + $mixed = query(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 = query(TestUser::class)->create( + name: 'Frieren', + email: 'frieren@magic.elf', + ); + + query(TestProfile::class)->create( + user: $user, + 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); + } + + // 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); + } +} + +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; + + #[BelongsTo(ownerJoin: 'user_id')] + public ?TestUser $user; + + public function __construct( + 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) + ->string('cache_key') + ->string('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 0f3aa6ca5..784477aed 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\PrimaryKey; +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', )); } @@ -76,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', ) @@ -85,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', ) @@ -94,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', ) @@ -103,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', ) @@ -121,13 +123,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(); - $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); + query(Publisher::class) + ->update(name: 'Updated Backup 1') + ->whereRaw('id = ?', 1) + ->onDatabase('backup') + ->execute(); - query(Publisher::class)->delete()->where('id = ?', 1)->onDatabase('main')->execute(); + $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() + ->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()); @@ -142,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', )); @@ -251,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'); } @@ -349,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/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/BelongsToStatementTest.php b/tests/Integration/Database/QueryStatements/BelongsToStatementTest.php new file mode 100644 index 000000000..8688c0354 --- /dev/null +++ b/tests/Integration/Database/QueryStatements/BelongsToStatementTest.php @@ -0,0 +1,117 @@ +primary() + ->text('name'); + } + + public function down(): ?QueryStatement + { + return null; + } + }; + + $belongsToMigration = new class() implements DatabaseMigration { + private(set) string $name = '0002_test_belongs_to'; + + public function up(): QueryStatement + { + return new CreateTableStatement('orders') + ->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 = '0003_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, $customersMigration, $belongsToMigration, $foreignKeyMigration); + + $this->expectNotToPerformAssertions(); + } + + public function test_foreign_key_allows_different_column_names(): void + { + $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 + { + 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, $categoriesMigration, $productsMigration); + + $this->expectNotToPerformAssertions(); + } +} 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..c6114068b 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, ); } @@ -281,4 +280,73 @@ 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 +{ + 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'; +} 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/Database/ToRawSqlTest.php b/tests/Integration/Database/ToRawSqlTest.php new file mode 100644 index 000000000..f54cdb9c6 --- /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() + ->where('published_date > ?', '2020-01-01') + ->where('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) + ->orderByRaw('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() + ->where('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) + ->orderByRaw('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/Framework/Commands/DatabaseSeedCommandTest.php b/tests/Integration/Framework/Commands/DatabaseSeedCommandTest.php index 54630403b..c2816e717 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); } 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), ); } 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/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/Mapper/Serializers/DtoSerializerTest.php b/tests/Integration/Mapper/Serializers/DtoSerializerTest.php index 2f65a7e5c..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 { @@ -28,10 +36,277 @@ 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'); + } + + 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), + ); } } diff --git a/tests/Integration/ORM/Foo.php b/tests/Integration/ORM/Foo.php deleted file mode 100644 index eda226386..000000000 --- a/tests/Integration/ORM/Foo.php +++ /dev/null @@ -1,14 +0,0 @@ -migrate( - CreateMigrationsTable::class, - FooDatabaseMigration::class, - ); - - $foo = Foo::create( - bar: 'baz', - ); - - $this->assertSame('baz', $foo->bar); - $this->assertInstanceOf(Id::class, $foo->id); - - $foo = Foo::get($foo->id); - - $this->assertSame('baz', $foo->bar); - $this->assertInstanceOf(Id::class, $foo->id); - - $foo->update( - bar: 'boo', - ); - - $foo = Foo::get($foo->id); - - $this->assertSame('boo', $foo->bar); - } - - public function test_get_with_non_id_object(): void - { - $this->migrate( - CreateMigrationsTable::class, - FooDatabaseMigration::class, - ); - - Foo::create( - bar: 'baz', - ); - - $foo = Foo::get(1); - - $this->assertSame(1, $foo->id->id); - } - - public function test_creating_many_and_saving_preserves_model_id(): void - { - $this->migrate( - CreateMigrationsTable::class, - FooDatabaseMigration::class, - ); - - $a = Foo::create( - bar: 'a', - ); - $b = Foo::create( - bar: 'b', - ); - - $this->assertEquals(1, $a->id->id); - $a->save(); - $this->assertEquals(1, $a->id->id); - } - - public function test_complex_query(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreatePublishersTable::class, - CreateAuthorTable::class, - CreateBookTable::class, - ); - - $book = Book::new( - title: 'Book Title', - author: new Author( - name: 'Author Name', - type: AuthorType::B, - ), - ); - - $book = $book->save(); - - $book = Book::get($book->id, relations: ['author']); - - $this->assertEquals(1, $book->id->id); - $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); - } - - public function test_all_with_relations(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreatePublishersTable::class, - CreateAuthorTable::class, - CreateBookTable::class, - ); - - Book::new( - title: 'Book Title', - author: new Author( - name: 'Author Name', - type: AuthorType::B, - ), - )->save(); - - $books = Book::all(relations: [ - 'author', - ]); - - $this->assertCount(1, $books); - - $book = $books[0]; - - $this->assertEquals(1, $book->id->id); - $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); - } - - public function test_missing_relation_exception(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateATable::class, - CreateBTable::class, - CreateCTable::class, - ); - - new A( - b: new B( - c: new C(name: 'test'), - ), - )->save(); - - $a = A::select()->first(); - - $this->expectException(RelationWasMissing::class); - - $b = $a->b; - } - - public function test_missing_value_exception(): void - { - $a = map([])->to(AWithValue::class); - - $this->expectException(ValueWasMissing::class); - - $name = $a->name; - } - - public function test_nested_relations(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateATable::class, - CreateBTable::class, - CreateCTable::class, - ); - - new A( - b: new B( - c: new C(name: 'test'), - ), - )->save(); - - $a = A::select()->with('b.c')->first(); - $this->assertSame('test', $a->b->c->name); - - $a = A::select()->with('b.c')->all()[0]; - $this->assertSame('test', $a->b->c->name); - } - - public function test_load_belongs_to(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateATable::class, - CreateBTable::class, - CreateCTable::class, - ); - - new A( - b: new B( - c: new C(name: 'test'), - ), - )->save(); - - $a = A::select()->first(); - $this->assertFalse(isset($a->b)); - - $a->load('b.c'); - $this->assertTrue(isset($a->b)); - $this->assertTrue(isset($a->b->c)); - } - - public function test_has_many_relations(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreatePublishersTable::class, - CreateAuthorTable::class, - CreateBookTable::class, - ); - - $author = Author::create( - name: 'Author Name', - type: AuthorType::B, - ); - - Book::create( - title: 'Book Title', - author: $author, - ); - - Book::create( - title: 'Timeline Taxi', - author: $author, - ); - - $author = Author::select()->with('books')->first(); - - $this->assertCount(2, $author->books); - } - - public function test_has_many_through_relation(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateHasManyParentTable::class, - CreateHasManyChildTable::class, - CreateHasManyThroughTable::class, - ); - - $parent = new ParentModel(name: 'parent')->save(); - - $childA = new ChildModel(name: 'A')->save(); - $childB = new ChildModel(name: 'B')->save(); - - new ThroughModel(parent: $parent, child: $childA)->save(); - new ThroughModel(parent: $parent, child: $childB)->save(); - - $parent = ParentModel::get($parent->id, ['through.child']); - - $this->assertSame('A', $parent->through[0]->child->name); - $this->assertSame('B', $parent->through[1]->child->name); - } - - public function test_empty_has_many_relation(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreatePublishersTable::class, - CreateAuthorTable::class, - CreateBookTable::class, - CreateChapterTable::class, - CreateHasManyChildTable::class, - ); - - Book::new(title: 'Timeline Taxi')->save(); - $book = Book::select()->with('chapters')->first(); - $this->assertEmpty($book->chapters); - } - - public function test_has_one_relation(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreatePublishersTable::class, - CreateAuthorTable::class, - CreateBookTable::class, - CreateChapterTable::class, - CreateHasManyChildTable::class, - CreateIsbnTable::class, - ); - - $book = Book::new(title: 'Timeline Taxi')->save(); - $isbn = Isbn::new(value: 'tt-1', book: $book)->save(); - - $isbn = Isbn::select()->with('book')->get($isbn->id); - - $this->assertSame('Timeline Taxi', $isbn->book->title); - } - - public function test_invalid_has_one_relation(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateHasManyParentTable::class, - CreateHasManyChildTable::class, - CreateHasManyThroughTable::class, - ); - - $parent = new ParentModel(name: 'parent')->save(); - - $childA = new ChildModel(name: 'A')->save(); - $childB = new ChildModel(name: 'B')->save(); - - new ThroughModel(parent: $parent, child: $childA, child2: $childB)->save(); - - $child = ChildModel::get($childA->id, ['through.parent']); - $this->assertSame('parent', $child->through->parent->name); - - $child2 = ChildModel::select()->with('through2.parent')->get($childB->id); - $this->assertSame('parent', $child2->through2->parent->name); - } - - public function test_lazy_load(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateATable::class, - CreateBTable::class, - CreateCTable::class, - ); - - new AWithLazy( - b: new B( - c: new C(name: 'test'), - ), - )->save(); - - $a = AWithLazy::select()->first(); - - $this->assertFalse(isset($a->b)); - - /** @phpstan-ignore expr.resultUnused */ - $a->b; // The side effect from accessing ->b will cause it to load - - $this->assertTrue(isset($a->b)); - } - - public function test_eager_load(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateATable::class, - CreateBTable::class, - CreateCTable::class, - ); - - new AWithLazy( - b: new B( - c: new C(name: 'test'), - ), - )->save(); - - $a = AWithEager::select()->first(); - $this->assertTrue(isset($a->b)); - $this->assertTrue(isset($a->b->c)); - } - - public function test_no_result(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateATable::class, - CreateBTable::class, - CreateCTable::class, - ); - - $this->assertNull(A::select()->first()); - } - - public function test_virtual_property(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateATable::class, - CreateBTable::class, - CreateCTable::class, - ); - - new A( - b: new B( - c: new C(name: 'test'), - ), - )->save(); - - $a = AWithVirtual::select()->first(); - - $this->assertSame(-$a->id->id, $a->fake); - } - - public function test_update_or_create(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreatePublishersTable::class, - CreateAuthorTable::class, - CreateBookTable::class, - ); - - Book::new( - title: 'A', - author: new Author( - name: 'Author Name', - type: AuthorType::B, - ), - )->save(); - - Book::updateOrCreate( - ['title' => 'A'], - ['title' => 'B'], - ); - - $this->assertNull(Book::select()->whereField('title', 'A')->first()); - $this->assertNotNull(Book::select()->whereField('title', 'B')->first()); - } - - public function test_delete(): void - { - $this->migrate( - CreateMigrationsTable::class, - FooDatabaseMigration::class, - ); - - $foo = Foo::create( - bar: 'baz', - ); - - $bar = Foo::create( - bar: 'baz', - ); - - $foo->delete(); - - $this->assertNull(Foo::get($foo->id)); - $this->assertNotNull(Foo::get($bar->id)); - } - - public function test_property_with_carbon_type(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateCarbonModelTable::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(); - - $model = CarbonModel::select()->first(); - - $this->assertTrue($model->createdAt->equalTo(new Carbon('2024-01-01'))); - } - - public function test_two_way_casters_on_models(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateCasterModelTable::class, - ); - - new CasterModel( - date: new DateTimeImmutable('2025-01-01 00:00:00'), - array_prop: ['a', 'b', 'c'], - enum_prop: CasterEnum::BAR, - )->save(); - - $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); - } - - public function test_find(): 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', 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); - } - - 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 Id(1), - index: 1, - ); - - $this->expectException(ValidationFailed::class); - - $model->update( - index: -1, - ); - } - - public function test_validation_on_new(): void - { - $model = ModelWithValidation::new( - index: 1, - ); - - $model->index = -1; - - $this->expectException(ValidationFailed::class); - - $model->save(); - } - - public function test_skipped_validation(): void - { - try { - model(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]); - } - } - - public function test_date_field(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreateDateTimeModelTable::class, - ); - - $id = query(DateTimeModel::class) - ->insert([ - 'phpDateTime' => new NativeDateTime('2024-01-01 00:00:00'), - 'tempestDateTime' => DateTime::parse('2024-01-01 00:00:00'), - ]) - ->execute(); - - /** @var DateTimeModel $model */ - $model = query(DateTimeModel::class)->select()->whereField('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 deleted file mode 100644 index 3343b4874..000000000 --- a/tests/Integration/ORM/Mappers/QueryMapperTest.php +++ /dev/null @@ -1,71 +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()); - $this->assertSame(['test'], $query->bindings); - } - - public function test_update_query(): void - { - $author = Author::new(id: new Id(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()); - - $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 261742460..000000000 --- a/tests/Integration/ORM/Models/AttributeTableNameModel.php +++ /dev/null @@ -1,12 +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 @@ -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); }