From 76e2f1bce4ad906bfa502e1b4ee78cc111b35a61 Mon Sep 17 00:00:00 2001 From: Meindert Date: Wed, 26 Aug 2026 16:55:18 +0200 Subject: [PATCH] Resolve nested relations against the innermost ancestor resolveNestedContext()'s ambient branch trusted the model handed to initRelation() as the relation's parent. That model is $this->model, set from the ENCLOSING relation, because the call arrives as relationRender() -> validateField() -> initRelation($this->model). At one level of nesting the enclosing record is the parent, so this works. At two it is the grandparent, and the leaf relation resolves against the wrong model - in practice a BadMethodCallException, since the grandparent's class usually has no such relation. Walk the ambient path's hops from the root instead, exactly as the reconstructed branch below already does. Both cases now share one resolution mechanism, and the ambient path gains resolveModelForHops()'s $relation->find($id) parent check at every depth. Co-Authored-By: Claude Opus 5 --- .../backend/behaviors/RelationController.php | 13 +++-- .../RelationControllerNestedTest.php | 51 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/modules/backend/behaviors/RelationController.php b/modules/backend/behaviors/RelationController.php index c171ee4e79..ae095fc8fd 100644 --- a/modules/backend/behaviors/RelationController.php +++ b/modules/backend/behaviors/RelationController.php @@ -536,8 +536,13 @@ protected function isNestedField() * a) AMBIENT - the nesting stack is non-empty, meaning a manage * form is actively rendering right now (see * relationMakePartial()) and this field is nested directly - * under whatever's on top of it. $model is trusted as-is - it's - * the widget's own bound model. + * under whatever's on top of it. $model canNOT be trusted + * here: initRelation() is reached as + * relationRender() -> validateField() -> initRelation($this->model), + * and $this->model is the record of the ENCLOSING relation, + * not of the manage form actually rendering. Those coincide at + * one level of nesting and diverge at two, so the parent is + * walked from the root exactly as in (b). * b) RECONSTRUCTED - $field is already a bracket path (e.g. * "items[5][taxes]"), walked from the root to find the actual * model. This is the common case for anything other than an @@ -574,7 +579,9 @@ protected function resolveNestedContext($model, $field) if ($ambientPath !== null) { $this->nestedField = $ambientPath . '[' . $field . ']'; - return [$model, $field, true]; + [$hops] = $this->parseBracketField($this->nestedField); + + return [$this->resolveModelForHops($this->rootModel, $hops), $field, true]; } if (strpos($field, '[') !== false) { diff --git a/modules/backend/tests/behaviors/RelationControllerNestedTest.php b/modules/backend/tests/behaviors/RelationControllerNestedTest.php index 880f34bd8a..4feea5b362 100644 --- a/modules/backend/tests/behaviors/RelationControllerNestedTest.php +++ b/modules/backend/tests/behaviors/RelationControllerNestedTest.php @@ -918,6 +918,57 @@ public function testAmbientFieldDistinguishesBetweenSiblingItems() $this->assertNotEquals($nestedFieldForA, $nestedFieldForB, 'Two sibling items\' ambient contexts must resolve to distinct qualified fields'); } + /** + * Two levels of ambient nesting - an Item's manage form rendering + * a sub-item's manage form, which itself renders a relation field. + * + * The model RelationManager hands initRelation() is always + * $this->model, i.e. the record of the ENCLOSING relation. At one + * level of nesting that IS the parent, so trusting it works. At + * two it is the grandparent, and trusting it resolves the leaf + * relation against the wrong ancestor - in the real world a + * BadMethodCallException, since the grandparent's class usually + * has no such relation at all. + */ + public function testAmbientFieldTwoLevelsDeepResolvesInnermostAncestorNotEnclosingRecord() + { + $order = RelationTestOrder::create(['name' => 'Order 1']); + $itemA = RelationTestItem::create(['order_id' => $order->id, 'name' => 'Item A']); + $subItem = RelationTestItem::create(['parent_item_id' => $itemA->id, 'name' => 'Sub A1']); + + $controller = $this->makeController($order); + $behavior = $controller->asExtension(RelationController::class); + $controller->initRelation($order, 'items'); + + // Item A's manage form is rendering; its sub-items field + // resolves ambiently against it. + $this->pushNestingStack($behavior, "items[{$itemA->id}]"); + $controller->initRelation($itemA, 'items'); + + // The sub-item's own manage form is now rendering INSIDE Item + // A's, and a relation field within it resolves ambiently. + $this->pushNestingStack($behavior, "items[{$itemA->id}][items][{$subItem->id}]"); + + // Item A, not the sub-item: exactly what + // validateField() -> initRelation($this->model) passes here. + $controller->initRelation($itemA, 'taxes'); + + $this->popNestingStack($behavior); + $this->popNestingStack($behavior); + + $this->assertEquals( + "items[{$itemA->id}][items][{$subItem->id}][taxes]", + $this->readProtectedProperty($behavior, 'nestedField'), + 'Two levels of ambient nesting should qualify the field against the whole stack path' + ); + + $this->assertEquals( + $subItem->id, + $this->readProtectedProperty($behavior, 'model')->id, + 'A field nested two levels deep must resolve against the innermost ancestor, not the enclosing record' + ); + } + /** * Narrower test for relationMakePartial() specifically - confirms * it actually pushes before rendering and pops after, regardless