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

[PropertyAccess] Allow usage of wildcard [*] #52723

Open
wants to merge 12 commits into
base: 7.2
Choose a base branch
from
Expand Up @@ -161,6 +161,11 @@ public function isNullSafe(int $index): bool
return false;
}

public function isWildcard(int $index): bool
{
return false;
}

/**
* Returns whether an element maps directly to a form.
*
Expand Down
5 changes: 5 additions & 0 deletions src/Symfony/Component/PropertyAccess/CHANGELOG.md
@@ -1,6 +1,11 @@
CHANGELOG
=========

7.1
---

* Allow wildcard `[*]` usage for reading multiple values

7.0
---

Expand Down
46 changes: 44 additions & 2 deletions src/Symfony/Component/PropertyAccess/PropertyAccessor.php
Expand Up @@ -262,7 +262,7 @@ public function isWritable(object|array $objectOrArray, string|PropertyPathInter
* @throws UnexpectedTypeException if a value within the path is neither object nor array
* @throws NoSuchIndexException If a non-existing index is accessed
*/
private function readPropertiesUntil(array $zval, PropertyPathInterface $propertyPath, int $lastIndex, bool $ignoreInvalidIndices = true): array
private function readPropertiesUntil(array $zval, PropertyPathInterface $propertyPath, int $lastIndex, bool $ignoreInvalidIndices = true, int $startIndex = 0): array
{
if (!\is_object($zval[self::VALUE]) && !\is_array($zval[self::VALUE])) {
throw new UnexpectedTypeException($zval[self::VALUE], $propertyPath, 0);
Expand All @@ -271,11 +271,19 @@ private function readPropertiesUntil(array $zval, PropertyPathInterface $propert
// Add the root object to the list
$propertyValues = [$zval];

for ($i = 0; $i < $lastIndex; ++$i) {
for ($i = $startIndex; $i < $lastIndex; ++$i) {
$property = $propertyPath->getElement($i);
$isIndex = $propertyPath->isIndex($i);
$isNullSafe = $propertyPath->isNullSafe($i);

$isWildcard = false;
if (method_exists($propertyPath, 'isWildcard')) {
// To be removed in symfony 8 once we are sure isNullSafe is always implemented.
Brajk19 marked this conversation as resolved.
Show resolved Hide resolved
Brajk19 marked this conversation as resolved.
Show resolved Hide resolved
$isWildcard = $propertyPath->isWildcard($i);
} else {
trigger_deprecation('symfony/property-access', '7.1', 'The "%s()" method in class "%s" needs to be implemented in version 8.0, not defining it is deprecated.', 'isWildcard', PropertyPathInterface::class);
}

if ($isIndex) {
// Create missing nested arrays on demand
if (($zval[self::VALUE] instanceof \ArrayAccess && !$zval[self::VALUE]->offsetExists($property))
Expand Down Expand Up @@ -304,6 +312,40 @@ private function readPropertiesUntil(array $zval, PropertyPathInterface $propert
}

$zval = $this->readIndex($zval, $property);
} elseif ($isWildcard) {
$newPropertyValues = [];

// replace wildcard with all posible values
// e.g. [*][foo] becomes [0][foo], [1][foo], ...
foreach (array_keys($zval[self::VALUE]) as $index) {
$path = preg_replace('/\[\*\]/', "[$index]", (string) $propertyPath, 1);
$subPath = $this->readPropertiesUntil($zval, $this->getPropertyPath($path), $lastIndex, $ignoreInvalidIndices, $i);

// merge property values from all sub paths
// skip first because it's same for all paths and is already in $propertyValues
for ($j = 1; $j < \count($subPath); ++$j) {
$newPropertyValues[$j][self::VALUE][] = $subPath[$j][self::VALUE];
}
}

foreach ($newPropertyValues as &$newValue) {
$shouldMerge = true;

foreach ($newValue[self::VALUE] as $value) {
$shouldMerge = \is_array($value) && array_is_list($value);

if (!$shouldMerge) {
break;
}
}

if ($shouldMerge) {
$newValue[self::VALUE] = array_merge(...$newValue[self::VALUE]);
}
}

array_push($propertyValues, ...$newPropertyValues);
break;
} elseif ($isNullSafe && !\is_object($zval[self::VALUE])) {
$zval[self::VALUE] = null;
} else {
Expand Down
27 changes: 26 additions & 1 deletion src/Symfony/Component/PropertyAccess/PropertyPath.php
Expand Up @@ -57,6 +57,14 @@ class PropertyPath implements \IteratorAggregate, PropertyPathInterface
*/
private array $isNullSafe = [];

/**
* Contains a Boolean for each property in $elements denoting whether this
Brajk19 marked this conversation as resolved.
Show resolved Hide resolved
* element is wildcard or not.
*
* @var array<bool>
*/
private array $isWildcard = [];

/**
* String representation of the path.
*/
Expand All @@ -78,6 +86,7 @@ public function __construct(self|string $propertyPath)
$this->isIndex = $propertyPath->isIndex;
$this->isNullSafe = $propertyPath->isNullSafe;
$this->pathAsString = $propertyPath->pathAsString;
$this->isWildcard = $propertyPath->isWildcard;

return;
}
Expand All @@ -97,9 +106,15 @@ public function __construct(self|string $propertyPath)
if ('' !== $matches[2]) {
$element = $matches[2];
$this->isIndex[] = false;
$this->isWildcard[] = false;
} elseif ('[*]' === $matches[1]) {
$element = '*';
$this->isIndex[] = false;
$this->isWildcard[] = true;
} else {
$element = $matches[3];
$this->isIndex[] = true;
$this->isWildcard[] = false;
}

// Mark as optional when last character is "?".
Expand All @@ -110,7 +125,7 @@ public function __construct(self|string $propertyPath)
$this->isNullSafe[] = false;
}

$element = preg_replace('/\\\([.[])/', '$1', $element);
$element = preg_replace('/\\\([.[*])/', '$1', $element);
if (str_ends_with($element, '\\\\')) {
$element = substr($element, 0, -1);
}
Expand Down Expand Up @@ -151,6 +166,7 @@ public function getParent(): ?PropertyPathInterface
array_pop($parent->elements);
array_pop($parent->isIndex);
array_pop($parent->isNullSafe);
array_pop($parent->isWildcard);

return $parent;
}
Expand Down Expand Up @@ -203,4 +219,13 @@ public function isNullSafe(int $index): bool

return $this->isNullSafe[$index];
}

public function isWildcard(int $index): bool
{
if (!isset($this->isWildcard[$index])) {
throw new OutOfBoundsException(sprintf('The index "%s" is not within the property path.', $index));
}

return $this->isWildcard[$index];
}
}
@@ -0,0 +1,184 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\PropertyAccess\Tests;

use PHPUnit\Framework\TestCase;
use Symfony\Component\PropertyAccess\PropertyAccessor;
use Symfony\Component\PropertyAccess\Tests\Fixtures\TestClass;

class PropertyAccessorWildcardTest extends TestCase
{
private PropertyAccessor $propertyAccessor;

protected function setUp(): void
{
$this->propertyAccessor = new PropertyAccessor();
}

private const TEST_ARRAY = [
[
'id' => 1,
'name' => 'John',
'languages' => ['EN'],
'*' => 'wildcard1',
'jobs' => [
[
'title' => 'chef',
'info' => [
'experience' => 6,
'salary' => 34,
],
],
[
'title' => 'waiter',
'info' => [
'experience' => 3,
'salary' => 30,
],
],
],
'info' => [
'age' => 32,
],
],
[
'id' => 2,
'name' => 'Luke',
'languages' => ['EN', 'FR'],
'*' => 'wildcard2',
'jobs' => [
[
'title' => 'chef',
'info' => [
'experience' => 3,
'salary' => 31,
],
],
[
'title' => 'bartender',
'info' => [
'experience' => 6,
'salary' => 30,
],
],
],
'info' => [
'age' => 28,
],
],
];

public static function provideWildcardPaths(): iterable
{
yield [
'path' => '[*][id]',
'expected' => [1, 2],
];

yield [
'path' => '[*][name]',
'expected' => ['John', 'Luke'],
];

yield [
'path' => '[*][languages]',
'expected' => ['EN', 'EN', 'FR'],
];

yield [
'path' => '[*][info][age]',
'expected' => [32, 28],
];

yield [
'path' => '[0][jobs][*][title]',
'expected' => ['chef', 'waiter'],
];

yield [
'path' => '[0][jobs][*][info]',
'expected' => [
['experience' => 6, 'salary' => 34],
['experience' => 3, 'salary' => 30],
],
];

yield [
'path' => '[0][jobs][*][info][experience]',
'expected' => [6, 3],
];

yield [
'path' => '[*][jobs][0][title]',
'expected' => ['chef', 'chef'],
];

yield [
'path' => '[*][jobs][*][title]',
'expected' => ['chef', 'waiter', 'chef', 'bartender'],
];

yield [
'path' => '[*][jobs][*][info][*]',
'expected' => [6, 34, 3, 30, 3, 31, 6, 30],
];

yield [
'path' => '[*][jobs][*][info]',
'expected' => [
['experience' => 6, 'salary' => 34],
['experience' => 3, 'salary' => 30],
['experience' => 3, 'salary' => 31],
['experience' => 6, 'salary' => 30],
],
];

yield [
'path' => '[0][\*]',
'expected' => 'wildcard1',
];

yield [
'path' => '[*][\*]',
'expected' => ['wildcard1', 'wildcard2'],
];
}

/**
* @dataProvider provideWildcardPaths
*/
public function testAccessorWithWildcard(string $path, string|array $expected)
{
self::assertSame($expected, $this->propertyAccessor->getValue(self::TEST_ARRAY, $path));
}

public function testAccessorWithWildcardAndObject()
{
$array = self::TEST_ARRAY;

$array[0]['class'] = new TestClass('foo');
$array[1]['class'] = new TestClass('bar');

self::assertSame(['foo', 'bar'], $this->propertyAccessor->getValue($array, '[*][class].publicAccessor'));

$array[0]['classes'] = [
new TestClass('foo'),
new TestClass('bar'),
];
$array[1]['classes'] = [
new TestClass('baz'),
new TestClass('qux'),
];

self::assertSame(['foo', 'bar', 'baz', 'qux'], $this->propertyAccessor->getValue($array, '[*][classes][*].publicAccessor'));
}
}
12 changes: 12 additions & 0 deletions src/Symfony/Component/PropertyAccess/Tests/PropertyPathTest.php
Expand Up @@ -198,4 +198,16 @@ public function testIsIndexDoesNotAcceptNegativeIndices()

$propertyPath->isIndex(-1);
}

public function testIsWildcard()
{
$propertyPath = new PropertyPath('[*][parent][child].name');

$this->assertTrue($propertyPath->isWildcard(0));
$this->assertFalse($propertyPath->isIndex(0));

$this->assertFalse($propertyPath->isWildcard(1));
$this->assertFalse($propertyPath->isWildcard(2));
$this->assertFalse($propertyPath->isWildcard(3));
}
}