Skip to content

Commit 1a7598d

Browse files
committed
Transition to using reflection-based, direct field access only
# Motivation There are a few issues about differences in behaviour when using the collection filtering API (the `Selectable` interface) against collections that are database-backed (in ORM, these are `PersistentCollections`) vs. memory-based collections using `ArrayCollection` from this package here. For example: * doctrine/orm#11160 * #170 * #149, especially see this [comment](#149 (comment)) * doctrine/orm#3591 * Maybe doctrine/orm#11021 Database-based matching can work on the raw field values only, as those values are persisted to the database and there is no PHP code involved when filtering at the database level. Memory-based matching currently tries a [series of access methods on the objects](https://www.doctrine-project.org/projects/doctrine-collections/en/2.3/index.html#selectable-methods:~:text=For%20collections%20that%20contain%20objects). The effects of this may be surprising. For example, with the ORM, it may be fine to filter entities based on the value of `private` or `protected` fields that have no getters. This works as long as a persistent collection is uninitialized. But as soon as it gets initialized, the `ArrayCollection` will require a getter method to be available. Another (more rare) example is a getter method that does some type of type conversion, like having a `string` field with values like `'y'|'n'` internally but returning a `bool` value from the getter; or, more generally, every type mismatch between the return value of the getter and the field value. Yet another example may be getters that cause side effects 🙈. # Proposed solution I discussed with @beberlei at the Doctrine hackathon that the primary use case for this library here was supporting the ORM/ODM use cases. This can be seen in places as `ClosureExpressionVisitor::getObjectFieldValue()` that take a `$field` parameter. So, although this library here has nothing to do with ORM/ODM mapping, I want to add a migration path here that moves the `doctrine/collection` behaviour closer to the implementation realities of ORM/ODM. This means to ultimately use direct (reflection-based) field access only. This feature is opt-in and will be activated by passing `accessRawFieldValues: true` to the `Criteria` constructor. The `Criteria` object is what is typically constructed by users in preparation for calling the `Selectable` API, so it seems to be a good fit. By opting in through this flag, memory-based comparisons and sorting will use direct field access only. Not activating the feature triggers a deprecation notice. In the next major version, direct field access will be the only (default) behaviour. The `$accessRawFieldValues` can be removed in the next major version (or, possibly, go through another round of deprecations in case when it is still passed, before being eventually removed). # Remaining edge case Given an inheritance hierarchy of classes where a multiple classes feature a `private` field of the same name, the downmost field will be picked. This may differ from Doctrine ORM behaviour when this field is not mapped at all in the ORM and another field (higher up the class hierarchy) is used as the mapped field instead. We cannot solve this without having access to ORM/ODM mapping metadata at hand, which is not possible from within an `ArrayCollection` that is typically created as [a newable type](https://testing.googleblog.com/2008/10/to-new-or-not-to-new.html). We rather plan to discourage or even prevent this kind of setup (entity class hierarchy with different classes having fields of the same name) at some point when loading and validating metadata.
1 parent 53b183b commit 1a7598d

13 files changed

+303
-60
lines changed

UPGRADE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ awareness about deprecated code.
66
- Use of our low-overhead runtime deprecation API, details:
77
https://github.com/doctrine/deprecations/
88

9+
# Upgrade to 2.4
10+
11+
## Deprecated accessing fields through other means than raw field access when using the criteria filtering API (the `Doctrine\Common\Collections\Selectable` interface)
12+
13+
Starting with the next major version, the only way to access data when using the criteria filtering
14+
API is through direct (reflection-based) access at properties directly, also bypassing property hooks.
15+
This is to ensure consistency with how the ORM/ODM work. See https://github.com/doctrine/collections/pull/472 for
16+
the full motivation.
17+
18+
To opt-in to the new behaviour, pass `true` for the `$accessRawFieldValues` parameter when creating a `Criteria`
19+
object through either `Doctrine\Common\Collections\Criteria::create()` or when calling the `Doctrine\Common\Collections\Criteria` constructor.
20+
921
# Upgrade to 2.2
1022

