From 8717a0a5ab7c0f097c450bbb2258074a28cdc3bb Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 5 Oct 2026 10:39:18 +1100 Subject: [PATCH 1/3] Read join and FROM aliases with array_key_first() AbstractSql::processJoin() and Select read an aliased table's alias with array_key_first() instead of key()/current(), so the result no longer depends on the array's internal pointer and the analyzer can see the alias is a string. Signed-off-by: Simon Mundy --- analyzer-baseline.toml | 30 ++++++++++++++++++------------ src/Sql/AbstractSql.php | 8 ++++---- src/Sql/Select.php | 8 ++++---- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/analyzer-baseline.toml b/analyzer-baseline.toml index e05716c7..ef9b4cbb 100644 --- a/analyzer-baseline.toml +++ b/analyzer-baseline.toml @@ -1842,12 +1842,6 @@ 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-false-argument" -message = 'Argument #1 of method `PhpDb\Adapter\Platform\PlatformInterface::quoteIdentifier` is possibly `false`, but parameter type `string` does not accept it.' -count = 1 - [[issues]] file = "src/Sql/AbstractSql.php" code = "possibly-invalid-argument" @@ -1914,6 +1908,12 @@ 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" @@ -3636,12 +3636,6 @@ 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-argument" -message = 'Argument #1 of method `PhpDb\Sql\AbstractSql::resolveTable` is possibly `false`, but parameter type `PhpDb\Sql\Select|PhpDb\Sql\TableIdentifier|null|string` does not accept it.' -count = 1 - [[issues]] file = "src/Sql/Select.php" code = "possibly-false-iterator" @@ -3690,6 +3684,12 @@ 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" @@ -3726,6 +3726,12 @@ code = "possibly-undefined-string-array-index" message = '''Possibly undefined array key 'type' accessed on `array{'modifier'?: truthy-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 `null|string` accessed on `array`.' +count = 1 + [[issues]] file = "src/Sql/Select.php" code = "redundant-comparison" diff --git a/src/Sql/AbstractSql.php b/src/Sql/AbstractSql.php index d290bdab..e3540f77 100644 --- a/src/Sql/AbstractSql.php +++ b/src/Sql/AbstractSql.php @@ -17,13 +17,12 @@ use PhpDb\Sql\Argument\Values; use PhpDb\Sql\Platform\PlatformDecoratorInterface; +use function array_key_first; use function count; -use function current; use function get_object_vars; use function implode; use function is_array; use function is_string; -use function key; use function rtrim; use function str_replace; use function strtoupper; @@ -343,8 +342,9 @@ protected function processJoin( $joinAs = null; $joinNameValue = $join['name']; if (is_array($joinNameValue)) { - $joinName = current($joinNameValue); - $joinAs = $platform->quoteIdentifier(key($joinNameValue)); + $alias = array_key_first($joinNameValue); + $joinName = $joinNameValue[$alias]; + $joinAs = $platform->quoteIdentifier($alias); } else { $joinName = $joinNameValue; } diff --git a/src/Sql/Select.php b/src/Sql/Select.php index d761a95e..fe673016 100644 --- a/src/Sql/Select.php +++ b/src/Sql/Select.php @@ -12,8 +12,8 @@ use PhpDb\Sql\Predicate\PredicateInterface; use function array_key_exists; +use function array_key_first; use function count; -use function current; use function explode; use function gettype; use function is_array; @@ -650,7 +650,7 @@ protected function processSelect( } foreach ($this->getJoins()->getJoins() as $join) { - $joinName = is_array($join['name']) ? key($join['name']) : $join['name']; + $joinName = is_array($join['name']) ? array_key_first($join['name']) : $join['name']; $joinName = parent::resolveTable($joinName, $platform, $driver, $parameterContainer); foreach ($join['columns'] as $jKey => $jColumn) { @@ -746,8 +746,8 @@ protected function resolveTable( $alias = null; if (is_array($table)) { - $alias = key($table); - $table = current($table); + $alias = array_key_first($table); + $table = $table[$alias]; } $table = parent::resolveTable($table, $platform, $driver, $parameterContainer); From 8043c858991851ee45aa3279a0ee62e53018c963 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 5 Oct 2026 10:39:32 +1100 Subject: [PATCH 2/3] Walk DDL column lists with foreach AbstractConstraint and Index iterate their column lists with foreach instead of indexing from zero, which the analyzer cannot prove is in range. Signed-off-by: Simon Mundy --- analyzer-baseline.toml | 24 ------------------- src/Sql/Ddl/Constraint/AbstractConstraint.php | 4 ++-- src/Sql/Ddl/Index/Index.php | 6 ++--- 3 files changed, 4 insertions(+), 30 deletions(-) diff --git a/analyzer-baseline.toml b/analyzer-baseline.toml index ef9b4cbb..9db6379b 100644 --- a/analyzer-baseline.toml +++ b/analyzer-baseline.toml @@ -2346,18 +2346,6 @@ code = "missing-api-or-internal" message = 'Abstract class `PhpDb\Sql\Ddl\Constraint\AbstractConstraint` is missing an `@api` or `@internal` annotation.' count = 1 -[[issues]] -file = "src/Sql/Ddl/Constraint/AbstractConstraint.php" -code = "possibly-null-argument" -message = 'Argument #1 of method `PhpDb\Sql\Argument\Identifier::__construct` is possibly `null`, but parameter type `string` does not accept it.' -count = 1 - -[[issues]] -file = "src/Sql/Ddl/Constraint/AbstractConstraint.php" -code = "possibly-undefined-int-array-index" -message = "Possibly undefined array index accessed on `list`." -count = 1 - [[issues]] file = "src/Sql/Ddl/Constraint/Check.php" code = "class-must-be-final" @@ -2532,18 +2520,6 @@ code = "class-must-be-final" message = 'Class `PhpDb\Sql\Ddl\Index\Index` should be declared `final`.' count = 1 -[[issues]] -file = "src/Sql/Ddl/Index/Index.php" -code = "possibly-null-argument" -message = 'Argument #1 of method `PhpDb\Sql\Argument\Identifier::__construct` is possibly `null`, but parameter type `string` does not accept it.' -count = 1 - -[[issues]] -file = "src/Sql/Ddl/Index/Index.php" -code = "possibly-undefined-int-array-index" -message = "Possibly undefined array index accessed on `list`." -count = 1 - [[issues]] file = "src/Sql/Delete.php" code = "class-must-be-final" diff --git a/src/Sql/Ddl/Constraint/AbstractConstraint.php b/src/Sql/Ddl/Constraint/AbstractConstraint.php index bea2ecee..687bba42 100644 --- a/src/Sql/Ddl/Constraint/AbstractConstraint.php +++ b/src/Sql/Ddl/Constraint/AbstractConstraint.php @@ -70,8 +70,8 @@ public function getExpressionData(): array if (0 !== $columnCount) { $columnSpec = array_fill(0, count: $columnCount, value: '%s'); $specParts[] = str_replace('%s', implode(', ', $columnSpec), $this->columnSpecification); - for ($i = 0; $i < $columnCount; $i++) { - $values[] = new Identifier($this->columns[$i]); + foreach ($this->columns as $column) { + $values[] = new Identifier($column); } } diff --git a/src/Sql/Ddl/Index/Index.php b/src/Sql/Ddl/Index/Index.php index 04597226..431b5f5a 100644 --- a/src/Sql/Ddl/Index/Index.php +++ b/src/Sql/Ddl/Index/Index.php @@ -8,7 +8,6 @@ use PhpDb\Sql\Argument\Identifier; use PhpDb\Sql\Argument\Literal; -use function count; use function implode; use function str_replace; @@ -36,13 +35,12 @@ public function __construct(array|string|null $columns, ?string $name = null, ar #[Override] public function getExpressionData(): array { - $colCount = count($this->columns); $values = [new Identifier($this->name)]; $specParts = []; - for ($i = 0; $i < $colCount; $i++) { + foreach ($this->columns as $i => $column) { $specPart = '%s'; - $values[] = new Identifier($this->columns[$i]); + $values[] = new Identifier($column); if (null !== ($this->lengths[$i] ?? null)) { $specPart .= "({$this->lengths[$i]})"; From 2ee2ed1e9f8f2b81f491b84b2aae0e4fe47a1b86 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Tue, 6 Oct 2026 13:18:01 +1100 Subject: [PATCH 3/3] Kill the escaped AbstractConstraint mutants Cover multi-column primary and unique keys, which a single column could not distinguish from leaving the column specification unexpanded. Ignore the array_fill() start-index mutants in getExpressionData(), as already done for ForeignKey: implode() discards the keys, so the start index cannot be observed. Signed-off-by: Simon Mundy --- infection.json5.dist | 2 ++ .../unit/Sql/Ddl/Constraint/PrimaryKeyTest.php | 17 +++++++++++++++++ test/unit/Sql/Ddl/Constraint/UniqueKeyTest.php | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/infection.json5.dist b/infection.json5.dist index a91839b3..9297d843 100644 --- a/infection.json5.dist +++ b/infection.json5.dist @@ -22,6 +22,7 @@ // implode() discards keys, so array_fill()'s start index cannot be observed. "PhpDb\\Sql\\Argument\\Identifiers::getSpecification", "PhpDb\\Sql\\Argument\\Values::getSpecification", + "PhpDb\\Sql\\Ddl\\Constraint\\AbstractConstraint::getExpressionData", "PhpDb\\Sql\\Ddl\\Constraint\\ForeignKey::getExpressionData" ] }, @@ -30,6 +31,7 @@ // implode() discards keys, so array_fill()'s start index cannot be observed. "PhpDb\\Sql\\Argument\\Identifiers::getSpecification", "PhpDb\\Sql\\Argument\\Values::getSpecification", + "PhpDb\\Sql\\Ddl\\Constraint\\AbstractConstraint::getExpressionData", "PhpDb\\Sql\\Ddl\\Constraint\\ForeignKey::getExpressionData" ] }, diff --git a/test/unit/Sql/Ddl/Constraint/PrimaryKeyTest.php b/test/unit/Sql/Ddl/Constraint/PrimaryKeyTest.php index b656a204..51db9d7c 100644 --- a/test/unit/Sql/Ddl/Constraint/PrimaryKeyTest.php +++ b/test/unit/Sql/Ddl/Constraint/PrimaryKeyTest.php @@ -28,4 +28,21 @@ public function getExpressionData(): void $expressionData['values'], ); } + + #[Test] + public function getExpressionDataListsEveryColumnInTheSpecification(): void + { + $pk = new PrimaryKey(['foo', 'bar']); + + $expressionData = $pk->getExpressionData(); + + static::assertSame('PRIMARY KEY (%s, %s)', $expressionData['spec']); + static::assertEquals( + [ + Argument::identifier('foo'), + Argument::identifier('bar'), + ], + $expressionData['values'], + ); + } } diff --git a/test/unit/Sql/Ddl/Constraint/UniqueKeyTest.php b/test/unit/Sql/Ddl/Constraint/UniqueKeyTest.php index 8eeef080..1da0f8e5 100644 --- a/test/unit/Sql/Ddl/Constraint/UniqueKeyTest.php +++ b/test/unit/Sql/Ddl/Constraint/UniqueKeyTest.php @@ -29,4 +29,22 @@ public function getExpressionData(): void $expressionData['values'], ); } + + #[Test] + public function getExpressionDataListsEveryColumnInTheSpecification(): void + { + $uk = new UniqueKey(['foo', 'bar'], 'my_uk'); + + $expressionData = $uk->getExpressionData(); + + static::assertSame('CONSTRAINT %s UNIQUE (%s, %s)', $expressionData['spec']); + static::assertEquals( + [ + Argument::identifier('my_uk'), + Argument::identifier('foo'), + Argument::identifier('bar'), + ], + $expressionData['values'], + ); + } }