From 86502fd41eb6f6f1d3c58362ac1b476f14566a0c Mon Sep 17 00:00:00 2001 From: markhuot Date: Tue, 15 Sep 2026 13:37:54 -0400 Subject: [PATCH] Don't let custom field handles shadow element query properties A field handle that collides with one of ElementQuery's own properties (e.g. `where`) was still being added to criteriaAttributes(), so getCriteria() returned internal query state rather than a field value. That leaked one element's relation constraint into the shared EagerLoadPlan built by ElementQuery::eagerLoad(), which made relational fields nested within Matrix fields fail count validation for every sibling element except the first. --- CHANGELOG.md | 1 + src/elements/db/ElementQuery.php | 4 +++ .../db/CollidingCustomFieldBehavior.php | 32 +++++++++++++++++++ tests/unit/elements/db/ElementQueryTest.php | 27 ++++++++++++++++ 4 files changed, 64 insertions(+) create mode 100644 tests/unit/elements/db/CollidingCustomFieldBehavior.php diff --git a/CHANGELOG.md b/CHANGELOG.md index aa6c28b90f1..32daa962ae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed a bug where public registration could fail when sending the activation email, if email verification wasn’t required but the password was deferred. ([#19610](https://github.com/craftcms/cms/issues/19610)) - Fixed a bug where custom fields nested within Content Block fields weren’t editable from element indexes. ([#19602](https://github.com/craftcms/cms/discussions/19602)) - Fixed a bug where nested relational fields weren’t enforcing their “Validate related {type}” setting. ([#19625](https://github.com/craftcms/cms/pull/19625)) +- Fixed a bug where relational fields nested within Matrix fields could fail validation, if the install had a custom field whose handle collided with an element query property (such as `where`). ## 5.11.1 - 2026-09-02 diff --git a/src/elements/db/ElementQuery.php b/src/elements/db/ElementQuery.php index 55034361670..47b53e1f0c7 100644 --- a/src/elements/db/ElementQuery.php +++ b/src/elements/db/ElementQuery.php @@ -2179,6 +2179,10 @@ public function criteriaAttributes(): array $name = $property->getName(); if ( !in_array($name, ['canSetProperties', 'hasMethods', 'owner']) && + // Skip handles that collide with one of the query’s own properties (e.g. `where`). + // The query property always wins when the value is read, so including the handle + // here would expose internal query state as though it were field criteria. + !property_exists($this, $name) && !method_exists($this, "get$name") ) { $names[] = $property->getName(); diff --git a/tests/unit/elements/db/CollidingCustomFieldBehavior.php b/tests/unit/elements/db/CollidingCustomFieldBehavior.php new file mode 100644 index 00000000000..8df197fcb4a --- /dev/null +++ b/tests/unit/elements/db/CollidingCustomFieldBehavior.php @@ -0,0 +1,32 @@ +invokeMethod($query, 'normalizeOrderBy', [$columns])); } + /** + * Field handles that collide with one of the query’s own properties (e.g. `where`) shouldn’t be + * treated as criteria attributes, or internal query state gets exposed as though it were field criteria. + */ + public function testCriteriaAttributesSkipQueryPropertyCollisions(): void + { + $query = Entry::find(); + $query->attachBehavior('customFields', new CollidingCustomFieldBehavior()); + + $attributes = $query->criteriaAttributes(); + + self::assertNotContains('where', $attributes); + self::assertContains('myFieldHandle', $attributes); + } + + /** + * @see testCriteriaAttributesSkipQueryPropertyCollisions() + */ + public function testGetCriteriaDoesntLeakQueryState(): void + { + $query = Entry::find(); + $query->attachBehavior('customFields', new CollidingCustomFieldBehavior()); + $query->andWhere(['elements.id' => [1, 2, 3]]); + + self::assertArrayNotHasKey('where', $query->getCriteria()); + } + /** * @return array */