From ffe11da2c50df47574b78e526a271859f7065579 Mon Sep 17 00:00:00 2001 From: Artem Goutsoul Date: Fri, 29 May 2026 15:20:17 +0300 Subject: [PATCH] Preserve reflector column type when AST narrowing yields ErrorType With FETCH_TYPE_ASSOC + utilizeSqlAst(true) on PHPStan >= 2.2, a SELECT that references columns through a table alias (e.g. `SELECT sg.col FROM service_group sg`) collapses every column's value type to *ERROR*. In ParserInference::narrowResultType() the per-column seed reads the row shape at an integer offset, but the FETCH_TYPE_ASSOC reflector row is string-keyed only. PHPStan 2.2 changed missing-offset reads to return ErrorType (previously benign). For unqualified columns QueryScope refines this back to the real column type; for alias- qualified columns resolveExpression() has no branch and falls through to MixedType, so the ErrorType ends up overwriting the correct reflector-derived type under the column-name key. Fix: after the QueryScope refinement step, skip the column when the value type is still ErrorType - there is no information to add, so leave the reflector's type untouched. --- src/SqlAst/ParserInference.php | 10 ++++ tests/default/ParserInferenceTest.php | 75 +++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 tests/default/ParserInferenceTest.php diff --git a/src/SqlAst/ParserInference.php b/src/SqlAst/ParserInference.php index 3c5ef599..b7e46a16 100644 --- a/src/SqlAst/ParserInference.php +++ b/src/SqlAst/ParserInference.php @@ -8,6 +8,7 @@ use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; use PHPStan\Type\Type; use SqlFtw\Parser\Parser; @@ -179,6 +180,15 @@ public function narrowResultType(string $queryString, ConstantArrayType $resultT $valueType = $type; } + // PHPStan 2.2+ returns ErrorType when reading a missing offset (here + // the integer offset is missing on a string-keyed assoc row shape). + // If QueryScope couldn't refine the column either (e.g. alias-qualified + // references like `t.col`, which resolveExpression() returns MixedType + // for), there is nothing to narrow — keep the reflector-derived type. + if ($valueType instanceof ErrorType) { + continue; + } + if (null !== $rawExpressionType && $resultType->hasOffsetValueType($rawExpressionType)->yes()) { $resultType = $resultType->setOffsetValueType( $rawExpressionType, diff --git a/tests/default/ParserInferenceTest.php b/tests/default/ParserInferenceTest.php new file mode 100644 index 00000000..18068326 --- /dev/null +++ b/tests/default/ParserInferenceTest.php @@ -0,0 +1,75 @@ += 2.2 (previously a benign type). For alias-qualified + * columns (`t.col`) QueryScope::resolveExpression() returns MixedType, + * so the ErrorType used to be written over the (correct) reflector type + * under the column name key, collapsing the row shape to + * `array{col: *ERROR*, ...}`. + * + * @dataProvider provideAliasedAndUnaliasedQueries + */ + public function testAssocOnlyRowShapeIsPreservedForAliasedColumns(string $sql): void + { + $serviceGroupIdType = IntegerRangeType::fromInterval(0, 4294967295); + $nameType = new StringType(); + + // mysqli FETCH_TYPE_ASSOC row shape: ONLY string keys, no integer offsets. + $resultType = new ConstantArrayType( + [ + new ConstantStringType('service_group_id'), + new ConstantStringType('name'), + ], + [ + $serviceGroupIdType, + $nameType, + ] + ); + + $schema = new SchemaReflection(static function (string $query) use ($resultType): ?Type { + if (stripos($query, 'SELECT * FROM service_group') === 0) { + return $resultType; + } + return null; + }); + + $inference = new ParserInference($schema); + $narrowed = $inference->narrowResultType($sql, $resultType); + + self::assertSame( + 'array{service_group_id: int<0, 4294967295>, name: string}', + $narrowed->describe(VerbosityLevel::precise()) + ); + } + + /** + * @return iterable + */ + public function provideAliasedAndUnaliasedQueries(): iterable + { + yield 'unqualified columns' => [ + 'SELECT service_group_id, name FROM service_group', + ]; + yield 'alias-qualified columns' => [ + 'SELECT sg.service_group_id, sg.name FROM service_group sg', + ]; + } +}