Skip to content

Commit 3bb7c25

Browse files
committed
Use pairwise TypeCombinator::intersect folding for conditional expression holders to avoid exponential union distribution
- Change N-ary `TypeCombinator::intersect(...$allHolderTypes)` to pairwise folding in `MutatingScope::filterBySpecifiedTypes()` when processing matched conditional expressions - The N-ary call caused exponential blowup via the distributive law (A & (B|C) -> (A&B)|(A&C)) when multiple holders had large UnionTypes (e.g. 24^5 = ~8M combinations with 5 holders of 24 constant strings) - Pairwise folding produces the same result but reduces each step to at most N*M comparisons, where M shrinks after each intersection - Regression introduced in 52704a4 (Pass 2: Supertype match for conditional expressions) which allowed more holders to match - Add regression test and benchmark for phpstan/phpstan#14475
1 parent 5ec9767 commit 3bb7c25

File tree

4 files changed

+252
-1
lines changed

4 files changed

+252
-1
lines changed

src/Analyser/MutatingScope.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3266,7 +3266,10 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self
32663266
unset($scope->expressionTypes[$conditionalExprString]);
32673267
} else {
32683268
if (array_key_exists($conditionalExprString, $scope->expressionTypes)) {
3269-
$type = TypeCombinator::intersect(...array_map(static fn (ConditionalExpressionHolder $holder) => $holder->getTypeHolder()->getType(), $expressions));
3269+
$type = $expressions[0]->getTypeHolder()->getType();
3270+
for ($i = 1, $count = count($expressions); $i < $count; $i++) {
3271+
$type = TypeCombinator::intersect($type, $expressions[$i]->getTypeHolder()->getType());
3272+
}
32703273

32713274
$scope->expressionTypes[$conditionalExprString] = new ExpressionTypeHolder(
32723275
$scope->expressionTypes[$conditionalExprString]->getExpr(),

tests/PHPStan/Analyser/AnalyserIntegrationTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1529,6 +1529,12 @@ public function testBug14439(): void
15291529
$this->assertNoErrors($errors);
15301530
}
15311531

1532+
public function testBug14475(): void
1533+
{
1534+
$errors = $this->runAnalyse(__DIR__ . '/data/bug-14475.php');
1535+
$this->assertNoErrors($errors);
1536+
}
1537+
15321538
/**
15331539
* @param string[]|null $allAnalysedFiles
15341540
* @return list<Error>
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14475;
4+
5+
final class Foo
6+
{
7+
public const TYPE_A = 'type_a';
8+
public const TYPE_B = 'type_b';
9+
10+
public const CATEGORY_A = 'category_a';
11+
public const CATEGORY_B = 'category_b';
12+
public const CATEGORY_C = 'category_c';
13+
public const CATEGORY_D = 'category_d';
14+
public const CATEGORY_E = 'category_e';
15+
public const CATEGORY_F = 'category_f';
16+
public const CATEGORY_G = 'category_g';
17+
public const CATEGORY_H = 'category_h';
18+
public const CATEGORY_I = 'category_i';
19+
public const CATEGORY_J = 'category_j';
20+
public const CATEGORY_K = 'category_k';
21+
public const CATEGORY_L = 'category_l';
22+
public const CATEGORY_M = 'category_m';
23+
public const CATEGORY_N = 'category_n';
24+
public const CATEGORY_O = 'category_o';
25+
public const CATEGORY_P = 'category_p';
26+
public const CATEGORY_Q = 'category_q';
27+
public const CATEGORY_R = 'category_r';
28+
public const CATEGORY_S = 'category_s';
29+
public const CATEGORY_T = 'category_t';
30+
public const CATEGORY_U = 'category_u';
31+
public const CATEGORY_V = 'category_v';
32+
33+
public const STATUS_A = 'status_a';
34+
public const STATUS_B = 'status_b';
35+
public const STATUS_C = 'status_c';
36+
37+
public const PAGE_A = 'page_a';
38+
public const PAGE_B = 'page_b';
39+
public const PAGE_C = 'page_c';
40+
}
41+
42+
final class AssertHelper
43+
{
44+
/**
45+
* @param list<string> $haystack
46+
*/
47+
public static function stringInArray(string $value, array $haystack): void
48+
{
49+
}
50+
}
51+
52+
final class MinCase
53+
{
54+
/**
55+
* @phpstan-param array{
56+
* type: Foo::TYPE_*,
57+
* category: Foo::CATEGORY_*|Foo::STATUS_*,
58+
* page?: Foo::PAGE_*,
59+
* flag: bool
60+
* } $input
61+
*/
62+
public static function run(array $input): void
63+
{
64+
AssertHelper::stringInArray(
65+
$input['category'],
66+
[
67+
Foo::CATEGORY_A,
68+
Foo::CATEGORY_B,
69+
Foo::CATEGORY_C,
70+
Foo::CATEGORY_D,
71+
Foo::CATEGORY_E,
72+
Foo::CATEGORY_F,
73+
Foo::CATEGORY_G,
74+
Foo::CATEGORY_H,
75+
Foo::CATEGORY_I,
76+
Foo::CATEGORY_J,
77+
Foo::CATEGORY_K,
78+
Foo::CATEGORY_L,
79+
Foo::CATEGORY_M,
80+
Foo::STATUS_A,
81+
Foo::STATUS_B,
82+
Foo::STATUS_C,
83+
Foo::CATEGORY_N,
84+
Foo::CATEGORY_O,
85+
Foo::CATEGORY_P,
86+
Foo::CATEGORY_Q,
87+
Foo::CATEGORY_R,
88+
Foo::CATEGORY_S,
89+
Foo::CATEGORY_T,
90+
Foo::CATEGORY_U,
91+
Foo::CATEGORY_V,
92+
]
93+
);
94+
95+
if ($input['category'] === Foo::CATEGORY_C) {
96+
}
97+
98+
if ($input['category'] === Foo::CATEGORY_F) {
99+
}
100+
101+
if ($input['category'] === Foo::CATEGORY_G) {
102+
}
103+
104+
if ($input['category'] === Foo::CATEGORY_H) {
105+
}
106+
107+
if ($input['category'] === Foo::CATEGORY_I) {
108+
}
109+
110+
if ($input['category'] === Foo::CATEGORY_B) {
111+
AssertHelper::stringInArray(
112+
$input['page'] ?? '',
113+
[
114+
Foo::PAGE_A,
115+
Foo::PAGE_B,
116+
Foo::PAGE_C,
117+
]
118+
);
119+
}
120+
}
121+
}

tests/bench/data/bug-14475.php

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14475Bench;
4+
5+
final class Foo
6+
{
7+
public const TYPE_A = 'type_a';
8+
public const TYPE_B = 'type_b';
9+
10+
public const CATEGORY_A = 'category_a';
11+
public const CATEGORY_B = 'category_b';
12+
public const CATEGORY_C = 'category_c';
13+
public const CATEGORY_D = 'category_d';
14+
public const CATEGORY_E = 'category_e';
15+
public const CATEGORY_F = 'category_f';
16+
public const CATEGORY_G = 'category_g';
17+
public const CATEGORY_H = 'category_h';
18+
public const CATEGORY_I = 'category_i';
19+
public const CATEGORY_J = 'category_j';
20+
public const CATEGORY_K = 'category_k';
21+
public const CATEGORY_L = 'category_l';
22+
public const CATEGORY_M = 'category_m';
23+
public const CATEGORY_N = 'category_n';
24+
public const CATEGORY_O = 'category_o';
25+
public const CATEGORY_P = 'category_p';
26+
public const CATEGORY_Q = 'category_q';
27+
public const CATEGORY_R = 'category_r';
28+
public const CATEGORY_S = 'category_s';
29+
public const CATEGORY_T = 'category_t';
30+
public const CATEGORY_U = 'category_u';
31+
public const CATEGORY_V = 'category_v';
32+
33+
public const STATUS_A = 'status_a';
34+
public const STATUS_B = 'status_b';
35+
public const STATUS_C = 'status_c';
36+
37+
public const PAGE_A = 'page_a';
38+
public const PAGE_B = 'page_b';
39+
public const PAGE_C = 'page_c';
40+
}
41+
42+
final class AssertHelper
43+
{
44+
/**
45+
* @param list<string> $haystack
46+
*/
47+
public static function stringInArray(string $value, array $haystack): void
48+
{
49+
}
50+
}
51+
52+
final class MinCase
53+
{
54+
/**
55+
* @phpstan-param array{
56+
* type: Foo::TYPE_*,
57+
* category: Foo::CATEGORY_*|Foo::STATUS_*,
58+
* page?: Foo::PAGE_*,
59+
* flag: bool
60+
* } $input
61+
*/
62+
public static function run(array $input): void
63+
{
64+
AssertHelper::stringInArray(
65+
$input['category'],
66+
[
67+
Foo::CATEGORY_A,
68+
Foo::CATEGORY_B,
69+
Foo::CATEGORY_C,
70+
Foo::CATEGORY_D,
71+
Foo::CATEGORY_E,
72+
Foo::CATEGORY_F,
73+
Foo::CATEGORY_G,
74+
Foo::CATEGORY_H,
75+
Foo::CATEGORY_I,
76+
Foo::CATEGORY_J,
77+
Foo::CATEGORY_K,
78+
Foo::CATEGORY_L,
79+
Foo::CATEGORY_M,
80+
Foo::STATUS_A,
81+
Foo::STATUS_B,
82+
Foo::STATUS_C,
83+
Foo::CATEGORY_N,
84+
Foo::CATEGORY_O,
85+
Foo::CATEGORY_P,
86+
Foo::CATEGORY_Q,
87+
Foo::CATEGORY_R,
88+
Foo::CATEGORY_S,
89+
Foo::CATEGORY_T,
90+
Foo::CATEGORY_U,
91+
Foo::CATEGORY_V,
92+
]
93+
);
94+
95+
if ($input['category'] === Foo::CATEGORY_C) {
96+
}
97+
98+
if ($input['category'] === Foo::CATEGORY_F) {
99+
}
100+
101+
if ($input['category'] === Foo::CATEGORY_G) {
102+
}
103+
104+
if ($input['category'] === Foo::CATEGORY_H) {
105+
}
106+
107+
if ($input['category'] === Foo::CATEGORY_I) {
108+
}
109+
110+
if ($input['category'] === Foo::CATEGORY_B) {
111+
AssertHelper::stringInArray(
112+
$input['page'] ?? '',
113+
[
114+
Foo::PAGE_A,
115+
Foo::PAGE_B,
116+
Foo::PAGE_C,
117+
]
118+
);
119+
}
120+
}
121+
}

0 commit comments

Comments
 (0)