diff --git a/src/View/Antlers/Language/Runtime/PathDataManager.php b/src/View/Antlers/Language/Runtime/PathDataManager.php index 050083e87a..cec692806e 100644 --- a/src/View/Antlers/Language/Runtime/PathDataManager.php +++ b/src/View/Antlers/Language/Runtime/PathDataManager.php @@ -4,6 +4,8 @@ use Exception; use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; @@ -641,10 +643,12 @@ public function getData(VariableReference $path, $data, $isForArrayIndex = false } } - if (count($path->pathParts) > 1 && $this->isPair == false) { + if (count($path->pathParts) > 1 && $this->isPair == false && ! $this->reducedVar instanceof Model) { // If we have more steps in the path to take, but we are // not a tag pair, we need to reduce anyway so we // can descend further into the nested values. + // We skip this step for Models to prevent + // some of the reflection stuff below. $this->lockData(); $this->reducedVar = self::reduce($this->reducedVar, true, $this->shouldDoValueIntercept); $this->unlockData(); @@ -694,7 +698,11 @@ public function getData(VariableReference $path, $data, $isForArrayIndex = false $wasBuilderGoingIntoLast = true; } - $this->reduceVar($pathItem, $data); + if ($this->reducedVar instanceof Model) { + $this->reducedVar = $this->reducedVar->{$pathItem->name}; + } else { + $this->reduceVar($pathItem, $data); + } $this->collapseValues($pathItem->isFinal); @@ -774,6 +782,10 @@ public function getData(VariableReference $path, $data, $isForArrayIndex = false $this->compact(true); } + if ($this->reducedVar instanceof Model && $this->isPair) { + $this->reducedVar = self::reduce($this->reducedVar, true, true, false); + } + $this->namedSlotsInScope = false; $this->resetInternalState(); @@ -909,6 +921,10 @@ private function reduceVar($path, $processorData = []) */ private function compact($isFinal) { + if (! $isFinal && $this->reducedVar instanceof Model) { + return; + } + if ($this->isForArrayIndex && $isFinal && is_object($this->reducedVar) && method_exists($this->reducedVar, '__toString')) { $this->reducedVar = (string) $this->reducedVar; @@ -951,13 +967,18 @@ protected static function guardRuntimeReturnValue($value) * @param mixed $value The value to reduce. * @param bool $isPair Indicates if the path belongs to a node pair. * @param bool $reduceBuildersAndAugmentables Indicates if Builder and Augmentable instances should be resolved. + * @param bool $leaveModelsAlone * @return array|string */ - public static function reduce($value, $isPair = true, $reduceBuildersAndAugmentables = true) + public static function reduce($value, $isPair = true, $reduceBuildersAndAugmentables = true, $leaveModelsAlone = true) { $reductionStack = [$value]; $returnValue = $value; + if ($value instanceof Model && $leaveModelsAlone) { + return $value; + } + while (! empty($reductionStack)) { $reductionValue = array_pop($reductionStack); @@ -1014,6 +1035,26 @@ public static function reduce($value, $isPair = true, $reduceBuildersAndAugmenta $reductionStack[] = $reductionValue->all(); GlobalRuntimeState::$isEvaluatingData = false; + continue; + } elseif ($reductionValue instanceof Model) { + GlobalRuntimeState::$isEvaluatingData = true; + $data = $reductionValue->toArray(); + + foreach (get_class_methods($reductionValue) as $method) { + if ((new \ReflectionMethod($reductionValue, $method))->getReturnType()?->getName() === Attribute::class) { + $method = Str::snake($method); + $data[$method] = $reductionValue->$method; + } + + if (Str::startsWith($method, 'get') && Str::endsWith($method, 'Attribute')) { + $method = Str::of($method)->after('get')->before('Attribute')->snake()->__toString(); + $data[$method] = $reductionValue->getAttribute($method); + } + } + + $reductionStack[] = $data; + GlobalRuntimeState::$isEvaluatingData = false; + continue; } elseif ($reductionValue instanceof Arrayable) { GlobalRuntimeState::$isEvaluatingData = true; @@ -1049,6 +1090,10 @@ public static function reduceForAntlers($value, Parser $parser, $data, $isPair = GlobalRuntimeState::$isEvaluatingUserData = true; GlobalRuntimeState::$isEvaluatingData = true; + if ($value instanceof Model) { + return $value; + } + if ($value instanceof Collection) { $value = $value->all(); } diff --git a/tests/Antlers/Runtime/ModelTest.php b/tests/Antlers/Runtime/ModelTest.php new file mode 100644 index 0000000000..89ad8eab64 --- /dev/null +++ b/tests/Antlers/Runtime/ModelTest.php @@ -0,0 +1,72 @@ +title = 'foo'; + + $this->assertSame($expected, $this->renderString("{{ model:$attribute }}", ['model' => $model])); + } + + #[Test, DataProvider('modelProvider')] + public function attributes_are_returned_in_tag_pair($attribute, $expected) + { + $model = new FakeModel; + $model->title = 'foo'; + + $this->assertSame($expected, $this->renderString("{{ model }}{{ $attribute }}{{ /model }}", ['model' => $model])); + } + + public static function modelProvider() + { + return [ + 'column' => ['title', 'foo'], + 'accessor' => ['alfa_bravo', 'charlie'], + 'old accessor' => ['delta_echo', 'foxtrot'], + ]; + } + + #[Test] + public function variable_references_receive_models() + { + (new class extends Tags + { + public static $handle = 'tag'; + + public function index() + { + $src = $this->params->get('value'); + + return $src instanceof FakeModel ? 'Yes' : 'No'; + } + })::register(); + + $this->assertSame('Yes', $this->renderString('{{ %tag :value="model" }}', ['model' => new FakeModel])); + } +} + +class FakeModel extends \Illuminate\Database\Eloquent\Model +{ + public function alfaBravo(): Attribute + { + return Attribute::make( + get: fn () => 'charlie', + ); + } + + public function getDeltaEchoAttribute() + { + return 'foxtrot'; + } +}