diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index 3ed5a4503d..64eb3f1681 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -699,6 +699,64 @@ $books[0]->chapters[2]->delete(); ::: +### Filtering by relations + +Use `whereHas` and `whereDoesntHave` to filter models based on whether related records exist: + +```php +// Authors who have at least one book +$authors = Author::select() + ->whereHas(relation: 'books') + ->all(); + +// Authors who have no books +$authors = Author::select() + ->whereDoesntHave(relation: 'books') + ->all(); +``` + +Add a callback to constrain the related records: + +```php +// Authors who have a published book +$authors = Author::select() + ->whereHas(relation: 'books', callback: function (SelectQueryBuilder $query): void { + $query->whereField(field: 'published', value: true); + }) + ->all(); +``` + +Use `operator` and `count` for count-based filtering: + +```php +// Authors with 3 or more books +$authors = Author::select() + ->whereHas(relation: 'books', operator: WhereOperator::GREATER_THAN_OR_EQUAL, count: 3) + ->all(); +``` + +Dot notation supports nested relations: + +```php +// Authors who have books with chapters +$authors = Author::select() + ->whereHas(relation: 'books.chapters') + ->all(); +``` + +These methods work on all query builders: + +```php +// Count authors with books +$count = Author::count()->whereHas(relation: 'books')->execute(); + +// Delete authors without books +query(model: Author::class)->delete()->whereDoesntHave(relation: 'books')->execute(); + +// Update authors who have books +query(model: Author::class)->update(verified: true)->whereHas(relation: 'books')->execute(); +``` + ## Migrations When persisting objects to the database, a table is required to store the data. A migration is a file that instructs the framework how to manage the database schema. diff --git a/packages/core/src/Kernel/LoadConfig.php b/packages/core/src/Kernel/LoadConfig.php index 14748304f2..934aa31a65 100644 --- a/packages/core/src/Kernel/LoadConfig.php +++ b/packages/core/src/Kernel/LoadConfig.php @@ -69,7 +69,7 @@ public function find(): array }) ->sortByCallback(function (string $path1, string $path2) use ($suffixes): int { $getPriority = fn (string $path): int => match (true) { - Str\contains($path, DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR) => 0, + Str\contains($path, '/vendor/') => 0, ! Str\contains($path, root_path()) => 0, Str\contains($path, $suffixes['testing']) => 6, Str\contains($path, $suffixes['development']) => 5, diff --git a/packages/database/src/AggregateFunction.php b/packages/database/src/AggregateFunction.php new file mode 100644 index 0000000000..546dc98274 --- /dev/null +++ b/packages/database/src/AggregateFunction.php @@ -0,0 +1,13 @@ +property->getType()->asClass()); + $parentModel = inspect(model: $this->property->getClass()); + + $relatedTable = $relatedModel->getTableName(); + $parentTable = $parentModel->getTableName(); + $relatedPK = $relatedModel->getPrimaryKey(); + + $fk = $this->getOwnerFieldName(); + + return new WhereExistsStatement( + relatedTable: $relatedTable, + relatedModelName: $relatedModel->getName(), + condition: "{$relatedTable}.{$relatedPK} = {$parentTable}.{$fk}", + ); + } + private function isSelfReferencing(): bool { $relationModel = inspect($this->property->getType()->asClass()); diff --git a/packages/database/src/BelongsToMany.php b/packages/database/src/BelongsToMany.php index c901c28601..cd22063961 100644 --- a/packages/database/src/BelongsToMany.php +++ b/packages/database/src/BelongsToMany.php @@ -9,6 +9,7 @@ use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; +use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Reflection\PropertyReflector; use Tempest\Support\Arr\ImmutableArray; @@ -364,4 +365,21 @@ private function resolveRelatedRelationJoin(ModelInspector $targetModel, string $primaryKey, ); } + + public function getExistsStatement(): WhereExistsStatement + { + $ownerModel = inspect(model: $this->property->getClass()); + $targetModel = inspect(model: $this->property->getIterableType()->asClass()); + $pivotTable = $this->resolvePivotTable(ownerModel: $ownerModel, targetModel: $targetModel); + + $ownerPK = $ownerModel->getPrimaryKey(); + $ownerTable = $ownerModel->getTableName(); + $fk = $this->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord()->append(suffix: "_{$ownerPK}"); + + return new WhereExistsStatement( + relatedTable: $pivotTable, + relatedModelName: $targetModel->getName(), + condition: "{$pivotTable}.{$fk} = {$ownerTable}.{$ownerPK}", + ); + } } diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index 22a14bc150..89db10c894 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -196,7 +196,7 @@ public function getBelongsTo(string $name): ?BelongsTo $singularizedName = $name->singularizeLastWord(); - if (! $singularizedName->equals($name)) { + if (! $singularizedName->equals($name) && $this->reflector->hasProperty($singularizedName)) { return $this->getBelongsTo($singularizedName); } @@ -264,7 +264,7 @@ public function getHasOne(string $name): ?HasOne $singularizedName = $name->singularizeLastWord(); - if (! $singularizedName->equals($name)) { + if (! $singularizedName->equals($name) && $this->reflector->hasProperty($singularizedName)) { return $this->getHasOne($singularizedName); } @@ -343,7 +343,7 @@ public function getHasOneThrough(string $name): ?HasOneThrough $singularizedName = $name->singularizeLastWord(); - if (! $singularizedName->equals($name)) { + if (! $singularizedName->equals($name) && $this->reflector->hasProperty($singularizedName)) { return $this->getHasOneThrough($singularizedName); } diff --git a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php index a21d6e1019..3dbd5cae3d 100644 --- a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php +++ b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php @@ -4,6 +4,7 @@ use Closure; use Tempest\Database\Builder\WhereOperator; +use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Database\QueryStatements\WhereGroupStatement; use Tempest\Database\QueryStatements\WhereStatement; @@ -12,8 +13,9 @@ trait HasWhereQueryBuilderMethods { use HasConvenientWhereMethods; + use HasWhereRelationMethods; - protected function appendWhere(WhereStatement|WhereGroupStatement $where): void + protected function appendWhere(WhereStatement|WhereGroupStatement|WhereExistsStatement $where): void { $this->wheres->offsetSet(null, $where); } diff --git a/packages/database/src/Builder/QueryBuilders/HasWhereRelationMethods.php b/packages/database/src/Builder/QueryBuilders/HasWhereRelationMethods.php new file mode 100644 index 0000000000..5620ecbecd --- /dev/null +++ b/packages/database/src/Builder/QueryBuilders/HasWhereRelationMethods.php @@ -0,0 +1,173 @@ +=` (defaults), uses an `EXISTS` subquery. + * Otherwise, uses a `COUNT(*)` subquery with the given operator and count. + * + * @phpstan-param (?Closure(SelectQueryBuilder): void) $callback + * + * @return static + */ + public function whereHas( + string $relation, + ?Closure $callback = null, + string|WhereOperator $operator = WhereOperator::GREATER_THAN_OR_EQUAL, + int $count = 1, + ): self { + return $this->addHasCondition( + relation: $relation, + callback: $callback, + operator: WhereOperator::fromOperator(value: $operator), + count: $count, + connector: WhereConnector::AND, + negate: false, + ); + } + + /** + * Adds a `WHERE NOT EXISTS` condition for a relation. + * + * @phpstan-param (?Closure(SelectQueryBuilder): void) $callback + * + * @return static + */ + public function whereDoesntHave( + string $relation, + ?Closure $callback = null, + ): self { + return $this->addHasCondition( + relation: $relation, + callback: $callback, + operator: WhereOperator::GREATER_THAN_OR_EQUAL, + count: 1, + connector: WhereConnector::AND, + negate: true, + ); + } + + /** + * Adds an `OR WHERE EXISTS` condition for a relation. + * + * @phpstan-param (?Closure(SelectQueryBuilder): void) $callback + * + * @return static + */ + public function orWhereHas( + string $relation, + ?Closure $callback = null, + string|WhereOperator $operator = WhereOperator::GREATER_THAN_OR_EQUAL, + int $count = 1, + ): self { + return $this->addHasCondition( + relation: $relation, + callback: $callback, + operator: WhereOperator::fromOperator(value: $operator), + count: $count, + connector: WhereConnector::OR, + negate: false, + ); + } + + /** + * Adds an `OR WHERE NOT EXISTS` condition for a relation. + * + * @phpstan-param (?Closure(SelectQueryBuilder): void) $callback + * + * @return static + */ + public function orWhereDoesntHave( + string $relation, + ?Closure $callback = null, + ): self { + return $this->addHasCondition( + relation: $relation, + callback: $callback, + operator: WhereOperator::GREATER_THAN_OR_EQUAL, + count: 1, + connector: WhereConnector::OR, + negate: true, + ); + } + + private function addHasCondition( + string $relation, + ?Closure $callback, + WhereOperator $operator, + int $count, + WhereConnector $connector, + bool $negate, + ): self { + $parts = str(string: $relation)->explode( + separator: '.', + limit: 2, + ); + $relationName = (string) $parts[0]; + $nestedPath = isset($parts[1]) + ? (string) $parts[1] + : null; + + $existsStatement = $this->model + ->getRelation(name: $relationName) + ->getExistsStatement(); + + $useCount = ! $negate && ($count !== 1 || $operator !== WhereOperator::GREATER_THAN_OR_EQUAL); + + /** @var ImmutableArray $innerWheres */ + $innerWheres = arr(); + /** @var ImmutableArray $innerBindings */ + $innerBindings = arr(); + + if ($nestedPath !== null) { + $innerBuilder = new SelectQueryBuilder(model: $existsStatement->relatedModelName); + $innerBuilder->whereHas( + relation: $nestedPath, + callback: $callback, + ); + + $innerWheres = $innerBuilder->wheres; + $innerBindings = arr(input: $innerBuilder->bindings); + } elseif ($callback instanceof Closure) { + $innerBuilder = new SelectQueryBuilder(model: $existsStatement->relatedModelName); + $callback($innerBuilder); + + $innerWheres = $innerBuilder->wheres; + $innerBindings = arr(input: $innerBuilder->bindings); + } + + $whereExists = new WhereExistsStatement( + relatedTable: $existsStatement->relatedTable, + relatedModelName: $existsStatement->relatedModelName, + condition: $existsStatement->condition, + innerWheres: $innerWheres, + negate: $negate, + useCount: $useCount, + operator: $operator, + count: $count, + ); + + if ($this->wheres->isNotEmpty()) { + $this->appendWhere(where: new WhereStatement(where: $connector->value)); + } + + $this->appendWhere(where: $whereExists); + $this->bind(...$innerBindings->toArray()); + + return $this; + } +} diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index 8f99aa2bd3..c3d4e46bfd 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -127,6 +127,58 @@ public function count(?string $column = null): CountQueryBuilder )->onDatabase($this->onDatabase); } + /** + * Executes an aggregate query and returns the sum of the given column. + * + * **Example** + * ```php + * query(User::class)->sum('price'); + * ``` + */ + public function sum(string $column): int|float + { + return $this->select()->onDatabase(databaseTag: $this->onDatabase)->sum(column: $column); + } + + /** + * Executes an aggregate query and returns the average of the given column. + * + * **Example** + * ```php + * query(User::class)->avg('price'); + * ``` + */ + public function avg(string $column): float + { + return $this->select()->onDatabase(databaseTag: $this->onDatabase)->avg(column: $column); + } + + /** + * Executes an aggregate query and returns the maximum value of the given column. + * + * **Example** + * ```php + * query(User::class)->max('price'); + * ``` + */ + public function max(string $column): mixed + { + return $this->select()->onDatabase(databaseTag: $this->onDatabase)->max(column: $column); + } + + /** + * Executes an aggregate query and returns the minimum value of the given column. + * + * **Example** + * ```php + * query(User::class)->min('price'); + * ``` + */ + public function min(string $column): mixed + { + return $this->select()->onDatabase(databaseTag: $this->onDatabase)->min(column: $column); + } + /** * Creates a new instance of this model without persisting it to the database. * diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index bc71150144..831d435da9 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -5,6 +5,7 @@ namespace Tempest\Database\Builder\QueryBuilders; use Closure; +use Tempest\Database\AggregateFunction; use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Database; use Tempest\Database\DatabaseContext; @@ -32,6 +33,8 @@ use function Tempest\Container\get; use function Tempest\Database\inspect; use function Tempest\Mapper\map; +use function Tempest\Support\arr; +use function Tempest\Support\str; /** * @template TModel @@ -396,6 +399,68 @@ public function build(mixed ...$bindings): Query return new Query($select, [...$this->bindings, ...$bindings])->onDatabase($this->onDatabase); } + /** + * Executes an aggregate query and returns the sum of the given column. + */ + public function sum(string $column): int|float + { + return $this->aggregate(function: AggregateFunction::SUM, column: $column); + } + + /** + * Executes an aggregate query and returns the average of the given column. + */ + public function avg(string $column): float + { + return (float) $this->aggregate(function: AggregateFunction::AVG, column: $column); + } + + /** + * Executes an aggregate query and returns the maximum value of the given column. + */ + public function max(string $column): mixed + { + return $this->aggregate(function: AggregateFunction::MAX, column: $column); + } + + /** + * Executes an aggregate query and returns the minimum value of the given column. + */ + public function min(string $column): mixed + { + return $this->aggregate(function: AggregateFunction::MIN, column: $column); + } + + private function aggregate(AggregateFunction $function, string $column): mixed + { + $key = str(string: $function->value)->lower()->toString(); + + $field = new FieldStatement( + field: "{$function->value}(`{$column}`) AS `{$key}`", + ); + + $result = + SelectQueryBuilder::fromQueryBuilder( + source: $this, + fields: arr(input: [$field]), + ) + ->build() + ->fetchFirst()[$key] ?? null; + + if ($result === null) { + return match ($function) { + AggregateFunction::AVG => 0.0, + AggregateFunction::SUM => 0, + default => null, + }; + } + + return match ($function) { + AggregateFunction::SUM => str(string: (string) $result)->contains(needle: '.') ? (float) $result : (int) $result, + default => $result, + }; + } + private function clone(): self { return clone $this; diff --git a/packages/database/src/Builder/WhereConnector.php b/packages/database/src/Builder/WhereConnector.php new file mode 100644 index 0000000000..33ede94f32 --- /dev/null +++ b/packages/database/src/Builder/WhereConnector.php @@ -0,0 +1,9 @@ + sprintf('`%s`', $identifier), + self::POSTGRESQL => sprintf('"%s"', $identifier), + }; + } + public function isTableNotFoundError(QueryWasInvalid $queryWasInvalid): bool { $pdoException = $queryWasInvalid->pdoException; diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index c39a3465ff..fef15dca2b 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -9,6 +9,7 @@ use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; +use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Reflection\PropertyReflector; use Tempest\Support\Arr\ImmutableArray; @@ -153,6 +154,24 @@ private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relati ); } + public function getExistsStatement(): WhereExistsStatement + { + $relatedModel = inspect(model: $this->property->getIterableType()->asClass()); + $parentModel = inspect(model: $this->property->getClass()); + + $relatedTable = $relatedModel->getTableName(); + $parentTable = $parentModel->getTableName(); + $parentPK = $parentModel->getPrimaryKey(); + + $fk = $this->ownerJoin ?? str(string: $parentTable)->singularizeLastWord()->append(suffix: "_{$parentPK}"); + + return new WhereExistsStatement( + relatedTable: $relatedTable, + relatedModelName: $relatedModel->getName(), + condition: "{$relatedTable}.{$fk} = {$parentTable}.{$parentPK}", + ); + } + private function isSelfReferencing(): bool { $relationModel = inspect($this->property->getIterableType()->asClass()); diff --git a/packages/database/src/HasManyThrough.php b/packages/database/src/HasManyThrough.php index 610812b45b..60f843454c 100644 --- a/packages/database/src/HasManyThrough.php +++ b/packages/database/src/HasManyThrough.php @@ -9,6 +9,7 @@ use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; +use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Reflection\PropertyReflector; use Tempest\Support\Arr\ImmutableArray; @@ -328,4 +329,22 @@ private function resolveThroughRelationJoin(ModelInspector $intermediateModel): $primaryKey, ); } + + public function getExistsStatement(): WhereExistsStatement + { + $ownerModel = inspect(model: $this->property->getClass()); + $intermediateModel = inspect(model: $this->through); + + $intermediateTable = $intermediateModel->getTableName(); + $ownerTable = $ownerModel->getTableName(); + $ownerPK = $ownerModel->getPrimaryKey(); + + $fk = $this->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord()->append(suffix: "_{$ownerPK}"); + + return new WhereExistsStatement( + relatedTable: $intermediateTable, + relatedModelName: inspect(model: $this->property->getIterableType()->asClass())->getName(), + condition: "{$intermediateTable}.{$fk} = {$ownerTable}.{$ownerPK}", + ); + } } diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index 8b1fb9d6e0..b800df9386 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -9,6 +9,7 @@ use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; +use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Reflection\PropertyReflector; use Tempest\Support\Arr\ImmutableArray; @@ -125,6 +126,24 @@ private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relati ); } + public function getExistsStatement(): WhereExistsStatement + { + $relatedModel = inspect(model: $this->property->getType()->asClass()); + $parentModel = inspect(model: $this->property->getClass()); + + $relatedTable = $relatedModel->getTableName(); + $parentTable = $parentModel->getTableName(); + $parentPK = $parentModel->getPrimaryKey(); + + $fk = $this->ownerJoin ?? str(string: $parentTable)->singularizeLastWord()->append(suffix: "_{$parentPK}"); + + return new WhereExistsStatement( + relatedTable: $relatedTable, + relatedModelName: $relatedModel->getName(), + condition: "{$relatedTable}.{$fk} = {$parentTable}.{$parentPK}", + ); + } + private function isSelfReferencing(): bool { $relationModel = inspect($this->property->getType()->asClass()); diff --git a/packages/database/src/HasOneThrough.php b/packages/database/src/HasOneThrough.php index 5d23f2d234..6c20af2d63 100644 --- a/packages/database/src/HasOneThrough.php +++ b/packages/database/src/HasOneThrough.php @@ -9,6 +9,7 @@ use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; use Tempest\Database\QueryStatements\FieldStatement; use Tempest\Database\QueryStatements\JoinStatement; +use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Reflection\PropertyReflector; use Tempest\Support\Arr\ImmutableArray; @@ -285,4 +286,22 @@ private function resolveThroughRelationJoin(ModelInspector $intermediateModel): $primaryKey, ); } + + public function getExistsStatement(): WhereExistsStatement + { + $ownerModel = inspect(model: $this->property->getClass()); + $intermediateModel = inspect(model: $this->through); + + $intermediateTable = $intermediateModel->getTableName(); + $ownerTable = $ownerModel->getTableName(); + $ownerPK = $ownerModel->getPrimaryKey(); + + $fk = $this->ownerJoin ?? str(string: $ownerTable)->singularizeLastWord()->append(suffix: "_{$ownerPK}"); + + return new WhereExistsStatement( + relatedTable: $intermediateTable, + relatedModelName: inspect(model: $this->property->getType()->asClass())->getName(), + condition: "{$intermediateTable}.{$fk} = {$ownerTable}.{$ownerPK}", + ); + } } diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 71def875e6..873439ff54 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -84,6 +84,38 @@ public static function count(): CountQueryBuilder return self::queryBuilder()->count(); } + /** + * Executes an aggregate query and returns the sum of the given column. + */ + public static function sum(string $column): int|float + { + return self::queryBuilder()->sum(column: $column); + } + + /** + * Executes an aggregate query and returns the average of the given column. + */ + public static function avg(string $column): float + { + return self::queryBuilder()->avg(column: $column); + } + + /** + * Executes an aggregate query and returns the maximum value of the given column. + */ + public static function max(string $column): mixed + { + return self::queryBuilder()->max(column: $column); + } + + /** + * Executes an aggregate query and returns the minimum value of the given column. + */ + public static function min(string $column): mixed + { + return self::queryBuilder()->min(column: $column); + } + /** * Creates a new instance of this model without persisting it to the database. */ diff --git a/packages/database/src/Query.php b/packages/database/src/Query.php index 958ee743de..6cf14d7dfc 100644 --- a/packages/database/src/Query.php +++ b/packages/database/src/Query.php @@ -76,7 +76,7 @@ public function compile(): ImmutableString } if ($dialect === DatabaseDialect::POSTGRESQL) { - $sql = str_replace('`', '', $sql); + $sql = str_replace('`', '"', $sql); } return new ImmutableString($sql); diff --git a/packages/database/src/QueryStatements/AlterTableStatement.php b/packages/database/src/QueryStatements/AlterTableStatement.php index d1d7530c0f..7250ee7542 100644 --- a/packages/database/src/QueryStatements/AlterTableStatement.php +++ b/packages/database/src/QueryStatements/AlterTableStatement.php @@ -4,7 +4,6 @@ namespace Tempest\Database\QueryStatements; -use Tempest\Database\Builder\TableDefinition; use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\HasTrailingStatements; use Tempest\Database\QueryStatement; @@ -92,7 +91,7 @@ public function compile(DatabaseDialect $dialect): string if ($this->statements !== []) { return sprintf( 'ALTER TABLE %s %s;', - new TableDefinition($this->tableName), + $dialect->quoteIdentifier($this->tableName), arr($this->statements) ->map(fn (QueryStatement $queryStatement) => str($queryStatement->compile($dialect))->trim()->replace(' ', ' ')) ->filter(fn (ImmutableString $line) => $line->isNotEmpty()) diff --git a/packages/database/src/QueryStatements/BooleanStatement.php b/packages/database/src/QueryStatements/BooleanStatement.php index 52c6240886..6bb9d7984c 100644 --- a/packages/database/src/QueryStatements/BooleanStatement.php +++ b/packages/database/src/QueryStatements/BooleanStatement.php @@ -27,8 +27,8 @@ public function compile(DatabaseDialect $dialect): string } return sprintf( - '`%s` BOOLEAN %s %s', - $this->name, + '%s BOOLEAN %s %s', + $dialect->quoteIdentifier($this->name), $default !== null ? "DEFAULT {$default}" : '', $this->nullable ? '' : 'NOT NULL', ); diff --git a/packages/database/src/QueryStatements/CharStatement.php b/packages/database/src/QueryStatements/CharStatement.php index 8e1fe53dbf..5d026071b6 100644 --- a/packages/database/src/QueryStatements/CharStatement.php +++ b/packages/database/src/QueryStatements/CharStatement.php @@ -11,6 +11,7 @@ { public function __construct( private string $name, + private int $size, private bool $nullable = false, private ?string $default = null, ) {} @@ -18,8 +19,9 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { return sprintf( - '`%s` CHAR %s %s', - $this->name, + '`%s` CHAR(%s) %s %s', + $dialect->quoteIdentifier($this->name), + $this->size, $this->default !== null ? "DEFAULT '{$this->default}'" : '', $this->nullable ? '' : 'NOT NULL', ); diff --git a/packages/database/src/QueryStatements/CountStatement.php b/packages/database/src/QueryStatements/CountStatement.php index 353d1f3540..b417561915 100644 --- a/packages/database/src/QueryStatements/CountStatement.php +++ b/packages/database/src/QueryStatements/CountStatement.php @@ -33,7 +33,7 @@ public function compile(DatabaseDialect $dialect): string $query = arr([ sprintf('SELECT %s', $countField->compile($dialect)), - sprintf('FROM `%s`', $this->table->name), + sprintf('FROM %s', $dialect->quoteIdentifier($this->table->name)), ]); if ($this->joins->isNotEmpty()) { diff --git a/packages/database/src/QueryStatements/CreateTableStatement.php b/packages/database/src/QueryStatements/CreateTableStatement.php index 5b9a1fb346..0ed01e17c0 100644 --- a/packages/database/src/QueryStatements/CreateTableStatement.php +++ b/packages/database/src/QueryStatements/CreateTableStatement.php @@ -5,7 +5,6 @@ namespace Tempest\Database\QueryStatements; use BackedEnum; -use Tempest\Database\Builder\TableDefinition; use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\Enums\DatabaseTextLength; use Tempest\Database\HasTrailingStatements; @@ -187,10 +186,11 @@ public function string(string $name, int $length = 255, bool $nullable = false, /** * Adds a `CHAR` column to the table. */ - public function char(string $name, bool $nullable = false, ?string $default = null): self + public function char(string $name, bool $nullable = false, ?string $default = null, int $size = 1): self { $this->statements[] = new CharStatement( name: $name, + size: $size, nullable: $nullable, default: $default, ); @@ -400,7 +400,7 @@ public function compile(DatabaseDialect $dialect): string { return sprintf( 'CREATE TABLE %s (%s);', - new TableDefinition($this->tableName), + $dialect->quoteIdentifier($this->tableName), arr($this->statements) // Remove BelongsTo for sqlLite as it does not support those queries ->filter(fn (QueryStatement $queryStatement) => ! ($dialect === DatabaseDialect::SQLITE && $queryStatement instanceof BelongsToStatement)) diff --git a/packages/database/src/QueryStatements/DateStatement.php b/packages/database/src/QueryStatements/DateStatement.php index d3f6bdf78d..37cc379668 100644 --- a/packages/database/src/QueryStatements/DateStatement.php +++ b/packages/database/src/QueryStatements/DateStatement.php @@ -18,8 +18,8 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { return sprintf( - '`%s` DATE %s %s', - $this->name, + '%s DATE %s %s', + $dialect->quoteIdentifier($this->name), $this->default !== null ? "DEFAULT '{$this->default}'" : '', $this->nullable ? '' : 'NOT NULL', ); diff --git a/packages/database/src/QueryStatements/DatetimeStatement.php b/packages/database/src/QueryStatements/DatetimeStatement.php index c4aa33997e..6acc42345c 100644 --- a/packages/database/src/QueryStatements/DatetimeStatement.php +++ b/packages/database/src/QueryStatements/DatetimeStatement.php @@ -29,16 +29,18 @@ public function compile(DatabaseDialect $dialect): string default => null, }; + $name = $dialect->quoteIdentifier($this->name); + return match ($dialect) { DatabaseDialect::POSTGRESQL => sprintf( - '`%s` TIMESTAMP %s %s', - $this->name, + '%s TIMESTAMP %s %s', + $name, $default !== null ? "DEFAULT {$default}" : '', $this->nullable ? '' : 'NOT NULL', ), default => sprintf( - '`%s` DATETIME %s %s', - $this->name, + '%s DATETIME %s %s', + $name, $default !== null ? "DEFAULT {$default}" : '', $this->nullable ? '' : 'NOT NULL', ), diff --git a/packages/database/src/QueryStatements/DeleteStatement.php b/packages/database/src/QueryStatements/DeleteStatement.php index a8a880a5df..f7e88c2f30 100644 --- a/packages/database/src/QueryStatements/DeleteStatement.php +++ b/packages/database/src/QueryStatements/DeleteStatement.php @@ -25,7 +25,7 @@ public function compile(DatabaseDialect $dialect): string } $query = arr([ - sprintf('DELETE FROM `%s`', $this->table->name), + sprintf('DELETE FROM %s', $dialect->quoteIdentifier($this->table->name)), ]); if ($this->where->isNotEmpty()) { diff --git a/packages/database/src/QueryStatements/DropConstraintStatement.php b/packages/database/src/QueryStatements/DropConstraintStatement.php index ffe7da51a1..85d3e5431f 100644 --- a/packages/database/src/QueryStatements/DropConstraintStatement.php +++ b/packages/database/src/QueryStatements/DropConstraintStatement.php @@ -26,8 +26,8 @@ public function compile(DatabaseDialect $dialect): string return match ($dialect) { DatabaseDialect::MYSQL => sprintf( - 'ALTER TABLE `%s` DROP CONSTRAINT %s', - $foreignTable, + 'ALTER TABLE %s DROP CONSTRAINT %s', + $dialect->quoteIdentifier($foreignTable), $constraintName, ), default => '', diff --git a/packages/database/src/QueryStatements/DropTableStatement.php b/packages/database/src/QueryStatements/DropTableStatement.php index a836b17441..259b374cd2 100644 --- a/packages/database/src/QueryStatements/DropTableStatement.php +++ b/packages/database/src/QueryStatements/DropTableStatement.php @@ -36,8 +36,8 @@ public static function forModel(string $modelClass): self public function compile(DatabaseDialect $dialect): string { return match ($dialect) { - DatabaseDialect::POSTGRESQL => sprintf('DROP TABLE IF EXISTS `%s` CASCADE', $this->tableName), - default => sprintf('DROP TABLE IF EXISTS `%s`', $this->tableName), + DatabaseDialect::POSTGRESQL => sprintf('DROP TABLE IF EXISTS %s CASCADE', $dialect->quoteIdentifier($this->tableName)), + default => sprintf('DROP TABLE IF EXISTS %s', $dialect->quoteIdentifier($this->tableName)), }; } } diff --git a/packages/database/src/QueryStatements/EnumStatement.php b/packages/database/src/QueryStatements/EnumStatement.php index 26ba7f5eb9..afcbd27c1c 100644 --- a/packages/database/src/QueryStatements/EnumStatement.php +++ b/packages/database/src/QueryStatements/EnumStatement.php @@ -35,23 +35,25 @@ public function compile(DatabaseDialect $dialect): string $defaultValue = null; } + $name = $dialect->quoteIdentifier($this->name); + return match ($dialect) { DatabaseDialect::MYSQL => sprintf( - '`%s` ENUM(%s) %s %s', - $this->name, + '%s ENUM(%s) %s %s', + $name, $cases->implode(', '), $defaultValue !== null ? "DEFAULT '{$defaultValue}'" : '', $this->nullable ? '' : 'NOT NULL', ), DatabaseDialect::SQLITE => sprintf( - '`%s` TEXT %s %s', - $this->name, + '%s TEXT %s %s', + $name, $defaultValue !== null ? "DEFAULT '{$defaultValue}'" : '', $this->nullable ? '' : 'NOT NULL', ), DatabaseDialect::POSTGRESQL => sprintf( - '"%s" "%s" %s %s', - $this->name, + '%s "%s" %s %s', + $name, str($this->enumClass)->replace('\\\\', '_'), $defaultValue !== null ? "DEFAULT ('{$defaultValue}')" : '', $this->nullable ? '' : 'NOT NULL', diff --git a/packages/database/src/QueryStatements/FieldStatement.php b/packages/database/src/QueryStatements/FieldStatement.php index 26dd22cfd2..bad8067f27 100644 --- a/packages/database/src/QueryStatements/FieldStatement.php +++ b/packages/database/src/QueryStatements/FieldStatement.php @@ -29,27 +29,18 @@ public function compile(DatabaseDialect $dialect): string $aliasPrefix = $this->aliasPrefix ? "{$this->aliasPrefix}." : ''; if ($this->alias === true) { - $alias = sprintf( - '`%s%s`', - $aliasPrefix, - str_replace('`', '', $field), - ); + $alias = $aliasPrefix . str_replace('`', '', $field); } elseif ($this->alias) { - $alias = sprintf( - '`%s%s`', - $aliasPrefix, - $this->alias, - ); + $alias = $aliasPrefix . $this->alias; } } else { - $alias = $parts[1]; + $alias = trim($parts[1], '`" '); } $field = arr(explode('.', $field)) - ->map(fn (string $part) => trim($part, '` ')) + ->map(fn (string $part) => trim($part, '`" ')) ->map( function (string $part) use ($dialect) { - // Function calls are never wrapped in backticks. if (str_contains($part, '(')) { return $part; } @@ -58,7 +49,7 @@ function (string $part) use ($dialect) { return $part; } - return sprintf('`%s`', $part); + return $dialect->quoteIdentifier($part); }, ) ->implode('.'); @@ -67,10 +58,7 @@ function (string $part) use ($dialect) { return $field; } - return match ($dialect) { - DatabaseDialect::POSTGRESQL => sprintf('%s AS "%s"', $field, trim($alias, '`')), - default => sprintf('%s AS `%s`', $field, trim($alias, '`')), - }; + return sprintf('%s AS %s', $field, $dialect->quoteIdentifier($alias)); } public function withAliasPrefix(?string $prefix = null): self diff --git a/packages/database/src/QueryStatements/FloatStatement.php b/packages/database/src/QueryStatements/FloatStatement.php index 02ba890dd0..d09f6b537a 100644 --- a/packages/database/src/QueryStatements/FloatStatement.php +++ b/packages/database/src/QueryStatements/FloatStatement.php @@ -18,8 +18,8 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { return sprintf( - '`%s` FLOAT %s %s', - $this->name, + '%s FLOAT %s %s', + $dialect->quoteIdentifier($this->name), $this->default !== null ? "DEFAULT {$this->default}" : '', $this->nullable ? '' : 'NOT NULL', ); diff --git a/packages/database/src/QueryStatements/IdentityStatement.php b/packages/database/src/QueryStatements/IdentityStatement.php index 5f2d7e38c5..c1cdb44fc0 100644 --- a/packages/database/src/QueryStatements/IdentityStatement.php +++ b/packages/database/src/QueryStatements/IdentityStatement.php @@ -15,6 +15,6 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { - return sprintf('`%s`', $this->name); + return $dialect->quoteIdentifier($this->name); } } diff --git a/packages/database/src/QueryStatements/IndexStatement.php b/packages/database/src/QueryStatements/IndexStatement.php index 2d2685e723..c2cdf97f6e 100644 --- a/packages/database/src/QueryStatements/IndexStatement.php +++ b/packages/database/src/QueryStatements/IndexStatement.php @@ -19,12 +19,15 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { - $columns = arr($this->columns)->implode('`, `')->wrap('`', '`'); + $columns = arr($this->columns) + ->map($dialect->quoteIdentifier(...)) + ->implode(', '); - $indexName = str($this->tableName . ' ' . $columns->replace(',', '')->snake())->snake()->toString(); + $rawColumns = arr($this->columns)->implode('_'); + $indexName = str($this->tableName . '_' . $rawColumns)->snake()->toString(); - $on = sprintf('`%s` (%s)', $this->tableName, $columns); + $on = sprintf('%s (%s)', $dialect->quoteIdentifier($this->tableName), $columns); - return sprintf('CREATE INDEX `%s` ON %s', $indexName, $on); + return sprintf('CREATE INDEX %s ON %s', $dialect->quoteIdentifier($indexName), $on); } } diff --git a/packages/database/src/QueryStatements/InsertStatement.php b/packages/database/src/QueryStatements/InsertStatement.php index 8b95d0c6f1..899d1be628 100644 --- a/packages/database/src/QueryStatements/InsertStatement.php +++ b/packages/database/src/QueryStatements/InsertStatement.php @@ -55,7 +55,7 @@ public function compile(DatabaseDialect $dialect): string $sql = sprintf( 'INSERT INTO %s (%s) VALUES %s', $this->table, - $columns->map(fn (string $column) => "`{$column}`")->implode(', '), + $columns->map($dialect->quoteIdentifier(...))->implode(', '), $entryPlaceholders, ); } diff --git a/packages/database/src/QueryStatements/IntegerStatement.php b/packages/database/src/QueryStatements/IntegerStatement.php index 873cf92287..9a274ee290 100644 --- a/packages/database/src/QueryStatements/IntegerStatement.php +++ b/packages/database/src/QueryStatements/IntegerStatement.php @@ -19,17 +19,19 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { + $name = $dialect->quoteIdentifier($this->name); + return match ($dialect) { DatabaseDialect::SQLITE => sprintf( - '`%s` INTEGER %s %s %s', - $this->name, + '%s INTEGER %s %s %s', + $name, $this->unsigned ? 'UNSIGNED' : '', $this->default !== null ? "DEFAULT {$this->default}" : '', $this->nullable ? '' : 'NOT NULL', ), default => sprintf( - '`%s` %s %s %s %s', - $this->name, + '%s %s %s %s %s', + $name, is_int($this->size) ? DatabaseIntegerSize::fromBytes($this->size)->toString() : $this->size->toString(), $this->unsigned ? 'UNSIGNED' : '', $this->default !== null ? "DEFAULT {$this->default}" : '', diff --git a/packages/database/src/QueryStatements/JsonStatement.php b/packages/database/src/QueryStatements/JsonStatement.php index f1238f22f9..2abfc6a6c9 100644 --- a/packages/database/src/QueryStatements/JsonStatement.php +++ b/packages/database/src/QueryStatements/JsonStatement.php @@ -22,21 +22,23 @@ public function compile(DatabaseDialect $dialect): string throw new DefaultValueWasInvalid($this->name, $this->default); } + $name = $dialect->quoteIdentifier($this->name); + return match ($dialect) { DatabaseDialect::MYSQL => sprintf( - '`%s` JSON %s', - $this->name, + '%s JSON %s', + $name, $this->nullable ? '' : 'NOT NULL', ), DatabaseDialect::SQLITE => sprintf( - '`%s` TEXT %s %s', - $this->name, + '%s TEXT %s %s', + $name, $this->default !== null ? "DEFAULT '{$this->default}'" : '', $this->nullable ? '' : 'NOT NULL', ), DatabaseDialect::POSTGRESQL => sprintf( - '`%s` JSONB %s %s', - $this->name, + '%s JSONB %s %s', + $name, $this->default !== null ? "DEFAULT ('{$this->default}')" : '', $this->nullable ? '' : 'NOT NULL', ), diff --git a/packages/database/src/QueryStatements/PrimaryKeyStatement.php b/packages/database/src/QueryStatements/PrimaryKeyStatement.php index 07153a071d..528cdc6fac 100644 --- a/packages/database/src/QueryStatements/PrimaryKeyStatement.php +++ b/packages/database/src/QueryStatements/PrimaryKeyStatement.php @@ -15,10 +15,12 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { + $name = $dialect->quoteIdentifier($this->name); + return match ($dialect) { - DatabaseDialect::MYSQL => sprintf('`%s` INTEGER PRIMARY KEY AUTO_INCREMENT', $this->name), - DatabaseDialect::POSTGRESQL => sprintf('`%s` SERIAL PRIMARY KEY', $this->name), - DatabaseDialect::SQLITE => sprintf('`%s` INTEGER PRIMARY KEY AUTOINCREMENT', $this->name), + DatabaseDialect::MYSQL => "{$name} INTEGER PRIMARY KEY AUTO_INCREMENT", + DatabaseDialect::POSTGRESQL => "{$name} SERIAL PRIMARY KEY", + DatabaseDialect::SQLITE => "{$name} INTEGER PRIMARY KEY AUTOINCREMENT", }; } } diff --git a/packages/database/src/QueryStatements/SelectStatement.php b/packages/database/src/QueryStatements/SelectStatement.php index 926b5afc52..bd18fe9da3 100644 --- a/packages/database/src/QueryStatements/SelectStatement.php +++ b/packages/database/src/QueryStatements/SelectStatement.php @@ -77,7 +77,7 @@ public function compile(DatabaseDialect $dialect): string if ($this->where->isNotEmpty()) { $query[] = 'WHERE ' . $this->where - ->map(fn (WhereStatement|WhereGroupStatement $where) => $where->compile($dialect)) + ->map(fn (WhereStatement|WhereGroupStatement|WhereExistsStatement $where) => $where->compile($dialect)) ->filter(fn (string $compiled) => $compiled !== '') ->implode(' '); } diff --git a/packages/database/src/QueryStatements/SetStatement.php b/packages/database/src/QueryStatements/SetStatement.php index 934f3101b5..d33a8c6999 100644 --- a/packages/database/src/QueryStatements/SetStatement.php +++ b/packages/database/src/QueryStatements/SetStatement.php @@ -27,8 +27,8 @@ public function compile(DatabaseDialect $dialect): string return match ($dialect) { DatabaseDialect::MYSQL => sprintf( - '`%s` SET (%s) %s %s', - $this->name, + '%s SET (%s) %s %s', + $dialect->quoteIdentifier($this->name), "'" . implode("', '", $this->values) . "'", $this->default ? "DEFAULT '{$this->default}'" : '', $this->nullable ? '' : 'NOT NULL', diff --git a/packages/database/src/QueryStatements/TextStatement.php b/packages/database/src/QueryStatements/TextStatement.php index 88c5441584..a6c581686c 100644 --- a/packages/database/src/QueryStatements/TextStatement.php +++ b/packages/database/src/QueryStatements/TextStatement.php @@ -19,16 +19,18 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { + $name = $dialect->quoteIdentifier($this->name); + return match ($dialect) { DatabaseDialect::MYSQL => sprintf( - '`%s` %s %s', - $this->name, + '%s %s %s', + $name, $this->getSQLTypeDeclaration($this->length), $this->nullable ? '' : 'NOT NULL', ), default => sprintf( - '`%s` TEXT %s %s', - $this->name, + '%s TEXT %s %s', + $name, $this->default !== null ? "DEFAULT '{$this->default}'" : '', $this->nullable ? '' : 'NOT NULL', ), diff --git a/packages/database/src/QueryStatements/UniqueStatement.php b/packages/database/src/QueryStatements/UniqueStatement.php index 90526fee11..40f5dad617 100644 --- a/packages/database/src/QueryStatements/UniqueStatement.php +++ b/packages/database/src/QueryStatements/UniqueStatement.php @@ -19,12 +19,15 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { - $columns = arr($this->columns)->implode('`, `')->wrap('`', '`'); + $columns = arr($this->columns) + ->map($dialect->quoteIdentifier(...)) + ->implode(', '); - $indexName = str($this->tableName . ' ' . $columns->replace(',', '')->snake())->snake()->toString(); + $rawColumns = arr($this->columns)->implode('_'); + $indexName = str($this->tableName . '_' . $rawColumns)->snake()->toString(); - $on = sprintf('`%s` (%s)', $this->tableName, $columns); + $on = sprintf('%s (%s)', $dialect->quoteIdentifier($this->tableName), $columns); - return sprintf('CREATE UNIQUE INDEX `%s` ON %s', $indexName, $on); + return sprintf('CREATE UNIQUE INDEX %s ON %s', $dialect->quoteIdentifier($indexName), $on); } } diff --git a/packages/database/src/QueryStatements/UpdateStatement.php b/packages/database/src/QueryStatements/UpdateStatement.php index 0a46b886d4..2fb69f094a 100644 --- a/packages/database/src/QueryStatements/UpdateStatement.php +++ b/packages/database/src/QueryStatements/UpdateStatement.php @@ -27,7 +27,7 @@ public function compile(DatabaseDialect $dialect): string } $query = arr([ - sprintf('UPDATE `%s`', $this->table->name), + sprintf('UPDATE %s', $dialect->quoteIdentifier($this->table->name)), ]); if ($this->values->isEmpty()) { @@ -35,12 +35,12 @@ public function compile(DatabaseDialect $dialect): string } $query[] = 'SET ' . $this->values - ->map(fn (mixed $_, mixed $key) => "`{$key}` = ?") + ->map(fn (mixed $_, mixed $key) => $dialect->quoteIdentifier($key) . ' = ?') ->implode(', '); if ($this->where->isNotEmpty()) { $query[] = 'WHERE ' . $this->where - ->map(fn (WhereStatement|WhereGroupStatement $where) => $where->compile($dialect)) + ->map(fn (WhereStatement|WhereGroupStatement|WhereExistsStatement $where) => $where->compile($dialect)) ->filter(fn (string $compiled) => $compiled !== '') ->implode(' '); } diff --git a/packages/database/src/QueryStatements/UuidPrimaryKeyStatement.php b/packages/database/src/QueryStatements/UuidPrimaryKeyStatement.php index 9ed2e5bab7..425aa9d20b 100644 --- a/packages/database/src/QueryStatements/UuidPrimaryKeyStatement.php +++ b/packages/database/src/QueryStatements/UuidPrimaryKeyStatement.php @@ -15,10 +15,12 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { + $name = $dialect->quoteIdentifier($this->name); + return match ($dialect) { - DatabaseDialect::MYSQL => sprintf('`%s` CHAR(36) PRIMARY KEY', $this->name), - DatabaseDialect::POSTGRESQL => sprintf('`%s` UUID PRIMARY KEY', $this->name), - DatabaseDialect::SQLITE => sprintf('`%s` TEXT PRIMARY KEY', $this->name), + DatabaseDialect::MYSQL => "{$name} CHAR(36) PRIMARY KEY", + DatabaseDialect::POSTGRESQL => "{$name} UUID PRIMARY KEY", + DatabaseDialect::SQLITE => "{$name} TEXT PRIMARY KEY", }; } } diff --git a/packages/database/src/QueryStatements/VarcharStatement.php b/packages/database/src/QueryStatements/VarcharStatement.php index 4f89e43c52..5356fee958 100644 --- a/packages/database/src/QueryStatements/VarcharStatement.php +++ b/packages/database/src/QueryStatements/VarcharStatement.php @@ -19,8 +19,8 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { return sprintf( - '`%s` VARCHAR(%s) %s %s', - $this->name, + '%s VARCHAR(%s) %s %s', + $dialect->quoteIdentifier($this->name), $this->size, $this->default !== null ? "DEFAULT '{$this->default}'" : '', $this->nullable ? '' : 'NOT NULL', diff --git a/packages/database/src/QueryStatements/WhereExistsStatement.php b/packages/database/src/QueryStatements/WhereExistsStatement.php new file mode 100644 index 0000000000..4c3f6f173f --- /dev/null +++ b/packages/database/src/QueryStatements/WhereExistsStatement.php @@ -0,0 +1,57 @@ +condition); + + if ($this->innerWheres->isNotEmpty()) { + $compiled = $this->innerWheres + ->map(map: fn ( + QueryStatement $where, + ) => $where->compile(dialect: $dialect)) + ->filter( + filter: fn ( + string $compiled, + ) => $compiled !== '', + ) + ->implode(glue: ' ') + ->toString(); + + if ($compiled !== '') { + $whereClause = $whereClause->append(suffix: " AND {$compiled}"); + } + } + + if ($this->useCount) { + return "(SELECT COUNT(*) FROM {$this->relatedTable} WHERE {$whereClause}) {$this->operator->value} {$this->count}"; + } + + $keyword = $this->negate + ? 'NOT EXISTS' + : 'EXISTS'; + + return "{$keyword} (SELECT 1 FROM {$this->relatedTable} WHERE {$whereClause})"; + } +} diff --git a/packages/database/src/Relation.php b/packages/database/src/Relation.php index ea40f63a6a..3c33c01c4d 100644 --- a/packages/database/src/Relation.php +++ b/packages/database/src/Relation.php @@ -3,6 +3,7 @@ namespace Tempest\Database; use Tempest\Database\QueryStatements\JoinStatement; +use Tempest\Database\QueryStatements\WhereExistsStatement; use Tempest\Reflection\PropertyAttribute; use Tempest\Support\Arr\ImmutableArray; @@ -15,4 +16,6 @@ public function setParent(string $name): self; public function getSelectFields(): ImmutableArray; public function getJoinStatement(): JoinStatement; + + public function getExistsStatement(): WhereExistsStatement; } diff --git a/packages/database/tests/QueryCompileTest.php b/packages/database/tests/QueryCompileTest.php new file mode 100644 index 0000000000..3f77d55af4 --- /dev/null +++ b/packages/database/tests/QueryCompileTest.php @@ -0,0 +1,177 @@ +singleton( + className: Database::class, + definition: $database, + ); + GenericContainer::setInstance(instance: $container); + } + + private function createDatabaseWithPostgresDialect(): void + { + $config = new PostgresConfig(); + $connection = new PDOConnection(config: $config); + + $database = new GenericDatabase( + connection: $connection, + transactionManager: new GenericTransactionManager(connection: $connection), + serializerFactory: new SerializerFactory(container: new GenericContainer()), + eventBus: new GenericEventBus( + container: new GenericContainer(), + eventBusConfig: new EventBusConfig(), + ), + ); + + $container = new GenericContainer(); + $container->singleton( + className: Database::class, + definition: $database, + ); + GenericContainer::setInstance(instance: $container); + } + + private function createDatabaseWithSqliteDialect(): void + { + $config = new SQLiteConfig(path: ':memory:'); + $connection = new PDOConnection(config: $config); + + $database = new GenericDatabase( + connection: $connection, + transactionManager: new GenericTransactionManager(connection: $connection), + serializerFactory: new SerializerFactory(container: new GenericContainer()), + eventBus: new GenericEventBus( + container: new GenericContainer(), + eventBusConfig: new EventBusConfig(), + ), + ); + + $container = new GenericContainer(); + $container->singleton( + className: Database::class, + definition: $database, + ); + GenericContainer::setInstance(instance: $container); + } + + #[Test] + public function postgresql_converts_backticks_to_double_quotes(): void + { + $this->createDatabaseWithPostgresDialect(); + + $query = new Query(sql: 'SELECT `name`, `email` FROM `users` WHERE `id` = ?'); + + $this->assertSame( + 'SELECT "name", "email" FROM "users" WHERE "id" = ?', + $query->compile()->toString(), + ); + } + + #[Test] + public function postgresql_handles_qualified_identifiers(): void + { + $this->createDatabaseWithPostgresDialect(); + + $query = new Query(sql: 'SELECT `users`.`name` FROM `users`'); + + $this->assertSame( + 'SELECT "users"."name" FROM "users"', + $query->compile()->toString(), + ); + } + + #[Test] + public function postgresql_handles_reserved_words(): void + { + $this->createDatabaseWithPostgresDialect(); + + $query = new Query(sql: 'SELECT `order`, `user`, `type` FROM `group`'); + + $this->assertSame( + 'SELECT "order", "user", "type" FROM "group"', + $query->compile()->toString(), + ); + } + + #[Test] + public function postgresql_passes_through_sql_without_backticks(): void + { + $this->createDatabaseWithPostgresDialect(); + + $query = new Query(sql: 'SELECT 1'); + + $this->assertSame( + 'SELECT 1', + $query->compile()->toString(), + ); + } + + #[Test] + public function mysql_retains_backticks(): void + { + $this->createDatabaseWithMysqlDialect(); + + $query = new Query(sql: 'SELECT `name` FROM `users` WHERE `id` = ?'); + + $this->assertSame( + 'SELECT `name` FROM `users` WHERE `id` = ?', + $query->compile()->toString(), + ); + } + + #[Test] + public function sqlite_retains_backticks(): void + { + $this->createDatabaseWithSqliteDialect(); + + $query = new Query(sql: 'SELECT `name` FROM `users` WHERE `id` = ?'); + + $this->assertSame( + 'SELECT `name` FROM `users` WHERE `id` = ?', + $query->compile()->toString(), + ); + } +} diff --git a/packages/database/tests/QueryStatements/AlterTableStatementTest.php b/packages/database/tests/QueryStatements/AlterTableStatementTest.php index e06f14e6f5..0eea1847ef 100644 --- a/packages/database/tests/QueryStatements/AlterTableStatementTest.php +++ b/packages/database/tests/QueryStatements/AlterTableStatementTest.php @@ -26,8 +26,16 @@ public function test_alter_for_only_indexes(DatabaseDialect $dialect): void ->index('foo') ->unique('bar'); - $this->assertEqualsIgnoringCase('CREATE INDEX `table_foo` ON `table` (`foo`)', $alterStatement->trailingStatements[0]->compile($dialect)); - $this->assertEqualsIgnoringCase('CREATE UNIQUE INDEX `table_bar` ON `table` (`bar`)', $alterStatement->trailingStatements[1]->compile($dialect)); + $q = $dialect->quoteIdentifier(...); + + $this->assertEqualsIgnoringCase( + 'CREATE INDEX ' . $q('table_foo') . ' ON ' . $q('table') . ' (' . $q('foo') . ')', + $alterStatement->trailingStatements[0]->compile($dialect), + ); + $this->assertEqualsIgnoringCase( + 'CREATE UNIQUE INDEX ' . $q('table_bar') . ' ON ' . $q('table') . ' (' . $q('bar') . ')', + $alterStatement->trailingStatements[1]->compile($dialect), + ); } #[TestWith([DatabaseDialect::MYSQL])] @@ -35,7 +43,8 @@ public function test_alter_for_only_indexes(DatabaseDialect $dialect): void #[TestWith([DatabaseDialect::SQLITE])] public function test_alter_add_column(DatabaseDialect $dialect): void { - $expected = "ALTER TABLE `table` ADD `bar` VARCHAR(42) DEFAULT 'xx' ;"; + $q = $dialect->quoteIdentifier(...); + $expected = 'ALTER TABLE ' . $q('table') . ' ADD ' . $q('bar') . " VARCHAR(42) DEFAULT 'xx' ;"; $statement = new AlterTableStatement('table') ->add(new VarcharStatement('bar', 42, true, 'xx')) ->compile($dialect); @@ -61,7 +70,7 @@ public function test_alter_add_belongs_to_mysql(DatabaseDialect $dialect): void #[TestWith([DatabaseDialect::POSTGRESQL])] public function test_alter_add_belongs_to_postgresql(DatabaseDialect $dialect): void { - $expected = 'ALTER TABLE `table` ADD CONSTRAINT `fk_parent_table_foo` FOREIGN KEY(foo) REFERENCES parent(bar) ON DELETE RESTRICT ON UPDATE NO ACTION ;'; + $expected = 'ALTER TABLE "table" ADD CONSTRAINT "fk_parent_table_foo" FOREIGN KEY(foo) REFERENCES parent(bar) ON DELETE RESTRICT ON UPDATE NO ACTION ;'; $statement = new AlterTableStatement('table') ->add(new BelongsToStatement('table.foo', 'parent.bar')) ->compile($dialect); @@ -86,7 +95,8 @@ public function test_alter_add_belongs_to_unsupported(DatabaseDialect $dialect): #[TestWith([DatabaseDialect::SQLITE])] public function test_alter_table_drop_column(DatabaseDialect $dialect): void { - $expected = 'ALTER TABLE `table` DROP COLUMN `foo` ;'; + $q = $dialect->quoteIdentifier(...); + $expected = 'ALTER TABLE ' . $q('table') . ' DROP COLUMN ' . $q('foo') . ' ;'; $statement = new AlterTableStatement('table') ->dropColumn('foo') ->compile($dialect); @@ -97,7 +107,7 @@ public function test_alter_table_drop_column(DatabaseDialect $dialect): void } #[TestWith([DatabaseDialect::MYSQL, 'ALTER TABLE `table` DROP CONSTRAINT `foo` ;'])] - #[TestWith([DatabaseDialect::POSTGRESQL, 'ALTER TABLE `table` DROP CONSTRAINT `foo` ;'])] + #[TestWith([DatabaseDialect::POSTGRESQL, 'ALTER TABLE "table" DROP CONSTRAINT "foo" ;'])] public function test_alter_table_drop_constraint(DatabaseDialect $dialect, string $expected): void { $statement = new AlterTableStatement('table') @@ -121,7 +131,7 @@ public function test_alter_table_drop_constraint_unsupported_dialects(DatabaseDi #[TestWith([DatabaseDialect::MYSQL, "ALTER TABLE `table` ADD `foo` VARCHAR(42) DEFAULT 'bar' NOT NULL ;"])] #[TestWith([ DatabaseDialect::POSTGRESQL, - "ALTER TABLE `table` ADD `foo` VARCHAR(42) DEFAULT 'bar' NOT NULL ;", + "ALTER TABLE \"table\" ADD \"foo\" VARCHAR(42) DEFAULT 'bar' NOT NULL ;", ])] #[TestWith([DatabaseDialect::SQLITE, "ALTER TABLE `table` ADD `foo` VARCHAR(42) DEFAULT 'bar' NOT NULL ;"])] public function test_alter_table_add_column(DatabaseDialect $dialect, string $expected): void @@ -140,7 +150,8 @@ public function test_alter_table_add_column(DatabaseDialect $dialect, string $ex #[TestWith([DatabaseDialect::SQLITE])] public function test_alter_table_rename_column(DatabaseDialect $dialect): void { - $expected = 'ALTER TABLE `table` RENAME COLUMN `foo` TO `bar` ;'; + $q = $dialect->quoteIdentifier(...); + $expected = 'ALTER TABLE ' . $q('table') . ' RENAME COLUMN ' . $q('foo') . ' TO ' . $q('bar') . ' ;'; $statement = new AlterTableStatement('table') ->rename('foo', 'bar') ->compile($dialect); @@ -151,7 +162,7 @@ public function test_alter_table_rename_column(DatabaseDialect $dialect): void } #[TestWith([DatabaseDialect::MYSQL, "ALTER TABLE `table` MODIFY COLUMN `foo` VARCHAR(42) DEFAULT 'bar' NOT NULL ;"])] - #[TestWith([DatabaseDialect::POSTGRESQL, "ALTER TABLE `table` ALTER COLUMN `foo` VARCHAR(42) DEFAULT 'bar' NOT NULL ;"])] + #[TestWith([DatabaseDialect::POSTGRESQL, "ALTER TABLE \"table\" ALTER COLUMN \"foo\" VARCHAR(42) DEFAULT 'bar' NOT NULL ;"])] public function test_alter_table_modify_column(DatabaseDialect $dialect, string $expected): void { $statement = new AlterTableStatement('table') diff --git a/packages/database/tests/QueryStatements/CharStatementTest.php b/packages/database/tests/QueryStatements/CharStatementTest.php new file mode 100644 index 0000000000..616e991438 --- /dev/null +++ b/packages/database/tests/QueryStatements/CharStatementTest.php @@ -0,0 +1,28 @@ +assertSame($expectedMysql, $statement->compile(DatabaseDialect::MYSQL)); + $this->assertSame($expectedPgsql, $statement->compile(DatabaseDialect::POSTGRESQL)); + } +} diff --git a/packages/database/tests/QueryStatements/CreateTableStatementTest.php b/packages/database/tests/QueryStatements/CreateTableStatementTest.php index b3989ba11f..56d4e087b7 100644 --- a/packages/database/tests/QueryStatements/CreateTableStatementTest.php +++ b/packages/database/tests/QueryStatements/CreateTableStatementTest.php @@ -46,8 +46,8 @@ public static function provide_create_table_database_dialects(): iterable yield 'postgresql' => [ DatabaseDialect::POSTGRESQL, << [ DatabaseDialect::POSTGRESQL, << [ DatabaseDialect::POSTGRESQL, << [ DatabaseDialect::POSTGRESQL, << [ DatabaseDialect::POSTGRESQL, <<assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); $this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE)); - $this->assertSame($expected, $statement->compile(DatabaseDialect::POSTGRESQL)); + $expectedPostgres = 'DELETE FROM "foo" WHERE `bar` = "1"'; + + $this->assertSame($expectedPostgres, $statement->compile(DatabaseDialect::POSTGRESQL)); } public function test_exception_when_no_condition_is_set(): void diff --git a/packages/database/tests/QueryStatements/FieldStatementTest.php b/packages/database/tests/QueryStatements/FieldStatementTest.php index 6524d25ce4..6610f20269 100644 --- a/packages/database/tests/QueryStatements/FieldStatementTest.php +++ b/packages/database/tests/QueryStatements/FieldStatementTest.php @@ -47,12 +47,12 @@ public function test_mysql(): void public function test_postgres(): void { $this->assertSame( - '`table`.`field`', + '"table"."field"', new FieldStatement('`table`.`field`')->compile(DatabaseDialect::POSTGRESQL), ); $this->assertSame( - '`table`.`field`', + '"table"."field"', new FieldStatement('table.field')->compile(DatabaseDialect::POSTGRESQL), ); } diff --git a/packages/database/tests/QueryStatements/InsertStatementTest.php b/packages/database/tests/QueryStatements/InsertStatementTest.php index 7c87898224..1de702d4f2 100644 --- a/packages/database/tests/QueryStatements/InsertStatementTest.php +++ b/packages/database/tests/QueryStatements/InsertStatementTest.php @@ -26,7 +26,7 @@ public function test_insert_statement(): void $this->assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); $this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE)); - $expectedPostgres = 'INSERT INTO `foo` AS `bar` (`foo`, `bar`) VALUES (?, ?), (?, ?) RETURNING *'; + $expectedPostgres = 'INSERT INTO `foo` AS `bar` ("foo", "bar") VALUES (?, ?), (?, ?) RETURNING *'; $this->assertSame($expectedPostgres, $statement->compile(DatabaseDialect::POSTGRESQL)); } diff --git a/packages/database/tests/QueryStatements/UpdateStatementTest.php b/packages/database/tests/QueryStatements/UpdateStatementTest.php index 2667725732..afee68d328 100644 --- a/packages/database/tests/QueryStatements/UpdateStatementTest.php +++ b/packages/database/tests/QueryStatements/UpdateStatementTest.php @@ -28,7 +28,9 @@ public function test_update(): void $this->assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); $this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE)); - $this->assertSame($expected, $statement->compile(DatabaseDialect::POSTGRESQL)); + $expectedPostgres = 'UPDATE "foo" SET "bar" = ?, "baz" = ? WHERE `bar` = ?'; + + $this->assertSame($expectedPostgres, $statement->compile(DatabaseDialect::POSTGRESQL)); } public function test_exception_when_no_values(): void diff --git a/packages/database/tests/QueryStatements/UuidPrimaryKeyStatementTest.php b/packages/database/tests/QueryStatements/UuidPrimaryKeyStatementTest.php index 70ed1b044c..bcaa919860 100644 --- a/packages/database/tests/QueryStatements/UuidPrimaryKeyStatementTest.php +++ b/packages/database/tests/QueryStatements/UuidPrimaryKeyStatementTest.php @@ -29,7 +29,7 @@ public function postgresql_compilation(): void $statement = new UuidPrimaryKeyStatement('uuid'); $compiled = $statement->compile(DatabaseDialect::POSTGRESQL); - $this->assertSame('`uuid` UUID PRIMARY KEY', $compiled); + $this->assertSame('"uuid" UUID PRIMARY KEY', $compiled); } #[Test] diff --git a/packages/discovery/src/AutoloadDiscoveryLocations.php b/packages/discovery/src/AutoloadDiscoveryLocations.php index 2766868fee..bba4f00963 100644 --- a/packages/discovery/src/AutoloadDiscoveryLocations.php +++ b/packages/discovery/src/AutoloadDiscoveryLocations.php @@ -101,7 +101,7 @@ private function discoverVendorPackages(): array } $packagePath = normalize($composerPath, $package['install-path'] ?? ''); - $requiresTempest = isset($package['require']['tempest/framework']) || isset($package['require']['tempest/core']); + $requiresTempest = isset($package['require']['tempest/discovery']) || isset($package['require']['tempest/framework']) || isset($package['require']['tempest/core']); $hasPsr4Namespaces = isset($package['autoload']['psr-4']); if (! ($requiresTempest && $hasPsr4Namespaces)) { diff --git a/packages/discovery/src/BootDiscovery.php b/packages/discovery/src/BootDiscovery.php index df677e88f4..25f514dee8 100644 --- a/packages/discovery/src/BootDiscovery.php +++ b/packages/discovery/src/BootDiscovery.php @@ -4,11 +4,13 @@ namespace Tempest\Discovery; +use ArgumentCountError; use AssertionError; use Closure; use Pest\Exceptions\InvalidPestCommand; use Psr\Container\ContainerInterface; -use Tempest\Container\GenericContainer; +use Psr\Container\NotFoundExceptionInterface; +use Tempest\Discovery\Exceptions\DiscoveryClassCouldNotBeResolved; use Tempest\Reflection\ClassReflector; use Tempest\Support\Filesystem; use Throwable; @@ -28,16 +30,18 @@ public function __construct( /** * @param class-string[]|null $discoveryClasses * @param DiscoveryLocation[]|null $discoveryLocations + * + * @return Discovery[] */ - public function __invoke( - ?array $discoveryClasses = null, - ?array $discoveryLocations = null, - ): void { + public function __invoke(?array $discoveryClasses = null, ?array $discoveryLocations = null): array + { $discoveries = $this->build($discoveryClasses, $discoveryLocations); foreach ($discoveries as $discovery) { $this->applyDiscovery($discovery); } + + return $discoveries; } /** @@ -45,10 +49,8 @@ public function __invoke( * @param DiscoveryLocation[]|null $discoveryLocations * @return Discovery[] */ - public function build( - ?array $discoveryClasses = null, - ?array $discoveryLocations = null, - ): array { + public function build(?array $discoveryClasses = null, ?array $discoveryLocations = null): array + { $discoveryLocations ??= $this->config->locations; if ($discoveryClasses === null) { @@ -260,18 +262,28 @@ private function discoverPath(string $input, DiscoveryLocation $location, array /** * Create a discovery instance from a class name. * Optionally set the cached discovery items whenever caching is enabled. + * * @template T of Discovery * @param class-string $discoveryClass * @return T */ private function resolveDiscovery(string $discoveryClass): Discovery { - if ($this->container instanceof GenericContainer || $this->container->has($discoveryClass)) { + $discovery = null; + + try { /** @var Discovery $discovery */ $discovery = $this->container->get($discoveryClass); - } else { - /** @var Discovery $discovery */ - $discovery = new $discoveryClass(); + } catch (NotFoundExceptionInterface) { + // @mago-expect lint:no-empty-catch-clause + } + + if ($discovery === null) { + try { + $discovery = new $discoveryClass(); + } catch (ArgumentCountError) { // @phpstan-ignore catch.neverThrown + throw DiscoveryClassCouldNotBeResolved::forDiscoveryClass($discoveryClass); + } } $discovery->setItems(new DiscoveryItems()); diff --git a/packages/discovery/src/Exceptions/DiscoveryClassCouldNotBeResolved.php b/packages/discovery/src/Exceptions/DiscoveryClassCouldNotBeResolved.php new file mode 100644 index 0000000000..aaa22764ca --- /dev/null +++ b/packages/discovery/src/Exceptions/DiscoveryClassCouldNotBeResolved.php @@ -0,0 +1,13 @@ +[]|null $discoveryClasses * @param DiscoveryLocation[]|null $discoveryLocations + * @param Discovery[]|null $discoveries */ public function __invoke( ContainerInterface $container, @@ -16,17 +17,16 @@ public function __invoke( DiscoveryCache $cache, ?array $discoveryClasses = null, ?array $discoveryLocations = null, + ?array $discoveries = null, ): void { $originalStrategy = $cache->strategy; $cache = $cache->withStrategy(DiscoveryCacheStrategy::NONE); - $bootDiscovery = new BootDiscovery( + $discoveries ??= new BootDiscovery( container: $container, config: $config, cache: $cache, - ); - - $discoveries = $bootDiscovery->build($discoveryClasses, $discoveryLocations); + )->build($discoveryClasses, $discoveryLocations); foreach ($config->locations as $location) { $cache->store($location, $discoveries); diff --git a/packages/intl/src/Pluralizer/InflectorPluralizer.php b/packages/intl/src/Pluralizer/InflectorPluralizer.php index ba5381654f..93b823d33b 100644 --- a/packages/intl/src/Pluralizer/InflectorPluralizer.php +++ b/packages/intl/src/Pluralizer/InflectorPluralizer.php @@ -9,6 +9,8 @@ use Doctrine\Inflector\InflectorFactory; use Stringable; +use function Tempest\Support\str; + final class InflectorPluralizer implements Pluralizer { private Inflector $inflector; @@ -39,20 +41,66 @@ public function singularize(Stringable|string $value): string public function singularizeLastWord(Stringable|string $value): string { - $string = (string) $value; - $parts = preg_split('/(.)(?=[A-Z])/u', $string, flags: PREG_SPLIT_DELIM_CAPTURE); - $lastWord = array_pop($parts); + $lastWord = $this->extractLastWord(value: (string) $value, prefix: $prefix, suffix: $suffix); + + if ($lastWord === null) { + return $suffix; + } - return implode('', $parts) . $this->singularize($lastWord); + return $prefix . $this->singularize(value: $lastWord) . $suffix; } public function pluralizeLastWord(Stringable|string $value, int|array|Countable $count = 2): string { - $string = (string) $value; - $parts = preg_split('/(.)(?=[A-Z])/u', $string, flags: PREG_SPLIT_DELIM_CAPTURE); - $lastWord = array_pop($parts); + $lastWord = $this->extractLastWord(value: (string) $value, prefix: $prefix, suffix: $suffix); + + if ($lastWord === null) { + return $suffix; + } + + return $prefix . $this->pluralize(value: $lastWord, count: $count) . $suffix; + } + + private function extractLastWord(string $value, ?string &$prefix = null, ?string &$suffix = null): ?string + { + $parts = $this->splitWords(value: $value); + + $last = end(array: $parts); + $suffix = $last !== false && str(string: $last)->trim(characters: '_')->isEmpty() + ? array_pop(array: $parts) + : ''; + + if ($parts === []) { + return null; + } - return implode('', $parts) . $this->pluralize($lastWord, $count); + $lastWord = array_pop(array: $parts); + $prefix = implode(separator: '', array: $parts); + + return $lastWord; + } + + /** + * Splits a string into word segments and underscore delimiters. + * + * The regex splits on five boundary types: + * - (_+)(?=[a-zA-Z0-9]) — underscore separators followed by a word character (snake_case, SCREAMING_SNAKE) + * - (_+)$ — trailing underscores at end of string + * - (?<=[a-z0-9])(?=[A-Z]) — lowercase/digit to uppercase transition (camelCase, PascalCase) + * - (?<=[A-Z])(?=[A-Z][a-z]) — end of uppercase run before a new word (HTMLElements → HTML + Elements) + * - (?<=\s)(?=\S) — whitespace to non-whitespace transition (Multiple Aircraft, small dogs) + * + * @return list + */ + private function splitWords(string $value): array + { + return ( + preg_split( + pattern: '/(_+)(?=[a-zA-Z0-9])|(_+)$|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=\s)(?=\S)/u', + subject: $value, + flags: PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY, + ) ?: [] + ); } private function matchCase(Stringable|string $value, Stringable|string $comparison): string diff --git a/packages/intl/tests/InflectorPluralizerTest.php b/packages/intl/tests/InflectorPluralizerTest.php index e08727f429..2bfceecf96 100644 --- a/packages/intl/tests/InflectorPluralizerTest.php +++ b/packages/intl/tests/InflectorPluralizerTest.php @@ -19,6 +19,9 @@ final class InflectorPluralizerTest extends TestCase #[TestWith(['Migration', 'Migrations', 2])] #[TestWith(['migration', 'migrations', 2])] #[TestWith(['migration', 'migrations', [1, 2]])] + #[TestWith(['category', 'categories', 2])] + #[TestWith(['status', 'statuses', 2])] + #[TestWith(['child', 'children', 2])] public function test_that_pluralizer_pluralizes(string $value, string $expected, int|array|Countable $count): void { $pluralizer = new InflectorPluralizer(); @@ -28,10 +31,71 @@ public function test_that_pluralizer_pluralizes(string $value, string $expected, #[TestWith(['Migrations', 'Migration'])] #[TestWith(['migrations', 'migration'])] + #[TestWith(['categories', 'category'])] + #[TestWith(['statuses', 'status'])] + #[TestWith(['children', 'child'])] public function test_that_pluralizer_singularizes(string $value, string $expected): void { $pluralizer = new InflectorPluralizer(); $this->assertEquals($expected, $pluralizer->singularize($value)); } + + #[TestWith(['bookAuthors', 'bookAuthor'])] + #[TestWith(['BookAuthors', 'BookAuthor'])] + #[TestWith(['book_authors', 'book_author'])] + #[TestWith(['product_metadata', 'product_metadata'])] + #[TestWith(['user_book_categories', 'user_book_category'])] + #[TestWith(['authors', 'author'])] + #[TestWith(['', ''])] + #[TestWith(['BOOK_AUTHORS', 'BOOK_AUTHOR'])] + #[TestWith(['USER_STATUSES', 'USER_STATUS'])] + #[TestWith(['BOOK_CATEGORIES', 'BOOK_CATEGORY'])] + #[TestWith(['parseHTMLElements', 'parseHTMLElement'])] + #[TestWith(['HTMLElements', 'HTMLElement'])] + #[TestWith(['getHTTPSResponses', 'getHTTPSResponse'])] + #[TestWith(['_authors', '_author'])] + #[TestWith(['authors_', 'author_'])] + #[TestWith(['authors__', 'author__'])] + #[TestWith(['book__authors', 'book__author'])] + #[TestWith(['_', '_'])] + #[TestWith(['__', '__'])] + #[TestWith(['Multiple Aircraft', 'Multiple Aircraft'])] + #[TestWith(['multiple aircraft', 'multiple aircraft'])] + #[TestWith(['small dogs', 'small dog'])] + public function test_singularize_last_word(string $value, string $expected): void + { + $pluralizer = new InflectorPluralizer(); + + $this->assertEquals($expected, $pluralizer->singularizeLastWord($value)); + } + + #[TestWith(['bookAuthor', 'bookAuthors'])] + #[TestWith(['BookAuthor', 'BookAuthors'])] + #[TestWith(['book_author', 'book_authors'])] + #[TestWith(['product_metadata', 'product_metadata'])] + #[TestWith(['user_book_category', 'user_book_categories'])] + #[TestWith(['author', 'authors'])] + #[TestWith(['', ''])] + #[TestWith(['BOOK_AUTHOR', 'BOOK_AUTHORS'])] + #[TestWith(['USER_STATUS', 'USER_STATUSES'])] + #[TestWith(['BOOK_CATEGORY', 'BOOK_CATEGORIES'])] + #[TestWith(['parseHTMLElement', 'parseHTMLElements'])] + #[TestWith(['HTMLElement', 'HTMLElements'])] + #[TestWith(['getHTTPSResponse', 'getHTTPSResponses'])] + #[TestWith(['_author', '_authors'])] + #[TestWith(['author_', 'authors_'])] + #[TestWith(['author__', 'authors__'])] + #[TestWith(['book__author', 'book__authors'])] + #[TestWith(['_', '_'])] + #[TestWith(['__', '__'])] + #[TestWith(['Multiple Aircraft', 'Multiple Aircraft'])] + #[TestWith(['multiple aircraft', 'multiple aircraft'])] + #[TestWith(['small dog', 'small dogs'])] + public function test_pluralize_last_word(string $value, string $expected): void + { + $pluralizer = new InflectorPluralizer(); + + $this->assertEquals($expected, $pluralizer->pluralizeLastWord($value)); + } } diff --git a/tests/Fixtures/Migrations/CreateBookReviewTable.php b/tests/Fixtures/Migrations/CreateBookReviewTable.php new file mode 100644 index 0000000000..f122c1d845 --- /dev/null +++ b/tests/Fixtures/Migrations/CreateBookReviewTable.php @@ -0,0 +1,30 @@ +primary() + ->text(name: 'content') + ->belongsTo(local: 'book_reviews.tag_id', foreign: 'tags.id'); + } + + public function down(): QueryStatement + { + return DropTableStatement::forModel(modelClass: BookReview::class); + } +} diff --git a/tests/Fixtures/Migrations/CreateReviewerTable.php b/tests/Fixtures/Migrations/CreateReviewerTable.php new file mode 100644 index 0000000000..00da80e648 --- /dev/null +++ b/tests/Fixtures/Migrations/CreateReviewerTable.php @@ -0,0 +1,30 @@ +primary() + ->text(name: 'name') + ->belongsTo(local: 'reviewers.book_review_id', foreign: 'book_reviews.id'); + } + + public function down(): QueryStatement + { + return DropTableStatement::forModel(modelClass: Reviewer::class); + } +} diff --git a/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php new file mode 100644 index 0000000000..0066181027 --- /dev/null +++ b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php @@ -0,0 +1,231 @@ +select(), + fields: arr(input: [new FieldStatement(field: 'SUM(`price`) AS `sum`')]), + )->build(); + + $this->assertSameWithoutBackticks(expected: 'SELECT SUM(`price`) AS `sum` FROM `books`', actual: $query->compile()); + } + + #[Test] + public function avg_compiles_correct_sql(): void + { + $query = SelectQueryBuilder::fromQueryBuilder( + source: query(model: 'books')->select(), + fields: arr(input: [new FieldStatement(field: 'AVG(`price`) AS `avg`')]), + )->build(); + + $this->assertSameWithoutBackticks(expected: 'SELECT AVG(`price`) AS `avg` FROM `books`', actual: $query->compile()); + } + + #[Test] + public function max_compiles_correct_sql(): void + { + $query = SelectQueryBuilder::fromQueryBuilder( + source: query(model: 'books')->select(), + fields: arr(input: [new FieldStatement(field: 'MAX(`price`) AS `max`')]), + )->build(); + + $this->assertSameWithoutBackticks(expected: 'SELECT MAX(`price`) AS `max` FROM `books`', actual: $query->compile()); + } + + #[Test] + public function min_compiles_correct_sql(): void + { + $query = SelectQueryBuilder::fromQueryBuilder( + source: query(model: 'books')->select(), + fields: arr(input: [new FieldStatement(field: 'MIN(`price`) AS `min`')]), + )->build(); + + $this->assertSameWithoutBackticks(expected: 'SELECT MIN(`price`) AS `min` FROM `books`', actual: $query->compile()); + } + + #[Test] + public function sum_with_where_compiles_correct_sql(): void + { + $query = SelectQueryBuilder::fromQueryBuilder( + source: query(model: 'books')->select()->where('author_id', 1), + fields: arr(input: [new FieldStatement(field: 'SUM(`price`) AS `sum`')]), + )->build(); + + $this->assertSameWithoutBackticks(expected: 'SELECT SUM(`price`) AS `sum` FROM `books` WHERE books.author_id = ?', actual: $query->compile()); + } + + #[Test] + public function sum_from_model(): void + { + $query = SelectQueryBuilder::fromQueryBuilder( + source: query(model: Author::class)->select(), + fields: arr(input: [new FieldStatement(field: 'SUM(`id`) AS `sum`')]), + )->build(); + + $this->assertSameWithoutBackticks(expected: 'SELECT SUM(`id`) AS `sum` FROM `authors`', actual: $query->compile()); + } + + #[Test] + public function sum_returns_correct_value(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + Author::create(name: 'Author C'); + + $this->assertSame(6, query(model: Author::class)->sum(column: 'id')); + } + + #[Test] + public function avg_returns_correct_value(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + Author::create(name: 'Author C'); + + $this->assertSame(2.0, query(model: Author::class)->avg(column: 'id')); + } + + #[Test] + public function max_returns_correct_value(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + Author::create(name: 'Author C'); + + $this->assertSame(3, query(model: Author::class)->max(column: 'id')); + } + + #[Test] + public function min_returns_correct_value(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + Author::create(name: 'Author C'); + + $this->assertSame(1, query(model: Author::class)->min(column: 'id')); + } + + #[Test] + public function sum_with_where_condition(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + Author::create(name: 'Author C'); + + $this->assertSame(1, query(model: Author::class)->find(name: 'Author A')->sum(column: 'id')); + } + + #[Test] + public function max_on_string_column(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Alpha'); + Author::create(name: 'Zeta'); + Author::create(name: 'Beta'); + + $this->assertSame('Zeta', query(model: Author::class)->max(column: 'name')); + } + + #[Test] + public function min_on_string_column(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Alpha'); + Author::create(name: 'Zeta'); + Author::create(name: 'Beta'); + + $this->assertSame('Alpha', query(model: Author::class)->min(column: 'name')); + } + + #[Test] + public function sum_static_shortcut_on_model(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + + $this->assertSame(3, Author::sum(column: 'id')); + } + + #[Test] + public function avg_static_shortcut_on_model(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + + $this->assertSame(1.5, Author::avg(column: 'id')); + } +} diff --git a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php index 1ea96334d4..fcfba7b850 100644 --- a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php @@ -192,7 +192,7 @@ public function test_insert_mapping(): void $expected = match ($dialect) { DatabaseDialect::POSTGRESQL => <<<'SQL' - INSERT INTO authors (name) VALUES (?) RETURNING * + INSERT INTO "authors" ("name") VALUES (?) RETURNING * SQL, default => <<<'SQL' INSERT INTO `authors` (`name`) VALUES (?) diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php index d34fb0ee1c..94600f57ff 100644 --- a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php @@ -314,7 +314,7 @@ public function test_update_mapping(): void $expected = match ($dialect) { DatabaseDialect::POSTGRESQL => <<<'SQL' - UPDATE authors SET name = ? WHERE authors.id = ? + UPDATE "authors" SET "name" = ? WHERE "authors"."id" = ? SQL, default => <<<'SQL' UPDATE `authors` SET `name` = ? WHERE `authors`.`id` = ? diff --git a/tests/Integration/Database/Builder/WhereHasTest.php b/tests/Integration/Database/Builder/WhereHasTest.php new file mode 100644 index 0000000000..72b6ecc48a --- /dev/null +++ b/tests/Integration/Database/Builder/WhereHasTest.php @@ -0,0 +1,859 @@ +whereHas(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id)', + $sql, + ); + } + + #[Test] + public function where_has_with_callback_compiles_constrained_subquery(): void + { + $sql = Author::select() + ->whereHas( + relation: 'books', + callback: function ( + SelectQueryBuilder $query, + ): void { + $query->whereField( + field: 'title', + value: 'LOTR 1', + ); + }, + ) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id AND books.title = ?)', + $sql, + ); + } + + #[Test] + public function where_doesnt_have_compiles_not_exists_subquery(): void + { + $sql = Author::select() + ->whereDoesntHave(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE NOT EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id)', + $sql, + ); + } + + #[Test] + public function where_doesnt_have_with_callback_compiles_constrained_subquery(): void + { + $sql = Author::select() + ->whereDoesntHave( + relation: 'books', + callback: function ( + SelectQueryBuilder $query, + ): void { + $query->whereField( + field: 'title', + value: 'LOTR 1', + ); + }, + ) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE NOT EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id AND books.title = ?)', + $sql, + ); + } + + #[Test] + public function where_has_combined_with_other_where_clauses(): void + { + $sql = Author::select() + ->whereField( + field: 'name', + value: 'Tolkien', + ) + ->whereHas(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE authors.name = ? AND EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id)', + $sql, + ); + } + + #[Test] + public function or_where_has_compiles_or_exists(): void + { + $sql = Author::select() + ->whereField( + field: 'name', + value: 'Tolkien', + ) + ->orWhereHas(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE authors.name = ? OR EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id)', + $sql, + ); + } + + #[Test] + public function or_where_doesnt_have_compiles_or_not_exists(): void + { + $sql = Author::select() + ->whereField( + field: 'name', + value: 'Tolkien', + ) + ->orWhereDoesntHave(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE authors.name = ? OR NOT EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id)', + $sql, + ); + } + + #[Test] + public function where_has_on_belongs_to_relation(): void + { + $sql = Book::select() + ->whereHas(relation: 'author') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT books.id AS books.id, books.title AS books.title, books.author_id AS books.author_id FROM books WHERE EXISTS (SELECT 1 FROM authors WHERE authors.id = books.author_id)', + $sql, + ); + } + + #[Test] + public function where_has_on_belongs_to_with_callback(): void + { + $sql = Book::select() + ->whereHas( + relation: 'author', + callback: function ( + SelectQueryBuilder $query, + ): void { + $query->whereField( + field: 'name', + value: 'Tolkien', + ); + }, + ) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT books.id AS books.id, books.title AS books.title, books.author_id AS books.author_id FROM books WHERE EXISTS (SELECT 1 FROM authors WHERE authors.id = books.author_id AND authors.name = ?)', + $sql, + ); + } + + #[Test] + public function where_has_with_where_group_callback(): void + { + $sql = Author::select() + ->whereHas(relation: 'books', callback: function (SelectQueryBuilder $query): void { + $query + ->whereField(field: 'title', value: 'LOTR 1') + ->orWhereGroup(callback: function ($group): void { + $group->whereField(field: 'title', value: 'Timeline Taxi'); + }); + }) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id AND books.title = ? OR books.title = ?)', + $sql, + ); + } + + #[Test] + public function where_has_with_where_group_callback_returns_correct_results(): void + { + $this->seed(); + + $authors = Author::select() + ->whereHas(relation: 'books', callback: function (SelectQueryBuilder $query): void { + $query->whereGroup(callback: function ($group): void { + $group->whereField(field: 'title', value: 'LOTR 1') + ->orWhere(field: 'title', value: 'Timeline Taxi'); + }); + }) + ->all(); + + $this->assertCount(2, $authors); + $this->assertSame('Brent', $authors[0]->name); + $this->assertSame('Tolkien', $authors[1]->name); + } + + #[Test] + public function where_has_on_has_one_relation(): void + { + $sql = Book::select() + ->whereHas(relation: 'isbn') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT books.id AS books.id, books.title AS books.title, books.author_id AS books.author_id FROM books WHERE EXISTS (SELECT 1 FROM isbns WHERE isbns.book_id = books.id)', + $sql, + ); + } + + #[Test] + public function where_has_on_belongs_to_many_relation(): void + { + $sql = Tag::select() + ->whereHas(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT tags.id AS tags.id, tags.label AS tags.label FROM tags WHERE EXISTS (SELECT 1 FROM books_tags WHERE books_tags.tag_id = tags.id)', + $sql, + ); + } + + #[Test] + public function where_has_on_has_many_through_relation(): void + { + $sql = Tag::select() + ->whereHas(relation: 'reviewers') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT tags.id AS tags.id, tags.label AS tags.label FROM tags WHERE EXISTS (SELECT 1 FROM book_reviews WHERE book_reviews.tag_id = tags.id)', + $sql, + ); + } + + #[Test] + public function where_has_on_has_one_through_relation(): void + { + $sql = Tag::select() + ->whereHas(relation: 'topReviewer') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT tags.id AS tags.id, tags.label AS tags.label FROM tags WHERE EXISTS (SELECT 1 FROM book_reviews WHERE book_reviews.tag_id = tags.id)', + $sql, + ); + } + + #[Test] + public function where_has_with_count_compiles_count_subquery(): void + { + $sql = Author::select() + ->whereHas( + relation: 'books', + operator: WhereOperator::GREATER_THAN_OR_EQUAL, + count: 3, + ) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE (SELECT COUNT(*) FROM books WHERE books.author_id = authors.id) >= 3', + $sql, + ); + } + + #[Test] + public function where_has_with_count_and_callback(): void + { + $sql = Author::select() + ->whereHas( + relation: 'books', + callback: function ( + SelectQueryBuilder $query, + ): void { + $query->whereField( + field: 'title', + value: 'LOTR 1', + operator: 'LIKE', + ); + }, + operator: WhereOperator::GREATER_THAN_OR_EQUAL, + count: 2, + ) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE (SELECT COUNT(*) FROM books WHERE books.author_id = authors.id AND books.title LIKE ?) >= 2', + $sql, + ); + } + + #[Test] + public function where_has_with_count_equals_zero(): void + { + $sql = Author::select() + ->whereHas( + relation: 'books', + operator: WhereOperator::EQUALS, + count: 0, + ) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE (SELECT COUNT(*) FROM books WHERE books.author_id = authors.id) = 0', + $sql, + ); + } + + #[Test] + public function or_where_has_with_count(): void + { + $sql = Author::select() + ->whereField( + field: 'name', + value: 'Nobody', + ) + ->orWhereHas( + relation: 'books', + operator: WhereOperator::GREATER_THAN_OR_EQUAL, + count: 3, + ) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE authors.name = ? OR (SELECT COUNT(*) FROM books WHERE books.author_id = authors.id) >= 3', + $sql, + ); + } + + #[Test] + public function where_has_nested_relation_compiles_nested_exists(): void + { + $sql = Author::select() + ->whereHas(relation: 'books.chapters') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id AND EXISTS (SELECT 1 FROM chapters WHERE chapters.book_id = books.id))', + $sql, + ); + } + + #[Test] + public function where_has_nested_relation_with_callback(): void + { + $sql = Author::select() + ->whereHas( + relation: 'books.chapters', + callback: function ( + SelectQueryBuilder $query, + ): void { + $query->whereField( + field: 'title', + value: 'Chapter 1', + ); + }, + ) + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id AND EXISTS (SELECT 1 FROM chapters WHERE chapters.book_id = books.id AND chapters.title = ?))', + $sql, + ); + } + + #[Test] + public function where_doesnt_have_nested_relation(): void + { + $sql = Author::select() + ->whereDoesntHave(relation: 'books.chapters') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT authors.id AS authors.id, authors.name AS authors.name, authors.type AS authors.type, authors.publisher_id AS authors.publisher_id FROM authors WHERE NOT EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id AND EXISTS (SELECT 1 FROM chapters WHERE chapters.book_id = books.id))', + $sql, + ); + } + + #[Test] + public function where_has_returns_models_with_related_records(): void + { + $this->seed(); + + $authors = Author::select() + ->whereHas(relation: 'books') + ->all(); + + $this->assertCount( + 2, + $authors, + ); + $this->assertSame( + 'Brent', + $authors[0]->name, + ); + $this->assertSame( + 'Tolkien', + $authors[1]->name, + ); + } + + #[Test] + public function where_has_with_callback_filters_by_related_records(): void + { + $this->seed(); + + $authors = Author::select() + ->whereHas( + relation: 'books', + callback: function ( + SelectQueryBuilder $query, + ): void { + $query->whereField( + field: 'title', + value: 'Timeline Taxi', + ); + }, + ) + ->all(); + + $this->assertCount( + 1, + $authors, + ); + $this->assertSame( + 'Brent', + $authors[0]->name, + ); + } + + #[Test] + public function where_doesnt_have_returns_models_without_related_records(): void + { + $this->seed(); + + $authors = Author::select() + ->whereDoesntHave(relation: 'books') + ->all(); + + $this->assertCount( + 1, + $authors, + ); + $this->assertSame( + 'Nobody', + $authors[0]->name, + ); + } + + #[Test] + public function where_has_nested_returns_models_with_deeply_related_records(): void + { + $this->seed(); + + $authors = Author::select() + ->whereHas(relation: 'books.chapters') + ->all(); + + $this->assertCount( + 1, + $authors, + ); + $this->assertSame( + 'Tolkien', + $authors[0]->name, + ); + } + + #[Test] + public function where_doesnt_have_nested_returns_models_without_deeply_related_records(): void + { + $this->seed(); + + $authors = Author::select() + ->whereDoesntHave(relation: 'books.chapters') + ->all(); + + $this->assertCount( + 2, + $authors, + ); + $this->assertSame( + 'Brent', + $authors[0]->name, + ); + $this->assertSame( + 'Nobody', + $authors[1]->name, + ); + } + + #[Test] + public function where_has_with_count_returns_authors_with_enough_books(): void + { + $this->seed(); + + $authors = Author::select() + ->whereHas( + relation: 'books', + operator: WhereOperator::GREATER_THAN_OR_EQUAL, + count: 3, + ) + ->all(); + + $this->assertCount( + 1, + $authors, + ); + $this->assertSame( + 'Tolkien', + $authors[0]->name, + ); + } + + #[Test] + public function where_has_with_count_and_callback_filters_correctly(): void + { + $this->seed(); + + $authors = Author::select() + ->whereHas( + relation: 'books', + callback: function ( + SelectQueryBuilder $query, + ): void { + $query->whereLike( + field: 'title', + value: 'LOTR%', + ); + }, + operator: WhereOperator::GREATER_THAN_OR_EQUAL, + count: 2, + ) + ->all(); + + $this->assertCount( + 1, + $authors, + ); + $this->assertSame( + 'Tolkien', + $authors[0]->name, + ); + } + + #[Test] + public function where_doesnt_have_with_callback_returns_models_without_matching_related_records(): void + { + $this->seed(); + + $authors = Author::select() + ->whereDoesntHave( + relation: 'books', + callback: function ( + SelectQueryBuilder $query, + ): void { + $query->whereField( + field: 'title', + value: 'Timeline Taxi', + ); + }, + ) + ->all(); + + $this->assertCount( + 2, + $authors, + ); + $this->assertSame( + 'Tolkien', + $authors[0]->name, + ); + $this->assertSame( + 'Nobody', + $authors[1]->name, + ); + } + + #[Test] + public function where_has_on_has_one_returns_models_with_related_record(): void + { + $this->seed(); + + $books = Book::select() + ->whereHas(relation: 'isbn') + ->all(); + + $this->assertCount(2, $books); + $this->assertSame('LOTR 1', $books[0]->title); + $this->assertSame('Timeline Taxi', $books[1]->title); + } + + #[Test] + public function where_doesnt_have_on_has_one(): void + { + $this->seed(); + + $books = Book::select() + ->whereDoesntHave(relation: 'isbn') + ->all(); + + $this->assertCount(2, $books); + $this->assertSame('LOTR 2', $books[0]->title); + $this->assertSame('LOTR 3', $books[1]->title); + } + + #[Test] + public function where_has_on_belongs_to_many_returns_models_with_related_records(): void + { + $this->seed(); + + $tags = Tag::select() + ->whereHas(relation: 'books') + ->all(); + + $this->assertCount(1, $tags); + $this->assertSame('fantasy', $tags[0]->label); + } + + #[Test] + public function where_doesnt_have_on_belongs_to_many(): void + { + $this->seed(); + + $tags = Tag::select() + ->whereDoesntHave(relation: 'books') + ->all(); + + $this->assertCount(1, $tags); + $this->assertSame('orphan', $tags[0]->label); + } + + #[Test] + public function where_has_on_has_many_through_returns_models_with_related_records(): void + { + $this->seed(); + + $tags = Tag::select() + ->whereHas(relation: 'reviewers') + ->all(); + + $this->assertCount(1, $tags); + $this->assertSame('fantasy', $tags[0]->label); + } + + #[Test] + public function where_doesnt_have_on_has_many_through(): void + { + $this->seed(); + + $tags = Tag::select() + ->whereDoesntHave(relation: 'reviewers') + ->all(); + + $this->assertCount(1, $tags); + $this->assertSame('orphan', $tags[0]->label); + } + + #[Test] + public function where_has_on_has_one_through_returns_models_with_related_record(): void + { + $this->seed(); + + $tags = Tag::select() + ->whereHas(relation: 'topReviewer') + ->all(); + + $this->assertCount(1, $tags); + $this->assertSame('fantasy', $tags[0]->label); + } + + #[Test] + public function count_with_where_has(): void + { + $this->seed(); + + $count = Author::count() + ->whereHas(relation: 'books') + ->execute(); + + $this->assertSame( + 2, + $count, + ); + } + + #[Test] + public function count_with_where_has_compiles_correctly(): void + { + $sql = Author::count() + ->whereHas(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'SELECT COUNT(*) AS count FROM authors WHERE EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id)', + $sql, + ); + } + + #[Test] + public function delete_with_where_has_compiles_correctly(): void + { + $sql = query(model: Author::class) + ->delete() + ->whereHas(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'DELETE FROM authors WHERE EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id)', + $sql, + ); + } + + #[Test] + public function delete_with_where_doesnt_have_removes_correct_records(): void + { + $this->seed(); + + query(model: Author::class) + ->delete() + ->whereDoesntHave(relation: 'books') + ->execute(); + + $authors = Author::select() + ->all(); + + $this->assertCount( + 2, + $authors, + ); + $this->assertSame( + 'Brent', + $authors[0]->name, + ); + $this->assertSame( + 'Tolkien', + $authors[1]->name, + ); + } + + #[Test] + public function update_with_where_has_compiles_correctly(): void + { + $sql = query(model: Author::class) + ->update(name: 'Updated') + ->whereHas(relation: 'books') + ->compile(); + + $this->assertSameWithoutBackticks( + 'UPDATE authors SET name = ? WHERE EXISTS (SELECT 1 FROM books WHERE books.author_id = authors.id)', + $sql, + ); + } + + #[Test] + public function update_with_where_has_updates_correct_records(): void + { + $this->seed(); + + query(model: Author::class) + ->update(name: 'Has Books') + ->whereHas(relation: 'books') + ->execute(); + + $authors = Author::select() + ->orderBy(field: 'id') + ->all(); + + $this->assertSame( + 'Has Books', + $authors[0]->name, + ); + $this->assertSame( + 'Has Books', + $authors[1]->name, + ); + $this->assertSame( + 'Nobody', + $authors[2]->name, + ); + } + + private function seed(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + CreateChapterTable::class, + CreateIsbnTable::class, + CreateTagTable::class, + CreateBookTagTable::class, + CreateBookReviewTable::class, + CreateReviewerTable::class, + ); + + $brent = Author::create(name: 'Brent'); + $tolkien = Author::create(name: 'Tolkien'); + Author::create(name: 'Nobody'); + + $lotr1 = Book::create(title: 'LOTR 1', author: $tolkien); + Book::create(title: 'LOTR 2', author: $tolkien); + Book::create(title: 'LOTR 3', author: $tolkien); + $timelineTaxi = Book::create(title: 'Timeline Taxi', author: $brent); + + Chapter::create(title: 'Chapter 1', book: $lotr1); + Chapter::create(title: 'Chapter 2', book: $lotr1); + + Isbn::create(value: 'isbn-lotr-1', book: $lotr1); + Isbn::create(value: 'isbn-tt', book: $timelineTaxi); + + $fantasy = Tag::create(label: 'fantasy'); + Tag::create(label: 'orphan'); + + query(model: 'books_tags') + ->insert(['book_id' => 1, 'tag_id' => 1]) + ->execute(); + + $review = BookReview::create(content: 'Great book', tag: $fantasy); + Reviewer::create(name: 'Alice', bookReview: $review); + } +} diff --git a/tests/Integration/Database/ModelInspector/LatinPluralRelationTest.php b/tests/Integration/Database/ModelInspector/LatinPluralRelationTest.php new file mode 100644 index 0000000000..5baa287d21 --- /dev/null +++ b/tests/Integration/Database/ModelInspector/LatinPluralRelationTest.php @@ -0,0 +1,127 @@ +getRelation(name: 'product_metadata'); + + $this->assertInstanceOf( + expected: BelongsTo::class, + actual: $relation, + ); + + $this->assertSame( + expected: 'LEFT JOIN metadata ON metadata.id = products.metadata_id', + actual: $relation + ->getJoinStatement() + ->compile(dialect: DatabaseDialect::SQLITE), + ); + } + + #[Test] + public function has_one_with_latin_plural_property_name(): void + { + $model = inspect(model: LatinPluralMetadataModel::class); + $relation = $model->getRelation(name: 'extra_data'); + + $this->assertInstanceOf( + expected: HasOne::class, + actual: $relation, + ); + } + + #[Test] + public function has_one_through_with_latin_plural_property_name(): void + { + $model = inspect(model: LatinPluralMetadataModel::class); + $relation = $model->getRelation(name: 'product_criteria'); + + $this->assertInstanceOf( + expected: HasOneThrough::class, + actual: $relation, + ); + } + + #[Test] + public function latin_plural_belongs_to_uses_fk_in_select_fields(): void + { + $model = inspect(model: LatinPluralProductModel::class); + $fields = $model->getSelectFields()->toArray(); + + $this->assertContains( + needle: 'metadata_id', + haystack: $fields, + ); + $this->assertNotContains( + needle: 'product_metadata', + haystack: $fields, + ); + } +} + +#[Table(name: 'products')] +final class LatinPluralProductModel +{ + public PrimaryKey $id; + + #[BelongsTo] + public ?LatinPluralMetadataModel $product_metadata = null; + + public string $name; +} + +#[Table(name: 'metadata')] +final class LatinPluralMetadataModel +{ + public PrimaryKey $id; + + public string $value; + + #[HasOne] + public ?LatinPluralExtraDataModel $extra_data = null; + + #[HasOneThrough(through: LatinPluralIntermediateModel::class)] + public ?LatinPluralCriteriaModel $product_criteria = null; +} + +#[Table(name: 'extra_data')] +final class LatinPluralExtraDataModel +{ + public PrimaryKey $id; + + public string $info; +} + +#[Table(name: 'intermediate')] +final class LatinPluralIntermediateModel +{ + public PrimaryKey $id; + + public LatinPluralMetadataModel $metadata; + + public string $link; +} + +#[Table(name: 'criteria')] +final class LatinPluralCriteriaModel +{ + public PrimaryKey $id; + + public string $label; +} diff --git a/tests/Integration/Database/QueryStatements/CreateTableStatementTest.php b/tests/Integration/Database/QueryStatements/CreateTableStatementTest.php index 18d74bb602..0cb2ea5f15 100644 --- a/tests/Integration/Database/QueryStatements/CreateTableStatementTest.php +++ b/tests/Integration/Database/QueryStatements/CreateTableStatementTest.php @@ -34,7 +34,7 @@ public function up(): QueryStatement { return new CreateTableStatement('test_table') ->text('text', default: 'default') - ->char('char', default: 'd') + ->char('char', 7, default: 'default') ->varchar('varchar', default: 'default') ->float('float', default: 0.1) ->integer('integer', default: 1) diff --git a/tests/Integration/FrameworkIntegrationTestCase.php b/tests/Integration/FrameworkIntegrationTestCase.php index 6a1c91959d..66efa7148a 100644 --- a/tests/Integration/FrameworkIntegrationTestCase.php +++ b/tests/Integration/FrameworkIntegrationTestCase.php @@ -62,7 +62,7 @@ protected function assertSameWithoutBackticks(Stringable|string $expected, Strin { $clean = fn (string $string): string => str($string) ->replace('`', '') - ->replaceRegex('/AS \"(?.*?)\"/', fn (array $matches) => "AS {$matches['alias']}") + ->replace('"', '') ->toString(); $this->assertSame(