diff --git a/src/Symfony/Component/Form/Extension/Validator/ViolationMapper/ViolationPath.php b/src/Symfony/Component/Form/Extension/Validator/ViolationMapper/ViolationPath.php index 0c2a130cc86bd..d408c999aa17d 100644 --- a/src/Symfony/Component/Form/Extension/Validator/ViolationMapper/ViolationPath.php +++ b/src/Symfony/Component/Form/Extension/Validator/ViolationMapper/ViolationPath.php @@ -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. * diff --git a/src/Symfony/Component/PropertyAccess/CHANGELOG.md b/src/Symfony/Component/PropertyAccess/CHANGELOG.md index 0dacd605277bf..df0f22c860678 100644 --- a/src/Symfony/Component/PropertyAccess/CHANGELOG.md +++ b/src/Symfony/Component/PropertyAccess/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.3 +--- + +* Allow wildcard `[*]` usage for reading multiple values + 7.0 --- diff --git a/src/Symfony/Component/PropertyAccess/PropertyAccessor.php b/src/Symfony/Component/PropertyAccess/PropertyAccessor.php index 3c98d41bab019..fc3b4c3cafaec 100644 --- a/src/Symfony/Component/PropertyAccess/PropertyAccessor.php +++ b/src/Symfony/Component/PropertyAccess/PropertyAccessor.php @@ -275,7 +275,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); @@ -284,11 +284,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 isWildcard is always implemented. + $isWildcard = $propertyPath->isWildcard($i); + } else { + trigger_deprecation('symfony/property-access', '7.3', '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)) @@ -317,6 +325,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 { diff --git a/src/Symfony/Component/PropertyAccess/PropertyPath.php b/src/Symfony/Component/PropertyAccess/PropertyPath.php index e797ab77be07c..557dc6e239d21 100644 --- a/src/Symfony/Component/PropertyAccess/PropertyPath.php +++ b/src/Symfony/Component/PropertyAccess/PropertyPath.php @@ -57,6 +57,14 @@ class PropertyPath implements \IteratorAggregate, PropertyPathInterface */ private array $isNullSafe = []; + /** + * Contains a boolean for each property in $elements denoting whether this + * element is wildcard or not. + * + * @var array + */ + private array $isWildcard = []; + /** * String representation of the path. */ @@ -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; } @@ -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 "?". @@ -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); } @@ -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; } @@ -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]; + } } diff --git a/src/Symfony/Component/PropertyAccess/PropertyPathInterface.php b/src/Symfony/Component/PropertyAccess/PropertyPathInterface.php index 729a1c8017a18..96a69d40d35e3 100644 --- a/src/Symfony/Component/PropertyAccess/PropertyPathInterface.php +++ b/src/Symfony/Component/PropertyAccess/PropertyPathInterface.php @@ -16,6 +16,8 @@ * * @author Bernhard Schussek * + * @method bool isWildcard(int $index) Returns whether the element at the given index is wildcard. Not implementing it is deprecated since Symfony 7.3 + * * @extends \Traversable */ interface PropertyPathInterface extends \Traversable, \Stringable diff --git a/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorWildcardTest.php b/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorWildcardTest.php new file mode 100644 index 0000000000000..4d24a28a2ed25 --- /dev/null +++ b/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorWildcardTest.php @@ -0,0 +1,184 @@ + + * + * 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')); + } +} diff --git a/src/Symfony/Component/PropertyAccess/Tests/PropertyPathTest.php b/src/Symfony/Component/PropertyAccess/Tests/PropertyPathTest.php index 9257229c3aebf..fee45f888677f 100644 --- a/src/Symfony/Component/PropertyAccess/Tests/PropertyPathTest.php +++ b/src/Symfony/Component/PropertyAccess/Tests/PropertyPathTest.php @@ -204,4 +204,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)); + } } diff --git a/src/Symfony/Component/PropertyAccess/composer.json b/src/Symfony/Component/PropertyAccess/composer.json index 376ee7e1afd0d..872501b32a99d 100644 --- a/src/Symfony/Component/PropertyAccess/composer.json +++ b/src/Symfony/Component/PropertyAccess/composer.json @@ -17,7 +17,8 @@ ], "require": { "php": ">=8.2", - "symfony/property-info": "^6.4|^7.0" + "symfony/property-info": "^6.4|^7.0", + "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { "symfony/cache": "^6.4|^7.0"