1123
## Deprecated string representation of sort order

docs/en/index.rst

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -364,11 +364,14 @@ You can read more about expressions :ref:`here <expressions>`.
364364
.. note::
365365

366366
For collections that contain objects, the field name given to ``Comparison`` will
367-
lead to various access methods being tried in sequence. For the exact implementation,
368-
refer to the ``ClosureExpressionVisitor::getObjectFieldValue()`` method.
369-
370-
Roughly speaking, for a field named ``field``, the following things will be tried
371-
in order:
367+
lead to various access methods being tried in sequence. This behavior is deprecated
368+
as of v2.4.0. Set the ``$accessRawFieldValues`` parameter in the ``Criteria`` constructor
369+
to ``true`` to opt-in to the new behaviour of using direct (reflection-based) field access only.
370+
This will be the only option in the next major version.
371+
372+
Unless you opt in, refer to the ``ClosureExpressionVisitor::getObjectFieldValue()`` method
373+
for the exact order of accessors tried. Roughly speaking, for a field named ``field``,
374+
the following things will be tried in order:
372375

373376
1. ``getField()``, ``isField()`` and ``field()`` as getter methods
374377
2. When the object implements a ``__call`` magic method, invoke it

phpcs.xml.dist

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,14 @@
4444
<exclude-pattern>src/ArrayCollection.php</exclude-pattern>
4545
<exclude-pattern>src/Collection.php</exclude-pattern>
4646
</rule>
47+
48+
<rule ref="PSR2.Classes.PropertyDeclaration.Multiple">
49+
<!-- https://github.com/doctrine/collections/pull/472#issuecomment-3384164908 -->
50+
<exclude-pattern>tests/TestObjectPropertyHook.php</exclude-pattern>
51+
</rule>
52+
53+
<rule ref="PSR2.Classes.PropertyDeclaration.ScopeMissing">
54+
<!-- https://github.com/doctrine/collections/pull/472#issuecomment-3384164908 -->
55+
<exclude-pattern>tests/TestObjectPropertyHook.php</exclude-pattern>
56+
</rule>
4757
</ruleset>

