From 2a3a85c9d093b864b46c8bf64150d0d7b4d608a6 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 12 Oct 2026 10:24:46 +1100 Subject: [PATCH 1/7] Fix four DML rendering bugs and type the statement builders Bugs fixed: - Preparing an expression with a null parameter threw a TypeError. processExpressionParameterName() now accepts null. - A float value in Update::set() or Insert::values() made getSqlString() throw a TypeError. resolveColumnValue() accepts floats. - An unkeyed expression in a join's column list was passed to quoteIdentifier() and threw. It is now aliased ExpressionN, as the main column list does. - Join::current() returned an undefined index past the last join. It returns null. Select, Update and Delete keep their WHERE, HAVING and JOIN clauses lazy, but the backing properties are renamed whereClause, havingClause and joinClause. They shared their names with the @property tags for __get(), so the analyzer read every access as the non-null magic property. Select's table resolution moves to a private resolveSelectTable(). It overrode resolveTable() with a different return shape, which forced AbstractSql::resolveTable() to declare string|array|null. It now returns ?string, which also clears the three processTable() findings left in Ddl. The specification renderer is split into steps that match a format, render each parameter and format a parameter list, each with exact types. A slot without a parameter specification now rejects a list with the catalogued parameter-count exception instead of printing "Array". Single-string specifications are read through formatSpecification(), which throws RuntimeException::forStringSpecificationRequired() for a specification that is not a string. Parameters are bound only when a driver is present to format their names, falling back to quoting the value. A ColumnValue alias types Update's set values and Insert's column values. The thrown RuntimeException is documented through every method it reaches, the DDL processors among them. processExpressionList() replaces the four loops in AlterTable and CreateTable that rendered expression lists. --- src/Sql/AbstractPreparableSql.php | 3 + src/Sql/AbstractSql.php | 233 +++++++++++------ src/Sql/Combine.php | 26 +- src/Sql/Ddl/AlterTable.php | 21 +- src/Sql/Ddl/CreateTable.php | 21 +- src/Sql/Ddl/DropTable.php | 7 +- src/Sql/Delete.php | 29 +-- src/Sql/Exception/RuntimeException.php | 7 + src/Sql/Expression.php | 6 +- src/Sql/Insert.php | 63 +++-- src/Sql/Join.php | 6 +- src/Sql/Select.php | 235 +++++++++--------- src/Sql/Sql.php | 8 +- src/Sql/Update.php | 69 ++--- test/unit/Sql/AbstractSqlTest.php | 144 +++++++++++ .../Sql/Exception/RuntimeExceptionTest.php | 6 + test/unit/Sql/InsertTest.php | 11 + test/unit/Sql/JoinTest.php | 10 + test/unit/Sql/SelectTest.php | 71 +++++- test/unit/Sql/UpdateTest.php | 22 ++ 20 files changed, 688 insertions(+), 310 deletions(-) diff --git a/src/Sql/AbstractPreparableSql.php b/src/Sql/AbstractPreparableSql.php index 83313f0a..311ce9fc 100644 --- a/src/Sql/AbstractPreparableSql.php +++ b/src/Sql/AbstractPreparableSql.php @@ -14,6 +14,9 @@ abstract class AbstractPreparableSql extends AbstractSql implements PreparableSqlInterface { + /** + * @throws Exception\RuntimeException + */ #[Override] public function prepareStatement( AdapterInterface $adapter, diff --git a/src/Sql/AbstractSql.php b/src/Sql/AbstractSql.php index 07b7a489..f16a6ffc 100644 --- a/src/Sql/AbstractSql.php +++ b/src/Sql/AbstractSql.php @@ -32,8 +32,9 @@ * @psalm-type ParameterSpecification = array|null * @psalm-type Specification = string|array> * @psalm-type SqlParameters = array>> + * @psalm-type ColumnValue = Select|ExpressionInterface|string|int|float|bool|null * @psalm-type ColumnReference = array{ - * column: Select|string|int|bool|ExpressionInterface|null, + * column: ColumnValue, * fromTable?: string, * isIdentifier?: bool, * } @@ -62,6 +63,8 @@ abstract class AbstractSql implements SqlInterface /** * {@inheritDoc} + * + * @throws Exception\RuntimeException */ #[Override] public function getSqlString(?PlatformInterface $adapterPlatform = null): string @@ -71,6 +74,9 @@ public function getSqlString(?PlatformInterface $adapterPlatform = null): string return $this->buildSqlString($adapterPlatform); } + /** + * @throws Exception\RuntimeException + */ protected function buildSqlString( PlatformInterface $platform, ?DriverInterface $driver = null, @@ -109,62 +115,17 @@ protected function buildSqlString( protected function createSqlFromSpecificationAndParameters(array|string $specifications, array $parameters): string { if (is_string($specifications)) { - return vsprintf($specifications, $parameters); + return vsprintf($specifications, $this->flattenParameterList($parameters)); } - $parametersCount = count($parameters); - $specificationString = null; - $paramSpecs = []; - - foreach ($specifications as $candidateString => $paramSpecs) { - if (count($paramSpecs) !== $parametersCount) { - continue; - } - - $specificationString = (string) $candidateString; - break; - } - - if (null === $specificationString) { - throw Exception\RuntimeException::forUnsupportedParameterCount(); - } + [$format, $parameterSpecifications] = $this->matchSpecification($specifications, count($parameters)); $topParameters = []; - foreach ($parameters as $position => $paramsForPosition) { - if (null !== ($paramSpecs[$position]['combinedby'] ?? null)) { - $multiParamValues = []; - foreach ($paramsForPosition as $multiParamsForPosition) { - if (! is_array($multiParamsForPosition)) { - $multiParamsForPosition = [$multiParamsForPosition]; - } - - $ppCount = count($multiParamsForPosition); - - if (null === ($paramSpecs[$position][$ppCount] ?? null)) { - throw Exception\RuntimeException::forUnsupportedParameterCountOf($ppCount); - } - - $multiParamValues[] = vsprintf($paramSpecs[$position][$ppCount], $multiParamsForPosition); - } - - $topParameters[] = implode($paramSpecs[$position]['combinedby'], $multiParamValues); - continue; - } - - if (null === $paramSpecs[$position]) { - $topParameters[] = $paramsForPosition; - continue; - } - - $ppCount = count($paramsForPosition); - if (null === ($paramSpecs[$position][$ppCount] ?? null)) { - throw Exception\RuntimeException::forUnsupportedParameterCountOf($ppCount); - } - - $topParameters[] = vsprintf($paramSpecs[$position][$ppCount], $paramsForPosition); + foreach ($parameters as $position => $parameter) { + $topParameters[] = $this->renderParameter($parameterSpecifications[$position] ?? null, $parameter); } - return vsprintf($specificationString, $topParameters); + return vsprintf($format, $topParameters); } /** @@ -204,9 +165,31 @@ protected function flattenExpressionValues(array $arguments): array return $values; } + /** + * Substitute values into the numbered placeholders of a string specification + * + * @throws Exception\RuntimeException When the named specification is not a format string. + */ + protected function formatSpecification(string $name, string ...$values): string + { + $specification = $this->specifications[$name] ?? null; + + if (! is_string($specification)) { + throw Exception\RuntimeException::forStringSpecificationRequired($name); + } + + $placeholders = []; + $position = 0; + foreach ($values as $_) { + $placeholders[] = '%' . ++$position . '$s'; + } + + return str_replace($placeholders, $values, $specification); + } + protected function localizeVariables(): void { - if (! $this instanceof PlatformDecoratorInterface) { + if (! $this instanceof PlatformDecoratorInterface || null === $this->subject) { return; } @@ -217,6 +200,7 @@ protected function localizeVariables(): void /** * @staticvar int $runtimeExpressionPrefix + * @throws Exception\RuntimeException */ protected function processExpression( ExpressionInterface $expression, @@ -252,14 +236,15 @@ protected function processExpression( foreach ($expressionValues as $vIndex => $argument) { $values[] = match (true) { $argument instanceof Value => $parameterContainer instanceof ParameterContainer - ? $this->processExpressionParameterName( - $argument->getValue(), - $namedParameterPrefix, - $expressionParamIndex, - $driver, - $parameterContainer, - ) - : $platform->quoteValue((string) $argument->getValue()), + && $driver instanceof DriverInterface + ? $this->processExpressionParameterName( + $argument->getValue(), + $namedParameterPrefix, + $expressionParamIndex, + $driver, + $parameterContainer, + ) + : $platform->quoteValue((string) $argument->getValue()), $argument instanceof Identifier => $platform->quoteIdentifierInFragment($argument->getValue()), $argument instanceof Literal => $argument->getValue(), $argument instanceof Identifiers => $this->processIdentifiersArgument($argument, $platform), @@ -278,6 +263,26 @@ protected function processExpression( return vsprintf($specification, $values); } + /** + * Render each expression in a list against the platform + * + * @param array $expressions + * @return list + * @throws Exception\RuntimeException + */ + protected function processExpressionList(array $expressions, PlatformInterface $platform): array + { + $sqls = []; + foreach ($expressions as $expression) { + $sqls[] = $this->processExpression($expression, $platform); + } + + return $sqls; + } + + /** + * @throws Exception\RuntimeException + */ protected function processExpressionOrSelect( ArgumentInterface $argument, string $namedParameterPrefix, @@ -302,12 +307,12 @@ protected function processExpressionOrSelect( } protected function processExpressionParameterName( - int|float|string|bool $value, + int|float|string|bool|null $value, string $namedParameterPrefix, int &$expressionParamIndex, DriverInterface $driver, ParameterContainer $parameterContainer, - ): ?string { + ): string { $name = $namedParameterPrefix . $expressionParamIndex++; $parameterContainer->offsetSet($name, $value); @@ -331,6 +336,7 @@ protected function processIdentifiersArgument( /** * @return null|array{0: array>} Null if no joins present, array of JOIN statements otherwise + * @throws Exception\RuntimeException */ protected function processJoin( ?Join $joins, @@ -344,13 +350,12 @@ protected function processJoin( $joinSpecArgArray = []; foreach ($joins->getJoins() as $j => $join) { - $joinAs = null; - $joinNameValue = $join['name']; - $joinName = $joinNameValue; - if (is_array($joinNameValue)) { - $alias = array_key_first($joinNameValue); - $joinName = $joinNameValue[$alias]; + $joinAs = null; + $joinName = $join['name']; + if (is_array($joinName)) { + $alias = array_key_first($joinName); $joinAs = $platform->quoteIdentifier($alias); + $joinName = $joinName[$alias] ?? ''; } $joinName = match (true) { @@ -387,6 +392,9 @@ protected function processJoin( return [$joinSpecArgArray]; } + /** + * @throws Exception\RuntimeException + */ protected function processSubSelect( Select $subselect, PlatformInterface $platform, @@ -425,10 +433,11 @@ protected function renderTable(string $table, ?string $alias = null): string } /** - * @param ColumnReference|Select|string|int|bool|ExpressionInterface|null $column + * @param ColumnReference|ColumnValue $column + * @throws Exception\RuntimeException */ protected function resolveColumnValue( - Select|array|string|int|bool|ExpressionInterface|null $column, + Select|array|string|int|float|bool|ExpressionInterface|null $column, PlatformInterface $platform, ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, @@ -458,16 +467,20 @@ protected function resolveColumnValue( } return $isIdentifier - ? $fromTable . $platform->quoteIdentifierInFragment($column) + ? $fromTable . $platform->quoteIdentifierInFragment((string) $column) : $platform->quoteValue((string) $column); } + /** + * @return ($table is null ? null : string) + * @throws Exception\RuntimeException + */ protected function resolveTable( Select|string|TableIdentifier|null $table, PlatformInterface $platform, ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, - ): string|array|null { + ): ?string { $schema = null; if ($table instanceof TableIdentifier) { [$table, $schema] = $table->getTableAndSchema(); @@ -490,6 +503,56 @@ protected function resolveTable( return $table; } + /** + * @param array> $values + * @return list + * @throws Exception\RuntimeException When a value is a nested list rather than a string. + */ + private function flattenParameterList(array $values): array + { + $strings = []; + foreach ($values as $value) { + if (! is_string($value)) { + throw Exception\RuntimeException::forUnsupportedParameterCountOf(count($value)); + } + + $strings[] = $value; + } + + return $strings; + } + + /** + * @param array $parameterSpecification + * @param array> $values + * @throws Exception\RuntimeException When no format accepts the number of values. + */ + private function formatParameterList(array $parameterSpecification, array $values): string + { + $count = count($values); + $format = $parameterSpecification[$count] ?? throw Exception\RuntimeException::forUnsupportedParameterCountOf( + $count, + ); + + return vsprintf($format, $this->flattenParameterList($values)); + } + + /** + * @param array> $specifications + * @return array{string, list} + * @throws Exception\RuntimeException When no specification takes the number of parameters given. + */ + private function matchSpecification(array $specifications, int $parameterCount): array + { + foreach ($specifications as $format => $parameterSpecifications) { + if (count($parameterSpecifications) === $parameterCount) { + return [$format, $parameterSpecifications]; + } + } + + throw Exception\RuntimeException::forUnsupportedParameterCount(); + } + private function quoteJoinTableIdentifier(TableIdentifier $identifier, PlatformInterface $platform): string { [$table, $schema] = $identifier->getTableAndSchema(); @@ -499,4 +562,30 @@ private function quoteJoinTableIdentifier(TableIdentifier $identifier, PlatformI . $platform->quoteIdentifier($table) ); } + + /** + * @param ParameterSpecification $parameterSpecification + * @param string|array> $parameter + * @throws Exception\RuntimeException + */ + private function renderParameter(?array $parameterSpecification, string|array $parameter): string + { + if (null === $parameterSpecification) { + return is_string($parameter) + ? $parameter + : throw Exception\RuntimeException::forUnsupportedParameterCountOf(count($parameter)); + } + + $combinedBy = $parameterSpecification['combinedby'] ?? null; + if (null === $combinedBy) { + return $this->formatParameterList($parameterSpecification, (array) $parameter); + } + + $rendered = []; + foreach ((array) $parameter as $values) { + $rendered[] = $this->formatParameterList($parameterSpecification, (array) $values); + } + + return implode($combinedBy, $rendered); + } } diff --git a/src/Sql/Combine.php b/src/Sql/Combine.php index dd5996c3..29dd52a0 100644 --- a/src/Sql/Combine.php +++ b/src/Sql/Combine.php @@ -11,8 +11,8 @@ use function array_key_exists; use function array_keys; +use function array_merge; use function is_array; -use function str_replace; use function strtoupper; use function trim; @@ -61,14 +61,13 @@ public function alignColumns(): static return $this; } - $allColumns = []; + $columnLists = []; foreach ($this->combine as $combine) { - $allColumns = [ - ...$allColumns, - ...$combine['select']->getRawState(self::COLUMNS), - ]; + $columnLists[] = $combine['select']->getRawState(self::COLUMNS); } + $allColumns = array_merge(...$columnLists); + foreach ($this->combine as $combine) { $combineColumns = $combine['select']->getRawState(self::COLUMNS); $aligned = []; @@ -177,15 +176,14 @@ protected function buildSqlString( $type = 0 === $i ? '' : strtoupper( - $combine['modifier'] - ? "{$combine['type']} {$combine['modifier']}" - : $combine['type'], + '' === $combine['modifier'] + ? $combine['type'] + : "{$combine['type']} {$combine['modifier']}", ); - $select = $this->processSubSelect($combine['select'], $platform, $driver, $parameterContainer); - $sql .= str_replace( - ['%1$s', '%2$s'], - [$type, $select], - $this->specifications[self::COMBINE], + $sql .= $this->formatSpecification( + self::COMBINE, + $type, + $this->processSubSelect($combine['select'], $platform, $driver, $parameterContainer), ); } diff --git a/src/Sql/Ddl/AlterTable.php b/src/Sql/Ddl/AlterTable.php index 1113b771..29b557db 100644 --- a/src/Sql/Ddl/AlterTable.php +++ b/src/Sql/Ddl/AlterTable.php @@ -6,7 +6,6 @@ use PhpDb\Adapter\Platform\PlatformInterface; use PhpDb\Sql\AbstractSql; -use PhpDb\Sql\ExpressionInterface; use PhpDb\Sql\Literal; use PhpDb\Sql\TableIdentifier; @@ -17,8 +16,6 @@ use function strtoupper; /** - * @api - * * @psalm-import-type Specification from AbstractSql * @psalm-type RawState = array{ * table: string|TableIdentifier, @@ -215,28 +212,25 @@ public function setTable(string|TableIdentifier $name): static /** * @return array{0: list} + * @throws \PhpDb\Sql\Exception\RuntimeException */ protected function processAddColumns(PlatformInterface $adapterPlatform): array { - return [array_map( - fn(ExpressionInterface $column): string => $this->processExpression($column, $adapterPlatform), - $this->addColumns, - )]; + return [$this->processExpressionList($this->addColumns, $adapterPlatform)]; } /** * @return array{0: list} + * @throws \PhpDb\Sql\Exception\RuntimeException */ protected function processAddConstraints(PlatformInterface $adapterPlatform): array { - return [array_map( - fn(ExpressionInterface $constraint): string => $this->processExpression($constraint, $adapterPlatform), - $this->addConstraints, - )]; + return [$this->processExpressionList($this->addConstraints, $adapterPlatform)]; } /** * @return array{0: list} + * @throws \PhpDb\Sql\Exception\RuntimeException */ protected function processChangeColumns(PlatformInterface $adapterPlatform): array { @@ -275,7 +269,10 @@ protected function processDropIndexes(PlatformInterface $adapterPlatform): array return [array_map($adapterPlatform->quoteIdentifier(...), $this->dropIndexes)]; } - /** @return string[] */ + /** + * @return string[] + * @throws \PhpDb\Sql\Exception\RuntimeException + */ protected function processTable(PlatformInterface $adapterPlatform): array { return [$this->resolveTable($this->table, $adapterPlatform)]; diff --git a/src/Sql/Ddl/CreateTable.php b/src/Sql/Ddl/CreateTable.php index 656030f2..f7656bdb 100644 --- a/src/Sql/Ddl/CreateTable.php +++ b/src/Sql/Ddl/CreateTable.php @@ -16,8 +16,6 @@ use function strtoupper; /** - * @api - * * @psalm-import-type Specification from AbstractSql * @psalm-type RawState = array{ * columns: list, @@ -151,6 +149,7 @@ public function setTemporary(string|int|bool $temporary): static /** * @return string[][]|null + * @throws \PhpDb\Sql\Exception\RuntimeException */ protected function processColumns(PlatformInterface $adapterPlatform): ?array { @@ -158,13 +157,7 @@ protected function processColumns(PlatformInterface $adapterPlatform): ?array return null; } - $sqls = []; - - foreach ($this->columns as $column) { - $sqls[] = $this->processExpression($column, $adapterPlatform); - } - - return [$sqls]; + return [$this->processExpressionList($this->columns, $adapterPlatform)]; } /** @@ -177,6 +170,7 @@ protected function processCombinedby(): ?array /** * @return string[][]|null + * @throws \PhpDb\Sql\Exception\RuntimeException */ protected function processConstraints(PlatformInterface $adapterPlatform): ?array { @@ -184,13 +178,7 @@ protected function processConstraints(PlatformInterface $adapterPlatform): ?arra return null; } - $sqls = []; - - foreach ($this->constraints as $constraint) { - $sqls[] = $this->processExpression($constraint, $adapterPlatform); - } - - return [$sqls]; + return [$this->processExpressionList($this->constraints, $adapterPlatform)]; } /** @@ -203,6 +191,7 @@ protected function processStatementEnd(): array /** * @return string[] + * @throws \PhpDb\Sql\Exception\RuntimeException */ protected function processTable(PlatformInterface $adapterPlatform): array { diff --git a/src/Sql/Ddl/DropTable.php b/src/Sql/Ddl/DropTable.php index 3a9bb62b..4956e562 100644 --- a/src/Sql/Ddl/DropTable.php +++ b/src/Sql/Ddl/DropTable.php @@ -9,8 +9,6 @@ use PhpDb\Sql\TableIdentifier; /** - * @api - * * @psalm-import-type Specification from AbstractSql */ class DropTable extends AbstractSql @@ -42,7 +40,10 @@ public function ifExists(bool $ifExists = true): static return $this; } - /** @return string[] */ + /** + * @return string[] + * @throws \PhpDb\Sql\Exception\RuntimeException + */ protected function processTable(PlatformInterface $adapterPlatform): array { return [ diff --git a/src/Sql/Delete.php b/src/Sql/Delete.php index 21def71f..60a06100 100644 --- a/src/Sql/Delete.php +++ b/src/Sql/Delete.php @@ -11,7 +11,6 @@ use PhpDb\Sql\Predicate\PredicateInterface; use function array_key_exists; -use function str_replace; use function strtolower; /** @@ -28,8 +27,6 @@ class Delete extends AbstractPreparableSql final public const string SPECIFICATION_WHERE = 'where'; - /** @#- */ - /** @var array */ protected array $specifications = [ self::SPECIFICATION_DELETE => 'DELETE FROM %1$s', @@ -41,7 +38,7 @@ class Delete extends AbstractPreparableSql protected bool $emptyWhereProtection = true; - protected ?Where $where = null; + protected ?Where $whereClause = null; /** * Constructor @@ -88,7 +85,7 @@ public function where( string $combination = Predicate\PredicateSet::OP_AND, ): static { if ($predicate instanceof Where) { - $this->where = $predicate; + $this->whereClause = $predicate; return $this; } @@ -98,37 +95,41 @@ public function where( return $this; } + /** + * @throws Exception\RuntimeException + */ protected function processDelete( PlatformInterface $platform, ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, ): string { - return str_replace( - '%1$s', + return $this->formatSpecification( + static::SPECIFICATION_DELETE, $this->resolveTable($this->unaliasTable($this->table), $platform, $driver, $parameterContainer), - $this->specifications[static::SPECIFICATION_DELETE], ); } + /** + * @throws Exception\RuntimeException + */ protected function processWhere( PlatformInterface $platform, ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, ): ?string { - if (null === $this->where || $this->where->count() === 0) { + if (null === $this->whereClause || $this->whereClause->count() === 0) { return null; } - return str_replace( - '%1$s', - $this->processExpression($this->where, $platform, $driver, $parameterContainer, 'where'), - $this->specifications[static::SPECIFICATION_WHERE], + return $this->formatSpecification( + static::SPECIFICATION_WHERE, + $this->processExpression($this->whereClause, $platform, $driver, $parameterContainer, 'where'), ); } private function getWhere(): Where { - return $this->where ??= new Where(); + return $this->whereClause ??= new Where(); } /** diff --git a/src/Sql/Exception/RuntimeException.php b/src/Sql/Exception/RuntimeException.php index bb9e184e..51ecffc7 100644 --- a/src/Sql/Exception/RuntimeException.php +++ b/src/Sql/Exception/RuntimeException.php @@ -14,6 +14,8 @@ class RuntimeException extends Exception\RuntimeException 'The number of replacements in the expression' . ' does not match the number of parameters'; + final public const string STRING_SPECIFICATION_REQUIRED = 'Specification "%s" must be a format string'; + final public const string SUBJECT_NOT_IMPLEMENTING = 'The subject does not appear to implement %s,' . ' thus calling %s has no effect'; @@ -37,6 +39,11 @@ public static function forReplacementMismatch(): self return new self(self::REPLACEMENT_MISMATCH); } + public static function forStringSpecificationRequired(string $name): self + { + return new self(sprintf(self::STRING_SPECIFICATION_REQUIRED, $name)); + } + public static function forSubjectNotImplementing(string $interface, string $method): self { return new self(sprintf(self::SUBJECT_NOT_IMPLEMENTING, $interface, $method)); diff --git a/src/Sql/Expression.php b/src/Sql/Expression.php index 5820bcb1..c63f8f8d 100644 --- a/src/Sql/Expression.php +++ b/src/Sql/Expression.php @@ -48,9 +48,9 @@ public function __construct( if (func_num_args() > 2) { /** - * @deprecated + * Passing parameters as separate arguments is deprecated * - * @todo Make notes in documentation + * @var list $parameters */ $parameters = array_slice(func_get_args(), offset: 1); } @@ -90,7 +90,7 @@ public function getExpressionData(): array if ($count !== $parametersCount) { $matches = []; preg_match_all('/:\w*/', $specification, $matches); - if (count(array_unique($matches[0])) !== $parametersCount) { + if (count(array_unique($matches[0] ?? [])) !== $parametersCount) { throw Exception\RuntimeException::forReplacementMismatch(); } } diff --git a/src/Sql/Insert.php b/src/Sql/Insert.php index d158b90f..836fdfef 100644 --- a/src/Sql/Insert.php +++ b/src/Sql/Insert.php @@ -19,10 +19,10 @@ use function implode; use function is_scalar; use function range; -use function str_replace; /** * @psalm-import-type Specification from AbstractSql + * @psalm-import-type ColumnValue from AbstractSql */ class Insert extends AbstractPreparableSql { @@ -48,9 +48,10 @@ class Insert extends AbstractPreparableSql /** @var TableIdentifier|string|array */ protected TableIdentifier|string|array $table = ''; - /** @var array */ + /** @var array */ protected array $columns = []; + /** @var Select|null */ protected array|Select|null $select = null; /** @@ -79,6 +80,12 @@ public function columns(array $columns): static /** * Get raw state + * + * @return TableIdentifier|string|array|list|array{ + * table: TableIdentifier|string|array, + * columns: list, + * values: list, + * } */ public function getRawState(?string $key = null): TableIdentifier|string|array { @@ -112,7 +119,7 @@ public function select(Select $select): static /** * Specify values to insert * - * @param array|Select $values + * @param array|Select $values * @param string $flag one of VALUES_MERGE or VALUES_SET; defaults to VALUES_SET * @throws Exception\InvalidArgumentException */ @@ -146,6 +153,9 @@ public function values(array|Select $values, string $flag = self::VALUES_SET): s return $this; } + /** + * @throws Exception\RuntimeException + */ protected function processInsert( PlatformInterface $platform, ?DriverInterface $driver = null, @@ -165,15 +175,15 @@ protected function processInsert( $isPdoDriver = $driver instanceof PdoDriverInterface; foreach ($this->columns as $column => $value) { - $columns[] = $platform->quoteIdentifier($column); - if (is_scalar($value) && $parameterContainer) { + $columns[] = $platform->quoteIdentifier((string) $column); + if (is_scalar($value) && $parameterContainer && $driver instanceof DriverInterface) { // use incremental value instead of column name for PDO // @see https://github.com/zendframework/zend-db/issues/35 if ($isPdoDriver) { $column = 'c_' . $i++; } - $values[] = $driver->formatParameterName($column); + $values[] = $driver->formatParameterName((string) $column); $parameterContainer->offsetSet($column, $value); continue; } @@ -186,17 +196,17 @@ protected function processInsert( ); } - return str_replace( - ['%1$s', '%2$s', '%3$s'], - [ - $this->resolveTable($this->unaliasTable($this->table), $platform, $driver, $parameterContainer), - implode(', ', $columns), - implode(', ', $values), - ], - $this->specifications[static::SPECIFICATION_INSERT], + return $this->formatSpecification( + static::SPECIFICATION_INSERT, + $this->resolveTable($this->unaliasTable($this->table), $platform, $driver, $parameterContainer), + implode(', ', $columns), + implode(', ', $values), ); } + /** + * @throws Exception\RuntimeException + */ protected function processSelect( PlatformInterface $platform, ?DriverInterface $driver = null, @@ -208,17 +218,16 @@ protected function processSelect( $selectSql = $this->processSubSelect($this->select, $platform, $driver, $parameterContainer); - $columns = array_map([$platform, 'quoteIdentifier'], array_keys($this->columns)); - $columns = implode(', ', $columns); - - return str_replace( - ['%1$s', '%2$s', '%3$s'], - [ - $this->resolveTable($this->unaliasTable($this->table), $platform, $driver, $parameterContainer), - $columns ? "({$columns})" : '', - $selectSql, - ], - $this->specifications[static::SPECIFICATION_SELECT], + $columns = implode(', ', array_map( + static fn(int|string $column): string => $platform->quoteIdentifier((string) $column), + array_keys($this->columns), + )); + + return $this->formatSpecification( + static::SPECIFICATION_SELECT, + $this->resolveTable($this->unaliasTable($this->table), $platform, $driver, $parameterContainer), + $columns ? "({$columns})" : '', + $selectSql, ); } @@ -237,8 +246,8 @@ private function isAssocativeArray(array $array): bool * Overloading: variable retrieval * Retrieves value by column name * + * @return ColumnValue * @throws Exception\InvalidArgumentException - * @return string */ public function __get(string $name): mixed { @@ -265,6 +274,8 @@ public function __isset(string $name) * Overloading: variable setting * * Proxies to values, using VALUES_MERGE strategy + * + * @param ColumnValue $value */ public function __set(string $name, mixed $value): void { diff --git a/src/Sql/Join.php b/src/Sql/Join.php index 9f619e01..deac1058 100644 --- a/src/Sql/Join.php +++ b/src/Sql/Join.php @@ -77,13 +77,13 @@ public function count(): int /** * Return current join specification. * - * @return JoinSpecification + * @return JoinSpecification|null Null once iteration has passed the last join */ #[Override] #[ReturnTypeWillChange] - public function current(): array + public function current(): ?array { - return $this->joins[$this->position]; + return $this->joins[$this->position] ?? null; } /** @return list */ diff --git a/src/Sql/Select.php b/src/Sql/Select.php index cbc72b69..f9307c81 100644 --- a/src/Sql/Select.php +++ b/src/Sql/Select.php @@ -5,7 +5,6 @@ namespace PhpDb\Sql; use Closure; -use Override; use PhpDb\Adapter\Driver\DriverInterface; use PhpDb\Adapter\ParameterContainer; use PhpDb\Adapter\Platform\PlatformInterface; @@ -24,7 +23,6 @@ use function key; use function method_exists; use function preg_split; -use function str_contains; use function strcasecmp; use function stripos; use function strtolower; @@ -155,9 +153,9 @@ class Select extends AbstractPreparableSql /** @var ColumnList */ protected array $columns = [self::SQL_STAR]; - protected ?Join $joins = null; + protected ?Join $joinClause = null; - protected ?Where $where = null; + protected ?Where $whereClause = null; /** @var ColumnList */ protected array $order = []; @@ -165,7 +163,7 @@ class Select extends AbstractPreparableSql /** @var list|null */ protected ?array $group = null; - protected ?Having $having = null; + protected ?Having $havingClause = null; protected string|int|null $limit = null; @@ -243,6 +241,9 @@ public function from(array|string|TableIdentifier $table): static return $this; } + /** + * @return ($key is self::COLUMNS ? ColumnList : mixed) + */ public function getRawState(?string $key = null): mixed { $rawState = [ @@ -290,7 +291,7 @@ public function having( string $combination = Predicate\PredicateSet::OP_AND, ): static { if ($predicate instanceof Having) { - $this->having = $predicate; + $this->havingClause = $predicate; return $this; } @@ -359,7 +360,7 @@ public function offset(int|string $offset): static public function order(ExpressionInterface|array|string $order): static { $order = match (true) { - is_string($order) => str_contains($order, ',') ? preg_split('#,\s+#', $order) : (array) $order, + is_string($order) => $this->splitOrderString($order), is_array($order) => $order, default => [$order], }; @@ -391,46 +392,25 @@ public function quantifier(ExpressionInterface|string $quantifier): static */ public function reset(string $part): static { - switch ($part) { - case self::TABLE: - if ($this->tableReadOnly) { - throw Exception\InvalidArgumentException::forReadOnlyConstructorState(); - } - - $this->table = null; - break; - case self::QUANTIFIER: - $this->quantifier = null; - break; - case self::COLUMNS: - $this->columns = []; - break; - case self::JOINS: - $this->joins = null; - break; - case self::WHERE: - $this->where = null; - break; - case self::GROUP: - $this->group = null; - break; - case self::HAVING: - $this->having = null; - break; - case self::LIMIT: - $this->limit = null; - break; - case self::OFFSET: - $this->offset = null; - break; - case self::ORDER: - $this->order = []; - break; - case self::COMBINE: - $this->combine = []; - break; + if (self::TABLE === $part && $this->tableReadOnly) { + throw Exception\InvalidArgumentException::forReadOnlyConstructorState(); } + match ($part) { + self::TABLE => $this->table = null, + self::QUANTIFIER => $this->quantifier = null, + self::COLUMNS => $this->columns = [], + self::JOINS => $this->joinClause = null, + self::WHERE => $this->whereClause = null, + self::GROUP => $this->group = null, + self::HAVING => $this->havingClause = null, + self::LIMIT => $this->limit = null, + self::OFFSET => $this->offset = null, + self::ORDER => $this->order = [], + self::COMBINE => $this->combine = [], + default => null, + }; + return $this; } @@ -459,7 +439,7 @@ public function where( string $combination = Predicate\PredicateSet::OP_AND, ): self { if ($predicate instanceof Where) { - $this->where = $predicate; + $this->whereClause = $predicate; return $this; } @@ -469,7 +449,10 @@ public function where( return $this; } - /** @return array{0: string, 1: string}|null */ + /** + * @return array{0: string, 1: string}|null + * @throws Exception\RuntimeException + */ protected function processCombine( PlatformInterface $platform, ?DriverInterface $driver = null, @@ -479,17 +462,23 @@ protected function processCombine( return null; } - $type = $this->combine['modifier'] - ? "{$this->combine['type']} {$this->combine['modifier']}" - : $this->combine['type']; + /** @var Combination $combine */ + $combine = $this->combine; + + $type = '' === $combine['modifier'] + ? $combine['type'] + : "{$combine['type']} {$combine['modifier']}"; return [ strtoupper($type), - $this->processSubSelect($this->combine['select'], $platform, $driver, $parameterContainer), + $this->processSubSelect($combine['select'], $platform, $driver, $parameterContainer), ]; } - /** @return array{0: list}|null */ + /** + * @return array{0: list}|null + * @throws Exception\RuntimeException + */ protected function processGroup( PlatformInterface $platform, ?DriverInterface $driver = null, @@ -516,28 +505,34 @@ protected function processGroup( return [$groups]; } - /** @return array{0: string}|null */ + /** + * @return array{0: string}|null + * @throws Exception\RuntimeException + */ protected function processHaving( PlatformInterface $platform, ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, ): ?array { - if (null === $this->having || $this->having->count() === 0) { + if (null === $this->havingClause || $this->havingClause->count() === 0) { return null; } return [ - $this->processExpression($this->having, $platform, $driver, $parameterContainer, 'having'), + $this->processExpression($this->havingClause, $platform, $driver, $parameterContainer, 'having'), ]; } - /** @return array{0: array>}|null */ + /** + * @return array{0: array>}|null + * @throws Exception\RuntimeException + */ protected function processJoins( PlatformInterface $platform, ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, ): ?array { - return $this->processJoin($this->joins, $platform, $driver, $parameterContainer); + return $this->processJoin($this->joinClause, $platform, $driver, $parameterContainer); } /** @return array{0: string}|null */ @@ -550,7 +545,7 @@ protected function processLimit( return null; } - if ($parameterContainer instanceof ParameterContainer) { + if ($parameterContainer instanceof ParameterContainer && $driver instanceof DriverInterface) { $paramPrefix = $this->processInfo['paramPrefix']; $parameterContainer->offsetSet("{$paramPrefix}limit", $this->limit, ParameterContainer::TYPE_INTEGER); return [$driver->formatParameterName("{$paramPrefix}limit")]; @@ -569,7 +564,7 @@ protected function processOffset( return null; } - if ($parameterContainer instanceof ParameterContainer) { + if ($parameterContainer instanceof ParameterContainer && $driver instanceof DriverInterface) { $paramPrefix = $this->processInfo['paramPrefix']; $parameterContainer->offsetSet("{$paramPrefix}offset", $this->offset, ParameterContainer::TYPE_INTEGER); return [$driver->formatParameterName("{$paramPrefix}offset")]; @@ -578,7 +573,10 @@ protected function processOffset( return [$platform->quoteValue((string) $this->offset)]; } - /** @return array{0: list>}|null */ + /** + * @return array{0: list>}|null + * @throws Exception\RuntimeException + */ protected function processOrder( PlatformInterface $platform, ?DriverInterface $driver = null, @@ -598,9 +596,9 @@ protected function processOrder( } if (is_int($k)) { - [$k, $v] = str_contains($v, ' ') - ? explode(' ', $v, limit: 2) - : [$v, self::ORDER_ASCENDING]; + $parts = explode(' ', $v, limit: 2); + $k = $parts[0]; + $v = $parts[1] ?? self::ORDER_ASCENDING; } $orders[] = [ @@ -618,6 +616,7 @@ protected function processOrder( * Process the select part * * @return list>> + * @throws Exception\RuntimeException */ protected function processSelect( PlatformInterface $platform, @@ -626,7 +625,7 @@ protected function processSelect( ): array { $expr = 1; - [$table, $fromTable] = $this->resolveTable($this->table, $platform, $driver, $parameterContainer); + [$table, $fromTable] = $this->resolveSelectTable($this->table, $platform, $driver, $parameterContainer); $columns = []; foreach ($this->columns as $columnIndexOrAs => $column) { if (self::SQL_STAR === $column) { @@ -658,7 +657,7 @@ protected function processSelect( foreach ($this->getJoins()->getJoins() as $join) { $joinName = is_array($join['name']) ? array_key_first($join['name']) : $join['name']; - $joinName = parent::resolveTable($joinName, $platform, $driver, $parameterContainer); + $joinName = $this->resolveTable($joinName, $platform, $driver, $parameterContainer); foreach ($join['columns'] as $jKey => $jColumn) { $jColumns = []; @@ -676,12 +675,15 @@ protected function processSelect( $parameterContainer, is_string($jKey) ? $jKey : 'column', ); - if (is_string($jKey)) { - $jColumns[] = $platform->quoteIdentifier($jKey); - } - - if (! is_string($jKey) && self::SQL_STAR !== $jColumn) { - $jColumns[] = $platform->quoteIdentifier($jColumn); + $jColumnAs = match (true) { + is_string($jKey) => $platform->quoteIdentifier($jKey), + self::SQL_STAR === $jColumn => null, + is_string($jColumn) => $platform->quoteIdentifier($jColumn), + default => 'Expression' . $expr++, + }; + + if (null !== $jColumnAs) { + $jColumns[] = $jColumnAs; } $columns[] = $jColumns; @@ -726,70 +728,83 @@ protected function processStatementStart(): ?array return null; } - /** @return array{0: string}|null */ + /** + * @return array{0: string}|null + * @throws Exception\RuntimeException + */ protected function processWhere( PlatformInterface $platform, ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, ): ?array { - if (null === $this->where || $this->where->count() === 0) { + if (null === $this->whereClause || $this->whereClause->count() === 0) { return null; } return [ - $this->processExpression($this->where, $platform, $driver, $parameterContainer, 'where'), + $this->processExpression($this->whereClause, $platform, $driver, $parameterContainer, 'where'), ]; } + private function getHaving(): Having + { + return $this->havingClause ??= new Having(); + } + + private function getJoins(): Join + { + return $this->joinClause ??= new Join(); + } + + private function getWhere(): Where + { + return $this->whereClause ??= new Where(); + } + + private function prefixFromTable(?string $fromTable, PlatformInterface $platform): string + { + return $this->prefixColumnsWithTable && null !== $fromTable && '' !== $fromTable + ? $fromTable . $platform->getIdentifierSeparator() + : ''; + } + /** * @param Select|TableReference|null $table * @return array{0: string|null, 1: string} + * @throws Exception\RuntimeException */ - #[Override] - protected function resolveTable( + private function resolveSelectTable( Select|string|array|TableIdentifier|null $table, PlatformInterface $platform, ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, ): array { - $alias = null; + if (! is_array($table)) { + $table = $this->resolveTable($table, $platform, $driver, $parameterContainer); - if (is_array($table)) { - $alias = array_key_first($table); - $table = $table[$alias]; + return [$table, $this->prefixFromTable($table, $platform)]; } - $table = parent::resolveTable($table, $platform, $driver, $parameterContainer); - - $fromTable = $table; - if ($alias) { - $fromTable = $platform->quoteIdentifier($alias); - $table = $this->renderTable($table, $fromTable); - } - - $fromTable = $this->prefixColumnsWithTable && $fromTable - ? $fromTable . $platform->getIdentifierSeparator() - : ''; + $alias = (string) array_key_first($table); + $fromTable = $platform->quoteIdentifier($alias); return [ - $table, - $fromTable, + $this->renderTable( + (string) $this->resolveTable($table[$alias] ?? null, $platform, $driver, $parameterContainer), + $fromTable, + ), + $this->prefixFromTable($fromTable, $platform), ]; } - private function getHaving(): Having - { - return $this->having ??= new Having(); - } - - private function getJoins(): Join + /** + * @return list + */ + private function splitOrderString(string $order): array { - return $this->joins ??= new Join(); - } + $parts = preg_split('#,\s+#', $order); - private function getWhere(): Where - { - return $this->where ??= new Where(); + return false === $parts ? [$order] : $parts; } /** @@ -801,15 +816,9 @@ private function getWhere(): Where */ public function __clone() { - if (null !== $this->where) { - $this->where = clone $this->where; - } - if (null !== $this->joins) { - $this->joins = clone $this->joins; - } - if (null !== $this->having) { - $this->having = clone $this->having; - } + $this->whereClause = null === $this->whereClause ? null : clone $this->whereClause; + $this->joinClause = null === $this->joinClause ? null : clone $this->joinClause; + $this->havingClause = null === $this->havingClause ? null : clone $this->havingClause; } /** diff --git a/src/Sql/Sql.php b/src/Sql/Sql.php index a0de5c0e..7871db93 100644 --- a/src/Sql/Sql.php +++ b/src/Sql/Sql.php @@ -28,6 +28,7 @@ public function __construct( /** * @throws Exception\InvalidArgumentException + * @throws Exception\RuntimeException */ public function buildSqlString(SqlInterface $sqlObject, ?AdapterInterface $adapter = null): string { @@ -51,12 +52,12 @@ public function delete(string|TableIdentifier|null $table = null): Delete return new Delete($table ?? $this->table); } - public function getAdapter(): ?AdapterInterface + public function getAdapter(): AdapterInterface { return $this->adapter; } - public function getSqlPlatform(): ?Platform\PlatformDecoratorInterface + public function getSqlPlatform(): Platform\PlatformDecoratorInterface { return $this->sqlPlatform; } @@ -81,6 +82,9 @@ public function insert(string|TableIdentifier|null $table = null): Insert return new Insert($table ?? $this->table); } + /** + * @throws Exception\RuntimeException + */ public function prepareStatementForSqlObject( PreparableSqlInterface $sqlObject, ?StatementInterface $statement = null, diff --git a/src/Sql/Update.php b/src/Sql/Update.php index d2c692cb..2282fb38 100644 --- a/src/Sql/Update.php +++ b/src/Sql/Update.php @@ -17,12 +17,12 @@ use function is_numeric; use function is_scalar; use function is_string; -use function str_replace; use function strtolower; /** * @property Where $where * @psalm-import-type Specification from AbstractSql + * @psalm-import-type ColumnValue from AbstractSql * @psalm-import-type PredicateValue from Predicate\PredicateSet * @psalm-import-type JoinName from Join */ @@ -62,11 +62,12 @@ class Update extends AbstractPreparableSql protected bool $emptyWhereProtection = true; + /** @var PriorityList|null */ protected ?PriorityList $set = null; - protected ?Where $where = null; + protected ?Where $whereClause = null; - protected ?Join $joins = null; + protected ?Join $joinClause = null; /** * Constructor @@ -109,7 +110,7 @@ public function join(array|string|TableIdentifier $name, string $on, string $typ /** * Set key/value pairs to update * - * @param array $values Associative array of key values + * @param array $values Associative array of key values * @param string|int $flag One of the VALUES_* constants, or a numeric priority * @throws Exception\InvalidArgumentException */ @@ -120,7 +121,7 @@ public function set(array $values, string|int $flag = self::VALUES_SET): static $set->clear(); } - $priority = is_numeric($flag) ? $flag : 0; + $priority = is_numeric($flag) ? (int) $flag : 0; foreach ($values as $k => $v) { if (! is_string($k)) { throw Exception\InvalidArgumentException::forNonStringValueKey(); @@ -155,7 +156,7 @@ public function where( string $combination = Predicate\PredicateSet::OP_AND, ): static { if ($predicate instanceof Where) { - $this->where = $predicate; + $this->whereClause = $predicate; return $this; } @@ -165,15 +166,21 @@ public function where( return $this; } - /** @return string[][][]|null */ + /** + * @return string[][][]|null + * @throws Exception\RuntimeException + */ protected function processJoins( PlatformInterface $platform, ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, ): ?array { - return $this->processJoin($this->joins, $platform, $driver, $parameterContainer); + return $this->processJoin($this->joinClause, $platform, $driver, $parameterContainer); } + /** + * @throws Exception\RuntimeException + */ protected function processSet( PlatformInterface $platform, ?DriverInterface $driver = null, @@ -196,7 +203,7 @@ protected function processSet( 'column', ); $prefix .= ' = '; - if (is_scalar($value) && $parameterContainer) { + if (is_scalar($value) && $parameterContainer && $driver instanceof DriverInterface) { // use incremental value instead of column name for PDO // @see https://github.com/zendframework/zend-db/issues/35 if ($isPdoDriver) { @@ -217,46 +224,52 @@ protected function processSet( ); } - return str_replace( - '%1$s', + return $this->formatSpecification( + static::SPECIFICATION_SET, implode(', ', $setSql), - $this->specifications[static::SPECIFICATION_SET], ); } + /** + * @throws Exception\RuntimeException + */ protected function processUpdate( PlatformInterface $platform, ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, ): string { - return str_replace( - '%1$s', + return $this->formatSpecification( + static::SPECIFICATION_UPDATE, $this->resolveTable($this->unaliasTable($this->table), $platform, $driver, $parameterContainer), - $this->specifications[static::SPECIFICATION_UPDATE], ); } + /** + * @throws Exception\RuntimeException + */ protected function processWhere( PlatformInterface $platform, ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, ): ?string { - if (null === $this->where || $this->where->count() === 0) { + if (null === $this->whereClause || $this->whereClause->count() === 0) { return null; } - return str_replace( - '%1$s', - $this->processExpression($this->where, $platform, $driver, $parameterContainer, 'where'), - $this->specifications[static::SPECIFICATION_WHERE], + return $this->formatSpecification( + static::SPECIFICATION_WHERE, + $this->processExpression($this->whereClause, $platform, $driver, $parameterContainer, 'where'), ); } private function getJoins(): Join { - return $this->joins ??= new Join(); + return $this->joinClause ??= new Join(); } + /** + * @return PriorityList + */ private function getSet(): PriorityList { if (null === $this->set) { @@ -268,7 +281,7 @@ private function getSet(): PriorityList private function getWhere(): Where { - return $this->where ??= new Where(); + return $this->whereClause ??= new Where(); } /** @@ -280,15 +293,9 @@ private function getWhere(): Where */ public function __clone() { - if (null !== $this->where) { - $this->where = clone $this->where; - } - if (null !== $this->joins) { - $this->joins = clone $this->joins; - } - if (null !== $this->set) { - $this->set = clone $this->set; - } + $this->whereClause = null === $this->whereClause ? null : clone $this->whereClause; + $this->joinClause = null === $this->joinClause ? null : clone $this->joinClause; + $this->set = null === $this->set ? null : clone $this->set; } /** diff --git a/test/unit/Sql/AbstractSqlTest.php b/test/unit/Sql/AbstractSqlTest.php index fdfcfe01..4908dd50 100644 --- a/test/unit/Sql/AbstractSqlTest.php +++ b/test/unit/Sql/AbstractSqlTest.php @@ -15,6 +15,7 @@ use PhpDb\Sql\Argument\Identifier; use PhpDb\Sql\ArgumentInterface; use PhpDb\Sql\ArgumentType; +use PhpDb\Sql\Delete; use PhpDb\Sql\Exception\InvalidArgumentException; use PhpDb\Sql\Exception\RuntimeException; use PhpDb\Sql\Expression; @@ -23,6 +24,7 @@ use PhpDb\Sql\Predicate; use PhpDb\Sql\Select; use PhpDb\Sql\TableIdentifier; +use PhpDb\Sql\Update; use PhpDbTest\TestAsset\SelectDecorator; use PhpDbTest\TestAsset\TrustingSql92Platform; use PHPUnit\Framework\Attributes\CoversMethod; @@ -37,6 +39,7 @@ use ReflectionException; use ReflectionMethod; +use function array_values; use function count; use function current; use function key; @@ -62,6 +65,12 @@ #[CoversMethod(AbstractSql::class, 'resolveTable')] #[CoversMethod(AbstractSql::class, 'localizeVariables')] #[CoversMethod(AbstractSql::class, 'quoteJoinTableIdentifier')] +#[CoversMethod(AbstractSql::class, 'formatSpecification')] +#[CoversMethod(AbstractSql::class, 'flattenParameterList')] +#[CoversMethod(AbstractSql::class, 'formatParameterList')] +#[CoversMethod(AbstractSql::class, 'matchSpecification')] +#[CoversMethod(AbstractSql::class, 'renderParameter')] +#[CoversMethod(AbstractSql::class, 'processExpressionList')] final class AbstractSqlTest extends TestCase { protected AbstractSql&MockObject $abstractSql; @@ -117,6 +126,26 @@ public function createSqlFromSpecNonCombinedByThrowsOnUnsupportedCount(): void $method->invoke($this->abstractSql, $spec, $params); } + /** + * @throws ReflectionException + */ + #[Test] + public function createSqlFromSpecRejectsListForSlotWithoutSpecification(): void + { + $method = new ReflectionMethod($this->abstractSql, 'createSqlFromSpecificationAndParameters'); + + $spec = [ + 'SELECT %1$s FROM %2$s' => [ + [1 => '%1$s', 'combinedby' => ', '], + null, + ], + ]; + + self::expectException(RuntimeException::class); + self::expectExceptionMessage(sprintf(RuntimeException::UNSUPPORTED_PARAMETER_COUNT_OF, 2)); + $method->invoke($this->abstractSql, $spec, [['col1'], ['a', 'b']]); + } + /** * @throws ReflectionException */ @@ -179,6 +208,19 @@ public function createSqlFromSpecWithNonCombinedByParam(): void static::assertSame('FROM my_table', $result); } + /** + * @throws ReflectionException + */ + #[Test] + public function createSqlFromStringSpecRejectsListParameter(): void + { + $method = new ReflectionMethod($this->abstractSql, 'createSqlFromSpecificationAndParameters'); + + self::expectException(RuntimeException::class); + self::expectExceptionMessage(sprintf(RuntimeException::UNSUPPORTED_PARAMETER_COUNT_OF, 2)); + $method->invoke($this->abstractSql, 'LIMIT %1$s', [['a', 'b']]); + } + #[Test] public function flattenExpressionValuesViaInPredicate(): void { @@ -215,6 +257,25 @@ public function flattenExpressionValuesViaInPredicateWithParameterContainer(): v static::assertSame(3, $parameterContainer->count()); } + #[Test] + public function formatSpecificationThrowsForSpecificationThatIsNotAString(): void + { + $delete = new class('foo') extends Delete { + /** @var array|null>>|string> */ + protected array $specifications = [ + self::SPECIFICATION_DELETE => ['DELETE FROM %1$s' => [null]], + self::SPECIFICATION_WHERE => 'WHERE %1$s', + ]; + }; + + self::expectException(RuntimeException::class); + self::expectExceptionMessage(sprintf( + RuntimeException::STRING_SPECIFICATION_REQUIRED, + Delete::SPECIFICATION_DELETE, + )); + $delete->getSqlString(new TrustingSql92Platform()); + } + #[Test] public function localizeVariablesCopiesSubjectProperties(): void { @@ -229,6 +290,63 @@ public function localizeVariablesCopiesSubjectProperties(): void static::assertStringContainsString('"id"', $sql); } + /** + * @throws ReflectionException + */ + #[Test] + public function processExpressionBindsNullParameterValue(): void + { + $method = new ReflectionMethod($this->abstractSql, 'processExpression'); + $container = new ParameterContainer(); + + $sql = $method->invoke( + $this->abstractSql, + new Expression('COALESCE(?, 1)', [null]), + new TrustingSql92Platform(), + $this->mockDriver, + $container, + ); + + static::assertMatchesRegularExpression('/^COALESCE\(:expr\d+Param1, 1\)$/', (string) $sql); + static::assertSame([null], array_values($container->getNamedArray())); + } + + /** + * @throws ReflectionException + */ + #[Test] + public function processExpressionListRendersEachExpression(): void + { + $method = new ReflectionMethod($this->abstractSql, 'processExpressionList'); + + $sqls = $method->invoke( + $this->abstractSql, + ['first' => new Expression('a'), 'second' => new Expression('b')], + new TrustingSql92Platform(), + ); + + static::assertSame(['a', 'b'], $sqls); + } + + /** + * @throws ReflectionException + */ + #[Test] + public function processExpressionQuotesValueWhenContainerHasNoDriver(): void + { + $method = new ReflectionMethod($this->abstractSql, 'processExpression'); + + $sql = $method->invoke( + $this->abstractSql, + new Expression('x = ?', ['a']), + new TrustingSql92Platform(), + null, + new ParameterContainer(), + ); + + static::assertSame("x = 'a'", $sql); + } + #[Test] public function processExpressionThrowsOnUnknownArgumentType(): void { @@ -656,6 +774,32 @@ public function renderTableWithAlias(): void static::assertSame('"foo" AS "f"', $result); } + /** + * @throws ReflectionException + */ + #[Test] + public function resolveColumnValuePrefixesIdentifierWithTable(): void + { + $method = new ReflectionMethod($this->abstractSql, 'resolveColumnValue'); + + static::assertSame( + '"t"."col"', + $method->invoke( + $this->abstractSql, + ['column' => 'col', 'fromTable' => '"t".', 'isIdentifier' => true], + new TrustingSql92Platform(), + ), + ); + } + + #[Test] + public function resolveColumnValueQuotesFloatValue(): void + { + $update = (new Update('t'))->set(['a' => 1.5]); + + static::assertSame('UPDATE "t" SET "a" = \'1.5\'', $update->getSqlString(new TrustingSql92Platform())); + } + /** * @throws ReflectionException */ diff --git a/test/unit/Sql/Exception/RuntimeExceptionTest.php b/test/unit/Sql/Exception/RuntimeExceptionTest.php index 01a8996d..57f965b2 100644 --- a/test/unit/Sql/Exception/RuntimeExceptionTest.php +++ b/test/unit/Sql/Exception/RuntimeExceptionTest.php @@ -17,6 +17,7 @@ #[Group('unit')] #[CoversMethod(RuntimeException::class, 'forReplacementMismatch')] +#[CoversMethod(RuntimeException::class, 'forStringSpecificationRequired')] #[CoversMethod(RuntimeException::class, 'forSubjectNotImplementing')] #[CoversMethod(RuntimeException::class, 'forSubjectNotPreparableSqlInterface')] #[CoversMethod(RuntimeException::class, 'forSubjectNotSqlInterface')] @@ -33,6 +34,11 @@ public static function namedConstructorProvider(): array [], RuntimeException::REPLACEMENT_MISMATCH, ], + 'string specification required' => [ + 'forStringSpecificationRequired', + ['delete'], + RuntimeException::STRING_SPECIFICATION_REQUIRED, + ], 'subject not implementing' => [ 'forSubjectNotImplementing', [SqlInterface::class, 'getSqlString()'], diff --git a/test/unit/Sql/InsertTest.php b/test/unit/Sql/InsertTest.php index 905afe63..4d6b8d42 100644 --- a/test/unit/Sql/InsertTest.php +++ b/test/unit/Sql/InsertTest.php @@ -364,6 +364,17 @@ public function processInsertWithPdoDriverUsesDriverColumnQuoting(): void static::assertStringContainsString(':c_1', $sql); } + #[Test] + public function quotesFloatValue(): void + { + $insert = (new Insert('t'))->values(['a' => 1.5]); + + static::assertSame( + 'INSERT INTO "t" ("a") VALUES (\'1.5\')', + $insert->getSqlString(new TrustingSql92Platform()), + ); + } + #[Test] #[CoversNothing] public function specificationconstantsCouldBeOverridedByExtensionInGetSqlString(): void diff --git a/test/unit/Sql/JoinTest.php b/test/unit/Sql/JoinTest.php index 9e004491..a485551f 100644 --- a/test/unit/Sql/JoinTest.php +++ b/test/unit/Sql/JoinTest.php @@ -55,6 +55,16 @@ public function countsJoinedTables(): void static::assertCount($join->count(), $join->getJoins()); } + #[Test] + public function currentReturnsNullPastTheLastJoin(): void + { + $join = new Join(); + $join->join('foo', 'a = b'); + $join->next(); + + static::assertNull($join->current()); + } + #[Test] public function currentReturnsTheCurrentJoinSpecification(): void { diff --git a/test/unit/Sql/SelectTest.php b/test/unit/Sql/SelectTest.php index eeabdc93..0daf0ce8 100644 --- a/test/unit/Sql/SelectTest.php +++ b/test/unit/Sql/SelectTest.php @@ -36,6 +36,7 @@ use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; use ReflectionException; +use ReflectionMethod; use ReflectionObject; use TypeError; @@ -72,7 +73,12 @@ #[CoversMethod(Select::class, 'processCombine')] #[CoversMethod(Select::class, 'processStatementStart')] #[CoversMethod(Select::class, 'processStatementEnd')] -#[CoversMethod(Select::class, 'resolveTable')] +#[CoversMethod(Select::class, 'resolveSelectTable')] +#[CoversMethod(Select::class, 'splitOrderString')] +#[CoversMethod(Select::class, 'prefixFromTable')] +#[CoversMethod(Select::class, 'getWhere')] +#[CoversMethod(Select::class, 'getHaving')] +#[CoversMethod(Select::class, 'getJoins')] final class SelectTest extends TestCase { use AdapterTestTrait; @@ -1214,6 +1220,17 @@ public function join(): void static::assertSame('bar', $joins2->getJoins()[1]['name']); } + #[Test] + public function joinAliasesUnkeyedExpressionColumn(): void + { + $select = (new Select('a'))->join('b', 'a.id = b.id', [new Expression('COUNT(*)')]); + + static::assertSame( + 'SELECT "a".*, COUNT(*) AS Expression1 FROM "a" INNER JOIN "b" ON "a"."id" = "b"."id"', + $select->getSqlString(new TrustingSql92Platform()), + ); + } + #[Test] #[TestDox(': unit test: test limit()')] public function limit(): void @@ -1248,6 +1265,18 @@ public function limitExceptionOnInvalidParameter(): void $select->limit('foobar'); } + #[Test] + public function limitIsQuotedWhenContainerHasNoDriver(): void + { + $select = (new Select('a'))->limit(5); + $method = new ReflectionMethod($select, 'processLimit'); + + static::assertSame( + ["'5'"], + $method->invoke($select, new TrustingSql92Platform(), null, new ParameterContainer()), + ); + } + #[Test] #[TestDox('unit test: Test __get() returns expected objects magically')] public function magicAccessor(): void @@ -1264,6 +1293,17 @@ public function magicGetJoinsReturnsJoinInstance(): void static::assertInstanceOf(Join::class, $select->joins); } + #[Test] + public function numbersEachUnkeyedJoinExpression(): void + { + $select = (new Select('a'))->join('b', 'a.id = b.id', [new Expression('MIN(x)'), new Expression('MAX(x)')]); + + static::assertSame( + 'SELECT "a".*, MIN(x) AS Expression1, MAX(x) AS Expression2 FROM "a" INNER JOIN "b" ON "a"."id" = "b"."id"', + $select->getSqlString(new TrustingSql92Platform()), + ); + } + #[Test] #[TestDox(': unit test: test offset()')] public function offset(): void @@ -1357,6 +1397,17 @@ public function orderCorrectlySplitsParameter(): void ); } + #[Test] + public function orderSplitsCommaSeparatedString(): void + { + $select = (new Select('a'))->order('x DESC, y'); + + static::assertSame( + 'SELECT "a".* FROM "a" ORDER BY "x" DESC, "y" ASC', + $select->getSqlString(new TrustingSql92Platform()), + ); + } + #[Test] public function orderWithAssociativeArray(): void { @@ -1497,6 +1548,24 @@ public function quantifierParameterExpressionInterface(): void ); } + #[Test] + public function repeatedClauseCallsAccumulate(): void + { + $select = (new Select('a'))->join('b', 'a.id = b.id', []) + ->join('c', 'a.id = c.id', []) + ->where('x = 1') + ->where('y = 2') + ->group('x') + ->having('x > 1') + ->having('y > 2'); + + static::assertSame( + 'SELECT "a".* FROM "a" INNER JOIN "b" ON "a"."id" = "b"."id" INNER JOIN "c" ON "a"."id" = "c"."id"' + . ' WHERE x = 1 AND y = 2 GROUP BY "x" HAVING x > 1 AND y > 2', + $select->getSqlString(new TrustingSql92Platform()), + ); + } + #[Test] #[TestDox('unit test: Test reset() resets internal stat of Select object, based on input')] public function reset(): void diff --git a/test/unit/Sql/UpdateTest.php b/test/unit/Sql/UpdateTest.php index 17182db4..d608d871 100644 --- a/test/unit/Sql/UpdateTest.php +++ b/test/unit/Sql/UpdateTest.php @@ -59,6 +59,7 @@ #[CoversMethod(Update::class, 'processWhere')] #[CoversMethod(Update::class, 'processJoins')] #[CoversMethod(AbstractPreparableSql::class, 'unaliasTable')] +#[CoversMethod(Update::class, 'getJoins')] final class UpdateTest extends TestCase { use AdapterTestTrait; @@ -337,6 +338,14 @@ public function joinWithTableIdentifier(): void static::assertStringContainsString('JOIN "schema"."bar"', $sql); } + #[Test] + public function numericFlagSetsPriorityAboveDefault(): void + { + $update = (new Update('t'))->set(['a' => new Expression('1')])->set(['b' => new Expression('2')], 1); + + static::assertSame('UPDATE "t" SET "b" = 2, "a" = 1', $update->getSqlString(new TrustingSql92Platform())); + } + #[Test] #[Group('Laminas-240')] public function passingMultipleKeyValueInWhereClause(): void @@ -420,6 +429,19 @@ public function processSetWithPdoDriverUsesDriverColumnQuoting(): void static::assertStringContainsString(':c_1', $sql); } + #[Test] + public function repeatedJoinCallsAccumulate(): void + { + $update = (new Update('a'))->set(['x' => new Expression('1')]) + ->join('b', 'a.id = b.id') + ->join('c', 'a.id = c.id'); + + static::assertSame( + 'UPDATE "a" INNER JOIN "b" ON "a"."id" = "b"."id" INNER JOIN "c" ON "a"."id" = "c"."id" SET "x" = 1', + $update->getSqlString(new TrustingSql92Platform()), + ); + } + #[Test] public function set(): void { From 69d0c73aebfb86241cb32d2681e71a5685a99e0b Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 12 Oct 2026 10:24:53 +1100 Subject: [PATCH 2/7] Tag the Sql statement classes @api and clear DML from the baselines Every top-level Sql class, interface and enum is marked @api rather than final, as in Predicate and Ddl. Select, Insert, Update and AbstractSql join the too-many-methods exclusions, and Select the too-many-properties exclusion: the builders carry one method or property per clause, and AbstractSql one hook per clause it renders. ArgumentType gets a scoped analyzer ignore for invalid-enum-case-value, which rejects ::class constants as values of a string-backed enum. Seventeen DML findings stay baselined: - class-level cyclomatic-complexity and kan-defect on AbstractSql, Select, Insert, Update and Combine, which would need the statement classes split up; - halstead on Select::reset(), an eleven-way dispatcher; - excessive-parameter-list on processExpressionOrSelect(), a protected signature; - string-member-selector and mixed-assignment in buildSqlString() and localizeVariables(), which dispatch dynamically by design; - missing-return-type on __clone(), __isset() and __unset(), left untyped so subclass overrides stay compatible. The typed predicate and column signatures move thirteen findings onto the TableGateway and RowGateway callers, for the gateway pass. --- analyzer-baseline.toml | 1140 +++++----------------------- lint-baseline.toml | 50 +- mago.toml | 19 +- src/Sql/AbstractExpression.php | 3 + src/Sql/AbstractPreparableSql.php | 3 + src/Sql/AbstractSql.php | 2 + src/Sql/Argument.php | 2 + src/Sql/ArgumentInterface.php | 3 + src/Sql/ArgumentType.php | 2 + src/Sql/Combine.php | 2 + src/Sql/Ddl/AlterTable.php | 2 + src/Sql/Ddl/CreateTable.php | 2 + src/Sql/Ddl/DropTable.php | 2 + src/Sql/Delete.php | 2 + src/Sql/Expression.php | 2 + src/Sql/ExpressionInterface.php | 3 + src/Sql/Having.php | 3 + src/Sql/Insert.php | 2 + src/Sql/InsertIgnore.php | 2 + src/Sql/Join.php | 2 + src/Sql/Literal.php | 3 + src/Sql/PreparableSqlInterface.php | 3 + src/Sql/Select.php | 2 + src/Sql/Sql.php | 3 + src/Sql/SqlInterface.php | 3 + src/Sql/Update.php | 2 + src/Sql/Where.php | 3 + 27 files changed, 270 insertions(+), 997 deletions(-) diff --git a/analyzer-baseline.toml b/analyzer-baseline.toml index d8284b60..39ab6cfa 100644 --- a/analyzer-baseline.toml +++ b/analyzer-baseline.toml @@ -1512,12 +1512,24 @@ code = "less-specific-nested-argument-type" message = 'Argument type mismatch for argument #1 of `PhpDb\Sql\Delete::where`: expected `(closure(...mixed): mixed)|PhpDb\Sql\Predicate\PredicateInterface|PhpDb\Sql\Where|array|null|scalar>|string`, but provided type `array` is less specific.' count = 1 +[[issues]] +file = "src/RowGateway/AbstractRowGateway.php" +code = "less-specific-nested-argument-type" +message = 'Argument type mismatch for argument #1 of `PhpDb\Sql\Insert::values`: expected `PhpDb\Sql\Select|array`, but provided type `array` is less specific.' +count = 1 + [[issues]] file = "src/RowGateway/AbstractRowGateway.php" code = "less-specific-nested-argument-type" message = 'Argument type mismatch for argument #1 of `PhpDb\Sql\Select::where`: expected `(closure(...mixed): mixed)|PhpDb\Sql\Predicate\PredicateInterface|array|null|scalar>|string`, but provided type `array` is less specific.' count = 1 +[[issues]] +file = "src/RowGateway/AbstractRowGateway.php" +code = "less-specific-nested-argument-type" +message = 'Argument type mismatch for argument #1 of `PhpDb\Sql\Update::set`: expected `array`, but provided type `array` is less specific.' +count = 1 + [[issues]] file = "src/RowGateway/AbstractRowGateway.php" code = "less-specific-nested-argument-type" @@ -1629,1045 +1641,229 @@ count = 1 [[issues]] file = "src/RowGateway/AbstractRowGateway.php" code = "unhandled-thrown-type" -message = 'Potentially unhandled exception `PhpDb\RowGateway\Exception\RuntimeException` in `PhpDb\RowGateway\AbstractRowGateway::populate`.' -count = 1 - -[[issues]] -file = "src/RowGateway/AbstractRowGateway.php" -code = "unhandled-thrown-type" -message = 'Potentially unhandled exception `PhpDb\RowGateway\Exception\RuntimeException` in `PhpDb\RowGateway\AbstractRowGateway::save`.' -count = 1 - -[[issues]] -file = "src/RowGateway/Exception/InvalidArgumentException.php" -code = "class-must-be-final" -message = 'Class `PhpDb\RowGateway\Exception\InvalidArgumentException` should be declared `final`.' -count = 1 - -[[issues]] -file = "src/RowGateway/Exception/RuntimeException.php" -code = "class-must-be-final" -message = 'Class `PhpDb\RowGateway\Exception\RuntimeException` should be declared `final`.' -count = 1 - -[[issues]] -file = "src/RowGateway/Feature/FeatureSet.php" -code = "unused-property" -message = "Property `$magicSpecifications` is never used." -count = 1 - -[[issues]] -file = "src/RowGateway/RowGateway.php" -code = "class-must-be-final" -message = 'Class `PhpDb\RowGateway\RowGateway` should be declared `final`.' -count = 1 - -[[issues]] -file = "src/RowGateway/RowGateway.php" -code = "imprecise-type" -message = "Type `array` in parameter `$primaryKeyColumn` is imprecise, equivalent to `array`." -count = 1 - -[[issues]] -file = "src/RowGateway/RowGatewayInterface.php" -code = "missing-api-or-internal" -message = 'Interface `PhpDb\RowGateway\RowGatewayInterface` is missing an `@api` or `@internal` annotation.' -count = 1 - -[[issues]] -file = "src/Sql/AbstractExpression.php" -code = "missing-api-or-internal" -message = 'Abstract class `PhpDb\Sql\AbstractExpression` is missing an `@api` or `@internal` annotation.' -count = 1 - -[[issues]] -file = "src/Sql/AbstractPreparableSql.php" -code = "missing-api-or-internal" -message = 'Abstract class `PhpDb\Sql\AbstractPreparableSql` is missing an `@api` or `@internal` annotation.' -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "imprecise-type" -message = "Type `array` in return type of `resolveTable` is imprecise, equivalent to `array`." -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "less-specific-nested-argument-type" -message = "Argument type mismatch for argument #2 of `vsprintf`: expected `array`, but provided type `array|list{mixed}` is less specific." -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "missing-api-or-internal" -message = 'Abstract class `PhpDb\Sql\AbstractSql` is missing an `@api` or `@internal` annotation.' -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "mixed-assignment" -message = "Assigning `mixed` type to a variable may lead to unexpected behavior." -count = 2 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "overly-wide-return-type" -message = "Declared return type `array|null|string` for `resolvetable` has unused branches: `array`." -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "overly-wide-return-type" -message = "Declared return type `null|string` for `processexpressionparametername` has unused branches: `null`." -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "possibly-invalid-argument" -message = 'Possible argument type mismatch for argument #1 of `PhpDb\Adapter\Platform\PlatformInterface::quoteIdentifierInFragment`: expected `string`, but possibly received `bool|int|string`.' -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "possibly-invalid-argument" -message = 'Possible argument type mismatch for argument #1 of `PhpDb\Adapter\Platform\PlatformInterface::quoteIdentifier`: expected `string`, but possibly received `non-empty-array|null|string`.' -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "possibly-invalid-argument" -message = "Possible argument type mismatch for argument #1 of `count`: expected `Countable|array`, but possibly received `array|string>|string`." -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "possibly-invalid-argument" -message = "Possible argument type mismatch for argument #2 of `vsprintf`: expected `array`, but possibly received `array|string>|string>`." -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "possibly-invalid-argument" -message = "Possible argument type mismatch for argument #2 of `vsprintf`: expected `array`, but possibly received `array|string>|string`." -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "possibly-invalid-argument" -message = "Possible argument type mismatch for argument #2 of `vsprintf`: expected `array`, but possibly received `list|string>|string>`." -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "possibly-invalid-iterator" -message = "The expression provided to `foreach` (type `array|string>|string`) might not be iterable at runtime." -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "possibly-null-argument" -message = "Argument #1 of function `get_object_vars` is possibly `null`, but parameter type `object` does not accept it." -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "possibly-null-argument" -message = 'Argument #1 of method `PhpDb\Adapter\Platform\PlatformInterface::quoteIdentifier` is possibly `null`, but parameter type `string` does not accept it.' -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "possibly-null-argument" -message = 'Argument #1 of method `PhpDb\Sql\AbstractSql::processExpressionParameterName` is possibly `null`, but parameter type `bool|float|int|string` does not accept it.' -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "possibly-null-argument" -message = 'Argument #4 of method `PhpDb\Sql\AbstractSql::processExpressionParameterName` is possibly `null`, but parameter type `PhpDb\Adapter\Driver\DriverInterface` does not accept it.' -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "possibly-undefined-string-array-index" -message = 'Possibly undefined array key `string` accessed on `non-empty-array`.' -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "redundant-cast" -message = "Redundant cast to `(string)`: the expression already has this type." -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "string-member-selector" -message = "This member selector uses a non-literal string type (`non-empty-string`); its specific value cannot be statically determined." -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "string-member-selector" -message = "This member selector uses a non-literal string type (`string`); its specific value cannot be statically determined." -count = 1 - -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "unhandled-thrown-type" -message = 'Potentially unhandled exception `PhpDb\Sql\Exception\RuntimeException` in `PhpDb\Sql\AbstractSql::buildSqlString`.' -count = 1 - -[[issues]] -file = "src/Sql/ArgumentInterface.php" -code = "missing-api-or-internal" -message = 'Interface `PhpDb\Sql\ArgumentInterface` is missing an `@api` or `@internal` annotation.' -count = 1 - -[[issues]] -file = "src/Sql/ArgumentType.php" -code = "invalid-enum-case-value" -message = '''Invalid case value for `PhpDb\Sql\ArgumentType::Identifier`. Expected `string`, but got `class-string('PhpDb\Sql\Argument\Identifier')`.''' -count = 1 - -[[issues]] -file = "src/Sql/ArgumentType.php" -code = "invalid-enum-case-value" -message = '''Invalid case value for `PhpDb\Sql\ArgumentType::Identifiers`. Expected `string`, but got `class-string('PhpDb\Sql\Argument\Identifiers')`.''' -count = 1 - -[[issues]] -file = "src/Sql/ArgumentType.php" -code = "invalid-enum-case-value" -message = '''Invalid case value for `PhpDb\Sql\ArgumentType::Literal`. Expected `string`, but got `class-string('PhpDb\Sql\Argument\Literal')`.''' -count = 1 - -[[issues]] -file = "src/Sql/ArgumentType.php" -code = "invalid-enum-case-value" -message = '''Invalid case value for `PhpDb\Sql\ArgumentType::Select`. Expected `string`, but got `class-string('PhpDb\Sql\Argument\Select')`.''' -count = 1 - -[[issues]] -file = "src/Sql/ArgumentType.php" -code = "invalid-enum-case-value" -message = '''Invalid case value for `PhpDb\Sql\ArgumentType::Value`. Expected `string`, but got `class-string('PhpDb\Sql\Argument\Value')`.''' -count = 1 - -[[issues]] -file = "src/Sql/ArgumentType.php" -code = "invalid-enum-case-value" -message = '''Invalid case value for `PhpDb\Sql\ArgumentType::Values`. Expected `string`, but got `class-string('PhpDb\Sql\Argument\Values')`.''' -count = 1 - -[[issues]] -file = "src/Sql/Combine.php" -code = "array-to-string-conversion" -message = "Potential array in right operand of string concatenation." -count = 1 - -[[issues]] -file = "src/Sql/Combine.php" -code = "class-must-be-final" -message = 'Class `PhpDb\Sql\Combine` should be declared `final`.' -count = 1 - -[[issues]] -file = "src/Sql/Combine.php" -code = "invalid-array-element" -message = "Cannot use spread operator on non-iterable type `mixed`." -count = 1 - -[[issues]] -file = "src/Sql/Combine.php" -code = "less-specific-nested-argument-type" -message = 'Argument type mismatch for argument #1 of `PhpDb\Sql\Select::columns`: expected `array`, but provided type `array` is less specific.' -count = 1 - -[[issues]] -file = "src/Sql/Combine.php" -code = "mixed-assignment" -message = "Assigning `mixed` type to a variable may lead to unexpected behavior." -count = 1 - -[[issues]] -file = "src/Sql/Combine.php" -code = "possibly-invalid-argument" -message = "Possible argument type mismatch for argument #3 of `str_replace`: expected `array|string`, but possibly received `array|null>>|null|string`." -count = 1 - -[[issues]] -file = "src/Sql/Combine.php" -code = "possibly-null-argument" -message = "Argument #3 of function `str_replace` is possibly `null`, but parameter type `array|string` does not accept it." -count = 1 - -[[issues]] -file = "src/Sql/Combine.php" -code = "possibly-undefined-string-array-index" -message = "Possibly undefined array key `string('combine')` accessed on `array|null>>|string>`." -count = 1 - -[[issues]] -file = "src/Sql/Ddl/AlterTable.php" -code = "invalid-return-statement" -message = 'Invalid return type for function `PhpDb\Sql\Ddl\AlterTable::processTable`: expected `array`, but found `list{array|null|string}`.' -count = 1 - -[[issues]] -file = "src/Sql/Ddl/CreateTable.php" -code = "invalid-return-statement" -message = '''Invalid return type for function `PhpDb\Sql\Ddl\CreateTable::processTable`: expected `array`, but found `list{string('')|string('TEMPORARY '), string('')|string('IF NOT EXISTS '), array|null|string}`.''' -count = 1 - -[[issues]] -file = "src/Sql/Ddl/DropTable.php" -code = "invalid-return-statement" -message = '''Invalid return type for function `PhpDb\Sql\Ddl\DropTable::processTable`: expected `array`, but found `list{string('')|string('IF EXISTS '), array|null|string}`.''' -count = 1 - -[[issues]] -file = "src/Sql/Delete.php" -code = "class-must-be-final" -message = 'Class `PhpDb\Sql\Delete` should be declared `final`.' -count = 1 - -[[issues]] -file = "src/Sql/Delete.php" -code = "impossible-null-type-comparison" -message = 'Impossible condition: variable `$this->where` (type `PhpDb\Sql\Where`) will always be `null`.' -count = 1 - -[[issues]] -file = "src/Sql/Delete.php" -code = "invalid-return-statement" -message = 'Invalid return type for function `PhpDb\Sql\Delete::processDelete`: expected `string`, but found `array|string`.' -count = 1 - -[[issues]] -file = "src/Sql/Delete.php" -code = "invalid-return-statement" -message = 'Invalid return type for function `PhpDb\Sql\Delete::processWhere`: expected `null|string`, but found `array|string`.' -count = 1 - -[[issues]] -file = "src/Sql/Delete.php" -code = "less-specific-nested-argument-type" -message = "Argument type mismatch for argument #2 of `str_replace`: expected `array|string`, but provided type `array|null|string` is less specific." -count = 1 - -[[issues]] -file = "src/Sql/Delete.php" -code = "possibly-invalid-argument" -message = "Possible argument type mismatch for argument #3 of `str_replace`: expected `array|string`, but possibly received `array|null>>|null|string`." -count = 2 - -[[issues]] -file = "src/Sql/Delete.php" -code = "possibly-null-argument" -message = "Argument #2 of function `str_replace` is possibly `null`, but parameter type `array|string` does not accept it." -count = 1 - -[[issues]] -file = "src/Sql/Delete.php" -code = "possibly-null-argument" -message = "Argument #3 of function `str_replace` is possibly `null`, but parameter type `array|string` does not accept it." -count = 2 - -[[issues]] -file = "src/Sql/Delete.php" -code = "possibly-undefined-string-array-index" -message = "Possibly undefined array key `string('delete')` accessed on `array|null>>|string>`." -count = 1 - -[[issues]] -file = "src/Sql/Delete.php" -code = "possibly-undefined-string-array-index" -message = "Possibly undefined array key `string('where')` accessed on `array|null>>|string>`." -count = 1 - -[[issues]] -file = "src/Sql/Delete.php" -code = "redundant-comparison" -message = "Redundant `===` comparison: left-hand side is never identical to right-hand side." -count = 1 - -[[issues]] -file = "src/Sql/Delete.php" -code = "redundant-logical-operation" -message = "Redundant `||` operation: left operand is always false and right operand is evaluated." -count = 1 - -[[issues]] -file = "src/Sql/Delete.php" -code = "redundant-null-coalesce" -message = "Redundant null coalesce: left-hand side can never be `null` or undefined." -count = 1 - -[[issues]] -file = "src/Sql/Expression.php" -code = "less-specific-nested-argument-type" -message = 'Argument type mismatch for argument #1 of `PhpDb\Sql\Expression::setParameters`: expected `PhpDb\Sql\ArgumentInterface|PhpDb\Sql\ExpressionInterface|array|null|scalar>|list|null|scalar`, but provided type `PhpDb\Sql\ArgumentInterface|PhpDb\Sql\ExpressionInterface|array|list|null|scalar` is less specific.' -count = 1 - -[[issues]] -file = "src/Sql/Expression.php" -code = "possibly-null-argument" -message = "Argument #1 of function `array_unique` is possibly `null`, but parameter type `array` does not accept it." -count = 1 - -[[issues]] -file = "src/Sql/Expression.php" -code = "possibly-undefined-int-array-index" -message = "Possibly undefined array index accessed on `list>`." -count = 1 - -[[issues]] -file = "src/Sql/ExpressionInterface.php" -code = "missing-api-or-internal" -message = 'Interface `PhpDb\Sql\ExpressionInterface` is missing an `@api` or `@internal` annotation.' -count = 1 - -[[issues]] -file = "src/Sql/Having.php" -code = "class-must-be-final" -message = 'Class `PhpDb\Sql\Having` should be declared `final`.' -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "imprecise-type" -message = "Type `array` in property `$select` is imprecise, equivalent to `array`." -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "imprecise-type" -message = "Type `array` in return type of `getRawState` is imprecise, equivalent to `array`." -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "invalid-return-statement" -message = 'Invalid return type for function `PhpDb\Sql\Insert::processInsert`: expected `null|string`, but found `array|string`.' -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "invalid-return-statement" -message = 'Invalid return type for function `PhpDb\Sql\Insert::processSelect`: expected `null|string`, but found `array|string`.' -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "less-specific-argument" -message = 'Argument type mismatch for argument #1 of `PhpDb\Adapter\Driver\DriverInterface::formatParameterName`: expected `string`, but provided type `array-key` is less specific.' -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "less-specific-argument" -message = 'Argument type mismatch for argument #1 of `PhpDb\Adapter\Platform\PlatformInterface::quoteIdentifier`: expected `string`, but provided type `array-key` is less specific.' -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "missing-return-type" -message = "Function `__isset` is missing a return type hint." -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "missing-return-type" -message = "Function `__unset` is missing a return type hint." -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "mixed-argument" -message = '''Invalid argument type for argument #1 of `PhpDb\Sql\AbstractSql::resolveColumnValue`: expected `PhpDb\Sql\ExpressionInterface|PhpDb\Sql\Select|array{'column': PhpDb\Sql\ExpressionInterface|PhpDb\Sql\Select|bool|int|null|string, 'fromTable'?: string, 'isIdentifier'?: bool}|bool|int|null|string`, but found `mixed`.''' -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "mixed-assignment" -message = "Assigning `mixed` type to a variable may lead to unexpected behavior." -count = 2 - -[[issues]] -file = "src/Sql/Insert.php" -code = "mixed-return-statement" -message = 'Could not infer a precise return type for function `PhpDb\Sql\Insert::__get`. Saw type `mixed`.' -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "possible-method-access-on-null" -message = "Attempting to call a method on `null`." -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "possibly-invalid-argument" -message = 'Possible argument type mismatch for argument #1 of `PhpDb\Sql\AbstractSql::processSubSelect`: expected `PhpDb\Sql\Select`, but possibly received `PhpDb\Sql\Select|non-empty-array`.' -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "possibly-invalid-argument" -message = '''Possible argument type mismatch for argument #1 of `array_map`: expected `(callable(array-key): string)|(callable(array-key, ('S.array_map() extends mixed)): string)|null`, but possibly received `list{PhpDb\Adapter\Platform\PlatformInterface, string('quoteIdentifier')}`.''' -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "possibly-invalid-argument" -message = "Possible argument type mismatch for argument #2 of `str_replace`: expected `array|string`, but possibly received `list{array|null|string, non-empty-string|string(''), string}`." -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "possibly-invalid-argument" -message = "Possible argument type mismatch for argument #2 of `str_replace`: expected `array|string`, but possibly received `list{array|null|string, string, string}`." -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "possibly-invalid-argument" -message = "Possible argument type mismatch for argument #3 of `str_replace`: expected `array|string`, but possibly received `array|null>>|null|string`." -count = 2 - -[[issues]] -file = "src/Sql/Insert.php" -code = "possibly-null-argument" -message = "Argument #3 of function `str_replace` is possibly `null`, but parameter type `array|string` does not accept it." -count = 2 - -[[issues]] -file = "src/Sql/Insert.php" -code = "possibly-undefined-string-array-index" -message = "Possibly undefined array key `string('insert')` accessed on `array|null>>|string>`." -count = 1 - -[[issues]] -file = "src/Sql/Insert.php" -code = "possibly-undefined-string-array-index" -message = "Possibly undefined array key `string('select')` accessed on `array|null>>|string>`." -count = 1 - -[[issues]] -file = "src/Sql/InsertIgnore.php" -code = "class-must-be-final" -message = 'Class `PhpDb\Sql\InsertIgnore` should be declared `final`.' -count = 1 - -[[issues]] -file = "src/Sql/Join.php" -code = "class-must-be-final" -message = 'Class `PhpDb\Sql\Join` should be declared `final`.' -count = 1 - -[[issues]] -file = "src/Sql/Join.php" -code = "invalid-return-statement" -message = '''Invalid return type for function `PhpDb\Sql\Join::current`: expected `array{'columns': array, 'name': PhpDb\Sql\TableIdentifier|non-empty-array|string, 'on': PhpDb\Sql\Predicate\PredicateInterface|string, 'type': string}`, but found `array{'columns': array, 'name': PhpDb\Sql\TableIdentifier|non-empty-array|string, 'on': PhpDb\Sql\Predicate\PredicateInterface|string, 'type': string}|null`.''' -count = 1 - -[[issues]] -file = "src/Sql/Join.php" -code = "nullable-return-statement" -message = '''Function `PhpDb\Sql\Join::current` is declared to return `array{'columns': array, 'name': PhpDb\Sql\TableIdentifier|non-empty-array|string, 'on': PhpDb\Sql\Predicate\PredicateInterface|string, 'type': string}` but possibly returns a nullable value (inferred as `array{'columns': array, 'name': PhpDb\Sql\TableIdentifier|non-empty-array|string, 'on': PhpDb\Sql\Predicate\PredicateInterface|string, 'type': string}|null`).''' -count = 1 - -[[issues]] -file = "src/Sql/Join.php" -code = "possibly-undefined-int-array-index" -message = '''Possibly undefined array index accessed on `list, 'name': PhpDb\Sql\TableIdentifier|non-empty-array|string, 'on': PhpDb\Sql\Predicate\PredicateInterface|string, 'type': string}>`.''' -count = 1 - -[[issues]] -file = "src/Sql/Platform/AbstractPlatform.php" -code = "imprecise-type" -message = "Type `array` in property `$decorators` is imprecise, equivalent to `array`." -count = 1 - -[[issues]] -file = "src/Sql/Platform/AbstractPlatform.php" -code = "invalid-property-assignment-value" -message = 'Invalid type for property `$subject`: expected `PhpDb\Sql\PreparableSqlInterface|PhpDb\Sql\SqlInterface`, but got `PhpDb\Sql\PreparableSqlInterface|PhpDb\Sql\SqlInterface|null`.' -count = 1 - -[[issues]] -file = "src/Sql/Platform/AbstractPlatform.php" -code = "missing-constructor" -message = 'Class `PhpDb\Sql\Platform\AbstractPlatform` has typed properties without default values but no constructor to initialize them.' -count = 1 - -[[issues]] -file = "src/Sql/Platform/AbstractPlatform.php" -code = "mixed-assignment" -message = "Assigning `mixed` type to a variable may lead to unexpected behavior." -count = 1 - -[[issues]] -file = "src/Sql/Platform/AbstractPlatform.php" -code = "mixed-method-access" -message = "Attempting to access a method on a non-object type (`mixed`)." -count = 1 - -[[issues]] -file = "src/Sql/Platform/AbstractPlatform.php" -code = "mixed-return-statement" -message = 'Could not infer a precise return type for function `PhpDb\Sql\Platform\AbstractPlatform::getSqlString`. Saw type `mixed`.' -count = 1 - -[[issues]] -file = "src/Sql/Platform/AbstractPlatform.php" -code = "mixed-return-statement" -message = 'Could not infer a precise return type for function `PhpDb\Sql\Platform\AbstractPlatform::getTypeDecorator`. Saw type `mixed`.' -count = 1 - -[[issues]] -file = "src/Sql/Platform/AbstractPlatform.php" -code = "non-existent-method" -message = 'Method `getSqlString` does not exist on type `PhpDb\Sql\Platform\PlatformDecoratorInterface`.' -count = 1 - -[[issues]] -file = "src/Sql/Platform/AbstractPlatform.php" -code = "non-existent-method" -message = 'Method `getSqlString` does not exist on type `PhpDb\Sql\PreparableSqlInterface`.' -count = 1 - -[[issues]] -file = "src/Sql/Platform/AbstractPlatform.php" -code = "non-existent-method" -message = 'Method `prepareStatement` does not exist on type `PhpDb\Sql\Platform\PlatformDecoratorInterface`.' -count = 1 - -[[issues]] -file = "src/Sql/Platform/AbstractPlatform.php" -code = "non-existent-method" -message = 'Method `prepareStatement` does not exist on type `PhpDb\Sql\SqlInterface`.' -count = 1 - -[[issues]] -file = "src/Sql/Platform/Platform.php" -code = "class-must-be-final" -message = 'Class `PhpDb\Sql\Platform\Platform` should be declared `final`.' -count = 1 - -[[issues]] -file = "src/Sql/Platform/Platform.php" -code = "mixed-array-assignment" -message = "Unsafe array assignment on type `mixed`." -count = 1 - -[[issues]] -file = "src/Sql/Platform/Platform.php" -code = "mixed-return-statement" -message = 'Could not infer a precise return type for function `PhpDb\Sql\Platform\Platform::getDecorators`. Saw type `mixed`.' -count = 1 - -[[issues]] -file = "src/Sql/Platform/Platform.php" -code = "mixed-return-statement" -message = 'Could not infer a precise return type for function `PhpDb\Sql\Platform\Platform::getSqlString`. Saw type `mixed`.' -count = 1 - -[[issues]] -file = "src/Sql/Platform/Platform.php" -code = "non-existent-method" -message = 'Method `getSqlString` does not exist on type `PhpDb\Sql\Platform\PlatformDecoratorInterface`.' -count = 1 - -[[issues]] -file = "src/Sql/Platform/Platform.php" -code = "non-existent-method" -message = 'Method `getSqlString` does not exist on type `PhpDb\Sql\PreparableSqlInterface`.' -count = 1 - -[[issues]] -file = "src/Sql/Platform/Platform.php" -code = "non-existent-method" -message = 'Method `prepareStatement` does not exist on type `PhpDb\Sql\Platform\PlatformDecoratorInterface`.' -count = 1 - -[[issues]] -file = "src/Sql/Platform/Platform.php" -code = "non-existent-method" -message = 'Method `prepareStatement` does not exist on type `PhpDb\Sql\SqlInterface`.' -count = 1 - -[[issues]] -file = "src/Sql/Platform/Platform.php" -code = "possibly-undefined-string-array-index" -message = "Possibly undefined array key `string` accessed on `array`." -count = 1 - -[[issues]] -file = "src/Sql/Platform/PlatformDecoratorInterface.php" -code = "missing-api-or-internal" -message = 'Interface `PhpDb\Sql\Platform\PlatformDecoratorInterface` is missing an `@api` or `@internal` annotation.' -count = 1 - -[[issues]] -file = "src/Sql/PreparableSqlInterface.php" -code = "missing-api-or-internal" -message = 'Interface `PhpDb\Sql\PreparableSqlInterface` is missing an `@api` or `@internal` annotation.' -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "array-to-string-conversion" -message = "Potential array in left operand of string concatenation." -count = 2 - -[[issues]] -file = "src/Sql/Select.php" -code = "class-must-be-final" -message = 'Class `PhpDb\Sql\Select` should be declared `final`.' -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "impossible-null-type-comparison" -message = 'Impossible condition: variable `$this->having` (type `PhpDb\Sql\Having`) will always be `null`.' -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "impossible-null-type-comparison" -message = 'Impossible condition: variable `$this->where` (type `PhpDb\Sql\Where`) will always be `null`.' -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "invalid-property-assignment-value" -message = 'Invalid type for property `$having`: expected `PhpDb\Sql\Having`, but got `null`.' -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "invalid-property-assignment-value" -message = 'Invalid type for property `$joins`: expected `PhpDb\Sql\Join`, but got `null`.' -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "invalid-property-assignment-value" -message = 'Invalid type for property `$where`: expected `PhpDb\Sql\Where`, but got `null`.' -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "invalid-return-statement" -message = '''Invalid return type for function `PhpDb\Sql\Select::resolveTable`: expected `array{0: null|string, 1: string}`, but found `list{array|null|string, string('')|truthy-string}`.''' -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "missing-return-type" -message = "Function `__clone` is missing a return type hint." -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "possible-method-access-on-null" -message = "Attempting to call a method on `null`." -count = 2 - -[[issues]] -file = "src/Sql/Select.php" -code = "possibly-false-iterator" -message = 'Expression being iterated (type `array|false|list`) might be `false` at runtime.' -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "possibly-invalid-argument" -message = 'Possible argument type mismatch for argument #1 of `PhpDb\Adapter\Platform\PlatformInterface::quoteIdentifier`: expected `string`, but possibly received `PhpDb\Sql\ExpressionInterface|string`.' -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "possibly-invalid-argument" -message = 'Possible argument type mismatch for argument #1 of `PhpDb\Sql\AbstractSql::renderTable`: expected `string`, but possibly received `array|null|string`.' -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "possibly-null-argument" -message = "Argument #1 of function `strtoupper` is possibly `null`, but parameter type `string` does not accept it." -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "possibly-null-argument" -message = "Argument #1 of function `trim` is possibly `null`, but parameter type `string` does not accept it." -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "possibly-null-argument" -message = 'Argument #1 of method `PhpDb\Sql\AbstractSql::processSubSelect` is possibly `null`, but parameter type `PhpDb\Sql\Select` does not accept it.' -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "possibly-null-argument" -message = 'Argument #1 of method `PhpDb\Sql\AbstractSql::renderTable` is possibly `null`, but parameter type `string` does not accept it.' -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "possibly-null-array-index" -message = "Possibly using `null` as an array index to access elementof variable $table." -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "possibly-null-operand" -message = "Possibly null left operand used in string concatenation (type `array|null|string`)." -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "possibly-undefined-int-array-index" -message = "Possibly undefined array index accessed on `non-empty-list`." -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "possibly-undefined-string-array-index" -message = '''Possibly undefined array key 'modifier' accessed on `array{'modifier'?: string, 'select'?: PhpDb\Sql\Select, 'type'?: string}`.''' -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "possibly-undefined-string-array-index" -message = '''Possibly undefined array key 'select' accessed on `array{'modifier'?: string, 'select'?: PhpDb\Sql\Select, 'type'?: string}`.''' +message = 'Potentially unhandled exception `PhpDb\RowGateway\Exception\RuntimeException` in `PhpDb\RowGateway\AbstractRowGateway::populate`.' count = 1 [[issues]] -file = "src/Sql/Select.php" -code = "possibly-undefined-string-array-index" -message = '''Possibly undefined array key 'type' accessed on `array{'modifier'?: string('')|string('0'), 'select'?: PhpDb\Sql\Select, 'type'?: string}`.''' +file = "src/RowGateway/AbstractRowGateway.php" +code = "unhandled-thrown-type" +message = 'Potentially unhandled exception `PhpDb\RowGateway\Exception\RuntimeException` in `PhpDb\RowGateway\AbstractRowGateway::save`.' count = 1 [[issues]] -file = "src/Sql/Select.php" -code = "possibly-undefined-string-array-index" -message = '''Possibly undefined array key 'type' accessed on `array{'modifier'?: truthy-string, 'select'?: PhpDb\Sql\Select, 'type'?: string}`.''' +file = "src/RowGateway/AbstractRowGateway.php" +code = "unhandled-thrown-type" +message = 'Potentially unhandled exception `PhpDb\Sql\Exception\RuntimeException` in `PhpDb\RowGateway\AbstractRowGateway::delete`.' count = 1 [[issues]] -file = "src/Sql/Select.php" -code = "possibly-undefined-string-array-index" -message = 'Possibly undefined array key `null|string` accessed on `array`.' +file = "src/RowGateway/AbstractRowGateway.php" +code = "unhandled-thrown-type" +message = 'Potentially unhandled exception `PhpDb\Sql\Exception\RuntimeException` in `PhpDb\RowGateway\AbstractRowGateway::save`.' count = 1 [[issues]] -file = "src/Sql/Select.php" -code = "redundant-comparison" -message = "Redundant `!==` comparison: left-hand side is always not identical to right-hand side." -count = 3 +file = "src/RowGateway/Exception/InvalidArgumentException.php" +code = "class-must-be-final" +message = 'Class `PhpDb\RowGateway\Exception\InvalidArgumentException` should be declared `final`.' +count = 1 [[issues]] -file = "src/Sql/Select.php" -code = "redundant-comparison" -message = "Redundant `===` comparison: left-hand side is never identical to right-hand side." -count = 2 +file = "src/RowGateway/Exception/RuntimeException.php" +code = "class-must-be-final" +message = 'Class `PhpDb\RowGateway\Exception\RuntimeException` should be declared `final`.' +count = 1 [[issues]] -file = "src/Sql/Select.php" -code = "redundant-condition" -message = "This condition (type `true`) will always evaluate to true." -count = 3 +file = "src/RowGateway/Feature/FeatureSet.php" +code = "unused-property" +message = "Property `$magicSpecifications` is never used." +count = 1 [[issues]] -file = "src/Sql/Select.php" -code = "redundant-logical-operation" -message = "Redundant `||` operation: left operand is always false and right operand is evaluated." -count = 2 +file = "src/RowGateway/RowGateway.php" +code = "class-must-be-final" +message = 'Class `PhpDb\RowGateway\RowGateway` should be declared `final`.' +count = 1 [[issues]] -file = "src/Sql/Select.php" -code = "redundant-null-coalesce" -message = "Redundant null coalesce: left-hand side can never be `null` or undefined." -count = 3 +file = "src/RowGateway/RowGateway.php" +code = "imprecise-type" +message = "Type `array` in parameter `$primaryKeyColumn` is imprecise, equivalent to `array`." +count = 1 [[issues]] -file = "src/Sql/Sql.php" -code = "class-must-be-final" -message = 'Class `PhpDb\Sql\Sql` should be declared `final`.' +file = "src/RowGateway/RowGatewayInterface.php" +code = "missing-api-or-internal" +message = 'Interface `PhpDb\RowGateway\RowGatewayInterface` is missing an `@api` or `@internal` annotation.' count = 1 [[issues]] -file = "src/Sql/Sql.php" -code = "overly-wide-return-type" -message = 'Declared return type `PhpDb\Adapter\AdapterInterface|null` for `getadapter` has unused branches: `null`.' +file = "src/Sql/AbstractSql.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." count = 1 [[issues]] -file = "src/Sql/Sql.php" -code = "overly-wide-return-type" -message = 'Declared return type `PhpDb\Sql\Platform\PlatformDecoratorInterface|null` for `getsqlplatform` has unused branches: `null`.' +file = "src/Sql/AbstractSql.php" +code = "string-member-selector" +message = "This member selector uses a non-literal string type (`non-empty-string`); its specific value cannot be statically determined." count = 1 [[issues]] -file = "src/Sql/Sql.php" -code = "unhandled-thrown-type" -message = 'Potentially unhandled exception `PhpDb\Sql\Exception\RuntimeException` in `PhpDb\Sql\Sql::buildSqlString`.' +file = "src/Sql/AbstractSql.php" +code = "string-member-selector" +message = "This member selector uses a non-literal string type (`string`); its specific value cannot be statically determined." count = 1 [[issues]] -file = "src/Sql/Sql.php" -code = "unhandled-thrown-type" -message = 'Potentially unhandled exception `PhpDb\Sql\Exception\RuntimeException` in `PhpDb\Sql\Sql::prepareStatementForSqlObject`.' +file = "src/Sql/Insert.php" +code = "missing-return-type" +message = "Function `__isset` is missing a return type hint." count = 1 [[issues]] -file = "src/Sql/SqlInterface.php" -code = "missing-api-or-internal" -message = 'Interface `PhpDb\Sql\SqlInterface` is missing an `@api` or `@internal` annotation.' +file = "src/Sql/Insert.php" +code = "missing-return-type" +message = "Function `__unset` is missing a return type hint." count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "class-must-be-final" -message = 'Class `PhpDb\Sql\Update` should be declared `final`.' +file = "src/Sql/Platform/AbstractPlatform.php" +code = "imprecise-type" +message = "Type `array` in property `$decorators` is imprecise, equivalent to `array`." count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "impossible-null-type-comparison" -message = 'Impossible condition: variable `$this->where` (type `PhpDb\Sql\Where`) will always be `null`.' +file = "src/Sql/Platform/AbstractPlatform.php" +code = "invalid-property-assignment-value" +message = 'Invalid type for property `$subject`: expected `PhpDb\Sql\PreparableSqlInterface|PhpDb\Sql\SqlInterface`, but got `PhpDb\Sql\PreparableSqlInterface|PhpDb\Sql\SqlInterface|null`.' count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "invalid-return-statement" -message = 'Invalid return type for function `PhpDb\Sql\Update::processSet`: expected `string`, but found `array|string`.' +file = "src/Sql/Platform/AbstractPlatform.php" +code = "missing-constructor" +message = 'Class `PhpDb\Sql\Platform\AbstractPlatform` has typed properties without default values but no constructor to initialize them.' count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "invalid-return-statement" -message = 'Invalid return type for function `PhpDb\Sql\Update::processUpdate`: expected `string`, but found `array|string`.' +file = "src/Sql/Platform/AbstractPlatform.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "invalid-return-statement" -message = 'Invalid return type for function `PhpDb\Sql\Update::processWhere`: expected `null|string`, but found `array|string`.' +file = "src/Sql/Platform/AbstractPlatform.php" +code = "mixed-method-access" +message = "Attempting to access a method on a non-object type (`mixed`)." count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "less-specific-nested-argument-type" -message = "Argument type mismatch for argument #2 of `str_replace`: expected `array|string`, but provided type `array|null|string` is less specific." +file = "src/Sql/Platform/AbstractPlatform.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `PhpDb\Sql\Platform\AbstractPlatform::getSqlString`. Saw type `mixed`.' count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "missing-return-type" -message = "Function `__clone` is missing a return type hint." +file = "src/Sql/Platform/AbstractPlatform.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `PhpDb\Sql\Platform\AbstractPlatform::getTypeDecorator`. Saw type `mixed`.' count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "mixed-argument" -message = '''Invalid argument type for argument #1 of `PhpDb\Sql\AbstractSql::resolveColumnValue`: expected `PhpDb\Sql\ExpressionInterface|PhpDb\Sql\Select|array{'column': PhpDb\Sql\ExpressionInterface|PhpDb\Sql\Select|bool|int|null|string, 'fromTable'?: string, 'isIdentifier'?: bool}|bool|int|null|string`, but found `mixed`.''' +file = "src/Sql/Platform/AbstractPlatform.php" +code = "non-existent-method" +message = 'Method `getSqlString` does not exist on type `PhpDb\Sql\Platform\PlatformDecoratorInterface`.' count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "mixed-assignment" -message = "Assigning `mixed` type to a variable may lead to unexpected behavior." -count = 2 +file = "src/Sql/Platform/AbstractPlatform.php" +code = "non-existent-method" +message = 'Method `getSqlString` does not exist on type `PhpDb\Sql\PreparableSqlInterface`.' +count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "possible-method-access-on-null" -message = "Attempting to call a method on `null`." +file = "src/Sql/Platform/AbstractPlatform.php" +code = "non-existent-method" +message = 'Method `prepareStatement` does not exist on type `PhpDb\Sql\Platform\PlatformDecoratorInterface`.' count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "possibly-invalid-argument" -message = 'Possible argument type mismatch for argument #3 of `Laminas\Stdlib\PriorityList::insert`: expected `int`, but possibly received `int|numeric-string`.' +file = "src/Sql/Platform/AbstractPlatform.php" +code = "non-existent-method" +message = 'Method `prepareStatement` does not exist on type `PhpDb\Sql\SqlInterface`.' count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "possibly-invalid-argument" -message = "Possible argument type mismatch for argument #3 of `str_replace`: expected `array|string`, but possibly received `array|null>>|null|string`." -count = 3 +file = "src/Sql/Platform/Platform.php" +code = "class-must-be-final" +message = 'Class `PhpDb\Sql\Platform\Platform` should be declared `final`.' +count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "possibly-null-argument" -message = "Argument #2 of function `str_replace` is possibly `null`, but parameter type `array|string` does not accept it." +file = "src/Sql/Platform/Platform.php" +code = "mixed-array-assignment" +message = "Unsafe array assignment on type `mixed`." count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "possibly-null-argument" -message = "Argument #3 of function `str_replace` is possibly `null`, but parameter type `array|string` does not accept it." -count = 3 +file = "src/Sql/Platform/Platform.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `PhpDb\Sql\Platform\Platform::getDecorators`. Saw type `mixed`.' +count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "possibly-undefined-string-array-index" -message = "Possibly undefined array key `string('set')` accessed on `array|null>>|string>`." +file = "src/Sql/Platform/Platform.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `PhpDb\Sql\Platform\Platform::getSqlString`. Saw type `mixed`.' count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "possibly-undefined-string-array-index" -message = "Possibly undefined array key `string('update')` accessed on `array|null>>|string>`." +file = "src/Sql/Platform/Platform.php" +code = "non-existent-method" +message = 'Method `getSqlString` does not exist on type `PhpDb\Sql\Platform\PlatformDecoratorInterface`.' count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "possibly-undefined-string-array-index" -message = "Possibly undefined array key `string('where')` accessed on `array|null>>|string>`." +file = "src/Sql/Platform/Platform.php" +code = "non-existent-method" +message = 'Method `getSqlString` does not exist on type `PhpDb\Sql\PreparableSqlInterface`.' count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "redundant-comparison" -message = "Redundant `!==` comparison: left-hand side is always not identical to right-hand side." +file = "src/Sql/Platform/Platform.php" +code = "non-existent-method" +message = 'Method `prepareStatement` does not exist on type `PhpDb\Sql\Platform\PlatformDecoratorInterface`.' count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "redundant-comparison" -message = "Redundant `===` comparison: left-hand side is never identical to right-hand side." +file = "src/Sql/Platform/Platform.php" +code = "non-existent-method" +message = 'Method `prepareStatement` does not exist on type `PhpDb\Sql\SqlInterface`.' count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "redundant-condition" -message = "This condition (type `true`) will always evaluate to true." +file = "src/Sql/Platform/Platform.php" +code = "possibly-undefined-string-array-index" +message = "Possibly undefined array key `string` accessed on `array`." count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "redundant-logical-operation" -message = "Redundant `||` operation: left operand is always false and right operand is evaluated." +file = "src/Sql/Platform/PlatformDecoratorInterface.php" +code = "missing-api-or-internal" +message = 'Interface `PhpDb\Sql\Platform\PlatformDecoratorInterface` is missing an `@api` or `@internal` annotation.' count = 1 [[issues]] -file = "src/Sql/Update.php" -code = "redundant-null-coalesce" -message = "Redundant null coalesce: left-hand side can never be `null` or undefined." +file = "src/Sql/Select.php" +code = "missing-return-type" +message = "Function `__clone` is missing a return type hint." count = 1 [[issues]] -file = "src/Sql/Where.php" -code = "class-must-be-final" -message = 'Class `PhpDb\Sql\Where` should be declared `final`.' +file = "src/Sql/Update.php" +code = "missing-return-type" +message = "Function `__clone` is missing a return type hint." count = 1 [[issues]] @@ -2766,6 +1962,12 @@ code = "invalid-return-statement" message = 'Invalid return type for function `PhpDb\TableGateway\AbstractTableGateway::getTable`: expected `PhpDb\Sql\TableIdentifier|array|string`, but found `PhpDb\Sql\TableIdentifier|array|null|string`.' count = 1 +[[issues]] +file = "src/TableGateway/AbstractTableGateway.php" +code = "less-specific-argument" +message = 'Argument type mismatch for argument #1 of `PhpDb\Sql\Insert::into`: expected `PhpDb\Sql\TableIdentifier|array|string`, but provided type `PhpDb\Sql\ExpressionInterface|PhpDb\Sql\Select|PhpDb\Sql\TableIdentifier|array|null|scalar` is less specific.' +count = 1 + [[issues]] file = "src/TableGateway/AbstractTableGateway.php" code = "less-specific-argument" @@ -2784,6 +1986,12 @@ code = "less-specific-nested-argument-type" message = 'Argument type mismatch for argument #1 of `PhpDb\Sql\Delete::where`: expected `(closure(...mixed): mixed)|PhpDb\Sql\Predicate\PredicateInterface|PhpDb\Sql\Where|array|null|scalar>|string`, but provided type `PhpDb\Sql\Where|array|string` is less specific.' count = 1 +[[issues]] +file = "src/TableGateway/AbstractTableGateway.php" +code = "less-specific-nested-argument-type" +message = 'Argument type mismatch for argument #1 of `PhpDb\Sql\Insert::values`: expected `PhpDb\Sql\Select|array`, but provided type `array` is less specific.' +count = 1 + [[issues]] file = "src/TableGateway/AbstractTableGateway.php" code = "less-specific-nested-argument-type" @@ -2796,6 +2004,12 @@ code = "less-specific-nested-argument-type" message = 'Argument type mismatch for argument #1 of `PhpDb\Sql\Select::where`: expected `(closure(...mixed): mixed)|PhpDb\Sql\Predicate\PredicateInterface|array|null|scalar>|string`, but provided type `PhpDb\Sql\Where|array|string` is less specific.' count = 1 +[[issues]] +file = "src/TableGateway/AbstractTableGateway.php" +code = "less-specific-nested-argument-type" +message = 'Argument type mismatch for argument #1 of `PhpDb\Sql\Update::set`: expected `array`, but provided type `array` is less specific.' +count = 1 + [[issues]] file = "src/TableGateway/AbstractTableGateway.php" code = "less-specific-nested-argument-type" @@ -2818,7 +2032,7 @@ count = 2 file = "src/TableGateway/AbstractTableGateway.php" code = "mixed-argument" message = 'Invalid argument type for argument #1 of `PhpDb\Sql\Insert::into`: expected `PhpDb\Sql\TableIdentifier|array|string`, but found `mixed`.' -count = 2 +count = 1 [[issues]] file = "src/TableGateway/AbstractTableGateway.php" @@ -2930,16 +2144,58 @@ count = 1 [[issues]] file = "src/TableGateway/AbstractTableGateway.php" -code = "possibly-undefined-string-array-index" -message = "Possibly undefined array key `string('table')` accessed on `array`." +code = "possibly-null-argument" +message = 'Argument #1 of method `PhpDb\Sql\Insert::into` is possibly `null`, but parameter type `PhpDb\Sql\TableIdentifier|array|string` does not accept it.' +count = 1 + +[[issues]] +file = "src/TableGateway/AbstractTableGateway.php" +code = "possibly-undefined-int-array-index" +message = 'Possibly undefined array index accessed on `list`.' count = 3 +[[issues]] +file = "src/TableGateway/AbstractTableGateway.php" +code = "possibly-undefined-string-array-index" +message = '''Possibly undefined array key `string('table')` accessed on `array|string>`.''' +count = 1 + +[[issues]] +file = "src/TableGateway/AbstractTableGateway.php" +code = "possibly-undefined-string-array-index" +message = '''Possibly undefined array key `string('table')` accessed on `array`.''' +count = 2 + [[issues]] file = "src/TableGateway/AbstractTableGateway.php" code = "redundant-comparison" message = "Redundant `===` comparison: left-hand side is never identical to right-hand side." count = 1 +[[issues]] +file = "src/TableGateway/AbstractTableGateway.php" +code = "unhandled-thrown-type" +message = 'Potentially unhandled exception `PhpDb\Sql\Exception\RuntimeException` in `PhpDb\TableGateway\AbstractTableGateway::executeDelete`.' +count = 1 + +[[issues]] +file = "src/TableGateway/AbstractTableGateway.php" +code = "unhandled-thrown-type" +message = 'Potentially unhandled exception `PhpDb\Sql\Exception\RuntimeException` in `PhpDb\TableGateway\AbstractTableGateway::executeInsert`.' +count = 1 + +[[issues]] +file = "src/TableGateway/AbstractTableGateway.php" +code = "unhandled-thrown-type" +message = 'Potentially unhandled exception `PhpDb\Sql\Exception\RuntimeException` in `PhpDb\TableGateway\AbstractTableGateway::executeSelect`.' +count = 1 + +[[issues]] +file = "src/TableGateway/AbstractTableGateway.php" +code = "unhandled-thrown-type" +message = 'Potentially unhandled exception `PhpDb\Sql\Exception\RuntimeException` in `PhpDb\TableGateway\AbstractTableGateway::executeUpdate`.' +count = 1 + [[issues]] file = "src/TableGateway/Exception/InvalidArgumentException.php" code = "class-must-be-final" @@ -2976,12 +2232,6 @@ code = "mixed-assignment" message = "Assigning `mixed` type to a variable may lead to unexpected behavior." count = 1 -[[issues]] -file = "src/TableGateway/Feature/SequenceFeature.php" -code = "mixed-assignment" -message = "Assigning `mixed` type to a variable may lead to unexpected behavior." -count = 1 - [[issues]] file = "src/TableGateway/TableGateway.php" code = "class-must-be-final" diff --git a/lint-baseline.toml b/lint-baseline.toml index 0bebf452..2b4cac96 100644 --- a/lint-baseline.toml +++ b/lint-baseline.toml @@ -342,24 +342,12 @@ code = "excessive-parameter-list" message = "Parameter list is too long." count = 1 -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "halstead" -message = "Method has a high halstead difficulty" -count = 1 - [[issues]] file = "src/Sql/AbstractSql.php" code = "kan-defect" message = "Class has a high kan defect score." count = 1 -[[issues]] -file = "src/Sql/AbstractSql.php" -code = "too-many-methods" -message = "Class has too many methods." -count = 1 - [[issues]] file = "src/Sql/Combine.php" code = "cyclomatic-complexity" @@ -372,24 +360,12 @@ code = "kan-defect" message = "Class has a high kan defect score." count = 1 -[[issues]] -file = "src/Sql/Combine.php" -code = "no-array-accumulation-in-loop" -message = "Array spread in reassignment inside a loop causes O(n²) array copying." -count = 1 - [[issues]] file = "src/Sql/Insert.php" code = "cyclomatic-complexity" message = "Class has high complexity." count = 1 -[[issues]] -file = "src/Sql/Insert.php" -code = "too-many-methods" -message = "Class has too many methods." -count = 1 - [[issues]] file = "src/Sql/Select.php" code = "cyclomatic-complexity" @@ -400,7 +376,7 @@ count = 1 file = "src/Sql/Select.php" code = "halstead" message = "Method has a high halstead difficulty" -count = 2 +count = 1 [[issues]] file = "src/Sql/Select.php" @@ -408,36 +384,12 @@ code = "kan-defect" message = "Class has a high kan defect score." count = 1 -[[issues]] -file = "src/Sql/Select.php" -code = "too-many-methods" -message = "Class has too many methods." -count = 1 - -[[issues]] -file = "src/Sql/Select.php" -code = "too-many-properties" -message = "Class has too many properties." -count = 1 - [[issues]] file = "src/Sql/Update.php" code = "cyclomatic-complexity" message = "Class has high complexity." count = 1 -[[issues]] -file = "src/Sql/Update.php" -code = "halstead" -message = "Method has a high halstead difficulty" -count = 1 - -[[issues]] -file = "src/Sql/Update.php" -code = "too-many-methods" -message = "Class has too many methods." -count = 1 - [[issues]] file = "src/TableGateway/AbstractTableGateway.php" code = "cyclomatic-complexity" diff --git a/mago.toml b/mago.toml index 8aab97ce..31246c5f 100644 --- a/mago.toml +++ b/mago.toml @@ -15,9 +15,10 @@ baseline = "lint-baseline.toml" # Exception classes are message catalogues: one template and one named constructor per # condition, per the convention in docs/book/exceptions.md. The method count tracks the # number of distinct failures a component can report, so the maintainability signal does -# not apply. Predicate, AlterTable and CreateTable are fluent builders: one public method per -# clause they can emit, so their method count is the size of their public API. "test/" -# repeats the phpdb-qa-tools default, which this key replaces. +# not apply. Predicate, AlterTable, CreateTable, Select, Insert and Update are fluent builders: +# one public method per clause they can emit, so their method count is the size of their +# public API. AbstractSql is their base and carries one protected hook per clause it renders. +# "test/" repeats the phpdb-qa-tools default, which this key replaces. too-many-methods = { exclude = [ "test/", "src/Sql/Exception/", @@ -27,8 +28,15 @@ too-many-methods = { exclude = [ "src/Sql/Predicate/Predicate.php", "src/Sql/Ddl/AlterTable.php", "src/Sql/Ddl/CreateTable.php", + "src/Sql/AbstractSql.php", + "src/Sql/Select.php", + "src/Sql/Insert.php", + "src/Sql/Update.php", ] } +# Select holds one property per clause of a SELECT statement. +too-many-properties = { exclude = ["src/Sql/Select.php"] } + # These constructors take one argument per part of the SQL definition they model # (a foreign key's name, columns, reference table, reference columns and two rules; a # column's name, digits, scale, nullability, default and options). They are public API, so @@ -48,6 +56,11 @@ prefer-self-return-type = { exclude = [ [analyzer] baseline = "analyzer-baseline.toml" +# ArgumentType is string-backed and each case's value is a ::class constant, which PHP accepts +# as a string. The analyzer reports the class-string type as not being a string. +ignore = [ + { code = "invalid-enum-case-value", in = ["src/Sql/ArgumentType.php"] }, +] class-initializers = [ "PhpDb\\ResultSet\\AbstractResultSet::initialize", "PhpDb\\TableGateway\\Feature\\AbstractFeature::setTableGateway", diff --git a/src/Sql/AbstractExpression.php b/src/Sql/AbstractExpression.php index e5ea98fc..c84753c0 100644 --- a/src/Sql/AbstractExpression.php +++ b/src/Sql/AbstractExpression.php @@ -4,6 +4,9 @@ namespace PhpDb\Sql; +/** + * @api + */ abstract class AbstractExpression implements ExpressionInterface { protected ?string $specification = null; diff --git a/src/Sql/AbstractPreparableSql.php b/src/Sql/AbstractPreparableSql.php index 311ce9fc..70f11f85 100644 --- a/src/Sql/AbstractPreparableSql.php +++ b/src/Sql/AbstractPreparableSql.php @@ -12,6 +12,9 @@ use function array_values; use function is_array; +/** + * @api + */ abstract class AbstractPreparableSql extends AbstractSql implements PreparableSqlInterface { /** diff --git a/src/Sql/AbstractSql.php b/src/Sql/AbstractSql.php index f16a6ffc..5ea9aaad 100644 --- a/src/Sql/AbstractSql.php +++ b/src/Sql/AbstractSql.php @@ -29,6 +29,8 @@ use function vsprintf; /** + * @api + * * @psalm-type ParameterSpecification = array|null * @psalm-type Specification = string|array> * @psalm-type SqlParameters = array>> diff --git a/src/Sql/Argument.php b/src/Sql/Argument.php index be2c778b..54dc26ef 100644 --- a/src/Sql/Argument.php +++ b/src/Sql/Argument.php @@ -12,6 +12,8 @@ use PhpDb\Sql\Argument\Values; /** + * @api + * * Factory for creating argument instances. */ final class Argument diff --git a/src/Sql/ArgumentInterface.php b/src/Sql/ArgumentInterface.php index 921ba0cb..160a5a91 100644 --- a/src/Sql/ArgumentInterface.php +++ b/src/Sql/ArgumentInterface.php @@ -4,6 +4,9 @@ namespace PhpDb\Sql; +/** + * @api + */ interface ArgumentInterface { public function getSpecification(): string; diff --git a/src/Sql/ArgumentType.php b/src/Sql/ArgumentType.php index 43314b02..5a3da0dd 100644 --- a/src/Sql/ArgumentType.php +++ b/src/Sql/ArgumentType.php @@ -12,6 +12,8 @@ use PhpDb\Sql\Argument\Values; /** + * @api + * * Defines types for SQL argument handling in expressions and statements. * Specifies how values should be treated during SQL generation: * - Identifier: Column names, table names, and other identifiers that require quoting diff --git a/src/Sql/Combine.php b/src/Sql/Combine.php index 29dd52a0..cbd4a968 100644 --- a/src/Sql/Combine.php +++ b/src/Sql/Combine.php @@ -17,6 +17,8 @@ use function trim; /** + * @api + * * Combine SQL statement - allows combining multiple select statements into one * * @psalm-import-type Specification from AbstractSql diff --git a/src/Sql/Ddl/AlterTable.php b/src/Sql/Ddl/AlterTable.php index 29b557db..07f86740 100644 --- a/src/Sql/Ddl/AlterTable.php +++ b/src/Sql/Ddl/AlterTable.php @@ -16,6 +16,8 @@ use function strtoupper; /** + * @api + * * @psalm-import-type Specification from AbstractSql * @psalm-type RawState = array{ * table: string|TableIdentifier, diff --git a/src/Sql/Ddl/CreateTable.php b/src/Sql/Ddl/CreateTable.php index f7656bdb..ba54463d 100644 --- a/src/Sql/Ddl/CreateTable.php +++ b/src/Sql/Ddl/CreateTable.php @@ -16,6 +16,8 @@ use function strtoupper; /** + * @api + * * @psalm-import-type Specification from AbstractSql * @psalm-type RawState = array{ * columns: list, diff --git a/src/Sql/Ddl/DropTable.php b/src/Sql/Ddl/DropTable.php index 4956e562..cb676874 100644 --- a/src/Sql/Ddl/DropTable.php +++ b/src/Sql/Ddl/DropTable.php @@ -9,6 +9,8 @@ use PhpDb\Sql\TableIdentifier; /** + * @api + * * @psalm-import-type Specification from AbstractSql */ class DropTable extends AbstractSql diff --git a/src/Sql/Delete.php b/src/Sql/Delete.php index 60a06100..b76a0fbd 100644 --- a/src/Sql/Delete.php +++ b/src/Sql/Delete.php @@ -14,6 +14,8 @@ use function strtolower; /** + * @api + * * @property Where $where * @psalm-import-type Specification from AbstractSql * @psalm-import-type PredicateValue from Predicate\PredicateSet diff --git a/src/Sql/Expression.php b/src/Sql/Expression.php index c63f8f8d..4450c39b 100644 --- a/src/Sql/Expression.php +++ b/src/Sql/Expression.php @@ -19,6 +19,8 @@ use function str_replace; /** + * @api + * * @psalm-type ExpressionParameter = bool|string|float|int|null|list|ExpressionInterface|ArgumentInterface */ class Expression extends AbstractExpression diff --git a/src/Sql/ExpressionInterface.php b/src/Sql/ExpressionInterface.php index eff6202a..c81c54df 100644 --- a/src/Sql/ExpressionInterface.php +++ b/src/Sql/ExpressionInterface.php @@ -4,6 +4,9 @@ namespace PhpDb\Sql; +/** + * @api + */ interface ExpressionInterface { /** diff --git a/src/Sql/Having.php b/src/Sql/Having.php index 010e7418..cfb93a2f 100644 --- a/src/Sql/Having.php +++ b/src/Sql/Having.php @@ -4,4 +4,7 @@ namespace PhpDb\Sql; +/** + * @api + */ class Having extends Predicate\Predicate {} diff --git a/src/Sql/Insert.php b/src/Sql/Insert.php index 836fdfef..ab76640d 100644 --- a/src/Sql/Insert.php +++ b/src/Sql/Insert.php @@ -21,6 +21,8 @@ use function range; /** + * @api + * * @psalm-import-type Specification from AbstractSql * @psalm-import-type ColumnValue from AbstractSql */ diff --git a/src/Sql/InsertIgnore.php b/src/Sql/InsertIgnore.php index 99939792..d5875e25 100644 --- a/src/Sql/InsertIgnore.php +++ b/src/Sql/InsertIgnore.php @@ -5,6 +5,8 @@ namespace PhpDb\Sql; /** + * @api + * * @psalm-import-type Specification from AbstractSql */ class InsertIgnore extends Insert diff --git a/src/Sql/Join.php b/src/Sql/Join.php index deac1058..20598d96 100644 --- a/src/Sql/Join.php +++ b/src/Sql/Join.php @@ -18,6 +18,8 @@ use function key; /** + * @api + * * Aggregate JOIN specifications. * Each specification is an array with the following keys: * - name: the JOIN name diff --git a/src/Sql/Literal.php b/src/Sql/Literal.php index d27974be..5adc57fb 100644 --- a/src/Sql/Literal.php +++ b/src/Sql/Literal.php @@ -8,6 +8,9 @@ use function str_replace; +/** + * @api + */ class Literal implements ExpressionInterface { public function __construct( diff --git a/src/Sql/PreparableSqlInterface.php b/src/Sql/PreparableSqlInterface.php index 3627acad..178f1993 100644 --- a/src/Sql/PreparableSqlInterface.php +++ b/src/Sql/PreparableSqlInterface.php @@ -7,6 +7,9 @@ use PhpDb\Adapter\AdapterInterface; use PhpDb\Adapter\StatementContainerInterface; +/** + * @api + */ interface PreparableSqlInterface { public function prepareStatement( diff --git a/src/Sql/Select.php b/src/Sql/Select.php index f9307c81..54849fd3 100644 --- a/src/Sql/Select.php +++ b/src/Sql/Select.php @@ -30,6 +30,8 @@ use function trim; /** + * @api + * * @property Where $where * @property Having $having * @property Join $joins diff --git a/src/Sql/Sql.php b/src/Sql/Sql.php index 7871db93..af17e312 100644 --- a/src/Sql/Sql.php +++ b/src/Sql/Sql.php @@ -7,6 +7,9 @@ use PhpDb\Adapter\AdapterInterface; use PhpDb\Adapter\Driver\StatementInterface; +/** + * @api + */ class Sql { protected AdapterInterface $adapter; diff --git a/src/Sql/SqlInterface.php b/src/Sql/SqlInterface.php index e1e4b94b..433ed032 100644 --- a/src/Sql/SqlInterface.php +++ b/src/Sql/SqlInterface.php @@ -6,6 +6,9 @@ use PhpDb\Adapter\Platform\PlatformInterface; +/** + * @api + */ interface SqlInterface { /** diff --git a/src/Sql/Update.php b/src/Sql/Update.php index 2282fb38..cd5f4fdf 100644 --- a/src/Sql/Update.php +++ b/src/Sql/Update.php @@ -20,6 +20,8 @@ use function strtolower; /** + * @api + * * @property Where $where * @psalm-import-type Specification from AbstractSql * @psalm-import-type ColumnValue from AbstractSql diff --git a/src/Sql/Where.php b/src/Sql/Where.php index e9bddaa2..11a4217e 100644 --- a/src/Sql/Where.php +++ b/src/Sql/Where.php @@ -4,4 +4,7 @@ namespace PhpDb\Sql; +/** + * @api + */ class Where extends Predicate\Predicate {} From 245a5be28fb4913e2d1e107349b61e60afde1755 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 12 Oct 2026 13:52:42 +1100 Subject: [PATCH 3/7] Import Sql\Expression rather than naming it fully qualified Predicate's ExpressionParameter import named \PhpDb\Sql\Expression in full, because Expression alone resolves to Predicate\Expression in this namespace. It now imports the class under a SqlExpression alias. --- src/Sql/Predicate/Predicate.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Sql/Predicate/Predicate.php b/src/Sql/Predicate/Predicate.php index c9d2afb7..7138418d 100644 --- a/src/Sql/Predicate/Predicate.php +++ b/src/Sql/Predicate/Predicate.php @@ -5,6 +5,7 @@ namespace PhpDb\Sql\Predicate; use PhpDb\Sql\ArgumentInterface; +use PhpDb\Sql\Expression as SqlExpression; use PhpDb\Sql\ExpressionInterface; use PhpDb\Sql\Predicate\Exception\InvalidArgumentException; use PhpDb\Sql\Predicate\Exception\RuntimeException; @@ -15,7 +16,7 @@ /** * @api * - * @psalm-import-type ExpressionParameter from \PhpDb\Sql\Expression + * @psalm-import-type ExpressionParameter from SqlExpression * * @property Predicate $and * @property Predicate $or From 65bb93cf70455fc7363563b9418ea443dc63645b Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 12 Oct 2026 13:53:10 +1100 Subject: [PATCH 4/7] Import RuntimeException in the DDL processors The @throws tags added to AlterTable, CreateTable and DropTable named \PhpDb\Sql\Exception\RuntimeException in full. Each class now imports it. --- src/Sql/Ddl/AlterTable.php | 9 +++++---- src/Sql/Ddl/CreateTable.php | 7 ++++--- src/Sql/Ddl/DropTable.php | 3 ++- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Sql/Ddl/AlterTable.php b/src/Sql/Ddl/AlterTable.php index 07f86740..eaee0bcc 100644 --- a/src/Sql/Ddl/AlterTable.php +++ b/src/Sql/Ddl/AlterTable.php @@ -6,6 +6,7 @@ use PhpDb\Adapter\Platform\PlatformInterface; use PhpDb\Sql\AbstractSql; +use PhpDb\Sql\Exception\RuntimeException; use PhpDb\Sql\Literal; use PhpDb\Sql\TableIdentifier; @@ -214,7 +215,7 @@ public function setTable(string|TableIdentifier $name): static /** * @return array{0: list} - * @throws \PhpDb\Sql\Exception\RuntimeException + * @throws RuntimeException */ protected function processAddColumns(PlatformInterface $adapterPlatform): array { @@ -223,7 +224,7 @@ protected function processAddColumns(PlatformInterface $adapterPlatform): array /** * @return array{0: list} - * @throws \PhpDb\Sql\Exception\RuntimeException + * @throws RuntimeException */ protected function processAddConstraints(PlatformInterface $adapterPlatform): array { @@ -232,7 +233,7 @@ protected function processAddConstraints(PlatformInterface $adapterPlatform): ar /** * @return array{0: list} - * @throws \PhpDb\Sql\Exception\RuntimeException + * @throws RuntimeException */ protected function processChangeColumns(PlatformInterface $adapterPlatform): array { @@ -273,7 +274,7 @@ protected function processDropIndexes(PlatformInterface $adapterPlatform): array /** * @return string[] - * @throws \PhpDb\Sql\Exception\RuntimeException + * @throws RuntimeException */ protected function processTable(PlatformInterface $adapterPlatform): array { diff --git a/src/Sql/Ddl/CreateTable.php b/src/Sql/Ddl/CreateTable.php index ba54463d..ee512e4a 100644 --- a/src/Sql/Ddl/CreateTable.php +++ b/src/Sql/Ddl/CreateTable.php @@ -6,6 +6,7 @@ use PhpDb\Adapter\Platform\PlatformInterface; use PhpDb\Sql\AbstractSql; +use PhpDb\Sql\Exception\RuntimeException; use PhpDb\Sql\Literal; use PhpDb\Sql\TableIdentifier; @@ -151,7 +152,7 @@ public function setTemporary(string|int|bool $temporary): static /** * @return string[][]|null - * @throws \PhpDb\Sql\Exception\RuntimeException + * @throws RuntimeException */ protected function processColumns(PlatformInterface $adapterPlatform): ?array { @@ -172,7 +173,7 @@ protected function processCombinedby(): ?array /** * @return string[][]|null - * @throws \PhpDb\Sql\Exception\RuntimeException + * @throws RuntimeException */ protected function processConstraints(PlatformInterface $adapterPlatform): ?array { @@ -193,7 +194,7 @@ protected function processStatementEnd(): array /** * @return string[] - * @throws \PhpDb\Sql\Exception\RuntimeException + * @throws RuntimeException */ protected function processTable(PlatformInterface $adapterPlatform): array { diff --git a/src/Sql/Ddl/DropTable.php b/src/Sql/Ddl/DropTable.php index cb676874..e10f4407 100644 --- a/src/Sql/Ddl/DropTable.php +++ b/src/Sql/Ddl/DropTable.php @@ -6,6 +6,7 @@ use PhpDb\Adapter\Platform\PlatformInterface; use PhpDb\Sql\AbstractSql; +use PhpDb\Sql\Exception\RuntimeException; use PhpDb\Sql\TableIdentifier; /** @@ -44,7 +45,7 @@ public function ifExists(bool $ifExists = true): static /** * @return string[] - * @throws \PhpDb\Sql\Exception\RuntimeException + * @throws RuntimeException */ protected function processTable(PlatformInterface $adapterPlatform): array { From 791a095d1ecb847b05df76410ea6ed7d8bfdd2c2 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 12 Oct 2026 13:54:36 +1100 Subject: [PATCH 5/7] Import the last two fully qualified docblock names HydratingResultSet documented \Laminas\Hydrator\Exception\RuntimeException in full; it now imports it as HydratorRuntimeException, since the class already imports the ResultSet RuntimeException. TableIdentifierFactory linked \PhpDb\ConfigProvider in full; it now imports ConfigProvider. --- src/ResultSet/HydratingResultSet.php | 3 ++- src/Sql/TableIdentifierFactory.php | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ResultSet/HydratingResultSet.php b/src/ResultSet/HydratingResultSet.php index 02c23cc9..3ea4ae43 100644 --- a/src/ResultSet/HydratingResultSet.php +++ b/src/ResultSet/HydratingResultSet.php @@ -6,6 +6,7 @@ use ArrayObject; use Laminas\Hydrator\ArraySerializableHydrator; +use Laminas\Hydrator\Exception\RuntimeException as HydratorRuntimeException; use Laminas\Hydrator\HydratorInterface; use Override; use PhpDb\ResultSet\Exception\RuntimeException; @@ -119,7 +120,7 @@ public function toArray(): array * Hydrate one row onto a clone of the prototype. * * @throws Exception\UnexpectedValueException If the row is not row data. - * @throws \Laminas\Hydrator\Exception\RuntimeException If the hydrator cannot fill the prototype. + * @throws HydratorRuntimeException If the hydrator cannot fill the prototype. */ #[Override] protected function mapRow(mixed $row): object diff --git a/src/Sql/TableIdentifierFactory.php b/src/Sql/TableIdentifierFactory.php index 0988cbf5..1c57ae96 100644 --- a/src/Sql/TableIdentifierFactory.php +++ b/src/Sql/TableIdentifierFactory.php @@ -4,11 +4,13 @@ namespace PhpDb\Sql; +use PhpDb\ConfigProvider; + /** * Callable factory producing {@see TableIdentifier} instances with a * preconfigured table prefix and separator. * - * Register via {@see \PhpDb\ConfigProvider} and configure the prefix once + * Register via {@see ConfigProvider} and configure the prefix once * (e.g. in application config) to have every identifier created through the * factory share the same prefix — convenient for creating backup_* tables * during a migration. From 4e1f911a62ad14e486c84e3eb0e2badf8d3db8b5 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 12 Oct 2026 15:51:47 +1100 Subject: [PATCH 6/7] Keep the original clause property names and make the @property tags nullable This reverts the whereClause, havingClause and joinClause renaming in Select, Update and Delete. The lazy clauses are stored in $where, $having and $joins again, so subclasses that use those protected properties keep working. The analyzer read every $this->where inside these classes as the @property tag for __get(), which declared a non-null Where, and reported the null checks and null assignments that make the clauses lazy. The tags now declare ?Where, ?Having and ?Join, matching the properties. __get() still always returns a clause at runtime. Also prunes three baseline entries for AbstractPdo that the createStatement() docblock fix resolved. --- analyzer-baseline.toml | 18 ----------------- src/Sql/Delete.php | 12 ++++++------ src/Sql/Select.php | 44 +++++++++++++++++++++--------------------- src/Sql/Update.php | 24 +++++++++++------------ 4 files changed, 40 insertions(+), 58 deletions(-) diff --git a/analyzer-baseline.toml b/analyzer-baseline.toml index 39ab6cfa..f34d05ef 100644 --- a/analyzer-baseline.toml +++ b/analyzer-baseline.toml @@ -324,12 +324,6 @@ code = "impossible-type-comparison" message = "Impossible type assertion: `$name` of type `string` can never be `int`." count = 1 -[[issues]] -file = "src/Adapter/Driver/Pdo/AbstractPdo.php" -code = "incompatible-parameter-type" -message = 'Parameter `$sqlOrResource` of `PhpDb\Adapter\Driver\Pdo\AbstractPdo::createstatement()` expects type `PDOStatement|string` but parent `PhpDb\Adapter\Driver\DriverInterface::createstatement()` expects type `resource|string`' -count = 1 - [[issues]] file = "src/Adapter/Driver/Pdo/AbstractPdo.php" code = "invalid-return-statement" @@ -372,24 +366,12 @@ code = "overly-wide-return-type" message = 'Declared return type `PhpDb\Adapter\Driver\ResultInterface|null` for `getresultprototype` has unused branches: `null`.' count = 1 -[[issues]] -file = "src/Adapter/Driver/Pdo/AbstractPdo.php" -code = "redundant-condition" -message = "This condition (type `true`) will always evaluate to true." -count = 1 - [[issues]] file = "src/Adapter/Driver/Pdo/AbstractPdo.php" code = "redundant-logical-operation" message = "Redundant `&&` operation: left operand is always falsy and right operand is not evaluated." count = 1 -[[issues]] -file = "src/Adapter/Driver/Pdo/AbstractPdo.php" -code = "redundant-type-comparison" -message = "Redundant type assertion: `$sqlOrResource` is already `string`." -count = 1 - [[issues]] file = "src/Adapter/Driver/Pdo/AbstractPdo.php" code = "too-few-arguments" diff --git a/src/Sql/Delete.php b/src/Sql/Delete.php index b76a0fbd..b8e9fefd 100644 --- a/src/Sql/Delete.php +++ b/src/Sql/Delete.php @@ -16,7 +16,7 @@ /** * @api * - * @property Where $where + * @property ?Where $where * @psalm-import-type Specification from AbstractSql * @psalm-import-type PredicateValue from Predicate\PredicateSet */ @@ -40,7 +40,7 @@ class Delete extends AbstractPreparableSql protected bool $emptyWhereProtection = true; - protected ?Where $whereClause = null; + protected ?Where $where = null; /** * Constructor @@ -87,7 +87,7 @@ public function where( string $combination = Predicate\PredicateSet::OP_AND, ): static { if ($predicate instanceof Where) { - $this->whereClause = $predicate; + $this->where = $predicate; return $this; } @@ -119,19 +119,19 @@ protected function processWhere( ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, ): ?string { - if (null === $this->whereClause || $this->whereClause->count() === 0) { + if (null === $this->where || $this->where->count() === 0) { return null; } return $this->formatSpecification( static::SPECIFICATION_WHERE, - $this->processExpression($this->whereClause, $platform, $driver, $parameterContainer, 'where'), + $this->processExpression($this->where, $platform, $driver, $parameterContainer, 'where'), ); } private function getWhere(): Where { - return $this->whereClause ??= new Where(); + return $this->where ??= new Where(); } /** diff --git a/src/Sql/Select.php b/src/Sql/Select.php index 54849fd3..183750be 100644 --- a/src/Sql/Select.php +++ b/src/Sql/Select.php @@ -32,9 +32,9 @@ /** * @api * - * @property Where $where - * @property Having $having - * @property Join $joins + * @property ?Where $where + * @property ?Having $having + * @property ?Join $joins * @psalm-import-type Specification from AbstractSql * @psalm-import-type PredicateValue from Predicate\PredicateSet * @psalm-import-type JoinName from Join @@ -155,9 +155,9 @@ class Select extends AbstractPreparableSql /** @var ColumnList */ protected array $columns = [self::SQL_STAR]; - protected ?Join $joinClause = null; + protected ?Join $joins = null; - protected ?Where $whereClause = null; + protected ?Where $where = null; /** @var ColumnList */ protected array $order = []; @@ -165,7 +165,7 @@ class Select extends AbstractPreparableSql /** @var list|null */ protected ?array $group = null; - protected ?Having $havingClause = null; + protected ?Having $having = null; protected string|int|null $limit = null; @@ -293,7 +293,7 @@ public function having( string $combination = Predicate\PredicateSet::OP_AND, ): static { if ($predicate instanceof Having) { - $this->havingClause = $predicate; + $this->having = $predicate; return $this; } @@ -402,10 +402,10 @@ public function reset(string $part): static self::TABLE => $this->table = null, self::QUANTIFIER => $this->quantifier = null, self::COLUMNS => $this->columns = [], - self::JOINS => $this->joinClause = null, - self::WHERE => $this->whereClause = null, + self::JOINS => $this->joins = null, + self::WHERE => $this->where = null, self::GROUP => $this->group = null, - self::HAVING => $this->havingClause = null, + self::HAVING => $this->having = null, self::LIMIT => $this->limit = null, self::OFFSET => $this->offset = null, self::ORDER => $this->order = [], @@ -441,7 +441,7 @@ public function where( string $combination = Predicate\PredicateSet::OP_AND, ): self { if ($predicate instanceof Where) { - $this->whereClause = $predicate; + $this->where = $predicate; return $this; } @@ -516,12 +516,12 @@ protected function processHaving( ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, ): ?array { - if (null === $this->havingClause || $this->havingClause->count() === 0) { + if (null === $this->having || $this->having->count() === 0) { return null; } return [ - $this->processExpression($this->havingClause, $platform, $driver, $parameterContainer, 'having'), + $this->processExpression($this->having, $platform, $driver, $parameterContainer, 'having'), ]; } @@ -534,7 +534,7 @@ protected function processJoins( ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, ): ?array { - return $this->processJoin($this->joinClause, $platform, $driver, $parameterContainer); + return $this->processJoin($this->joins, $platform, $driver, $parameterContainer); } /** @return array{0: string}|null */ @@ -739,28 +739,28 @@ protected function processWhere( ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, ): ?array { - if (null === $this->whereClause || $this->whereClause->count() === 0) { + if (null === $this->where || $this->where->count() === 0) { return null; } return [ - $this->processExpression($this->whereClause, $platform, $driver, $parameterContainer, 'where'), + $this->processExpression($this->where, $platform, $driver, $parameterContainer, 'where'), ]; } private function getHaving(): Having { - return $this->havingClause ??= new Having(); + return $this->having ??= new Having(); } private function getJoins(): Join { - return $this->joinClause ??= new Join(); + return $this->joins ??= new Join(); } private function getWhere(): Where { - return $this->whereClause ??= new Where(); + return $this->where ??= new Where(); } private function prefixFromTable(?string $fromTable, PlatformInterface $platform): string @@ -818,9 +818,9 @@ private function splitOrderString(string $order): array */ public function __clone() { - $this->whereClause = null === $this->whereClause ? null : clone $this->whereClause; - $this->joinClause = null === $this->joinClause ? null : clone $this->joinClause; - $this->havingClause = null === $this->havingClause ? null : clone $this->havingClause; + $this->where = null === $this->where ? null : clone $this->where; + $this->joins = null === $this->joins ? null : clone $this->joins; + $this->having = null === $this->having ? null : clone $this->having; } /** diff --git a/src/Sql/Update.php b/src/Sql/Update.php index cd5f4fdf..ee9e6020 100644 --- a/src/Sql/Update.php +++ b/src/Sql/Update.php @@ -22,7 +22,7 @@ /** * @api * - * @property Where $where + * @property ?Where $where * @psalm-import-type Specification from AbstractSql * @psalm-import-type ColumnValue from AbstractSql * @psalm-import-type PredicateValue from Predicate\PredicateSet @@ -67,9 +67,9 @@ class Update extends AbstractPreparableSql /** @var PriorityList|null */ protected ?PriorityList $set = null; - protected ?Where $whereClause = null; + protected ?Where $where = null; - protected ?Join $joinClause = null; + protected ?Join $joins = null; /** * Constructor @@ -158,7 +158,7 @@ public function where( string $combination = Predicate\PredicateSet::OP_AND, ): static { if ($predicate instanceof Where) { - $this->whereClause = $predicate; + $this->where = $predicate; return $this; } @@ -177,7 +177,7 @@ protected function processJoins( ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, ): ?array { - return $this->processJoin($this->joinClause, $platform, $driver, $parameterContainer); + return $this->processJoin($this->joins, $platform, $driver, $parameterContainer); } /** @@ -254,19 +254,19 @@ protected function processWhere( ?DriverInterface $driver = null, ?ParameterContainer $parameterContainer = null, ): ?string { - if (null === $this->whereClause || $this->whereClause->count() === 0) { + if (null === $this->where || $this->where->count() === 0) { return null; } return $this->formatSpecification( static::SPECIFICATION_WHERE, - $this->processExpression($this->whereClause, $platform, $driver, $parameterContainer, 'where'), + $this->processExpression($this->where, $platform, $driver, $parameterContainer, 'where'), ); } private function getJoins(): Join { - return $this->joinClause ??= new Join(); + return $this->joins ??= new Join(); } /** @@ -283,7 +283,7 @@ private function getSet(): PriorityList private function getWhere(): Where { - return $this->whereClause ??= new Where(); + return $this->where ??= new Where(); } /** @@ -295,9 +295,9 @@ private function getWhere(): Where */ public function __clone() { - $this->whereClause = null === $this->whereClause ? null : clone $this->whereClause; - $this->joinClause = null === $this->joinClause ? null : clone $this->joinClause; - $this->set = null === $this->set ? null : clone $this->set; + $this->where = null === $this->where ? null : clone $this->where; + $this->joins = null === $this->joins ? null : clone $this->joins; + $this->set = null === $this->set ? null : clone $this->set; } /** From 261bc47dccee9aaec04d14ffef240842b826ca64 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 12 Oct 2026 15:54:23 +1100 Subject: [PATCH 7/7] Keep Predicate's $unnest name and make its @property tags nullable This reverts the $parent renaming, to match the clause properties on Select, Update and Delete. The private property is $unnest again, and the @property tags for unnest and UNNEST declare ?Predicate so the analyzer accepts the null checks and null assignments inside the class. __get('unnest') still returns the parent or throws when the predicate is not nested. --- src/Sql/Predicate/Predicate.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Sql/Predicate/Predicate.php b/src/Sql/Predicate/Predicate.php index 7138418d..25222869 100644 --- a/src/Sql/Predicate/Predicate.php +++ b/src/Sql/Predicate/Predicate.php @@ -23,13 +23,13 @@ * @property Predicate $AND * @property Predicate $OR * @property Predicate $nest - * @property Predicate $unnest + * @property ?Predicate $unnest * @property Predicate $NEST - * @property Predicate $UNNEST + * @property ?Predicate $UNNEST */ class Predicate extends PredicateSet { - private ?Predicate $parent = null; + private ?Predicate $unnest = null; protected ?string $nextPredicateCombineOperator = null; @@ -346,7 +346,7 @@ public function predicate(PredicateInterface $predicate): static */ public function setUnnest(?Predicate $predicate = null): void { - $this->parent = $predicate; + $this->unnest = $predicate; } /** @@ -356,12 +356,12 @@ public function setUnnest(?Predicate $predicate = null): void */ public function unnest(): self { - if (! $this->parent instanceof Predicate) { + if (! $this->unnest instanceof Predicate) { throw RuntimeException::forNotNested(); } - $parent = $this->parent; - $this->parent = null; + $parent = $this->unnest; + $this->unnest = null; return $parent; }