Skip to content
Closed
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
115 changes: 107 additions & 8 deletions src/Analyser/ExprHandler/BooleanAndHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@
use PHPStan\Type\NeverType;
use PHPStan\Type\Type;
use function array_filter;
use function array_key_last;
use function array_merge;
use function array_reverse;
use function array_values;
use function count;
use function is_string;

/**
Expand All @@ -58,6 +60,13 @@ public function supports(Expr $expr): bool

public function resolveType(MutatingScope $scope, Expr $expr): Type
{
// For deep BooleanAnd chains, resolve the boolean type by iterating the flattened arms
// while threading the truthy scope, instead of recursing into the left operand and
// re-narrowing the whole chain at each level - the latter is O(n^2) in the number of arms.
if (self::getBooleanExpressionDepth($expr) > self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) {
return $this->resolveTypeForFlattenedBooleanAnd($scope, $expr);
}

$leftBooleanType = $scope->getType($expr->left)->toBoolean();
if ($leftBooleanType->isFalse()->yes()) {
return new ConstantBooleanType(false);
Expand All @@ -84,20 +93,61 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type
return new BooleanType();
}

/**
* The whole chain is false if any arm is false (given the previous arms are true), true if
* every arm is true, and bool otherwise. Threading the truthy scope arm by arm keeps this
* O(n), matching the recursive resolveType() result without re-narrowing the whole left
* chain at each level.
*
* @param BooleanAnd|LogicalAnd $expr
*/
private function resolveTypeForFlattenedBooleanAnd(MutatingScope $scope, Expr $expr): Type
{
$arms = [];
$current = $expr;
while ($current instanceof BooleanAnd || $current instanceof LogicalAnd) {
$arms[] = $current->right;
$current = $current->left;
}
$arms[] = $current;
$arms = array_reverse($arms);

$allArmsAreTrue = true;
$armScope = $scope;
$lastArmKey = array_key_last($arms);
foreach ($arms as $key => $arm) {
$armType = $armScope->getType($arm);
if ($armType->toBoolean()->isFalse()->yes()) {
return new ConstantBooleanType(false);
}
if (!$armType->toBoolean()->isTrue()->yes()) {
$allArmsAreTrue = false;
}
if ($key === $lastArmKey) {
continue;
}
$armScope = $armScope->filterByTruthyValue($arm);
}

return $allArmsAreTrue ? new ConstantBooleanType(true) : new BooleanType();
}

