Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/SqlAst/ParserInference.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Comment on lines +183 to +190
Copy link
Copy Markdown
Owner

@staabm staabm May 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we check $resultType->hasOffsetValueType() before we call $resultType->getOffsetValueType instead?


if (null !== $rawExpressionType && $resultType->hasOffsetValueType($rawExpressionType)->yes()) {
$resultType = $resultType->setOffsetValueType(
$rawExpressionType,
Expand Down
75 changes: 75 additions & 0 deletions tests/default/ParserInferenceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace staabm\PHPStanDba\Tests;

use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\IntegerRangeType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\VerbosityLevel;
use PHPUnit\Framework\TestCase;
use staabm\PHPStanDba\SchemaReflection\SchemaReflection;
use staabm\PHPStanDba\SqlAst\ParserInference;

class ParserInferenceTest extends TestCase
{
/**
* Regression: with FETCH_TYPE_ASSOC the reflector-derived row shape is
* string-keyed only. Reading an integer offset on it returns ErrorType
* under PHPStan >= 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<string, array{string}>
*/
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',
];
}
}
Loading