Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Indexed array subset #3499

Closed
wants to merge 12 commits into from
105 changes: 97 additions & 8 deletions src/Framework/Constraint/ArraySubset.php
Expand Up @@ -60,23 +60,22 @@ public function evaluate($other, string $description = '', bool $returnResult =
$other = $this->toArray($other);
$this->subset = $this->toArray($this->subset);

$patched = \array_replace_recursive($other, $this->subset);
$intersect = $this->arrayIntersectRecursive($other, $this->subset);

if ($this->strict) {
$result = $other === $patched;
} else {
$result = $other == $patched;
}
$this->deepSort($intersect);
$this->deepSort($this->subset);

$result = $this->compare($intersect, $this->subset);

if ($returnResult) {
return $result;
}

if (!$result) {
$f = new ComparisonFailure(
$patched,
$this->subset,
$other,
\var_export($patched, true),
\var_export($this->subset, true),
\var_export($other, true)
);

Expand Down Expand Up @@ -126,4 +125,94 @@ private function toArray(iterable $other): array
// Keep BC even if we know that array would not be the expected one
return (array) $other;
}

private function isAssociative(array $array): bool
{
return \array_reduce(
\array_keys($array),
function (bool $carry, $key): bool {
return $carry || \is_string($key);
},
false
);
}

private function compare($first, $second): bool
{
return $this->strict ? $first === $second : $first == $second;
}

private function deepSort(array &$array): void
{
foreach ($array as &$value) {
if (\is_array($value)) {
$this->deepSort($value);
} elseif ((\is_scalar($value) && !\is_string($value)) || empty($value)) {
// In order to ensure consistent sorting results in data sets
// that have a mix of strings and integers we need to perform
// string comparisons on all scalar values. Typically this is
// done by passing the SORT_STRING flag to a sorting function
// but this doesn't work on multidimensional arrays.
$value = (string) (int) $value;
}
}

unset($value);

if ($this->isAssociative($array)) {
\ksort($array);
} else {
\sort($array);
}
}

private function arrayIntersectRecursive(array $array, array $subset): array
{
$intersect = [];

if ($this->isAssociative($subset)) {
// If the subset is an associative array, get the intersection while
// preserving the keys.
foreach ($subset as $key => $subset_value) {
if (\array_key_exists($key, $array)) {
$array_value = $array[$key];

if (\is_array($subset_value) && \is_array($array_value)) {
$intersect[$key] = $this->arrayIntersectRecursive($array_value, $subset_value);
} elseif ($this->compare($subset_value, $array_value)) {
$intersect[$key] = $array_value;
}
}
}
} else {
// If the subset is an indexed array, loop over all entries in the
// haystack and check if they match the ones in the subset.
foreach ($array as $array_value) {
if (\is_array($array_value)) {
foreach (\array_diff_key($subset, $intersect) as $key => $subset_value) {
if (\is_array($subset_value)) {
$recursed = $this->arrayIntersectRecursive($array_value, $subset_value);

if (!empty($recursed)) {
$intersect[$key] = $recursed;

break;
}
}
}
} else {
foreach (\array_diff_key($subset, $intersect) as $key => $subset_value) {
if (!\is_array($subset_value) && $this->compare($subset_value, $array_value)) {
$intersect[$key] = $array_value;

break;
}
}
}
}
}

// Only return the result if it fully matches the subset.
return \count($subset) == \count($intersect) ? $intersect : [];
}
}
138 changes: 138 additions & 0 deletions tests/unit/Framework/AssertTest.php
Expand Up @@ -69,6 +69,144 @@ public function testAssertArrayHasIntegerKey(): void
$this->assertArrayHasKey(1, ['foo']);
}

