Skip to content

Navigation Menu

Sign in
Appearance settings
Sign up
Appearance settings

[PropertyAccess] Add wildcard reads - #65297

#65297
Open
Boulea7 wants to merge 2 commits into
symfony:8.2symfony/symfony:8.2from
Boulea7:feature/property-access-wildcardsBoulea7/symfony:feature/property-access-wildcardsCopy head branch name to clipboard
Open

[PropertyAccess] Add wildcard reads#65297
Boulea7 wants to merge 2 commits into
symfony:8.2symfony/symfony:8.2from
Boulea7:feature/property-access-wildcardsBoulea7/symfony:feature/property-access-wildcardsCopy head branch name to clipboard

Conversation

@Boulea7

@Boulea7 Boulea7 commented Aug 13, 2026

Copy link
Copy Markdown
Q A
Branch? 8.2
Bug fix? no
New feature? yes
Deprecations? no
Issues Ref #52723
License MIT

This follows up on #52723 with the interface-free approach suggested there. PropertyPathInterface and PropertyPath stay unchanged; PropertyAccessor recognizes the existing * path element only while reading.

An unescaped [*] collects values across arrays and traversable array-access objects, including nested wildcards. [\*] still reads a literal * key. List results are flattened, matching the behavior proposed in #52723.

The component test suite passes on PHP 8.4 and PHP 8.5 (498 tests, 578 assertions).

Thanks @Brajk19 for the original proposal.

$propertyPath = $this->getPropertyPath($propertyPath);

$propertyValues = $this->readPropertiesUntil($zval, $propertyPath, $propertyPath->getLength(), $this->ignoreInvalidIndices);
$propertyValues = $this->readPropertiesUntil($zval, $propertyPath, $propertyPath->getLength(), $this->ignoreInvalidIndices, true);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expanding wildcards only in the getter causes some strange behavior, the setter and the getter interpret the same path differently:

$pa = new PropertyAccessor();

$data = [
    ['name' => 'John'],
    ['name' => 'Sue'],
];
$pa->setValue($data, '[*][name]', 'X');

var_dump($data);
// [
//     ['name' => 'John'],
//     ['name' => 'Sue'],
//     '*' => ['name' => 'X'],
// ]

var_dump($pa->getValue($data, '[*][name]'));
// ['John', 'Sue', 'X']

The setter treats * as a literal key and creates $data['*'] = ['name' => 'X'], while the getter then merges that phantom entry into the wildcard result.

The same asymmetry exists with escaping:

$pa = new PropertyAccessor();

$data = [
    '*' => 'One',
];
$pa->setValue($data, '[\*]', 'Two'); // writes the key '\*'

var_dump($pa->getValue($data, '[\*]')); // reads the key '*' → "One"

Comment on lines +292 to +300
if ($expandWildcards && $isIndex && '*' === $property) {
$propertyValues[] = [self::VALUE => $this->readWildcard($zval, $propertyPath, $i + 1, $lastIndex, $ignoreInvalidIndices)];
break;
}

if ($expandWildcards && $isIndex && '\\*' === $property) {
$property = '*';
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually a BC break: in the current version [*] reads the literal * key, while with this PR it expands as a wildcard and returns a list, e.g.:

$pa = new PropertyAccessor();

$data = [
    ['name' => 'John'],
    ['name' => 'Sue'],
];
$pa->setValue($data, '[*][name]', 'X');

var_dump($pa->getValue($data, '[*][name]'));

currently returns only "X", while with this PR it returns ['John', 'Sue', 'X'].

The same applies to the escaped form: [\*] currently reads the literal key \*, while with this PR it reads the key *.


private function readWildcard(array $zval, PropertyPathInterface $propertyPath, int $nextIndex, int $lastIndex, bool $ignoreInvalidIndices): array
{
if (!\is_array($zval[self::VALUE]) && (!$zval[self::VALUE] instanceof \ArrayAccess || !$zval[self::VALUE] instanceof \Traversable)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why require ArrayAccess? Since the wildcard only foreaches the collection, Iterator/IteratorAggregate would work just fine:

$people = new class() implements \IteratorAggregate {
    public function getIterator(): \Iterator
    {
        yield ['name' => 'John'];
        yield ['name' => 'Sue'];
    }
};

var_dump((new PropertyAccessor())->getValue($people, '[*][name]'));
// throws a NoSuchIndexException, even though iterating it works fine

Comment on lines +376 to +382
foreach ($values as $value) {
if (!\is_array($value) || !array_is_list($value)) {
return $values;
}
}

return $values ? array_merge(...$values) : [];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The list merging causes some strange inconsistent results, since the shape of the result depends on the data:

$people = [
    ['languages' => ['English', 'French']],
    ['languages' => ['English']],
];

$pa->getValue($people, '[*][languages]');
// ['English', 'French', 'English']

$people = [
    ['languages' => ['English', 'French']],
    ['languages' => 'English'],
];

$pa->getValue($people, '[*][languages]');
// [['English', 'French'], 'English']

A single non-list value disables the merging entirely and the whole result becomes nested, so the same path returns differently shaped results depending on the data.

I'm not sure the merging is needed at all. Without it the result would always be predictable: one entry per item.

@symfony symfony deleted a comment from carsonbot Aug 15, 2026
@nicolas-grekas

Copy link
Copy Markdown
Member

I ran the branch against these cases, and I confirm both of @HypeMC's findings. There are two more of the same kind.

The escape syntax is a second BC break. On 8.2, [\*] round-trips: setValue($d, '[\*]', 'v') writes the key \* and getValue($d, '[\*]') returns 'v'. On this branch the setter still writes \* but the getter now unescapes and reads the key *, so the same round trip returns null. Code that already uses [\*] paths silently loses its values, so the syntax added to preserve BC breaks the people who already had that literal.

The result shape depends on the data, not on the path. readWildcard() merges the collected values only when every one of them is a list:

$pa->getValue([['tags' => ['a', 'b']], ['tags' => ['c']]], '[*][tags]');        // ['a', 'b', 'c']
$pa->getValue([['tags' => ['a', 'b']], ['tags' => ['k' => 'c']]], '[*][tags]'); // [['a', 'b'], ['k' => 'c']]

One non-list value anywhere switches the whole result from flat to nested, so a consumer cannot type the return of a fixed path.

All four issues have the same root: [*] changes meaning implicitly, and only on the read side. I would rather see wildcard reads opt-in on PropertyAccessorBuilder, for example enableWildcardReads(). That removes both BC breaks, since the default accessor stays byte-identical, and it gives the setter question a coherent answer: with wildcards enabled, a write through a [*] path should throw as unsupported instead of writing a literal key. Independently of that, the result shape needs one rule: one entry per matched element, never data-dependent merging, with chained wildcards nesting.

Would you rework it in that direction?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.