Skip to content

Commit

Permalink
[5.x] Support rendering model attributes in Antlers (#10869)
Browse files Browse the repository at this point in the history
Co-authored-by: Duncan McClean <[email protected]>
Co-authored-by: Jason Varga <[email protected]>
Co-authored-by: John Koster <[email protected]>
  • Loading branch information
4 people authored Dec 5, 2024
1 parent be8113d commit 5df78e3
Show file tree
Hide file tree
Showing 2 changed files with 120 additions and 3 deletions.
51 changes: 48 additions & 3 deletions src/View/Antlers/Language/Runtime/PathDataManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down
72 changes: 72 additions & 0 deletions tests/Antlers/Runtime/ModelTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

namespace Tests\Antlers\Runtime;

use Illuminate\Database\Eloquent\Casts\Attribute;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Tags\Tags;
use Tests\Antlers\ParserTestCase;

class ModelTest extends ParserTestCase
{
#[Test, DataProvider('modelProvider')]
public function attributes_are_returned($attribute, $expected)
{
$model = new FakeModel;
$model->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';
}
}

0 comments on commit 5df78e3

Please sign in to comment.