Skip to content
Merged
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
110 changes: 110 additions & 0 deletions src/Type/TypeCombinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@
use function get_class;
use function implode;
use function in_array;
use function is_bool;
use function is_int;
use function is_string;
use function sprintf;
use function usort;
use const PHP_INT_MAX;
Expand Down Expand Up @@ -1498,6 +1500,98 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged
return array_merge($newArrays, $arraysToProcess);
}

/**
* Fast path for intersect(): the intersection of two unions whose members are all
* finite, mutually-disjoint values (constant scalars and/or enum cases) is their
* identity-keyed set intersection. Returns null when either union has a member that is
* not such a value, in which case the caller falls back to the general A & (B|C)
* distribution.
*/
private static function intersectFiniteUnions(UnionType $a, UnionType $b): ?Type
{
$membersA = self::finiteUnionMembers($a);
if ($membersA === null) {
return null;
}

$membersB = self::finiteUnionMembers($b);
Comment thread
staabm marked this conversation as resolved.
if ($membersB === null) {
return null;
}

$common = [];
foreach ($membersA as $key => $member) {
if (!array_key_exists($key, $membersB)) {
continue;
}

$common[] = $member;
}

if ($common === []) {
return new NeverType();
}

return self::union(...$common);
}

/**
* Keys a union's members by identity for the finite-union fast path in intersect().
*
* Handles constant scalars and enum cases: each stands for one concrete value, so two
* members are interchangeable iff they share a key and are otherwise disjoint. Returns
* null if any member is not such a value. Class-string constant strings are excluded
* (the class-string flag is not captured by the value) and floats are excluded (-0.0 /
* NAN comparison quirks). Enum cases are keyed by class + case name, the identity
* EnumCaseObjectType::equals() compares.
*
* @return array<string, Type>|null
*/
private static function finiteUnionMembers(UnionType $union): ?array
{
$members = [];
foreach ($union->getTypes() as $member) {
$enumCase = $member->getEnumCaseObject();
if ($member->isNull()->yes()) {
$key = 'null';
} elseif ($enumCase !== null) {
// getEnumCaseObject() also returns the case for a refined member - an
// intersection like $this & Enum::C, a whole single-case enum, or an enum
// subtracted to one case - none of which are a bare EnumCaseObjectType.
// Only a bare case is safe to key by class + case name; for the rest,
// EnumCaseObjectType::equals() is false (it requires an EnumCaseObjectType),
// so bail to the slow path rather than collapse the refinement.
if (!$enumCase->equals($member)) {
return null;
}

// Key by class + case name, the identity EnumCaseObjectType::equals() compares
// (describe() would also fold in a subtracted type, which equals() ignores).
$key = 'enum:' . $enumCase->getClassName() . '::' . $enumCase->getEnumCaseName();
} else {
$values = $member->getConstantScalarValues();
if (count($values) !== 1) {
return null;
}

$value = $values[0];
if (is_int($value)) {
$key = 'i:' . $value;
} elseif (is_bool($value)) {
$key = $value ? 'b:1' : 'b:0';
} elseif (is_string($value) && $member->isClassString()->no()) {
$key = 's:' . $value;
} else {
return null;
}
}

$members[$key] = $member;
}

return $members;
}

public static function intersect(Type ...$types): Type
{
$typesCount = count($types);
Expand All @@ -1516,6 +1610,22 @@ public static function intersect(Type ...$types): Type
}
}

// Fast path: the intersection of two plain unions whose members are all finite,
// mutually-disjoint values (constant scalars and/or enum cases) is their
// identity-keyed set intersection (O(n)), avoiding the O(n*m) `A & (B|C)`
// distribution + union rebuild below. Restricted to the exact UnionType class so
// BenevolentUnionType and the template union types keep their dedicated handling.
if (
$typesCount === 2
&& get_class($types[0]) === UnionType::class
&& get_class($types[1]) === UnionType::class
) {
$finiteIntersection = self::intersectFiniteUnions($types[0], $types[1]);
if ($finiteIntersection !== null) {
return $finiteIntersection;
}
}

$sortTypes = static function (Type $a, Type $b): int {
if (!$a instanceof UnionType || !$b instanceof UnionType) {
return 0;
Expand Down
127 changes: 127 additions & 0 deletions tests/PHPStan/Type/TypeCombinatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5632,6 +5632,133 @@ public static function dataIntersect(): iterable
ConstantArrayType::class,
'array{a: int}',
];

// intersection of two constant-scalar unions (constant-union fast path)
yield [
[
'0|1|2|3',
'2|3|4|5',
],
UnionType::class,
'2|3',
];

yield [
[
"'a'|'b'|'c'",
"'b'|'c'|'d'",
],
UnionType::class,
"'b'|'c'",
];

yield [
[
'1|2',
'3|4',
],
NeverType::class,
'*NEVER*=implicit',
];

yield [
[
'0|1',
'1|2',
],
ConstantIntegerType::class,
'1',
];

yield [
[
"0|1|'a'|'b'|null",
"1|2|'a'|'c'|null",
],
UnionType::class,
"1|'a'|null",
];

// a non-constant member makes the fast path bail to the normal distribution
yield [
[
'0|1|2|non-empty-string',
'1|2',
],
UnionType::class,
'1|2',
];

// finite-union fast path: enum-case unions
yield [
[
new UnionType([
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'ONE'),
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'TWO'),
new EnumCaseObjectType('PHPStan\Fixture\AnotherTestEnum', 'ONE'),
]),
new UnionType([
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'ONE'),
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'TWO'),
new EnumCaseObjectType('PHPStan\Fixture\AnotherTestEnum', 'TWO'),
]),
],
UnionType::class,
'PHPStan\Fixture\TestEnum::ONE|PHPStan\Fixture\TestEnum::TWO',
];

// finite-union fast path: mixed constant scalars + enum cases
yield [
[
new UnionType([
new ConstantIntegerType(0),
new ConstantIntegerType(1),
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'ONE'),
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'TWO'),
]),
new UnionType([
new ConstantIntegerType(1),
new ConstantIntegerType(2),
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'ONE'),
]),
],
UnionType::class,
'1|PHPStan\Fixture\TestEnum::ONE',
];

// a backed enum case (TestEnum::TWO = 2) must not be conflated with the integer 2
yield [
[
new UnionType([
new ConstantIntegerType(2),
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'TWO'),
]),
new UnionType([
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'ONE'),
new EnumCaseObjectType('PHPStan\Fixture\TestEnum', 'TWO'),
]),
],
EnumCaseObjectType::class,
'PHPStan\Fixture\TestEnum::TWO',
];

// a constant string '0'/'1' must not be conflated with the integer 0/1
yield [
[
new UnionType([
new ConstantIntegerType(0),
new ConstantStringType('0'),
new ConstantIntegerType(1),
new ConstantStringType('1'),
]),
new UnionType([
new ConstantStringType('0'),
new ConstantIntegerType(1),
]),
],
UnionType::class,
"1|'0'",
];
}

/**
Expand Down
Loading
Loading