public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes
{
if (!$scope instanceof MutatingScope) {
throw new ShouldNotHappenException();
}

// For deep BooleanAnd chains in truthy context, flatten and
// process all arms at once to avoid O(N²) recursive
// filterByTruthyValue calls.
if (
$context->true()
&& self::getBooleanExpressionDepth($expr) > self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH
) {
return $this->specifyTypesForFlattenedBooleanAnd($typeSpecifier, $scope, $expr, $context);
// For deep BooleanAnd chains, flatten and process all arms at once to avoid
// O(N²) recursive filterByTruthyValue / filterByFalseyValue calls.
if (self::getBooleanExpressionDepth($expr) > self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) {
if ($context->true()) {
return $this->specifyTypesForFlattenedBooleanAnd($typeSpecifier, $scope, $expr, $context);
}

if (!$context->null()) {
return $this->specifyTypesForFlattenedFalseyBooleanAnd($typeSpecifier, $scope, $expr, $context);
}
}

$leftTypes = $typeSpecifier->specifyTypesInCondition($scope, $expr->left, $context)->setRootExpr($expr);
Expand Down Expand Up @@ -223,6 +273,55 @@ private function specifyTypesForFlattenedBooleanAnd(
return SpecifiedTypes::unionAll($armTypes)->setRootExpr($expr);
}

/**
* The falsey counterpart of specifyTypesForFlattenedBooleanAnd(): at least one arm is
* false, so the arms' narrowings intersect, the same merge intersectWith() does for the
* recursive path. This is the De Morgan mirror of the flattened truthy BooleanOr chain,
* and like it, deep chains trade the per-pair conditional-holder augments for linear time.
*
* A mixed truthy-and-false context takes this path too, so it also forgoes the holders the
* recursive path re-derives from the falsey narrowing - the same class of information a
* plain falsey context already forgoes here.
*
* @param BooleanAnd|LogicalAnd $expr
*/
private function specifyTypesForFlattenedFalseyBooleanAnd(
TypeSpecifier $typeSpecifier,
MutatingScope $scope,
Expr $expr,
TypeSpecifierContext $context,
): SpecifiedTypes
{
$arms = [];
$current = $expr;
while ($current instanceof BooleanAnd || $current instanceof LogicalAnd) {
$arms[] = $current->right;
$current = $current->left;
}
$arms[] = $current;
$arms = array_reverse($arms);

$armSpecifiedTypes = [];
foreach ($arms as $arm) {
$armSpecifiedTypes[] = $typeSpecifier->specifyTypesInCondition($scope, $arm, $context);
}

$types = $armSpecifiedTypes[0];
for ($i = 1; $i < count($armSpecifiedTypes); $i++) {
$types = $types->intersectWith($armSpecifiedTypes[$i]);
}

$result = (new SpecifiedTypes(
$types->getSureTypes(),
$types->getSureNotTypes(),
))->withAlternativeTypesOf($types);
if ($types->shouldOverwrite()) {
$result = $result->setAlwaysOverwriteTypes();
}

return $result->setRootExpr($expr);
}

/**
* Whether the side's truthy narrowing is EQUIVALENT to the side being
* true - the requirement for using its inversion as a holder antecedent.
Expand Down
106 changes: 106 additions & 0 deletions tests/PHPStan/Analyser/nsrt/deep-boolean-and-chain.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php declare(strict_types = 1);

namespace DeepBooleanAndChain;

use function PHPStan\Testing\assertType;

/**
* Chains longer than BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH take the flattened path in
* BooleanAndHandler, both for the truthy side and - the De Morgan counterpart - the falsey
* side. The narrowing must match what the recursive path produces for a short chain.
*
* @param 1|2|3|4|5|6|7|8 $x
*/
function deepChain(int $x): void
{
if ($x !== 1 && $x !== 2 && $x !== 3 && $x !== 4 && $x !== 5 && $x !== 6 && $x !== 7) {
assertType('8', $x);
} else {
assertType('1|2|3|4|5|6|7', $x);
}

assertType('1|2|3|4|5|6|7|8', $x);
}

/** @param 1|2|3 $x */
function shortChain(int $x): void
{
// short enough to stay on the recursive path, for comparison
if ($x !== 1 && $x !== 2) {
assertType('3', $x);
} else {
assertType('1|2', $x);
}
}

/**
* @param 1|2|3|4|5|6|7|8 $x
*/
function deepChainNegated(int $x): void
{
if (!($x !== 1 && $x !== 2 && $x !== 3 && $x !== 4 && $x !== 5 && $x !== 6 && $x !== 7)) {
assertType('1|2|3|4|5|6|7', $x);
} else {
assertType('8', $x);
}
}

/**
* Comparing the chain against a boolean puts it in a mixed truthy-and-false context, which
* must not be handled as if it were a plain truthy one - the chain being false does not make
* every arm false.
*
* @param 1|2|3|4|5|6|7|8 $x
*/
function deepChainComparedToTrue(int $x): void
{
if (($x !== 1 && $x !== 2 && $x !== 3 && $x !== 4 && $x !== 5 && $x !== 6 && $x !== 7) !== true) {
assertType('1|2|3|4|5|6|7', $x);
} else {
assertType('8', $x);
}
}

/** @param 1|2|3|4|5|6|7|8 $x */
function deepChainComparedToFalse(int $x): void
{
if (($x !== 1 && $x !== 2 && $x !== 3 && $x !== 4 && $x !== 5 && $x !== 6 && $x !== 7) === false) {
assertType('1|2|3|4|5|6|7', $x);
} else {
assertType('8', $x);
}
}

/**
* A deep chain of mixed arms: the falsey side cannot narrow the subject to a single value,
* so the else branch keeps the declared type.
*
* @param 1|2|3|4|5|6|7|8 $x
*/
function deepChainMixed(int $x, bool $b): void
{
if ($x !== 1 && $x !== 2 && $x !== 3 && $b && $x !== 4 && $x !== 5 && $x !== 6) {
assertType('7|8', $x);
assertType('true', $b);
} else {
assertType('1|2|3|4|5|6|7|8', $x);
assertType('bool', $b);
}
}

/**
* @param non-empty-string|null $s
*/
function deepChainIsset(?string $s, ?int $i, ?float $f, ?bool $bo, ?object $o): void
{
if ($s !== null && $i !== null && $f !== null && $bo !== null && $o !== null) {
assertType('non-empty-string', $s);
assertType('int', $i);
assertType('float', $f);
assertType('bool', $bo);
assertType('object', $o);
} else {
assertType('non-empty-string|null', $s);
assertType('int|null', $i);
}
}
119 changes: 119 additions & 0 deletions tests/bench/data/and-chain-resolve-type-blowup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php declare(strict_types = 1);

namespace BenchAndChainResolveTypeBlowup;

/**
* Regression test for the O(N^2) cost of deep BooleanAnd chains of instanceof arms.
*
* Without the flattening, both resolveType() and the falsey specifyTypes() path recursed
* into the left operand and re-narrowed the whole left chain with filterByTruthyValue() at
* each level. This file exercises both: 17.3s unflattened, 9.2s with only resolveType()
* flattened, 1.1s with both. instanceof arms make the per-level narrowing expensive enough
* to show at the BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH = 4 default (no raised cap needed).
*/
final class C1 {}
final class C2 {}
final class C3 {}
final class C4 {}
final class C5 {}
final class C6 {}
final class C7 {}
final class C8 {}
final class C9 {}
final class C10 {}
final class C11 {}
final class C12 {}
final class C13 {}
final class C14 {}
final class C15 {}
final class C16 {}
final class C17 {}
final class C18 {}
final class C19 {}
final class C20 {}
final class C21 {}
final class C22 {}
final class C23 {}
final class C24 {}
final class C25 {}
final class C26 {}
final class C27 {}
final class C28 {}
final class C29 {}
final class C30 {}
final class C31 {}
final class C32 {}
final class C33 {}
final class C34 {}
final class C35 {}
final class C36 {}
final class C37 {}
final class C38 {}
final class C39 {}
final class C40 {}
final class C41 {}
final class C42 {}
final class C43 {}
final class C44 {}
final class C45 {}
final class C46 {}
final class C47 {}
final class C48 {}
final class C49 {}
final class C50 {}
final class C51 {}
final class C52 {}
final class C53 {}
final class C54 {}
final class C55 {}
final class C56 {}
final class C57 {}
final class C58 {}
final class C59 {}
final class C60 {}
final class C61 {}
final class C62 {}
final class C63 {}
final class C64 {}
final class C65 {}
final class C66 {}
final class C67 {}
final class C68 {}
final class C69 {}
final class C70 {}
final class C71 {}
final class C72 {}
final class C73 {}
final class C74 {}
final class C75 {}
final class C76 {}
final class C77 {}
final class C78 {}
final class C79 {}
final class C80 {}
final class C81 {}
final class C82 {}
final class C83 {}
final class C84 {}
final class C85 {}
final class C86 {}
final class C87 {}
final class C88 {}
final class C89 {}
final class C90 {}
final class C91 {}
final class C92 {}
final class C93 {}
final class C94 {}
final class C95 {}
final class C96 {}
final class C97 {}
final class C98 {}
final class C99 {}
final class C100 {}

function test(object $x): void {
if (!$x instanceof C1 && !$x instanceof C2 && !$x instanceof C3 && !$x instanceof C4 && !$x instanceof C5 && !$x instanceof C6 && !$x instanceof C7 && !$x instanceof C8 && !$x instanceof C9 && !$x instanceof C10 && !$x instanceof C11 && !$x instanceof C12 && !$x instanceof C13 && !$x instanceof C14 && !$x instanceof C15 && !$x instanceof C16 && !$x instanceof C17 && !$x instanceof C18 && !$x instanceof C19 && !$x instanceof C20 && !$x instanceof C21 && !$x instanceof C22 && !$x instanceof C23 && !$x instanceof C24 && !$x instanceof C25 && !$x instanceof C26 && !$x instanceof C27 && !$x instanceof C28 && !$x instanceof C29 && !$x instanceof C30 && !$x instanceof C31 && !$x instanceof C32 && !$x instanceof C33 && !$x instanceof C34 && !$x instanceof C35 && !$x instanceof C36 && !$x instanceof C37 && !$x instanceof C38 && !$x instanceof C39 && !$x instanceof C40 && !$x instanceof C41 && !$x instanceof C42 && !$x instanceof C43 && !$x instanceof C44 && !$x instanceof C45 && !$x instanceof C46 && !$x instanceof C47 && !$x instanceof C48 && !$x instanceof C49 && !$x instanceof C50 && !$x instanceof C51 && !$x instanceof C52 && !$x instanceof C53 && !$x instanceof C54 && !$x instanceof C55 && !$x instanceof C56 && !$x instanceof C57 && !$x instanceof C58 && !$x instanceof C59 && !$x instanceof C60 && !$x instanceof C61 && !$x instanceof C62 && !$x instanceof C63 && !$x instanceof C64 && !$x instanceof C65 && !$x instanceof C66 && !$x instanceof C67 && !$x instanceof C68 && !$x instanceof C69 && !$x instanceof C70 && !$x instanceof C71 && !$x instanceof C72 && !$x instanceof C73 && !$x instanceof C74 && !$x instanceof C75 && !$x instanceof C76 && !$x instanceof C77 && !$x instanceof C78 && !$x instanceof C79 && !$x instanceof C80 && !$x instanceof C81 && !$x instanceof C82 && !$x instanceof C83 && !$x instanceof C84 && !$x instanceof C85 && !$x instanceof C86 && !$x instanceof C87 && !$x instanceof C88 && !$x instanceof C89 && !$x instanceof C90 && !$x instanceof C91 && !$x instanceof C92 && !$x instanceof C93 && !$x instanceof C94 && !$x instanceof C95 && !$x instanceof C96 && !$x instanceof C97 && !$x instanceof C98 && !$x instanceof C99 && !$x instanceof C100) {
echo "none";
}
}
Loading