src/ArrayCollection.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -450,11 +450,13 @@ public function slice(int $offset, int|null $length = null)
450450
/** @phpstan-return Collection<TKey, T>&Selectable<TKey,T> */
451451
public function matching(Criteria $criteria)
452452
{
453+
$accessRawFieldValues = $criteria->isRawFieldValueAccessEnabled();
454+
453455
$expr = $criteria->getWhereExpression();
454456
$filtered = $this->elements;
455457

456458
if ($expr) {
457-
$visitor = new ClosureExpressionVisitor();
459+
$visitor = new ClosureExpressionVisitor($accessRawFieldValues);
458460
$filter = $visitor->dispatch($expr);
459461
$filtered = array_filter($filtered, $filter);
460462
}
@@ -464,7 +466,7 @@ public function matching(Criteria $criteria)
464466
if ($orderings) {
465467
$next = null;
466468
foreach (array_reverse($orderings) as $field => $ordering) {
467-
$next = ClosureExpressionVisitor::sortByField($field, $ordering === Order::Descending ? -1 : 1, $next);
469+
$next = ClosureExpressionVisitor::sortByField($field, $ordering === Order::Descending ? -1 : 1, $next, $accessRawFieldValues);
468470
}
469471

470472
uasort($filtered, $next);

src/Criteria.php

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,12 @@ class Criteria
3838
*
3939
* @return static
4040
*/
41-
public static function create()
41+
public static function create(bool $accessRawFieldValues = false): self
4242
{
43-
return new static();
43+
$new = new static();
44+
$new->accessRawFieldValues = $accessRawFieldValues;
45+
46+
return $new;
4447
}
4548

4649
/**
@@ -67,8 +70,16 @@ public function __construct(
6770
array|null $orderings = null,
6871
int|null $firstResult = null,
6972
int|null $maxResults = null,
73+
private bool $accessRawFieldValues = false,
7074
) {
71-
$this->expression = $expression;
75+
if (! $accessRawFieldValues) {
76+
Deprecation::trigger(
77+
'doctrine/collections',
78+
'https://github.com/doctrine/collections/pull/472',
79+
'Not enabling raw field value access for the Criteria matching API in %s is deprecated. Raw field access will be the only supported method in 3.0',
80+
self::class,
81+
);
82+
}
7283

7384
if ($firstResult === null && func_num_args() > 2) {
7485
Deprecation::trigger(
@@ -282,4 +293,10 @@ public function setMaxResults(int|null $maxResults)
282293

283294
return $this;
284295
}
296+
297+
/** @internal */
298+
public function isRawFieldValueAccessEnabled(): bool
299+
{
300+
return $this->accessRawFieldValues;
301+
}
285302
}

src/Expr/ClosureExpressionVisitor.php

Lines changed: 73 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
use ArrayAccess;
88
use Closure;
9+
use Doctrine\Deprecations\Deprecation;
10+
use ReflectionClass;
911
use RuntimeException;
1012

1113
use function array_all;
@@ -18,11 +20,14 @@
1820
use function method_exists;
1921
use function preg_match;
2022
use function preg_replace_callback;
23+
use function sprintf;
2124
use function str_contains;
2225
use function str_ends_with;
2326
use function str_starts_with;
2427
use function strtoupper;
2528

29+
use const PHP_VERSION_ID;
30+
2631
/**
2732
* Walks an expression graph and turns it into a PHP closure.
2833
*
@@ -31,6 +36,11 @@
3136
*/
3237
class ClosureExpressionVisitor extends ExpressionVisitor
3338
{
39+
public function __construct(
40+
private readonly bool $accessRawFieldValues = false,
41+
) {
42+
}
43+
3444
/**
3545
* Accesses the field of a given object. This field has to be public
3646
* directly or indirectly (through an accessor get*, is*, or a magic
@@ -40,19 +50,30 @@ class ClosureExpressionVisitor extends ExpressionVisitor
4050
*
4151
* @return mixed
4252
*/
43-
public static function getObjectFieldValue(object|array $object, string $field)
53+
public static function getObjectFieldValue(object|array $object, string $field, bool $accessRawFieldValues = false)
4454
{
4555
if (str_contains($field, '.')) {
4656
[$field, $subField] = explode('.', $field, 2);
47-
$object = self::getObjectFieldValue($object, $field);
57+
$object = self::getObjectFieldValue($object, $field, $accessRawFieldValues);
4858

49-
return self::getObjectFieldValue($object, $subField);
59+
return self::getObjectFieldValue($object, $subField, $accessRawFieldValues);
5060
}
5161

5262
if (is_array($object)) {
5363
return $object[$field];
5464
}
5565

66+
if ($accessRawFieldValues) {
67+
return self::getNearestFieldValue($object, $field);
68+
}
69+
70+
Deprecation::trigger(
71+
'doctrine/collections',
72+
'https://github.com/doctrine/collections/pull/472',
73+
'Not enabling raw field value access for %s is deprecated. Raw field access will be the only supported method in 3.0',
74+
__METHOD__,
75+
);
76+
5677
$accessors = ['get', 'is', ''];
5778

5879
foreach ($accessors as $accessor) {
@@ -96,21 +117,50 @@ public static function getObjectFieldValue(object|array $object, string $field)
96117
return $object->$field;
97118
}
98119

120+
private static function getNearestFieldValue(object $object, string $field): mixed
121+
{
122+
$reflectionClass = new ReflectionClass($object);
123+
124+
while ($reflectionClass && ! $reflectionClass->hasProperty($field)) {
125+
$reflectionClass = $reflectionClass->getParentClass();
126+
}
127+
128+
if ($reflectionClass === false) {
129+
throw new RuntimeException(sprintf('Field "%s" does not exist in class "%s"', $field, $object::class));
130+
}
131+
132+
$property = $reflectionClass->getProperty($field);
133+
134+
if (PHP_VERSION_ID >= 80400) {
135+
return $property->getRawValue($object);
136+
}
137+
138+
return $property->getValue($object);
139+
}
140+
99141
/**
100142
* Helper for sorting arrays of objects based on multiple fields + orientations.
101143
*
102144
* @return Closure
103145
*/
104-
public static function sortByField(string $name, int $orientation = 1, Closure|null $next = null)
146+
public static function sortByField(string $name, int $orientation = 1, Closure|null $next = null, bool $accessRawFieldValues = false)
105147
{
148+
if (! $accessRawFieldValues) {
149+
Deprecation::trigger(
150+
'doctrine/collections',
151+
'https://github.com/doctrine/collections/pull/472',
152+
'Not enabling raw field value access for %s is deprecated. Raw field access will be the only supported method in 3.0',
153+
__METHOD__,
154+
);
155+
}
156+
106157
if (! $next) {
107158
$next = static fn (): int => 0;
108159
}
109160

110-
return static function ($a, $b) use ($name, $next, $orientation): int {
111-
$aValue = ClosureExpressionVisitor::getObjectFieldValue($a, $name);
112-
113-
$bValue = ClosureExpressionVisitor::getObjectFieldValue($b, $name);
161+
return static function ($a, $b) use ($name, $next, $orientation, $accessRawFieldValues): int {
162+
$aValue = ClosureExpressionVisitor::getObjectFieldValue($a, $name, $accessRawFieldValues);
163+
$bValue = ClosureExpressionVisitor::getObjectFieldValue($b, $name, $accessRawFieldValues);
114164

115165
if ($aValue === $bValue) {
116166
return $next($a, $b);
@@ -129,34 +179,34 @@ public function walkComparison(Comparison $comparison)
129179
$value = $comparison->getValue()->getValue();
130180

131181
return match ($comparison->getOperator()) {
132-
Comparison::EQ => static fn ($object): bool => self::getObjectFieldValue($object, $field) === $value,
133-
Comparison::NEQ => static fn ($object): bool => self::getObjectFieldValue($object, $field) !== $value,
134-
Comparison::LT => static fn ($object): bool => self::getObjectFieldValue($object, $field) < $value,
135-
Comparison::LTE => static fn ($object): bool => self::getObjectFieldValue($object, $field) <= $value,
136-
Comparison::GT => static fn ($object): bool => self::getObjectFieldValue($object, $field) > $value,
137-
Comparison::GTE => static fn ($object): bool => self::getObjectFieldValue($object, $field) >= $value,
138-
Comparison::IN => static function ($object) use ($field, $value): bool {
139-
$fieldValue = ClosureExpressionVisitor::getObjectFieldValue($object, $field);
182+
Comparison::EQ => fn ($object): bool => self::getObjectFieldValue($object, $field, $this->accessRawFieldValues) === $value,
183+
Comparison::NEQ => fn ($object): bool => self::getObjectFieldValue($object, $field, $this->accessRawFieldValues) !== $value,
184+
Comparison::LT => fn ($object): bool => self::getObjectFieldValue($object, $field, $this->accessRawFieldValues) < $value,
185+
Comparison::LTE => fn ($object): bool => self::getObjectFieldValue($object, $field, $this->accessRawFieldValues) <= $value,
186+
Comparison::GT => fn ($object): bool => self::getObjectFieldValue($object, $field, $this->accessRawFieldValues) > $value,
187+
Comparison::GTE => fn ($object): bool => self::getObjectFieldValue($object, $field, $this->accessRawFieldValues) >= $value,
188+
Comparison::IN => function ($object) use ($field, $value): bool {
189+
$fieldValue = ClosureExpressionVisitor::getObjectFieldValue($object, $field, $this->accessRawFieldValues);
140190

141191
return in_array($fieldValue, $value, is_scalar($fieldValue));
142192
},
143-
Comparison::NIN => static function ($object) use ($field, $value): bool {
144-
$fieldValue = ClosureExpressionVisitor::getObjectFieldValue($object, $field);
193+
Comparison::NIN => function ($object) use ($field, $value): bool {
194+
$fieldValue = ClosureExpressionVisitor::getObjectFieldValue($object, $field, $this->accessRawFieldValues);
145195

146196
return ! in_array($fieldValue, $value, is_scalar($fieldValue));
147197
},
148-
Comparison::CONTAINS => static fn ($object): bool => str_contains((string) self::getObjectFieldValue($object, $field), (string) $value),
149-
Comparison::MEMBER_OF => static function ($object) use ($field, $value): bool {
150-
$fieldValues = ClosureExpressionVisitor::getObjectFieldValue($object, $field);
198+
Comparison::CONTAINS => fn ($object): bool => str_contains((string) self::getObjectFieldValue($object, $field, $this->accessRawFieldValues), (string) $value),
199+
Comparison::MEMBER_OF => function ($object) use ($field, $value): bool {
200+
$fieldValues = ClosureExpressionVisitor::getObjectFieldValue($object, $field, $this->accessRawFieldValues);
151201

152202
if (! is_array($fieldValues)) {
153203
$fieldValues = iterator_to_array($fieldValues);
154204
}
155205

156206
return in_array($value, $fieldValues, true);
157207
},
158-
Comparison::STARTS_WITH => static fn ($object): bool => str_starts_with((string) self::getObjectFieldValue($object, $field), (string) $value),
159-
Comparison::ENDS_WITH => static fn ($object): bool => str_ends_with((string) self::getObjectFieldValue($object, $field), (string) $value),
208+
Comparison::STARTS_WITH => fn ($object): bool => str_starts_with((string) self::getObjectFieldValue($object, $field, $this->accessRawFieldValues), (string) $value),
209+
Comparison::ENDS_WITH => fn ($object): bool => str_ends_with((string) self::getObjectFieldValue($object, $field, $this->accessRawFieldValues), (string) $value),
160210
default => throw new RuntimeException('Unknown comparison operator: ' . $comparison->getOperator()),
161211
};
162212
}

tests/ArrayCollectionTestCase.php

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,8 @@ public function testGet(): void
283283

284284
public function testMatchingWithSortingPreserveKeys(): void
285285
{
286-
$object1 = new stdClass();
287-
$object2 = new stdClass();
288-
289-
$object1->sortField = 2;
290-
$object2->sortField = 1;
286+
$object1 = new TestObjectPrivatePropertyOnly(2);
287+
$object2 = new TestObjectPrivatePropertyOnly(1);
291288

292289
$collection = $this->buildCollection([
293290
'object1' => $object1,
@@ -304,7 +301,7 @@ public function testMatchingWithSortingPreserveKeys(): void
304301
'object1' => $object1,
305302
],
306303
$collection
307-
->matching(new Criteria(null, ['sortField' => Order::Ascending]))
304+
->matching(new Criteria(null, ['fooBar' => Order::Ascending], 0, accessRawFieldValues: true))
308305
->toArray(),
309306
);
310307
}
@@ -333,7 +330,7 @@ public function testLegacyMatchingWithSortingPreserveKeys(): void
333330
'object1' => $object1,
334331
],
335332
$collection
336-
->matching(new Criteria(null, ['sortField' => Criteria::ASC]))
333+
->matching(new Criteria(null, ['sortField' => Order::Ascending]))
337334
->toArray(),
338335
);
339336
}
@@ -358,7 +355,7 @@ public function testMatchingWithSlicingPreserveKeys(
358355
self::assertSame(
359356
$slicedArray,
360357
$collection
361-
->matching(new Criteria(null, null, $firstResult, $maxResult))
358+
->matching(new Criteria(null, null, $firstResult, $maxResult, accessRawFieldValues: true))
362359
->toArray(),
363360
);
364361
}
@@ -480,7 +477,7 @@ public function testMultiColumnSortAppliesAllSorts(): void
480477
self::assertSame(
481478
$expected,
482479
$collection
483-
->matching(new Criteria(null, ['foo' => Order::Descending, 'bar' => Order::Descending]))
480+
->matching(new Criteria(null, ['foo' => Order::Descending, 'bar' => Order::Descending], accessRawFieldValues: true))
484481
->toArray(),
485482
);
486483
}

0 commit comments

Comments
 (0)