diff --git a/packages/auth/src/Install/User.php b/packages/auth/src/Install/User.php index 647a8ef76..36d457201 100644 --- a/packages/auth/src/Install/User.php +++ b/packages/auth/src/Install/User.php @@ -40,7 +40,7 @@ public function grantPermission(string|UnitEnum|Permission $permission): self { $permission = $this->resolvePermission($permission); - new UserPermission( + UserPermission::new( user: $this, permission: $permission, )->save(); diff --git a/packages/auth/src/Install/UserPermission.php b/packages/auth/src/Install/UserPermission.php index bab55629a..18cb67a2a 100644 --- a/packages/auth/src/Install/UserPermission.php +++ b/packages/auth/src/Install/UserPermission.php @@ -10,8 +10,7 @@ final class UserPermission { use IsDatabaseModel; - public function __construct( - public User $user, - public Permission $permission, - ) {} + public User $user; + + public Permission $permission; } diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index f810324d3..8722ad539 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -5,12 +5,121 @@ namespace Tempest\Database; use Attribute; +use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\QueryStatements\FieldStatement; +use Tempest\Database\QueryStatements\JoinStatement; +use Tempest\Reflection\PropertyReflector; +use Tempest\Support\Arr\ImmutableArray; + +use function Tempest\Support\str; #[Attribute(Attribute::TARGET_PROPERTY)] -final readonly class BelongsTo +final class BelongsTo implements Relation { + public PropertyReflector $property; + + public string $name { + get => $this->property->getName(); + } + + private ?string $parent = null; + public function __construct( - public string $localPropertyName, - public string $inversePropertyName = 'id', + private readonly ?string $relationJoin = null, + private readonly ?string $ownerJoin = null, ) {} + + public function setParent(string $name): self + { + $this->parent = $name; + + return $this; + } + + public function getOwnerFieldName(): string + { + if ($this->ownerJoin) { + if (str_contains($this->ownerJoin, '.')) { + return explode('.', $this->ownerJoin)[1]; + } else { + return $this->ownerJoin; + } + } + + $relationModel = model($this->property->getType()->asClass()); + + return str($relationModel->getTableName())->singularizeLastWord() . '_' . $relationModel->getPrimaryKey(); + } + + public function getSelectFields(): ImmutableArray + { + $relationModel = model($this->property->getType()->asClass()); + + return $relationModel + ->getSelectFields() + ->map(function ($field) use ($relationModel) { + return new FieldStatement( + $relationModel->getTableName() . '.' . $field, + ) + ->withAlias( + sprintf('%s.%s', $this->property->getName(), $field), + ) + ->withAliasPrefix($this->parent); + }); + } + + public function getJoinStatement(): JoinStatement + { + $relationModel = model($this->property->getType()->asClass()); + $ownerModel = model($this->property->getClass()); + + $relationJoin = $this->getRelationJoin($relationModel); + $ownerJoin = $this->getOwnerJoin($ownerModel); + + // LEFT JOIN authors ON authors.id = books.author_id + return new JoinStatement(sprintf( + 'LEFT JOIN %s ON %s = %s', + $relationModel->getTableName(), + $relationJoin, + $ownerJoin, + )); + } + + private function getRelationJoin(ModelInspector $relationModel): string + { + $relationJoin = $this->relationJoin; + + if ($relationJoin && ! strpos($relationJoin, '.')) { + $relationJoin = sprintf('%s.%s', $relationModel->getTableName(), $relationJoin); + } + + if ($relationJoin) { + return $relationJoin; + } + + return sprintf( + '%s.%s', + $relationModel->getTableName(), + $relationModel->getPrimaryKey(), + ); + } + + private function getOwnerJoin(ModelInspector $ownerModel): string + { + $ownerJoin = $this->ownerJoin; + + if ($ownerJoin && ! strpos($ownerJoin, '.')) { + $ownerJoin = sprintf('%s.%s', $ownerModel->getTableName(), $ownerJoin); + } + + if ($ownerJoin) { + return $ownerJoin; + } + + return sprintf( + '%s.%s', + $ownerModel->getTableName(), + $this->getOwnerFieldName(), + ); + } } diff --git a/packages/database/src/Builder/FieldDefinition.php b/packages/database/src/Builder/FieldDefinition.php index 59b025e8e..aa2a2363c 100644 --- a/packages/database/src/Builder/FieldDefinition.php +++ b/packages/database/src/Builder/FieldDefinition.php @@ -11,6 +11,7 @@ use function Tempest\get; +// TODO: remove final class FieldDefinition implements Stringable { public function __construct( diff --git a/packages/database/src/Builder/ModelDefinition.php b/packages/database/src/Builder/ModelDefinition.php index 9d0d5447d..5fe0985f0 100644 --- a/packages/database/src/Builder/ModelDefinition.php +++ b/packages/database/src/Builder/ModelDefinition.php @@ -5,20 +5,14 @@ namespace Tempest\Database\Builder; use ReflectionException; -use Tempest\Database\BelongsTo; -use Tempest\Database\Builder\Relations\BelongsToRelation; -use Tempest\Database\Builder\Relations\HasManyRelation; -use Tempest\Database\Builder\Relations\HasOneRelation; use Tempest\Database\Config\DatabaseConfig; -use Tempest\Database\Eager; -use Tempest\Database\HasMany; -use Tempest\Database\HasOne; use Tempest\Database\Table; use Tempest\Reflection\ClassReflector; use Tempest\Support\Arr\ImmutableArray; use function Tempest\get; +// TODO: remove final readonly class ModelDefinition { private ClassReflector $modelClass; @@ -41,80 +35,6 @@ public function __construct(string|object $model) } } - /** @return \Tempest\Database\Builder\Relations\Relation[] */ - public function getRelations(string $relationName): array - { - $relations = []; - $relationNames = explode('.', $relationName); - $alias = $this->getTableDefinition()->name; - $class = $this->modelClass; - - foreach ($relationNames as $relationNamePart) { - $property = $class->getProperty($relationNamePart); - - if ($property->hasAttribute(HasMany::class)) { - /** @var HasMany $relationAttribute */ - $relationAttribute = $property->getAttribute(HasMany::class); - $relations[] = HasManyRelation::fromAttribute($relationAttribute, $property, $alias); - $class = HasManyRelation::getRelationModelClass($property, $relationAttribute)->getType()->asClass(); - $alias .= ".{$property->getName()}"; - } elseif ($property->getType()->isIterable()) { - $relations[] = HasManyRelation::fromInference($property, $alias); - $class = $property->getIterableType()->asClass(); - $alias .= ".{$property->getName()}[]"; - } elseif ($property->hasAttribute(HasOne::class)) { - $relations[] = new HasOneRelation($property, $alias); - $class = $property->getType()->asClass(); - $alias .= ".{$property->getName()}"; - } elseif ($property->hasAttribute(BelongsTo::class)) { - /** @var BelongsTo $relationAttribute */ - $relationAttribute = $property->getAttribute(BelongsTo::class); - $relations[] = BelongsToRelation::fromAttribute($relationAttribute, $property, $alias); - $class = $property->getType()->asClass(); - $alias .= ".{$property->getName()}"; - } else { - $relations[] = BelongsToRelation::fromInference($property, $alias); - $class = $property->getType()->asClass(); - $alias .= ".{$property->getName()}"; - } - } - - return $relations; - } - - /** @return \Tempest\Database\Builder\Relations\Relation[] */ - public function getEagerRelations(): array - { - $relations = []; - - foreach ($this->buildEagerRelationNames($this->modelClass) as $relationName) { - foreach ($this->getRelations($relationName) as $relation) { - $relations[$relation->getRelationName()] = $relation; - } - } - - return $relations; - } - - private function buildEagerRelationNames(ClassReflector $class): array - { - $relations = []; - - foreach ($class->getPublicProperties() as $property) { - if (! $property->hasAttribute(Eager::class)) { - continue; - } - - $relations[] = $property->getName(); - - foreach ($this->buildEagerRelationNames($property->getType()->asClass()) as $childRelation) { - $relations[] = "{$property->getName()}.{$childRelation}"; - } - } - - return $relations; - } - public function getTableDefinition(): TableDefinition { $specificName = $this->modelClass diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index c387272b0..725d584ba 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -3,32 +3,51 @@ namespace Tempest\Database\Builder; use ReflectionException; +use Tempest\Database\BelongsTo; use Tempest\Database\Config\DatabaseConfig; +use Tempest\Database\Eager; +use Tempest\Database\HasMany; use Tempest\Database\HasOne; +use Tempest\Database\Relation; use Tempest\Database\Table; +use Tempest\Database\Virtual; use Tempest\Reflection\ClassReflector; +use Tempest\Reflection\PropertyReflector; +use Tempest\Support\Arr\ImmutableArray; use Tempest\Validation\Exceptions\ValidationException; use Tempest\Validation\SkipValidation; use Tempest\Validation\Validator; +use function Tempest\Database\model; use function Tempest\get; +use function Tempest\Support\arr; +use function Tempest\Support\str; final class ModelInspector { private ?ClassReflector $modelClass; - public function __construct( - private object|string $model, - ) { - if ($this->model instanceof ClassReflector) { - $this->modelClass = $this->model; + private object|string $model; + + public function __construct(object|string $model) + { + if ($model instanceof HasMany) { + $model = $model->property->getIterableType()->asClass(); + $this->modelClass = $model; + } elseif ($model instanceof BelongsTo || $model instanceof HasOne) { + $model = $model->property->getType()->asClass(); + $this->modelClass = $model; + } elseif ($model instanceof ClassReflector) { + $this->modelClass = $model; } else { try { - $this->modelClass = new ClassReflector($this->model); + $this->modelClass = new ClassReflector($model); } catch (ReflectionException) { $this->modelClass = null; } } + + $this->model = $model; } public function isObjectModel(): bool @@ -53,6 +72,11 @@ public function getTableDefinition(): TableDefinition return new TableDefinition($specificName ?? $conventionalName); } + public function getTableName(): string + { + return $this->getTableDefinition()->name; + } + public function getPropertyValues(): array { if (! $this->isObjectModel()) { @@ -70,7 +94,7 @@ public function getPropertyValues(): array continue; } - if ($this->isHasManyRelation($property->getName()) || $this->isHasOneRelation($property->getName())) { + if ($this->getHasMany($property->getName()) || $this->getHasOne($property->getName())) { continue; } @@ -82,42 +106,201 @@ public function getPropertyValues(): array return $values; } - public function isHasManyRelation(string $name): bool + public function getBelongsTo(string $name): ?BelongsTo { if (! $this->isObjectModel()) { - return false; + return null; + } + + $name = str($name)->camel(); + + $singularizedName = $name->singularizeLastWord(); + + if (! $singularizedName->equals($name)) { + return $this->getBelongsTo($singularizedName); } if (! $this->modelClass->hasProperty($name)) { - return false; + return null; } $property = $this->modelClass->getProperty($name); - if ($property->getIterableType()?->isRelation()) { - return true; + if ($belongsTo = $property->getAttribute(BelongsTo::class)) { + return $belongsTo; } - return false; + if (! $property->getType()->isRelation()) { + return null; + } + + if ($property->hasAttribute(HasOne::class)) { + return null; + } + + $belongsTo = new BelongsTo(); + $belongsTo->property = $property; + + return $belongsTo; } - public function isHasOneRelation(string $name): bool + public function getHasOne(string $name): ?HasOne { if (! $this->isObjectModel()) { - return false; + return null; + } + + $name = str($name)->camel(); + + $singularizedName = $name->singularizeLastWord(); + + if (! $singularizedName->equals($name)) { + return $this->getHasOne($singularizedName); } if (! $this->modelClass->hasProperty($name)) { - return false; + return null; } $property = $this->modelClass->getProperty($name); - if ($property->hasAttribute(HasOne::class)) { - return true; + if ($hasOne = $property->getAttribute(HasOne::class)) { + return $hasOne; + } + + return null; + } + + public function getHasMany(string $name): ?HasMany + { + if (! $this->isObjectModel()) { + return null; + } + + $name = str($name)->camel(); + + if (! $this->modelClass->hasProperty($name)) { + return null; + } + + $property = $this->modelClass->getProperty($name); + + if ($hasMany = $property->getAttribute(HasMany::class)) { + return $hasMany; + } + + if (! $property->getIterableType()?->isRelation()) { + return null; + } + + $hasMany = new HasMany(); + $hasMany->property = $property; + + return $hasMany; + } + + public function getSelectFields(): ImmutableArray + { + if (! $this->isObjectModel()) { + return arr(); + } + + $selectFields = arr(); + + foreach ($this->modelClass->getPublicProperties() as $property) { + $relation = $this->getRelation($property->getName()); + + if ($relation instanceof HasMany || $relation instanceof HasOne) { + continue; + } + + if ($property->hasAttribute(Virtual::class)) { + continue; + } + + if ($relation instanceof BelongsTo) { + $selectFields[] = $relation->getOwnerFieldName(); + } else { + $selectFields[] = $property->getName(); + } + } + + return $selectFields; + } + + public function getRelation(string|PropertyReflector $name): ?Relation + { + $name = ($name instanceof PropertyReflector) ? $name->getName() : $name; + + return $this->getBelongsTo($name) ?? $this->getHasOne($name) ?? $this->getHasMany($name); + } + + public function resolveRelations(string $relationString, string $parent = ''): array + { + if ($relationString === '') { + return []; + } + + $relationNames = explode('.', $relationString); + + $currentRelationName = $relationNames[0]; + + $currentRelation = $this->getRelation($currentRelationName); + + if ($currentRelation === null) { + return []; + } + + unset($relationNames[0]); + + $relationModel = model($currentRelation); + + $newRelationString = implode('.', $relationNames); + $currentRelation->setParent($parent); + $newParent = ltrim(sprintf( + '%s.%s', + $parent, + $currentRelationName, + ), '.'); + + $relations = [$currentRelationName => $currentRelation]; + + return [...$relations, ...$relationModel->resolveRelations($newRelationString, $newParent)]; + } + + public function resolveEagerRelations(string $parent = ''): array + { + if (! $this->isObjectModel()) { + return []; } - return false; + $relations = []; + + foreach ($this->modelClass->getPublicProperties() as $property) { + if (! $property->hasAttribute(Eager::class)) { + continue; + } + + $currentRelationName = $property->getName(); + $currentRelation = $this->getRelation($currentRelationName); + + if (! $currentRelation) { + continue; + } + + $relations[$property->getName()] = $currentRelation->setParent($parent); + $newParent = ltrim(sprintf( + '%s.%s', + $parent, + $currentRelationName, + ), '.'); + + foreach (model($currentRelation)->resolveEagerRelations($newParent) as $name => $nestedEagerRelation) { + $relations[$name] = $nestedEagerRelation; + } + } + + return array_filter($relations); } public function validate(mixed ...$data): void @@ -159,4 +342,14 @@ public function getName(): string return $this->modelClass; } + + public function getPrimaryKey(): string + { + return 'id'; + } + + public function getPrimaryField(): string + { + return $this->getTableDefinition()->name . '.' . $this->getPrimaryKey(); + } } diff --git a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php index 440112220..37b8ce0f7 100644 --- a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php @@ -90,7 +90,7 @@ public function bind(mixed ...$bindings): self public function toSql(): string { - return $this->build()->getSql(); + return $this->build()->toSql(); } 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 530e52372..c8a71a892 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -61,6 +61,11 @@ public function bind(mixed ...$bindings): self return $this; } + public function toSql(): string + { + return $this->build()->toSql(); + } + public function build(mixed ...$bindings): Query { return new Query($this->delete, [...$this->bindings, ...$bindings]); diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index 6f37f3fdf..f8ad7f9d5 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -45,17 +45,22 @@ public function execute(mixed ...$bindings): Id return $id; } + public function toSql(): string + { + return $this->build()->toSql(); + } + public function build(mixed ...$bindings): Query { $definition = model($this->model); foreach ($this->resolveData() as $data) { foreach ($data as $key => $value) { - if ($definition->isHasManyRelation($key)) { + if ($definition->getHasMany($key)) { throw new CannotInsertHasManyRelation($definition->getName(), $key); } - if ($definition->isHasOneRelation($key)) { + if ($definition->getHasOne($key)) { throw new CannotInsertHasOneRelation($definition->getName(), $key); } @@ -104,7 +109,7 @@ private function resolveData(): array } // HasMany and HasOne relations are skipped - if ($definition->isHasManyRelation($property->getName()) || $definition->isHasOneRelation($property->getName())) { + if ($definition->getHasMany($property->getName()) || $definition->getHasOne($property->getName())) { continue; } diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index 750888951..2f4df44b3 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -17,7 +17,7 @@ public function select(string ...$columns): SelectQueryBuilder { return new SelectQueryBuilder( model: $this->model, - columns: $columns !== [] ? arr($columns) : null, + fields: $columns !== [] ? arr($columns) : null, ); } diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 5857c745d..46dec2628 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -7,21 +7,21 @@ use Closure; use Tempest\Database\Builder\FieldDefinition; use Tempest\Database\Builder\ModelDefinition; -use Tempest\Database\Builder\TableDefinition; +use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Id; +use Tempest\Database\Mappers\SelectModelMapper; use Tempest\Database\Query; +use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; use Tempest\Database\QueryStatements\OrderByStatement; use Tempest\Database\QueryStatements\RawStatement; use Tempest\Database\QueryStatements\SelectStatement; use Tempest\Database\QueryStatements\WhereStatement; -use Tempest\Database\Virtual; use Tempest\Support\Arr\ImmutableArray; use Tempest\Support\Conditions\HasConditions; +use function Tempest\Database\model; use function Tempest\map; -use function Tempest\reflect; -use function Tempest\Support\arr; /** * @template TModelClass of object @@ -33,22 +33,26 @@ final class SelectQueryBuilder implements BuildsQuery /** @var class-string $modelClass */ private readonly string $modelClass; - private ?ModelDefinition $modelDefinition; + private ModelInspector $model; private SelectStatement $select; + private array $joins = []; + private array $relations = []; private array $bindings = []; - public function __construct(string|object $model, ?ImmutableArray $columns = null) + public function __construct(string|object $model, ?ImmutableArray $fields = null) { - $this->modelDefinition = ModelDefinition::tryFrom($model); $this->modelClass = is_object($model) ? $model::class : $model; + $this->model = model($this->modelClass); $this->select = new SelectStatement( - table: $this->resolveTable($model), - columns: $columns ?? $this->resolveColumns(), + table: $this->model->getTableDefinition(), + fields: $fields ?? $this->model + ->getSelectFields() + ->map(fn (string $fieldName) => new FieldStatement("{$this->model->getTableName()}.{$fieldName}")->withAlias()), ); } @@ -59,11 +63,13 @@ public function first(mixed ...$bindings): mixed { $query = $this->build(...$bindings); - if (! $this->modelDefinition) { + if (! $this->model->isObjectModel()) { return $query->fetchFirst(); } - $result = map($query)->collection()->to($this->modelClass); + $result = map($query->fetch()) + ->with(SelectModelMapper::class) + ->to($this->modelClass); if ($result === []) { return null; @@ -85,11 +91,13 @@ public function all(mixed ...$bindings): array { $query = $this->build(...$bindings); - if (! $this->modelDefinition) { + if (! $this->model->isObjectModel()) { return $query->fetch(); } - return map($query)->collection()->to($this->modelClass); + return map($query->fetch()) + ->with(SelectModelMapper::class) + ->to($this->modelClass); } /** @@ -134,14 +142,10 @@ public function orWhere(string $where, mixed ...$bindings): self /** @return self */ public function whereField(string $field, mixed $value): self { - if ($this->modelDefinition) { - $field = $this->modelDefinition->getFieldDefinition($field); - } else { - $field = new FieldDefinition( - $this->resolveTable($this->modelClass), - $field, - ); - } + $field = new FieldDefinition( + $this->model->getTableDefinition(), + $field, + ); return $this->where("{$field} = :{$field->name}", ...[$field->name => $value]); } @@ -170,6 +174,14 @@ public function offset(int $offset): self return $this; } + /** @return self */ + public function join(string ...$joins): self + { + $this->joins = [...$this->joins, ...$joins]; + + return $this; + } + /** @return self */ public function with(string ...$relations): self { @@ -196,16 +208,21 @@ public function bind(mixed ...$bindings): self public function toSql(): string { - return $this->build()->getSql(); + return $this->build()->toSql(); } public function build(mixed ...$bindings): Query { - $resolvedRelations = $this->resolveRelations(); + foreach ($this->joins as $join) { + $this->select->join[] = new JoinStatement($join); + } - foreach ($resolvedRelations as $relation) { - $this->select->columns = $this->select->columns->append(...$relation->getFieldDefinitions()->map(fn (FieldDefinition $field) => (string) $field->withAlias())); - $this->select->join[] = new JoinStatement($relation->getStatement()); + foreach ($this->getIncludedRelations() as $relation) { + $this->select->fields = $this->select->fields->append( + ...$relation->getSelectFields(), + ); + + $this->select->join[] = $relation->getJoinStatement(); } return new Query($this->select, [...$this->bindings, ...$bindings]); @@ -216,41 +233,27 @@ private function clone(): self return clone $this; } - private function resolveTable(string|object $model): TableDefinition + /** @return \Tempest\Database\Relation[] */ + private function getIncludedRelations(): array { - if ($this->modelDefinition === null) { - return new TableDefinition($model); - } + $definition = model($this->modelClass); - return $this->modelDefinition->getTableDefinition(); - } - - private function resolveColumns(): ImmutableArray - { - if ($this->modelDefinition === null) { - return arr(); + if (! $definition->isObjectModel()) { + return []; } - return $this->modelDefinition - ->getFieldDefinitions() - ->filter(fn (FieldDefinition $field) => ! reflect($this->modelClass, $field->name)->hasAttribute(Virtual::class)) - ->map(fn (FieldDefinition $field) => (string) $field->withAlias()); - } + $relations = $definition->resolveEagerRelations(); - private function resolveRelations(): ImmutableArray - { - if ($this->modelDefinition === null) { - return arr(); - } + foreach ($this->relations as $relationString) { + $resolvedRelations = $definition->resolveRelations($relationString); - $relations = $this->modelDefinition->getEagerRelations(); - - foreach ($this->relations as $relationName) { - foreach ($this->modelDefinition->getRelations($relationName) as $relation) { - $relations[$relation->getRelationName()] = $relation; + if ($resolvedRelations === []) { + continue; } + + $relations = [...$relations, ...$resolvedRelations]; } - return arr($relations); + return $relations; } } diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index d6a49c438..4455aa707 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -62,6 +62,11 @@ public function bind(mixed ...$bindings): self return $this; } + public function toSql(): string + { + return $this->build()->toSql(); + } + public function build(mixed ...$bindings): Query { $values = $this->resolveValues(); @@ -100,11 +105,11 @@ private function resolveValues(): ImmutableArray foreach ($this->values as $column => $value) { $property = $modelClass->getProperty($column); - if ($modelDefinition->isHasManyRelation($property->getName())) { + if ($modelDefinition->getHasMany($property->getName())) { throw new CannotUpdateHasManyRelation($modelClass->getName(), $property->getName()); } - if ($modelDefinition->isHasOneRelation($property->getName())) { + if ($modelDefinition->getHasOne($property->getName())) { throw new CannotUpdateHasOneRelation($modelClass->getName(), $property->getName()); } diff --git a/packages/database/src/Builder/Relations/BelongsToRelation.php b/packages/database/src/Builder/Relations/BelongsToRelation.php deleted file mode 100644 index b8710cec2..000000000 --- a/packages/database/src/Builder/Relations/BelongsToRelation.php +++ /dev/null @@ -1,67 +0,0 @@ -getType()->asClass(); - - $localTable = TableDefinition::for($property->getClass(), $alias); - $localField = new FieldDefinition($localTable, $property->getName() . '_id'); - - $joinTable = TableDefinition::for($property->getType()->asClass(), "{$alias}.{$property->getName()}"); - $joinField = new FieldDefinition($joinTable, 'id'); - - return new self($relationModelClass, $localField, $joinField); - } - - public static function fromAttribute(BelongsTo $belongsTo, PropertyReflector $property, string $alias): self - { - $relationModelClass = $property->getType()->asClass(); - - $localTable = TableDefinition::for($property->getClass(), $alias); - $localField = new FieldDefinition($localTable, $belongsTo->localPropertyName); - - $joinTable = TableDefinition::for($property->getType()->asClass(), "{$alias}.{$property->getName()}"); - $joinField = new FieldDefinition($joinTable, $belongsTo->inversePropertyName); - - return new self($relationModelClass, $localField, $joinField); - } - - public function getStatement(): string - { - return sprintf( - 'LEFT JOIN %s ON %s = %s', - $this->joinField->tableDefinition, - $this->localField, - $this->joinField, - ); - } - - public function getRelationName(): string - { - return $this->joinField->tableDefinition->as; - } - - public function getFieldDefinitions(): ImmutableArray - { - return FieldDefinition::all($this->relationModelClass, $this->joinField->tableDefinition); - } -} diff --git a/packages/database/src/Builder/Relations/HasManyRelation.php b/packages/database/src/Builder/Relations/HasManyRelation.php deleted file mode 100644 index 276e1f0b8..000000000 --- a/packages/database/src/Builder/Relations/HasManyRelation.php +++ /dev/null @@ -1,97 +0,0 @@ -getPublicProperties() as $potentialInverseProperty) { - if ($potentialInverseProperty->getType()->equals($property->getClass()->getType())) { - $inverseProperty = $potentialInverseProperty; - - break; - } - } - - if ($inverseProperty === null) { - throw InvalidRelation::inversePropertyNotFound( - $property->getClass()->getName(), - $property->getName(), - $relationModelClass->getName(), - ); - } - - $localTable = TableDefinition::for($property->getClass(), $alias); - $localField = new FieldDefinition($localTable, 'id'); - - $joinTable = TableDefinition::for($relationModelClass, "{$alias}.{$property->getName()}[]"); - $joinField = new FieldDefinition($joinTable, $inverseProperty->getName() . '_id'); - - return new self($relationModelClass, $localField, $joinField); - } - - public static function getRelationModelClass( - PropertyReflector $property, - ?HasMany $relation = null, - ): ClassReflector { - if ($relation !== null && $relation->inverseClassName !== null) { - return new ClassReflector($relation->inverseClassName); - } - - return $property->getIterableType()->asClass(); - } - - public static function fromAttribute(HasMany $relation, PropertyReflector $property, string $alias): self - { - $relationModelClass = self::getRelationModelClass($property, $relation); - - $localTable = TableDefinition::for($property->getClass(), $alias); - $localField = new FieldDefinition($localTable, $relation->localPropertyName); - - $joinTable = TableDefinition::for($relationModelClass, "{$alias}.{$property->getName()}[]"); - $joinField = new FieldDefinition($joinTable, $relation->inversePropertyName); - - return new self($relationModelClass, $localField, $joinField); - } - - public function getStatement(): string - { - return sprintf( - 'LEFT JOIN %s ON %s = %s', - $this->joinField->tableDefinition, - $this->localField, - $this->joinField, - ); - } - - public function getRelationName(): string - { - return $this->joinField->tableDefinition->as; - } - - public function getFieldDefinitions(): ImmutableArray - { - return FieldDefinition::all($this->relationModelClass, $this->joinField->tableDefinition); - } -} diff --git a/packages/database/src/Builder/Relations/HasOneRelation.php b/packages/database/src/Builder/Relations/HasOneRelation.php deleted file mode 100644 index eba6d1a5c..000000000 --- a/packages/database/src/Builder/Relations/HasOneRelation.php +++ /dev/null @@ -1,109 +0,0 @@ -getAttribute(HasOne::class); - $inversePropertyName = $hasOneAttribute?->inversePropertyName; - - $inverseProperty = $inversePropertyName === null - ? $this->findInversePropertyByType($property) - : $this->findInversePropertyByName($property, $inversePropertyName); - - $this->relationModelClass = $property->getType()->asClass(); - - $localTable = TableDefinition::for($property->getClass(), $alias); - $this->localField = new FieldDefinition($localTable, 'id'); - - $joinTable = TableDefinition::for($property->getType()->asClass(), "{$alias}.{$property->getName()}"); - $this->joinField = new FieldDefinition($joinTable, $inverseProperty->getName() . '_id'); - } - - public function getStatement(): string - { - return sprintf( - 'LEFT JOIN %s ON %s = %s', - $this->joinField->tableDefinition, - $this->localField, - $this->joinField, - ); - } - - public function getRelationName(): string - { - return $this->joinField->tableDefinition->as; - } - - public function getFieldDefinitions(): ImmutableArray - { - return FieldDefinition::all($this->relationModelClass, $this->joinField->tableDefinition); - } - - private function findInversePropertyByType(PropertyReflector $property): PropertyReflector - { - $currentModelClass = $property->getClass(); - $propertyClass = $property->getType()->asClass(); - - foreach ($propertyClass->getPublicProperties() as $possibleInverseProperty) { - if ($possibleInverseProperty->getType()->matches($currentModelClass->getName())) { - return $possibleInverseProperty; - } - } - - throw InvalidRelation::inversePropertyNotFound( - $currentModelClass->getName(), - $property->getName(), - $propertyClass->getName(), - ); - } - - private function findInversePropertyByName(PropertyReflector $property, string $inversePropertyName): PropertyReflector - { - $currentModelClass = $property->getClass(); - $relatedClass = $property->getType()->asClass(); - - if (! $relatedClass->hasProperty($inversePropertyName)) { - throw InvalidRelation::inversePropertyMissing( - $currentModelClass->getName(), - $property->getName(), - $relatedClass->getName(), - $inversePropertyName, - ); - } - - $inverseProperty = $relatedClass->getProperty($inversePropertyName); - $expectedType = $currentModelClass->getType(); - - if (! $inverseProperty->getType()->matches($expectedType->getName())) { - throw InvalidRelation::inversePropertyInvalidType( - $currentModelClass->getName(), - $property->getName(), - $relatedClass->getName(), - $inversePropertyName, - $property->getType()->getName(), - $inverseProperty->getType()->getName(), - ); - } - - return $inverseProperty; - } -} diff --git a/packages/database/src/Builder/Relations/Relation.php b/packages/database/src/Builder/Relations/Relation.php deleted file mode 100644 index 9419d93ff..000000000 --- a/packages/database/src/Builder/Relations/Relation.php +++ /dev/null @@ -1,17 +0,0 @@ - */ - public function getFieldDefinitions(): ImmutableArray; -} diff --git a/packages/database/src/Builder/TableDefinition.php b/packages/database/src/Builder/TableDefinition.php index c2a343f05..c7a32183d 100644 --- a/packages/database/src/Builder/TableDefinition.php +++ b/packages/database/src/Builder/TableDefinition.php @@ -7,6 +7,7 @@ use Stringable; use Tempest\Reflection\ClassReflector; +// TODO: remove final readonly class TableDefinition implements Stringable { public function __construct( diff --git a/packages/database/src/Exceptions/InvalidRelation.php b/packages/database/src/Exceptions/InvalidRelation.php deleted file mode 100644 index 02854e78c..000000000 --- a/packages/database/src/Exceptions/InvalidRelation.php +++ /dev/null @@ -1,48 +0,0 @@ -getMessage(); - $message .= PHP_EOL . PHP_EOL . $query->getSql() . PHP_EOL; + $message .= PHP_EOL . PHP_EOL . $query->toSql() . PHP_EOL; $message .= PHP_EOL . 'bindings: ' . json_encode($bindings, JSON_PRETTY_PRINT); diff --git a/packages/database/src/GenericDatabase.php b/packages/database/src/GenericDatabase.php index 4690318b1..0f7b7e1aa 100644 --- a/packages/database/src/GenericDatabase.php +++ b/packages/database/src/GenericDatabase.php @@ -26,7 +26,7 @@ public function execute(Query $query): void try { $this->connection - ->prepare($query->getSql()) + ->prepare($query->toSql()) ->execute($bindings); } catch (PDOException $pdoException) { throw new QueryException($query, $bindings, $pdoException); @@ -40,7 +40,7 @@ public function getLastInsertId(): Id public function fetch(Query $query): array { - $pdoQuery = $this->connection->prepare($query->getSql()); + $pdoQuery = $this->connection->prepare($query->toSql()); $pdoQuery->execute($this->resolveBindings($query)); diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index 42679a647..b04fe96ad 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -5,14 +5,127 @@ namespace Tempest\Database; use Attribute; +use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\QueryStatements\FieldStatement; +use Tempest\Database\QueryStatements\JoinStatement; +use Tempest\Reflection\PropertyReflector; +use Tempest\Support\Arr\ImmutableArray; + +use function Tempest\Support\str; #[Attribute(Attribute::TARGET_PROPERTY)] -final readonly class HasMany +final class HasMany implements Relation { - /** @param null|class-string $inverseClassName */ + public PropertyReflector $property; + + public string $name { + get => $this->property->getName(); + } + + private ?string $parent = null; + public function __construct( - public string $inversePropertyName, - public ?string $inverseClassName = null, - public string $localPropertyName = 'id', + public ?string $ownerJoin = null, + public ?string $relationJoin = null, ) {} + + public function setParent(string $name): self + { + $this->parent = $name; + + return $this; + } + + public function getSelectFields(): ImmutableArray + { + $relationModel = model($this->property->getIterableType()->asClass()); + + return $relationModel + ->getSelectFields() + ->map(fn ($field) => new FieldStatement( + $relationModel->getTableName() . '.' . $field, + ) + ->withAlias( + sprintf('%s.%s', $this->property->getName(), $field), + ) + ->withAliasPrefix($this->parent)); + } + + public function primaryKey(): string + { + return model($this->property->getIterableType()->asClass())->getPrimaryKey(); + } + + public function idField(): string + { + $relationModel = model($this->property->getIterableType()->asClass()); + + return sprintf( + '%s.%s', + $this->property->getName(), + $relationModel->getPrimaryKey(), + ); + } + + public function getJoinStatement(): JoinStatement + { + $ownerModel = model($this->property->getIterableType()->asClass()); + $relationModel = model($this->property->getClass()); + + $ownerJoin = $this->getOwnerJoin($ownerModel, $relationModel); + $relationJoin = $this->getRelationJoin($relationModel); + + return new JoinStatement(sprintf( + 'LEFT JOIN %s ON %s = %s', + $ownerModel->getTableName(), + $ownerJoin, + $relationJoin, + )); + } + + private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relationModel): string + { + $ownerJoin = $this->ownerJoin; + + if ($ownerJoin && ! strpos($ownerJoin, '.')) { + $ownerJoin = sprintf( + '%s.%s', + $ownerModel->getTableName(), + $ownerJoin, + ); + } + + if ($ownerJoin) { + return $ownerJoin; + } + + return sprintf( + '%s.%s', + $ownerModel->getTableName(), + str($relationModel->getTableName())->singularizeLastWord() . '_' . $relationModel->getPrimaryKey(), + ); + } + + private function getRelationJoin(ModelInspector $relationModel): string + { + $relationJoin = $this->relationJoin; + + if ($relationJoin && ! strpos($relationJoin, '.')) { + $relationJoin = sprintf( + '%s.%s', + $relationModel->getTableName(), + $relationJoin, + ); + } + + if ($relationJoin) { + return $relationJoin; + } + + return sprintf( + '%s.%s', + $relationModel->getTableName(), + $relationModel->getPrimaryKey(), + ); + } } diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index e82960917..878452587 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -5,11 +5,111 @@ namespace Tempest\Database; use Attribute; +use Tempest\Database\Builder\ModelInspector; +use Tempest\Database\QueryStatements\FieldStatement; +use Tempest\Database\QueryStatements\JoinStatement; +use Tempest\Reflection\PropertyReflector; +use Tempest\Support\Arr\ImmutableArray; + +use function Tempest\Support\str; #[Attribute(Attribute::TARGET_PROPERTY)] -final readonly class HasOne +final class HasOne implements Relation { + public PropertyReflector $property; + + public string $name { + get => $this->property->getName(); + } + + private ?string $parent = null; + public function __construct( - public ?string $inversePropertyName = null, + public ?string $ownerJoin = null, + public ?string $relationJoin = null, ) {} + + public function setParent(string $name): self + { + $this->parent = $name; + + return $this; + } + + public function getSelectFields(): ImmutableArray + { + $relationModel = model($this->property->getType()->asClass()); + + return $relationModel + ->getSelectFields() + ->map(fn ($field) => new FieldStatement( + $relationModel->getTableName() . '.' . $field, + ) + ->withAlias( + sprintf('%s.%s', $this->property->getName(), $field), + ) + ->withAliasPrefix($this->parent)); + } + + public function getJoinStatement(): JoinStatement + { + $ownerModel = model($this->property->getType()->asClass()); + $relationModel = model($this->property->getClass()); + + $ownerJoin = $this->getOwnerJoin($ownerModel, $relationModel); + $relationJoin = $this->getRelationJoin($relationModel); + + return new JoinStatement(sprintf( + 'LEFT JOIN %s ON %s = %s', + $ownerModel->getTableName(), + $ownerJoin, + $relationJoin, + )); + } + + private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relationModel): string + { + $ownerJoin = $this->ownerJoin; + + if ($ownerJoin && ! strpos($ownerJoin, '.')) { + $ownerJoin = sprintf( + '%s.%s', + $ownerModel->getTableName(), + $ownerJoin, + ); + } + + if ($ownerJoin) { + return $ownerJoin; + } + + return sprintf( + '%s.%s', + $ownerModel->getTableName(), + str($relationModel->getTableName())->singularizeLastWord() . '_' . $relationModel->getPrimaryKey(), + ); + } + + private function getRelationJoin(ModelInspector $relationModel): string + { + $relationJoin = $this->relationJoin; + + if ($relationJoin && ! strpos($relationJoin, '.')) { + $relationJoin = sprintf( + '%s.%s', + $relationModel->getTableName(), + $relationJoin, + ); + } + + if ($relationJoin) { + return $relationJoin; + } + + return sprintf( + '%s.%s', + $relationModel->getTableName(), + $relationModel->getPrimaryKey(), + ); + } } diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index a6434c41b..bb9eb8a9b 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -120,7 +120,7 @@ public function __get(string $name): mixed $type = $property->getType(); - if ($type->isIterable()) { + if ($type->isRelation()) { throw new MissingRelation($this, $name); } diff --git a/packages/database/src/Mappers/QueryToModelMapper.php b/packages/database/src/Mappers/QueryToModelMapper.php deleted file mode 100644 index 23fc01c01..000000000 --- a/packages/database/src/Mappers/QueryToModelMapper.php +++ /dev/null @@ -1,224 +0,0 @@ -getTableDefinition(); - - $models = []; - - foreach ($from->fetch() as $row) { - $idField = $table->name . '.id'; - - $id = $row[$idField]; - - $model = $models[$id] ?? $class->newInstanceWithoutConstructor(); - - $models[$id] = $this->parse($class, $model, $row); - } - - return $this->makeLazyCollection($models); - } - - private function parse(ClassReflector $class, object $model, array $row): object - { - foreach ($row as $key => $value) { - $keyParts = explode('.', $key); - - $propertyName = $keyParts[1]; - - $count = count($keyParts); - - // TODO: clean up and document - if ($count > 3) { - $property = $class->getProperty(rtrim($propertyName, '[]')); - - if ($property->getIterableType()?->isRelation()) { - $collection = $property->get($model, []); - $childId = $row[$keyParts[0] . '.' . $keyParts[1] . '.id']; - - if ($childId) { - $iterableType = $property->getIterableType(); - - $childModel = $collection[$childId] ?? $iterableType->asClass()->newInstanceWithoutConstructor(); - - unset($keyParts[0]); - - $collection[$childId] = $this->parse( - $iterableType->asClass(), - $childModel, - [implode('.', $keyParts) => $value], - ); - } - - $property->set($model, $collection); - } else { - $childModelType = $property->getType(); - - $childModel = $property->get($model, $childModelType->asClass()->newInstanceWithoutConstructor()); - - unset($keyParts[0]); - - $property->set($model, $this->parse( - $childModelType->asClass(), - $childModel, - [implode('.', $keyParts) => $value], - )); - } - } elseif ($count === 3) { - $childId = $row[$keyParts[0] . '.' . $keyParts[1] . '.id'] ?? null; - - if (str_contains($keyParts[1], '[]')) { - $property = $class->getProperty(rtrim($propertyName, '[]')); - - $model = $this->parseHasMany( - $property, - $model, - (string) $childId, - $keyParts[2], - $value, - ); - } else { - $property = $class->getProperty($propertyName); - - $model = $this->parseBelongsTo( - $property, - $model, - $keyParts[2], - $value, - ); - } - } else { - $property = $class->getProperty($propertyName); - - $model = $this->parseProperty($property, $model, $value); - } - } - - return $model; - } - - private function parseProperty(PropertyReflector $property, object $model, mixed $value): object - { - $caster = $this->casterFactory->forProperty($property); - - if ($value && $caster !== null) { - $value = $caster->cast($value); - } - - if ($value === null && ! $property->isNullable()) { - return $model; - } - - $property->set($model, $value); - - return $model; - } - - private function parseBelongsTo(PropertyReflector $property, object $model, string $childProperty, mixed $value): object - { - $childModel = $property->get( - $model, - $property->getType()->asClass()->newInstanceWithoutConstructor(), - ); - - $childProperty = new ClassReflector($childModel)->getProperty($childProperty); - - // TODO: must pass through the mapper - $this->parseProperty( - $childProperty, - $childModel, - $value, - ); - - $property->set($model, $childModel); - - return $model; - } - - private function parseHasMany(PropertyReflector $property, object $model, ?string $childId, string $childProperty, mixed $value): object - { - $collection = $property->get($model, []); - - if (! $childId) { - $property->set($model, $collection); - - return $model; - } - - $childModel = $collection[$childId] ?? $property->getIterableType()->asClass()->newInstanceWithoutConstructor(); - - $childProperty = new ClassReflector($childModel)->getProperty($childProperty); - - // TODO: must pass through the mapper - $this->parseProperty( - $childProperty, - $childModel, - $value, - ); - - $collection[$childId] = $childModel; - - $property->set($model, $collection); - - return $model; - } - - private function makeLazyCollection(array $models): array - { - $lazy = []; - - foreach ($models as $model) { - $lazy[] = $this->makeLazyModel($model); - } - - return $lazy; - } - - private function makeLazyModel(object $model): object - { - $classReflector = new ClassReflector($model); - - foreach ($classReflector->getPublicProperties() as $property) { - if ($property->isUninitialized($model)) { - $property->unset($model); - - continue; - } - - if ($property->getIterableType()?->isRelation()) { - foreach ($property->get($model) as $childModel) { - $this->makeLazyModel($childModel); - } - - break; - } - } - - return $model; - } -} diff --git a/packages/database/src/Mappers/SelectModelMapper.php b/packages/database/src/Mappers/SelectModelMapper.php new file mode 100644 index 000000000..62234310d --- /dev/null +++ b/packages/database/src/Mappers/SelectModelMapper.php @@ -0,0 +1,130 @@ +getPrimaryField(); + + $parsed = arr($from) + ->groupBy(function (array $data) use ($idField) { + return $data[$idField]; + }) + ->map(fn (array $rows) => $this->normalizeFields($model, $rows)) + ->values(); + + return map($parsed->toArray())->collection()->to($to); + } + + private function normalizeFields(ModelInspector $model, array $rows): array + { + $data = new MutableArray(); + + foreach ($rows as $row) { + $this->normalizeRow($model, $row, $data); + } + + return $this->values($model, $data->toArray()); + } + + private function values(ModelInspector $model, array $data): array + { + foreach ($data as $key => $value) { + $relation = $model->getRelation($key); + + if (! ($relation instanceof HasMany)) { + continue; + } + + $mapped = []; + $relationModel = model($relation); + + foreach ($value as $item) { + $mapped[] = $this->values($relationModel, $item); + } + + $data[$key] = $mapped; + } + + return $data; + } + + public function normalizeRow(ModelInspector $model, array $row, MutableArray $data): array + { + $mainTable = $model->getTableName(); + + foreach ($row as $field => $value) { + $parts = explode('.', $field); + + $mainField = $parts[0]; + + // Main fields + if ($mainField === $mainTable) { + $data->set($parts[1], $value); + continue; + } + + // Relations + $key = ''; + $originalKey = ''; + $currentModel = $model; + + foreach ($parts as $part) { + $relation = $currentModel->getRelation($part); + + if ($relation instanceof BelongsTo || $relation instanceof HasOne) { + $key .= $relation->name . '.'; + $originalKey .= $relation->name . '.'; + } elseif ($relation instanceof HasMany) { + $hasManyId = $data->get($key . $relation->idField()) ?? $row[$originalKey . $relation->idField()] ?? null; + + $originalKey .= $relation->name . '.'; + + if (! $data->has(trim($originalKey, '.'))) { + $data->set(trim($originalKey, '.'), []); + } + + if ($hasManyId === null) { + break; + } + + $key .= $relation->name . '.' . $hasManyId . '.'; + } else { + $key .= $part; + break; + } + + $currentModel = model($relation); + } + + if ($key) { + $data->set($key, $value); + } + } + + return $data->toArray(); + } +} diff --git a/packages/database/src/Migrations/MigrationManager.php b/packages/database/src/Migrations/MigrationManager.php index ada1587ad..fb14929fb 100644 --- a/packages/database/src/Migrations/MigrationManager.php +++ b/packages/database/src/Migrations/MigrationManager.php @@ -281,7 +281,7 @@ private function getMinifiedSqlFromStatement(?QueryStatement $statement): string $query = new Query($statement->compile($this->databaseConfig->dialect)); // Remove comments - $sql = preg_replace('/--.*$/m', '', $query->getSql()); // Remove SQL single-line comments + $sql = preg_replace('/--.*$/m', '', $query->toSql()); // Remove SQL single-line comments $sql = preg_replace('/\/\*[\s\S]*?\*\//', '', $sql); // Remove block comments // Remove blank lines and excessive spaces diff --git a/packages/database/src/Query.php b/packages/database/src/Query.php index 97d6a8e4d..1457be5d3 100644 --- a/packages/database/src/Query.php +++ b/packages/database/src/Query.php @@ -44,7 +44,7 @@ public function fetchFirst(mixed ...$bindings): ?array return $this->getDatabase()->fetchFirst($this->withBindings($bindings)); } - public function getSql(): string + public function toSql(): string { $sql = $this->sql; diff --git a/packages/database/src/QueryStatements/FieldStatement.php b/packages/database/src/QueryStatements/FieldStatement.php new file mode 100644 index 000000000..8ee8d6c29 --- /dev/null +++ b/packages/database/src/QueryStatements/FieldStatement.php @@ -0,0 +1,77 @@ +field)); + + $field = $parts[0]; + + if (count($parts) === 1) { + $alias = null; + $aliasPrefix = $this->aliasPrefix ? "{$this->aliasPrefix}." : ''; + + if ($this->alias === true) { + $alias = sprintf( + '`%s%s`', + $aliasPrefix, + str_replace('`', '', $field), + ); + } elseif ($this->alias) { + $alias = sprintf( + '`%s%s`', + $aliasPrefix, + $this->alias, + ); + } + } else { + $alias = $parts[1]; + } + + $field = arr(explode('.', $field)) + ->map(fn (string $part) => trim($part, '` ')) + ->map( + fn (string $part) => match ($dialect) { + DatabaseDialect::SQLITE => $part, + default => sprintf('`%s`', $part), + }, + ) + ->implode('.'); + + if ($alias === null) { + return $field; + } + + return sprintf('%s AS `%s`', $field, trim($alias, '`')); + } + + public function withAliasPrefix(?string $prefix = null): self + { + $this->aliasPrefix = $prefix; + + return $this; + } + + public function withAlias(bool|string $alias = true): self + { + $this->alias = $alias; + + return $this; + } +} diff --git a/packages/database/src/QueryStatements/JoinStatement.php b/packages/database/src/QueryStatements/JoinStatement.php index 8a915c5c1..23ab04d0b 100644 --- a/packages/database/src/QueryStatements/JoinStatement.php +++ b/packages/database/src/QueryStatements/JoinStatement.php @@ -5,14 +5,22 @@ use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\QueryStatement; +use function Tempest\Support\str; + final readonly class JoinStatement implements QueryStatement { public function __construct( - private string $join, + private string $statement, ) {} public function compile(DatabaseDialect $dialect): string { - return $this->join; + $statement = $this->statement; + + if (! str($statement)->lower()->startsWith(['join', 'inner join', 'left join', 'right join', 'full join', 'full outer join', 'self join'])) { + $statement = sprintf('INNER JOIN %s', $statement); + } + + return $statement; } } diff --git a/packages/database/src/QueryStatements/SelectStatement.php b/packages/database/src/QueryStatements/SelectStatement.php index 4aeda5907..366376b9f 100644 --- a/packages/database/src/QueryStatements/SelectStatement.php +++ b/packages/database/src/QueryStatements/SelectStatement.php @@ -13,7 +13,7 @@ final class SelectStatement implements QueryStatement { public function __construct( public TableDefinition $table, - public ImmutableArray $columns = new ImmutableArray(), + public ImmutableArray $fields = new ImmutableArray(), public ImmutableArray $join = new ImmutableArray(), public ImmutableArray $where = new ImmutableArray(), public ImmutableArray $orderBy = new ImmutableArray(), @@ -26,23 +26,15 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { - $columns = $this->columns->isEmpty() + $columns = $this->fields->isEmpty() ? '*' - : $this->columns - ->map(function (string|Stringable $column) { - if ($column instanceof FieldDefinition) { - return (string) $column; + : $this->fields + ->map(function (string|Stringable|FieldStatement $field) use ($dialect) { + if (! ($field instanceof FieldStatement)) { + $field = new FieldStatement($field); } - if (! str_starts_with($column, '`')) { - $column = "`{$column}"; - } - - if (! str_ends_with($column, '`')) { - $column = "{$column}`"; - } - - return (string) $column; + return $field->compile($dialect); }) ->implode(', '); @@ -95,6 +87,8 @@ public function compile(DatabaseDialect $dialect): string ->implode(PHP_EOL); } - return $query->implode(PHP_EOL); + $compiled = $query->implode(PHP_EOL); + + return $compiled; } } diff --git a/packages/database/src/Relation.php b/packages/database/src/Relation.php new file mode 100644 index 000000000..c53139f0b --- /dev/null +++ b/packages/database/src/Relation.php @@ -0,0 +1,20 @@ +assertSame( + 'table.field', + new FieldStatement('table.field')->compile(DatabaseDialect::SQLITE), + ); + + $this->assertSame( + 'table.field', + new FieldStatement('`table`.`field`')->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_mysql(): void + { + $this->assertSame( + '`table`.`field`', + new FieldStatement('`table`.`field`')->compile(DatabaseDialect::MYSQL), + ); + + $this->assertSame( + '`table`.`field`', + new FieldStatement('table.field')->compile(DatabaseDialect::MYSQL), + ); + } + + public function test_postgres(): void + { + $this->assertSame( + '`table`.`field`', + new FieldStatement('`table`.`field`')->compile(DatabaseDialect::POSTGRESQL), + ); + + $this->assertSame( + '`table`.`field`', + new FieldStatement('table.field')->compile(DatabaseDialect::POSTGRESQL), + ); + } + + public function test_with_as(): void + { + $this->assertSame( + 'authors.name AS `authors.name`', + new FieldStatement('authors.name AS `authors.name`')->compile(DatabaseDialect::SQLITE), + ); + + $this->assertSame( + 'authors.name AS `authors.name`', + new FieldStatement('authors.name AS authors.name')->compile(DatabaseDialect::SQLITE), + ); + + $this->assertSame( + '`authors`.`name` AS `authors.name`', + new FieldStatement('authors.name AS `authors.name`')->compile(DatabaseDialect::MYSQL), + ); + } + + public function test_with_alias(): void + { + $this->assertSame( + 'authors.name AS `authors.name`', + new FieldStatement('authors.name') + ->withAlias() + ->compile(DatabaseDialect::SQLITE), + ); + + $this->assertSame( + '`authors`.`name` AS `authors.name`', + new FieldStatement('`authors`.`name`') + ->withAlias() + ->compile(DatabaseDialect::MYSQL), + ); + } + + public function test_with_alias_prefix(): void + { + $this->assertSame( + 'authors.name AS `parent.authors.name`', + new FieldStatement('authors.name') + ->withAlias() + ->withAliasPrefix('parent') + ->compile(DatabaseDialect::SQLITE), + ); + } +} diff --git a/packages/database/tests/QueryStatements/JoinStatementTest.php b/packages/database/tests/QueryStatements/JoinStatementTest.php new file mode 100644 index 000000000..895a14528 --- /dev/null +++ b/packages/database/tests/QueryStatements/JoinStatementTest.php @@ -0,0 +1,63 @@ +assertSame( + 'INNER JOIN authors on authors.id = books.author_id', + new JoinStatement('authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), + ); + + $this->assertSame( + 'inner join authors on authors.id = books.author_id', + new JoinStatement('inner join authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), + ); + + $this->assertSame( + 'INNER JOIN authors on authors.id = books.author_id', + new JoinStatement('INNER JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), + ); + + $this->assertSame( + 'LEFT JOIN authors on authors.id = books.author_id', + new JoinStatement('LEFT JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), + ); + + $this->assertSame( + 'RIGHT JOIN authors on authors.id = books.author_id', + new JoinStatement('RIGHT JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), + ); + + $this->assertSame( + 'FULL JOIN authors on authors.id = books.author_id', + new JoinStatement('FULL JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), + ); + + $this->assertSame( + 'JOIN authors on authors.id = books.author_id', + new JoinStatement('JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), + ); + + $this->assertSame( + 'FULL OUTER JOIN authors on authors.id = books.author_id', + new JoinStatement('FULL OUTER JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), + ); + + $this->assertSame( + 'FULL JOIN authors on authors.id = books.author_id', + new JoinStatement('FULL JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), + ); + + $this->assertSame( + 'SELF JOIN authors on authors.id = books.author_id', + new JoinStatement('SELF JOIN authors on authors.id = books.author_id')->compile(DatabaseDialect::MYSQL), + ); + } +} diff --git a/packages/database/tests/QueryStatements/SelectStatementTest.php b/packages/database/tests/QueryStatements/SelectStatementTest.php index 76c51d613..d4f2c6a1c 100644 --- a/packages/database/tests/QueryStatements/SelectStatementTest.php +++ b/packages/database/tests/QueryStatements/SelectStatementTest.php @@ -23,7 +23,7 @@ public function test_select(): void $statement = new SelectStatement( table: $tableDefinition, - columns: arr(['`a`', 'b', 'c', new FieldDefinition($tableDefinition, 'd', 'd_alias')]), + fields: arr(['`a`', 'b', 'c', new FieldDefinition($tableDefinition, 'd', 'd_alias')]), join: arr(new JoinStatement('INNER JOIN foo ON bar.id = foo.id')), where: arr(new WhereStatement('`foo` = "bar"')), orderBy: arr(new OrderByStatement('`foo` DESC')), @@ -33,7 +33,7 @@ public function test_select(): void offset: 100, ); - $expected = <<assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); - $this->assertSame($expected, $statement->compile(DatabaseDialect::POSTGRESQL)); - $this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE)); + $this->assertSame($expectedWithBackticks, $statement->compile(DatabaseDialect::MYSQL)); + $this->assertSame($expectedWithBackticks, $statement->compile(DatabaseDialect::POSTGRESQL)); + + $expectedWithoutBackticks = <<assertSame($expectedWithoutBackticks, $statement->compile(DatabaseDialect::SQLITE)); } } diff --git a/packages/reflection/src/HasAttributes.php b/packages/reflection/src/HasAttributes.php index fe7d84e6c..14ac25898 100644 --- a/packages/reflection/src/HasAttributes.php +++ b/packages/reflection/src/HasAttributes.php @@ -4,6 +4,7 @@ namespace Tempest\Reflection; +use ReflectionAttribute; use ReflectionAttribute as PHPReflectionAttribute; use ReflectionClass as PHPReflectionClass; use ReflectionMethod as PHPReflectionMethod; @@ -31,7 +32,7 @@ public function getAttribute(string $attributeClass, bool $recursive = false): ? { $attribute = $this->getReflection()->getAttributes($attributeClass, PHPReflectionAttribute::IS_INSTANCEOF)[0] ?? null; - $attributeInstance = $attribute?->newInstance(); + $attributeInstance = $this->instantiate($attribute); if ($attributeInstance || ! $recursive) { return $attributeInstance; @@ -62,8 +63,23 @@ public function getAttribute(string $attributeClass, bool $recursive = false): ? public function getAttributes(string $attributeClass): array { return array_map( - fn (PHPReflectionAttribute $attribute) => $attribute->newInstance(), + fn (PHPReflectionAttribute $attribute) => $this->instantiate($attribute), $this->getReflection()->getAttributes($attributeClass, PHPReflectionAttribute::IS_INSTANCEOF), ); } + + private function instantiate(?ReflectionAttribute $attribute): ?object + { + $object = $attribute?->newInstance(); + + if (! $object) { + return null; + } + + if ($object instanceof PropertyAttribute && $this instanceof PropertyReflector) { + $object->property = $this; + } + + return $object; + } } diff --git a/packages/reflection/src/PropertyAttribute.php b/packages/reflection/src/PropertyAttribute.php new file mode 100644 index 000000000..0e669c9ce --- /dev/null +++ b/packages/reflection/src/PropertyAttribute.php @@ -0,0 +1,11 @@ +getProperty('prop'); + $attribute = $property->getAttribute(AttributeImplementingPropertyAttribute::class); + + $this->assertSame('prop', $attribute->property->getName()); + } +} diff --git a/packages/support/src/Str/ManipulatesString.php b/packages/support/src/Str/ManipulatesString.php index f18f82524..dbe7777e3 100644 --- a/packages/support/src/Str/ManipulatesString.php +++ b/packages/support/src/Str/ManipulatesString.php @@ -129,6 +129,14 @@ public function pluralizeLastWord(int|array|Countable $count = 2): self return $this->createOrModify(pluralize_last_word($this->value, $count)); } + /** + * Converts the last word to its English plural form. + */ + public function singularizeLastWord(): self + { + return $this->createOrModify(singularize_last_word($this->value)); + } + /** * Creates a pseudo-random alpha-numeric string of the given length. */ diff --git a/packages/support/src/Str/functions.php b/packages/support/src/Str/functions.php index 78408a900..691b465ce 100644 --- a/packages/support/src/Str/functions.php +++ b/packages/support/src/Str/functions.php @@ -199,6 +199,18 @@ function pluralize_last_word(Stringable|string $string, int|array|Countable $cou return implode('', $parts) . pluralize($lastWord, $count); } + /** + * Converts the last word of the given string to its English plural form. + */ + function singularize_last_word(Stringable|string $string): string + { + $string = (string) $string; + $parts = preg_split('/(.)(?=[A-Z])/u', $string, -1, PREG_SPLIT_DELIM_CAPTURE); + $lastWord = array_pop($parts); + + return implode('', $parts) . Language\singularize($lastWord); + } + /** * Ensures the given string starts with the specified `$prefix`. */ diff --git a/tests/Fixtures/Migrations/CreateAuthorTable.php b/tests/Fixtures/Migrations/CreateAuthorTable.php index 8fd287ac7..4b6558a9a 100644 --- a/tests/Fixtures/Migrations/CreateAuthorTable.php +++ b/tests/Fixtures/Migrations/CreateAuthorTable.php @@ -8,24 +8,19 @@ use Tempest\Database\QueryStatement; use Tempest\Database\QueryStatements\CreateTableStatement; use Tempest\Database\QueryStatements\DropTableStatement; -use Tempest\Database\QueryStatements\PrimaryKeyStatement; -use Tempest\Database\QueryStatements\TextStatement; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; final class CreateAuthorTable implements DatabaseMigration { - private(set) string $name = '0000-00-00_create_authors_table'; + private(set) string $name = '0000-00-01_create_authors_table'; public function up(): QueryStatement { - return new CreateTableStatement( - 'authors', - [ - new PrimaryKeyStatement(), - new TextStatement('name'), - new TextStatement('type', nullable: true), - ], - ); + return CreateTableStatement::forModel(Author::class) + ->primary() + ->text('name') + ->text('type', nullable: true) + ->belongsTo('authors.publisher_id', 'publishers.id', nullable: true); } public function down(): QueryStatement diff --git a/tests/Fixtures/Migrations/CreateBookTable.php b/tests/Fixtures/Migrations/CreateBookTable.php index cd54861a7..6c69d9e9d 100644 --- a/tests/Fixtures/Migrations/CreateBookTable.php +++ b/tests/Fixtures/Migrations/CreateBookTable.php @@ -12,7 +12,7 @@ final class CreateBookTable implements DatabaseMigration { - private(set) string $name = '0000-00-00_create_books_table'; + private(set) string $name = '0000-00-02_create_books_table'; public function up(): QueryStatement { diff --git a/tests/Fixtures/Migrations/CreateChapterTable.php b/tests/Fixtures/Migrations/CreateChapterTable.php index c2454aea9..051f85e69 100644 --- a/tests/Fixtures/Migrations/CreateChapterTable.php +++ b/tests/Fixtures/Migrations/CreateChapterTable.php @@ -13,7 +13,7 @@ final class CreateChapterTable implements DatabaseMigration { - private(set) string $name = '0000-00-00_create_chapters_table'; + private(set) string $name = '0000-00-03_create_chapters_table'; public function up(): QueryStatement { diff --git a/tests/Fixtures/Migrations/CreateIsbnTable.php b/tests/Fixtures/Migrations/CreateIsbnTable.php new file mode 100644 index 000000000..0dbbd4609 --- /dev/null +++ b/tests/Fixtures/Migrations/CreateIsbnTable.php @@ -0,0 +1,30 @@ +primary() + ->text('value') + ->belongsTo('isbns.book_id', 'books.id'); + } + + public function down(): QueryStatement + { + return DropTableStatement::forModel(Book::class); + } +} diff --git a/tests/Fixtures/Migrations/CreatePublishersTable.php b/tests/Fixtures/Migrations/CreatePublishersTable.php new file mode 100644 index 000000000..3fa1d360f --- /dev/null +++ b/tests/Fixtures/Migrations/CreatePublishersTable.php @@ -0,0 +1,29 @@ +primary() + ->text('name') + ->text('description'); + } + + public function down(): QueryStatement + { + return DropTableStatement::forModel(Publisher::class); + } +} diff --git a/tests/Fixtures/Modules/Books/Models/Author.php b/tests/Fixtures/Modules/Books/Models/Author.php index 14a1047ed..771b3554f 100644 --- a/tests/Fixtures/Modules/Books/Models/Author.php +++ b/tests/Fixtures/Modules/Books/Models/Author.php @@ -17,5 +17,6 @@ public function __construct( /** @var \Tests\Tempest\Fixtures\Modules\Books\Models\Book[] */ public array $books = [], + public ?Publisher $publisher = null, ) {} } diff --git a/tests/Fixtures/Modules/Books/Models/Chapter.php b/tests/Fixtures/Modules/Books/Models/Chapter.php index 4c813a662..cfc28c317 100644 --- a/tests/Fixtures/Modules/Books/Models/Chapter.php +++ b/tests/Fixtures/Modules/Books/Models/Chapter.php @@ -12,7 +12,7 @@ final class Chapter public string $title; - public string $contents; + public ?string $contents; public Book $book; } diff --git a/tests/Fixtures/Modules/Books/Models/Isbn.php b/tests/Fixtures/Modules/Books/Models/Isbn.php index 21fff2cfc..f54d76a7c 100644 --- a/tests/Fixtures/Modules/Books/Models/Isbn.php +++ b/tests/Fixtures/Modules/Books/Models/Isbn.php @@ -2,8 +2,12 @@ namespace Tests\Tempest\Fixtures\Modules\Books\Models; +use Tempest\Database\IsDatabaseModel; + final class Isbn { + use IsDatabaseModel; + public string $value; public Book $book; diff --git a/tests/Fixtures/Modules/Books/Models/Publisher.php b/tests/Fixtures/Modules/Books/Models/Publisher.php new file mode 100644 index 000000000..f3f89b726 --- /dev/null +++ b/tests/Fixtures/Modules/Books/Models/Publisher.php @@ -0,0 +1,14 @@ +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/CountQueryBuilderTest.php b/tests/Integration/Database/Builder/CountQueryBuilderTest.php index 19847807d..891869c04 100644 --- a/tests/Integration/Database/Builder/CountQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/CountQueryBuilderTest.php @@ -33,7 +33,7 @@ public function test_simple_count_query(): void OR `createdAt` > ? SQL; - $sql = $query->getSql(); + $sql = $query->toSql(); $bindings = $query->bindings; $this->assertSame($expected, $sql); @@ -46,7 +46,7 @@ public function test_count_query_with_specified_asterisk(): void ->count('*') ->build(); - $sql = $query->getSql(); + $sql = $query->toSql(); $expected = <<count('title')->build(); - $sql = $query->getSql(); + $sql = $query->toSql(); $expected = <<distinct() ->build(); - $sql = $query->getSql(); + $sql = $query->toSql(); $expected = <<count()->build(); - $sql = $query->getSql(); + $sql = $query->toSql(); $expected = << ? SQL; - $sql = $query->getSql(); + $sql = $query->toSql(); $bindings = $query->bindings; $this->assertSame($expected, $sql); diff --git a/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php b/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php index b8c9aec96..3aaff3a26 100644 --- a/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/DeleteQueryBuilderTest.php @@ -6,6 +6,7 @@ use Tempest\Database\Id; use Tempest\Database\Migrations\CreateMigrationsTable; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -25,7 +26,7 @@ public function test_delete_on_plain_table(): void DELETE FROM `foo` WHERE `bar` = ? SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -45,7 +46,7 @@ public function test_delete_on_model_table(): void <<getSql(), + $query->toSql(), ); } @@ -63,7 +64,7 @@ public function test_delete_on_model_object(): void DELETE FROM `authors` WHERE `id` = :id SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -91,7 +92,7 @@ public function test_delete_on_plain_table_with_conditions(): void DELETE FROM `foo` WHERE `bar` = ? SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -102,7 +103,7 @@ public function test_delete_on_plain_table_with_conditions(): void public function test_delete_with_non_object_model(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); query('authors')->insert( ['id' => 1, 'name' => 'Brent'], diff --git a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php index bd6c9c938..73a1925a0 100644 --- a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php @@ -10,6 +10,7 @@ use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; use Tests\Tempest\Fixtures\Migrations\CreateChapterTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\AuthorType; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; @@ -34,7 +35,7 @@ public function test_insert_on_plain_table(): void INSERT INTO `chapters` (`title`, `index`) VALUES (?, ?) SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -60,7 +61,7 @@ public function test_insert_with_batch(): void INSERT INTO `chapters` (`chapter`, `index`) VALUES (?, ?), (?, ?), (?, ?) SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -79,17 +80,17 @@ public function test_insert_on_model_table(): void $query = query(Author::class) ->insert( $author, - ['name' => 'other name', 'type' => AuthorType::B->value], + ['name' => 'other name', 'type' => AuthorType::B->value, 'publisher_id' => null], ) ->build(); $expected = <<assertSame($expected, $query->getSql()); - $this->assertSame(['brent', 'a', 'other name', 'b'], $query->bindings); + $this->assertSame($expected, $query->toSql()); + $this->assertSame(['brent', 'a', null, 'other name', 'b', null], $query->bindings); } public function test_insert_on_model_table_with_new_relation(): void @@ -112,7 +113,7 @@ public function test_insert_on_model_table_with_new_relation(): void VALUES (?, ?) SQL; - $this->assertSame($expectedBookQuery, $bookQuery->getSql()); + $this->assertSame($expectedBookQuery, $bookQuery->toSql()); $this->assertSame('Timeline Taxi', $bookQuery->bindings[0]); $this->assertInstanceOf(Query::class, $bookQuery->bindings[1]); @@ -123,7 +124,7 @@ public function test_insert_on_model_table_with_new_relation(): void VALUES (?) SQL; - $this->assertSame($expectedAuthorQuery, $authorQuery->getSql()); + $this->assertSame($expectedAuthorQuery, $authorQuery->toSql()); $this->assertSame('Brent', $authorQuery->bindings[0]); } @@ -148,7 +149,7 @@ public function test_insert_on_model_table_with_existing_relation(): void VALUES (?, ?) SQL; - $this->assertSame($expectedBookQuery, $bookQuery->getSql()); + $this->assertSame($expectedBookQuery, $bookQuery->toSql()); $this->assertSame('Timeline Taxi', $bookQuery->bindings[0]); $this->assertSame(10, $bookQuery->bindings[1]); } @@ -183,7 +184,7 @@ public function test_inserting_has_one_via_parent_model_throws_exception(): void public function test_then_method(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, CreateChapterTable::class); $id = query(Book::class) ->insert(title: 'Timeline Taxi') @@ -201,14 +202,14 @@ public function test_then_method(): void $book = Book::select()->with('chapters')->get($id); $this->assertCount(3, $book->chapters); - $this->assertSame('Chapter 01', $book->chapters[1]->title); - $this->assertSame('Chapter 02', $book->chapters[2]->title); - $this->assertSame('Chapter 03', $book->chapters[3]->title); + $this->assertSame('Chapter 01', $book->chapters[0]->title); + $this->assertSame('Chapter 02', $book->chapters[1]->title); + $this->assertSame('Chapter 03', $book->chapters[2]->title); } public function test_insert_with_non_object_model(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); query('authors')->insert( ['id' => 1, 'name' => 'Brent'], diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index 851be9a7d..28cc1f892 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -5,12 +5,13 @@ namespace Tests\Tempest\Integration\Database\Builder; use Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder; -use Tempest\Database\Database; use Tempest\Database\Migrations\CreateMigrationsTable; -use Tempest\Database\Query; -use Tempest\Database\QueryStatements\CreateTableStatement; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; +use Tests\Tempest\Fixtures\Migrations\CreateChapterTable; +use Tests\Tempest\Fixtures\Migrations\CreateIsbnTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; +use Tests\Tempest\Fixtures\Models\AWithEager; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\AuthorType; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; @@ -34,18 +35,18 @@ public function test_select_query(): void ->build(); $expected = << ? - OR `createdAt` > ? - ORDER BY `index` ASC + SELECT title, index + FROM chapters + WHERE title = ? + AND index <> ? + OR createdAt > ? + ORDER BY index ASC SQL; - $sql = $query->getSql(); + $sql = $query->toSql(); $bindings = $query->bindings; - $this->assertSame($expected, $sql); + $this->assertSameWithoutBackticks($expected, $sql); $this->assertSame(['Timeline Taxi', '1', '2025-01-01'], $bindings); } @@ -53,34 +54,35 @@ public function test_select_without_any_fields_specified(): void { $query = query('chapters')->select()->build(); - $sql = $query->getSql(); + $sql = $query->toSql(); $expected = <<assertSame($expected, $sql); + $this->assertSameWithoutBackticks($expected, $sql); } public function test_select_from_model(): void { $query = query(Author::class)->select()->build(); - $sql = $query->getSql(); + $sql = $query->toSql(); $expected = <<assertSame($expected, $sql); + $this->assertSameWithoutBackticks($expected, $sql); } public function test_where_statement(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -95,10 +97,34 @@ public function test_where_statement(): void $this->assertSame('B', $book->title); } + public function test_join(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $author = Author::new(name: 'Brent')->save(); + Book::new(title: 'A', author: $author)->save(); + + $query = query('books')->select('books.title AS book_title', 'authors.name')->join('authors on authors.id = books.author_id'); + + $this->assertSame( + [ + 'book_title' => 'A', + 'name' => 'Brent', + ], + $query->first(), + ); + } + public function test_order_by(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -117,6 +143,7 @@ public function test_limit(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -137,6 +164,7 @@ public function test_offset(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -160,6 +188,7 @@ public function test_chunk(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -186,6 +215,7 @@ public function test_raw(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -220,7 +250,7 @@ public function test_select_query_with_conditions(): void ->build(); $expected = << ? @@ -228,16 +258,16 @@ public function test_select_query_with_conditions(): void ORDER BY `index` ASC SQL; - $sql = $query->getSql(); + $sql = $query->toSql(); $bindings = $query->bindings; - $this->assertSame($expected, $sql); + $this->assertSameWithoutBackticks($expected, $sql); $this->assertSame(['Timeline Taxi', '1', '2025-01-01'], $bindings); } public function test_select_first_with_non_object_model(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); query('authors')->insert( ['id' => 1, 'name' => 'Brent'], @@ -249,12 +279,12 @@ public function test_select_first_with_non_object_model(): void ->whereField('id', 2) ->first(); - $this->assertSame(['id' => 2, 'name' => 'Other', 'type' => null], $author); + $this->assertSame(['id' => 2, 'name' => 'Other', 'type' => null, 'publisher_id' => null], $author); } public function test_select_all_with_non_object_model(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); query('authors')->insert( ['id' => 1, 'name' => 'Brent', 'type' => AuthorType::B], @@ -268,8 +298,127 @@ public function test_select_all_with_non_object_model(): void ->all(); $this->assertSame( - [['id' => 2, 'name' => 'Other', 'type' => null], ['id' => 3, 'name' => 'Another', 'type' => 'a']], + [['id' => 2, 'name' => 'Other', 'type' => null, 'publisher_id' => null], ['id' => 3, 'name' => 'Another', 'type' => 'a', 'publisher_id' => null]], $authors, ); } + + public function test_select_includes_belongs_to(): void + { + $query = query(Book::class)->select(); + + $this->assertSameWithoutBackticks(<<build()->toSql()); + } + + public function test_with_belongs_to_relation(): void + { + $query = query(Book::class) + ->select() + ->with('author', 'chapters', 'isbn') + ->build(); + + $this->assertSameWithoutBackticks(<<toSql()); + } + + public function test_select_query_execute_with_relations(): void + { + $this->seed(); + + $books = query(Book::class) + ->select() + ->with('author', 'chapters', 'isbn') + ->all(); + + $this->assertCount(4, $books); + $this->assertSame('LOTR 1', $books[0]->title); + $this->assertSame('LOTR 2', $books[1]->title); + $this->assertSame('LOTR 3', $books[2]->title); + $this->assertSame('Timeline Taxi', $books[3]->title); + + $book = $books[0]; + $this->assertSame('Tolkien', $book->author->name); + $this->assertCount(3, $book->chapters); + + $this->assertSame('LOTR 1.1', $book->chapters[0]->title); + $this->assertSame('LOTR 1.2', $book->chapters[1]->title); + $this->assertSame('LOTR 1.3', $book->chapters[2]->title); + + $this->assertSame('lotr-1', $book->isbn->value); + } + + public function test_eager_loads_combined_with_manual_loads(): void + { + $query = AWithEager::select()->with('b.c')->toSql(); + + $this->assertSameWithoutBackticks(<<migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + CreateIsbnTable::class, + ); + + query('authors')->insert( + ['name' => 'Brent'], + ['name' => 'Tolkien'], + )->execute(); + + query('books')->insert( + ['title' => 'LOTR 1', 'author_id' => 2], + ['title' => 'LOTR 2', 'author_id' => 2], + ['title' => 'LOTR 3', 'author_id' => 2], + ['title' => 'Timeline Taxi', 'author_id' => 1], + )->execute(); + + query('isbns')->insert( + ['value' => 'lotr-1', 'book_id' => 1], + ['value' => 'lotr-2', 'book_id' => 2], + ['value' => 'lotr-3', 'book_id' => 3], + ['value' => 'tt', 'book_id' => 4], + )->execute(); + + query('chapters')->insert( + ['title' => 'LOTR 1.1', 'book_id' => 1], + ['title' => 'LOTR 1.2', 'book_id' => 1], + ['title' => 'LOTR 1.3', 'book_id' => 1], + ['title' => 'LOTR 2.1', 'book_id' => 2], + ['title' => 'LOTR 2.2', 'book_id' => 2], + ['title' => 'LOTR 2.3', 'book_id' => 2], + ['title' => 'LOTR 3.1', 'book_id' => 3], + ['title' => 'LOTR 3.2', 'book_id' => 3], + ['title' => 'LOTR 3.3', 'book_id' => 3], + ['title' => 'Timeline Taxi Chapter 1', 'book_id' => 4], + ['title' => 'Timeline Taxi Chapter 2', 'book_id' => 4], + ['title' => 'Timeline Taxi Chapter 3', 'book_id' => 4], + ['title' => 'Timeline Taxi Chapter 4', 'book_id' => 4], + )->execute(); + } + + private function assertSameWithoutBackticks(string $expected, string $actual): void + { + $this->assertSame( + str_replace('`', '', $expected), + str_replace('`', '', $actual), + ); + } } diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php index 1ef2c9cd6..40c960a85 100644 --- a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php @@ -11,6 +11,7 @@ use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\Query; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\AuthorType; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; @@ -37,7 +38,7 @@ public function test_update_on_plain_table(): void SET `title` = ?, `index` = ? WHERE `id` = ? SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -58,7 +59,7 @@ public function test_global_update(): void UPDATE `chapters` SET `index` = ? SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -74,7 +75,7 @@ public function test_global_update_fails_without_allow_all(): void query('chapters') ->update(index: 0) ->build() - ->getSql(); + ->toSql(); } public function test_model_update_with_values(): void @@ -92,7 +93,7 @@ public function test_model_update_with_values(): void SET `title` = ? WHERE `id` = ? SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -120,7 +121,7 @@ public function test_model_update_with_object(): void SET `title` = ? WHERE `id` = ? SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -163,7 +164,7 @@ public function test_insert_new_relation_on_update(): void SET `author_id` = ? WHERE `id` = ? SQL, - $bookQuery->getSql(), + $bookQuery->toSql(), ); $this->assertInstanceOf(Query::class, $bookQuery->bindings[0]); @@ -175,7 +176,7 @@ public function test_insert_new_relation_on_update(): void INSERT INTO `authors` (`name`) VALUES (?) SQL, - $authorQuery->getSql(), + $authorQuery->toSql(), ); $this->assertSame(['Brent'], $authorQuery->bindings); @@ -197,7 +198,7 @@ public function test_attach_existing_relation_on_update(): void SET `author_id` = ? WHERE `id` = ? SQL, - $bookQuery->getSql(), + $bookQuery->toSql(), ); $this->assertSame([5, 10], $bookQuery->bindings); @@ -254,7 +255,7 @@ public function test_update_on_plain_table_with_conditions(): void SET `title` = ?, `index` = ? WHERE `id` = ? SQL, - $query->getSql(), + $query->toSql(), ); $this->assertSame( @@ -265,7 +266,7 @@ public function test_update_on_plain_table_with_conditions(): void public function test_update_with_non_object_model(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); query('authors')->insert( ['id' => 1, 'name' => 'Brent'], diff --git a/tests/Integration/Database/Fixtures/HasOneRelationModel.php b/tests/Integration/Database/Fixtures/HasOneRelationModel.php new file mode 100644 index 000000000..da8331cfc --- /dev/null +++ b/tests/Integration/Database/Fixtures/HasOneRelationModel.php @@ -0,0 +1,29 @@ +container->get(Database::class); - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); $database->withinTransaction(function (): never { new Author(name: 'test')->save(); diff --git a/tests/Integration/Database/GenericTransactionManagerTest.php b/tests/Integration/Database/GenericTransactionManagerTest.php index 03d951f50..d2d6ad71a 100644 --- a/tests/Integration/Database/GenericTransactionManagerTest.php +++ b/tests/Integration/Database/GenericTransactionManagerTest.php @@ -8,6 +8,7 @@ use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\Transactions\TransactionManager; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -18,7 +19,7 @@ final class GenericTransactionManagerTest extends FrameworkIntegrationTestCase { public function test_transaction_manager(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); $manager = $this->container->get(TransactionManager::class); @@ -34,7 +35,7 @@ public function test_transaction_manager(): void public function test_transaction_manager_commit(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); $manager = $this->container->get(TransactionManager::class); @@ -50,7 +51,7 @@ public function test_transaction_manager_commit(): void public function test_transaction_manager_commit_rollback(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); $manager = $this->container->get(TransactionManager::class); diff --git a/tests/Integration/Database/HasManyTest.php b/tests/Integration/Database/HasManyTest.php new file mode 100644 index 000000000..b48bf3d26 --- /dev/null +++ b/tests/Integration/Database/HasManyTest.php @@ -0,0 +1,85 @@ +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 new file mode 100644 index 000000000..a7bb3543f --- /dev/null +++ b/tests/Integration/Database/HasOneTest.php @@ -0,0 +1,85 @@ +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/Mappers/SelectModelMapperTest.php b/tests/Integration/Database/Mappers/SelectModelMapperTest.php new file mode 100644 index 000000000..f3dbd39cb --- /dev/null +++ b/tests/Integration/Database/Mappers/SelectModelMapperTest.php @@ -0,0 +1,259 @@ +data(); + + $books = map($data)->with(SelectModelMapper::class)->to(Book::class); + + $this->assertCount(4, $books); + $this->assertSame('LOTR 1', $books[0]->title); + $this->assertSame('LOTR 2', $books[1]->title); + $this->assertSame('LOTR 3', $books[2]->title); + $this->assertSame('Timeline Taxi', $books[3]->title); + + $book = $books[0]; + $this->assertSame('Tolkien', $book->author->name); + $this->assertCount(3, $book->chapters); + + $this->assertSame('LOTR 1.1', $book->chapters[0]->title); + $this->assertSame('LOTR 1.2', $book->chapters[1]->title); + $this->assertSame('LOTR 1.3', $book->chapters[2]->title); + + $this->assertSame('lotr-1', $book->isbn->value); + } + + public function test_has_many_map(): void + { + $data = [ + [ + 'books.id' => 1, + 'books.title' => 'LOTR', + 'chapters.id' => 1, + 'chapters.title' => 'LOTR 1.1', + ], + [ + 'books.id' => 1, + 'chapters.id' => 2, + 'chapters.title' => 'LOTR 1.2', + ], + [ + 'books.id' => 1, + 'chapters.id' => 3, + 'chapters.title' => 'LOTR 1.3', + ], + ]; + + $books = map($data)->with(SelectModelMapper::class)->to(Book::class); + $this->assertCount(3, $books[0]->chapters); + $this->assertSame('LOTR 1.1', $books[0]->chapters[0]->title); + $this->assertSame('LOTR 1.2', $books[0]->chapters[1]->title); + $this->assertSame('LOTR 1.3', $books[0]->chapters[2]->title); + } + + public function test_deeply_nested_map(): void + { + $data = [ + [ + 'books.id' => 1, + 'books.title' => 'LOTR 1', + 'authors.name' => 'Tolkien', + 'authors.publishers.id' => 2, + 'authors.publishers.name' => 'Houghton Mifflin', + 'authors.publishers.description' => 'Hello!', + ], + ]; + + $books = map($data)->with(SelectModelMapper::class)->to(Book::class); + + $this->assertSame('Houghton Mifflin', $books[0]->author->publisher->name); + } + + public function test_deeply_nested_has_many_map(): void + { + $data = [ + [ + 'authors.id' => 1, + 'books.id' => 1, + 'books.chapters.id' => 1, + 'books.chapters.title' => 'LOTR 1.1', + ], + [ + 'authors.id' => 1, + 'books.id' => 1, + 'books.chapters.id' => 2, + 'books.chapters.title' => 'LOTR 1.2', + ], + ]; + + $authors = map($data)->with(SelectModelMapper::class)->to(Author::class); + + $this->assertCount(2, $authors[0]->books[0]->chapters); + } + + public function test_map_user_permissions(): void + { + $data = [ + [ + 'users.name' => 'Brent', + 'users.email' => 'brendt@stitcher.io', + 'users.id' => 1, + 'userPermissions.user_id' => 1, + 'userPermissions.permission_id' => 1, + 'userPermissions.id' => 1, + ], + ]; + + $users = map($data)->with(SelectModelMapper::class)->to(User::class); + + $this->assertCount(1, $users[0]->userPermissions); + } + + private function data(): array + { + return [ + 0 => [ + 'books.id' => 1, + 'authors.id' => 2, + 'chapters.id' => 1, + 'isbns.id' => 1, + 'books.title' => 'LOTR 1', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 1.1', + 'isbns.value' => 'lotr-1', + ], + 1 => [ + 'books.id' => 1, + 'authors.id' => 2, + 'chapters.id' => 2, + 'isbns.id' => 1, + 'books.title' => 'LOTR 1', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 1.2', + 'isbns.value' => 'lotr-1', + ], + 2 => [ + 'books.id' => 1, + 'authors.id' => 2, + 'chapters.id' => 3, + 'isbns.id' => 1, + 'books.title' => 'LOTR 1', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 1.3', + 'isbns.value' => 'lotr-1', + ], + 3 => [ + 'books.id' => 2, + 'authors.id' => 2, + 'chapters.id' => 4, + 'isbns.id' => 2, + 'books.title' => 'LOTR 2', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 2.1', + 'isbns.value' => 'lotr-2', + ], + 4 => [ + 'books.id' => 2, + 'authors.id' => 2, + 'chapters.id' => 5, + 'isbns.id' => 2, + 'books.title' => 'LOTR 2', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 2.2', + 'isbns.value' => 'lotr-2', + ], + 5 => [ + 'books.id' => 2, + 'authors.id' => 2, + 'chapters.id' => 6, + 'isbns.id' => 2, + 'books.title' => 'LOTR 2', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 2.3', + 'isbns.value' => 'lotr-2', + ], + 6 => [ + 'books.id' => 3, + 'authors.id' => 2, + 'chapters.id' => 7, + 'isbns.id' => 3, + 'books.title' => 'LOTR 3', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 3.1', + 'isbns.value' => 'lotr-3', + ], + 7 => [ + 'books.id' => 3, + 'authors.id' => 2, + 'chapters.id' => 8, + 'isbns.id' => 3, + 'books.title' => 'LOTR 3', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 3.2', + 'isbns.value' => 'lotr-3', + ], + 8 => [ + 'books.id' => 3, + 'authors.id' => 2, + 'chapters.id' => 9, + 'isbns.id' => 3, + 'books.title' => 'LOTR 3', + 'authors.name' => 'Tolkien', + 'chapters.title' => 'LOTR 3.3', + 'isbns.value' => 'lotr-3', + ], + 9 => [ + 'books.id' => 4, + 'authors.id' => 1, + 'chapters.id' => 10, + 'isbns.id' => 4, + 'books.title' => 'Timeline Taxi', + 'authors.name' => 'Brent', + 'chapters.title' => 'Timeline Taxi Chapter 1', + 'isbns.value' => 'tt', + ], + 10 => [ + 'books.id' => 4, + 'authors.id' => 1, + 'chapters.id' => 11, + 'isbns.id' => 4, + 'books.title' => 'Timeline Taxi', + 'authors.name' => 'Brent', + 'chapters.title' => 'Timeline Taxi Chapter 2', + 'isbns.value' => 'tt', + ], + 11 => [ + 'books.id' => 4, + 'authors.id' => 1, + 'chapters.id' => 12, + 'isbns.id' => 4, + 'books.title' => 'Timeline Taxi', + 'authors.name' => 'Brent', + 'chapters.title' => 'Timeline Taxi Chapter 3', + 'isbns.value' => 'tt', + ], + 12 => [ + 'books.id' => 4, + 'authors.id' => 1, + 'chapters.id' => 13, + 'isbns.id' => 4, + 'books.title' => 'Timeline Taxi', + 'authors.name' => 'Brent', + 'chapters.title' => 'Timeline Taxi Chapter 4', + 'isbns.value' => 'tt', + ], + ]; + } +} diff --git a/tests/Integration/Database/QueryTest.php b/tests/Integration/Database/QueryTest.php index 55e4d83c5..6b0dd0ffa 100644 --- a/tests/Integration/Database/QueryTest.php +++ b/tests/Integration/Database/QueryTest.php @@ -7,6 +7,7 @@ use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\Query; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -17,7 +18,7 @@ final class QueryTest extends FrameworkIntegrationTestCase { public function test_with_bindings(): void { - $this->migrate(CreateMigrationsTable::class, CreateAuthorTable::class); + $this->migrate(CreateMigrationsTable::class, CreatePublishersTable::class, CreateAuthorTable::class); new Author(name: 'A')->save(); new Author(name: 'B')->save(); diff --git a/tests/Integration/Database/Relations/BelongsToRelationTest.php b/tests/Integration/Database/Relations/BelongsToRelationTest.php deleted file mode 100644 index c22237830..000000000 --- a/tests/Integration/Database/Relations/BelongsToRelationTest.php +++ /dev/null @@ -1,59 +0,0 @@ -getRelations('relatedModel'); - - $this->assertCount(1, $inferredRelation); - $this->assertSame('belongs_to_parent_model.relatedModel', $inferredRelation[0]->getRelationName()); - $this->assertEquals( - 'LEFT JOIN `belongs_to_related` AS `belongs_to_parent_model.relatedModel`' . - ' ON `belongs_to_parent_model`.`relatedModel_id` = `belongs_to_parent_model.relatedModel`.`id`', - $inferredRelation[0]->getStatement(), - ); - } - - public function test_attribute_with_default_belongs_to_relation(): void - { - $definition = new ModelDefinition(BelongsToParentModel::class); - $namedRelation = $definition->getRelations('otherRelatedModel'); - - $this->assertCount(1, $namedRelation); - - $this->assertSame('belongs_to_parent_model.otherRelatedModel', $namedRelation[0]->getRelationName()); - $this->assertEquals( - 'LEFT JOIN `belongs_to_related` AS `belongs_to_parent_model.otherRelatedModel`' . - ' ON `belongs_to_parent_model`.`other_id` = `belongs_to_parent_model.otherRelatedModel`.`id`', - $namedRelation[0]->getStatement(), - ); - } - - public function test_attribute_belongs_to_relation(): void - { - $definition = new ModelDefinition(BelongsToParentModel::class); - $doublyNamedRelation = $definition->getRelations('stillOtherRelatedModel'); - - $this->assertCount(1, $doublyNamedRelation); - - $this->assertSame('belongs_to_parent_model.stillOtherRelatedModel', $doublyNamedRelation[0]->getRelationName()); - $this->assertEquals( - 'LEFT JOIN `belongs_to_related` AS `belongs_to_parent_model.stillOtherRelatedModel`' . - ' ON `belongs_to_parent_model`.`other_id` = `belongs_to_parent_model.stillOtherRelatedModel`.`other_id`', - $doublyNamedRelation[0]->getStatement(), - ); - } -} diff --git a/tests/Integration/Database/Relations/Fixtures/BelongsToParentModel.php b/tests/Integration/Database/Relations/Fixtures/BelongsToParentModel.php deleted file mode 100644 index d08fa7487..000000000 --- a/tests/Integration/Database/Relations/Fixtures/BelongsToParentModel.php +++ /dev/null @@ -1,23 +0,0 @@ -expectException(InvalidRelation::class); - $definition->getRelations('invalid'); - } - - public function test_inferred_has_many_relation(): void - { - $definition = new ModelDefinition(BelongsToRelatedModel::class); - $inferredRelation = $definition->getRelations('inferred'); - - $this->assertCount(1, $inferredRelation); - $this->assertSame('belongs_to_related.inferred[]', $inferredRelation[0]->getRelationName()); - $this->assertEquals( - 'LEFT JOIN `belongs_to_parent_model` AS `belongs_to_related.inferred[]` ON `belongs_to_related`.`id` = `belongs_to_related.inferred[]`.`relatedModel_id`', - $inferredRelation[0]->getStatement(), - ); - } - - public function test_attribute_with_defaults_has_many_relation(): void - { - $definition = new ModelDefinition(BelongsToRelatedModel::class); - $relation = $definition->getRelations('attribute'); - - $this->assertCount(1, $relation); - $this->assertSame('belongs_to_related.attribute[]', $relation[0]->getRelationName()); - $this->assertEquals( - 'LEFT JOIN `belongs_to_parent_model` AS `belongs_to_related.attribute[]` ON `belongs_to_related`.`id` = `belongs_to_related.attribute[]`.`other_id`', - $relation[0]->getStatement(), - ); - } - - public function test_fully_filled_attribute_has_many_relation(): void - { - $definition = new ModelDefinition(BelongsToRelatedModel::class); - $relation = $definition->getRelations('full'); - - $this->assertCount(1, $relation); - $this->assertSame('belongs_to_related.full[]', $relation[0]->getRelationName()); - $this->assertEquals( - 'LEFT JOIN `belongs_to_parent_model` AS `belongs_to_related.full[]` ON `belongs_to_related`.`other_id` = `belongs_to_related.full[]`.`other_id`', - $relation[0]->getStatement(), - ); - } -} diff --git a/tests/Integration/Database/Relations/HasOneRelationTest.php b/tests/Integration/Database/Relations/HasOneRelationTest.php deleted file mode 100644 index f1a73bd5e..000000000 --- a/tests/Integration/Database/Relations/HasOneRelationTest.php +++ /dev/null @@ -1,40 +0,0 @@ -expectException(InvalidRelation::class); - - $definition = new ModelDefinition(HasOneParentModel::class); - $definition->getRelations($relationName); - } - - public function test_has_one_relation(): void - { - $definition = new ModelDefinition(HasOneParentModel::class); - $autoResolvedRelation = $definition->getRelations('relatedModel'); - $namedRelation = $definition->getRelations('otherRelatedModel'); - - $this->assertCount(1, $autoResolvedRelation); - $this->assertCount(1, $namedRelation); - $this->assertSame('has_one_parent_model.relatedModel', $autoResolvedRelation[0]->getRelationName()); - $this->assertSame('has_one_parent_model.otherRelatedModel', $namedRelation[0]->getRelationName()); - } -} diff --git a/tests/Integration/ORM/IsDatabaseModelTest.php b/tests/Integration/ORM/IsDatabaseModelTest.php index 1db63508c..05de1d892 100644 --- a/tests/Integration/ORM/IsDatabaseModelTest.php +++ b/tests/Integration/ORM/IsDatabaseModelTest.php @@ -16,6 +16,9 @@ use Tempest\Validation\Exceptions\ValidationException; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; +use Tests\Tempest\Fixtures\Migrations\CreateChapterTable; +use Tests\Tempest\Fixtures\Migrations\CreateIsbnTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Models\A; use Tests\Tempest\Fixtures\Models\AWithEager; use Tests\Tempest\Fixtures\Models\AWithLazy; @@ -26,6 +29,7 @@ use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\AuthorType; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; +use Tests\Tempest\Fixtures\Modules\Books\Models\Isbn; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use Tests\Tempest\Integration\ORM\Migrations\CreateATable; use Tests\Tempest\Integration\ORM\Migrations\CreateBTable; @@ -107,6 +111,7 @@ public function test_complex_query(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -135,6 +140,7 @@ public function test_all_with_relations(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -243,6 +249,7 @@ public function test_has_many_relations(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -286,49 +293,44 @@ public function test_has_many_through_relation(): void $parent = ParentModel::get($parent->id, ['through.child']); - $this->assertSame('A', $parent->through[1]->child->name); - $this->assertSame('B', $parent->through[2]->child->name); + $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, - CreateHasManyParentTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, CreateHasManyChildTable::class, - CreateHasManyThroughTable::class, ); - $parent = new ParentModel(name: 'parent')->save(); - - $parent = ParentModel::get($parent->id, ['through.child']); - - $this->assertInstanceOf(ParentModel::class, $parent); - $this->assertEmpty($parent->through); + 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, - CreateHasManyParentTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, CreateHasManyChildTable::class, - CreateHasManyThroughTable::class, + CreateIsbnTable::class, ); - $parent = new ParentModel(name: 'parent')->save(); - - $childA = new ChildModel(name: 'A')->save(); - - $childB = new ChildModel(name: 'B')->save(); + $book = Book::new(title: 'Timeline Taxi')->save(); + $isbn = Isbn::new(value: 'tt-1', book: $book)->save(); - new ThroughModel(parent: $parent, child: $childA, child2: $childB)->save(); - - $child = ChildModel::get($childA->id, ['through.parent']); - $child2 = ChildModel::get($childB->id, ['through2.parent']); + $isbn = Isbn::select()->with('book')->get($isbn->id); - $this->assertSame('parent', $child->through->parent->name); - $this->assertSame('parent', $child2->through2->parent->name); + $this->assertSame('Timeline Taxi', $isbn->book->title); } public function test_invalid_has_one_relation(): void @@ -343,15 +345,14 @@ public function test_invalid_has_one_relation(): void $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']); - $child2 = ChildModel::get($childB->id, ['through2.parent']); - $this->assertSame('parent', $child->through->parent->name); + + $child2 = ChildModel::select()->with('through2.parent')->get($childB->id); $this->assertSame('parent', $child2->through2->parent->name); } @@ -396,6 +397,7 @@ public function test_eager_load(): void )->save(); $a = AWithEager::select()->first(); + $this->assertTrue(isset($a->b)); $this->assertTrue(isset($a->b->c)); } @@ -435,6 +437,7 @@ public function test_update_or_create(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); diff --git a/tests/Integration/ORM/Mappers/QueryMapperTest.php b/tests/Integration/ORM/Mappers/QueryMapperTest.php index 79915a76e..915235e15 100644 --- a/tests/Integration/ORM/Mappers/QueryMapperTest.php +++ b/tests/Integration/ORM/Mappers/QueryMapperTest.php @@ -27,7 +27,7 @@ public function test_insert_query(): void $this->assertSame(<<<'SQL' INSERT INTO `authors` (`name`) VALUES (?) - SQL, $query->getSql()); + SQL, $query->toSql()); $this->assertSame(['test'], $query->bindings); } @@ -41,7 +41,7 @@ public function test_update_query(): void UPDATE `authors` SET `name` = ? WHERE `id` = ? - SQL, $query->getSql()); + SQL, $query->toSql()); $this->assertSame(['other', 1], $query->bindings); } diff --git a/tests/Integration/ORM/Models/ChildModel.php b/tests/Integration/ORM/Models/ChildModel.php index f515f7c62..164985615 100644 --- a/tests/Integration/ORM/Models/ChildModel.php +++ b/tests/Integration/ORM/Models/ChildModel.php @@ -16,7 +16,7 @@ final class ChildModel #[HasOne] public ThroughModel $through; - #[HasOne('child2')] + #[HasOne(ownerJoin: 'child2_id')] public ThroughModel $through2; public function __construct( diff --git a/tests/Integration/ORM/Models/ThroughModel.php b/tests/Integration/ORM/Models/ThroughModel.php index e65162bc6..5e0035a42 100644 --- a/tests/Integration/ORM/Models/ThroughModel.php +++ b/tests/Integration/ORM/Models/ThroughModel.php @@ -4,6 +4,7 @@ namespace Tests\Tempest\Integration\ORM\Models; +use Tempest\Database\BelongsTo; use Tempest\Database\IsDatabaseModel; use Tempest\Database\Table; @@ -15,6 +16,7 @@ final class ThroughModel public function __construct( public ParentModel $parent, public ChildModel $child, + #[BelongsTo(ownerJoin: 'child2_id')] public ?ChildModel $child2 = null, ) {} } diff --git a/tests/Integration/Route/RequestTest.php b/tests/Integration/Route/RequestTest.php index 3f512ee4c..aeda0a8f3 100644 --- a/tests/Integration/Route/RequestTest.php +++ b/tests/Integration/Route/RequestTest.php @@ -13,6 +13,7 @@ use Tempest\Http\Status; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\BookController; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -117,6 +118,7 @@ public function test_custom_request_test_with_validation(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); @@ -145,6 +147,7 @@ public function test_custom_request_test_with_nested_validation(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); diff --git a/tests/Integration/Route/RouterTest.php b/tests/Integration/Route/RouterTest.php index f59138a2a..89f8dcc7e 100644 --- a/tests/Integration/Route/RouterTest.php +++ b/tests/Integration/Route/RouterTest.php @@ -28,6 +28,7 @@ use Tests\Tempest\Fixtures\Controllers\UriGeneratorController; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; +use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\Book; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -132,6 +133,7 @@ public function test_route_binding(): void { $this->migrate( CreateMigrationsTable::class, + CreatePublishersTable::class, CreateAuthorTable::class, CreateBookTable::class, ); diff --git a/tests/Integration/Support/StringTest.php b/tests/Integration/Support/StringTest.php index 8a6df5895..2208c6ec6 100644 --- a/tests/Integration/Support/StringTest.php +++ b/tests/Integration/Support/StringTest.php @@ -20,4 +20,12 @@ public function test_plural_studly(): void $this->assertTrue(str('VortexField')->pluralizeLastWord()->equals('VortexFields')); $this->assertTrue(str('MultipleWordsInOneString')->pluralizeLastWord()->equals('MultipleWordsInOneStrings')); } + + public function test_singularize(): void + { + $this->assertTrue(str('RealHumans')->singularizeLastWord()->equals('RealHuman')); + $this->assertTrue(str('Models')->singularizeLastWord()->equals('Model')); + $this->assertTrue(str('VortexFields')->singularizeLastWord()->equals('VortexField')); + $this->assertTrue(str('MultipleWordsInOneStrings')->singularizeLastWord()->equals('MultipleWordsInOneString')); + } }