public function testAssertArraySubset(): void
{
$array = [
'a' => 'item a',
'b' => 'item b',
'c' => ['a2' => 'item a2', 'b2' => 'item b2'],
'd' => ['a2' => ['a3' => 'item a3', 'b3' => 'item b3']],
];

$this->assertArraySubset(['a' => 'item a'], $array);
$this->assertArraySubset(['a' => 'item a', 'c' => ['a2' => 'item a2']], $array);
$this->assertArraySubset(['a' => 'item a', 'd' => ['a2' => ['b3' => 'item b3']]], $array);
$this->assertArraySubset(['b' => 'item b', 'd' => ['a2' => ['b3' => 'item b3']]], $array);

$arrayAccessData = new \ArrayObject($array);

$this->assertArraySubset(['a' => 'item a'], $arrayAccessData);
$this->assertArraySubset(['a' => 'item a', 'c' => ['a2' => 'item a2']], $arrayAccessData);
$this->assertArraySubset(['a' => 'item a', 'd' => ['a2' => ['b3' => 'item b3']]], $arrayAccessData);
$this->assertArraySubset(['b' => 'item b', 'd' => ['a2' => ['b3' => 'item b3']]], $arrayAccessData);

try {
$this->assertArraySubset(['a' => 'bad value'], $array);
} catch (AssertionFailedError $e) {
}

try {
$this->assertArraySubset(['d' => ['a2' => ['bad index' => 'item b3']]], $array);
} catch (AssertionFailedError $e) {
return;
}

$this->fail();
}

public function testAssertArraySubsetWithIndexedArrays(): void
{
$array = [
'item a',
'item b',
['a2' => 'item a2', 'b2' => 'item b2'],
['a2' => ['a3' => 'item a3', 'b3' => 'item b3']],
];

$this->assertArraySubset(['item a', ['a2' => 'item a2']], $array);
$this->assertArraySubset(['item a', ['a2' => ['b3' => 'item b3']]], $array);
$this->assertArraySubset(['item b', ['a2' => ['b3' => 'item b3']]], $array);

$arrayAccessData = new \ArrayObject($array);

$this->assertArraySubset(['item a', ['a2' => 'item a2']], $arrayAccessData);
$this->assertArraySubset(['item a', ['a2' => ['b3' => 'item b3']]], $arrayAccessData);
$this->assertArraySubset(['item b', ['a2' => ['b3' => 'item b3']]], $arrayAccessData);

try {
$this->assertArraySubset(['bad value'], $array);
} catch (AssertionFailedError $e) {
}

try {
$this->assertArraySubset([['a2' => ['bad index' => 'item b3']]], $array);
} catch (AssertionFailedError $e) {
return;
}

$this->fail();
}

public function testAssertArraySubsetWithDeepNestedArrays(): void
{
$array = [
'path' => [
'to' => [
'the' => [
'cake' => 'is a lie',
],
],
],
];

$this->assertArraySubset(['path' => []], $array);
$this->assertArraySubset(['path' => ['to' => []]], $array);
$this->assertArraySubset(['path' => ['to' => ['the' => []]]], $array);
$this->assertArraySubset(['path' => ['to' => ['the' => ['cake' => 'is a lie']]]], $array);

$this->expectException(AssertionFailedError::class);

$this->assertArraySubset(['path' => ['to' => ['the' => ['cake' => 'is not a lie']]]], $array);
}

public function testAssertArraySubsetWithNoStrictCheckAndObjects(): void
{
$obj = new \stdClass;
$reference = &$obj;
$array = ['a' => $obj];

$this->assertArraySubset(['a' => $reference], $array);
$this->assertArraySubset(['a' => new \stdClass], $array);
}

public function testAssertArraySubsetWithStrictCheckAndObjects(): void
{
$obj = new \stdClass;
$reference = &$obj;
$array = ['a' => $obj];

$this->assertArraySubset(['a' => $reference], $array, true);

$this->expectException(AssertionFailedError::class);

$this->assertArraySubset(['a' => new \stdClass], $array, true);
}

/**
* @dataProvider assertArraySubsetInvalidArgumentProvider
*
* @throws Exception
* @throws ExpectationFailedException
* @throws \SebastianBergmann\RecursionContext\InvalidArgumentException
*/
public function testAssertArraySubsetRaisesExceptionForInvalidArguments($partial, $subject): void
{
$this->expectException(Exception::class);

$this->assertArraySubset($partial, $subject);
}

/**
* @return array
*/
public function assertArraySubsetInvalidArgumentProvider()
{
return [
[false, []],
[[], false],
];
}

public function testAssertArrayNotHasKeyThrowsExceptionForInvalidFirstArgument(): void
{
$this->expectException(Exception::class);
Expand Down