Skip to content

Commit 229f741

Browse files
marcorieserclaudejasonvarga
authored
[6.x] Add support for public properties to PathDataManager (#11697)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Jason Varga <jason@pixelfear.com>
1 parent 5a409e0 commit 229f741

2 files changed

Lines changed: 241 additions & 3 deletions

File tree

src/View/Antlers/Language/Runtime/PathDataManager.php

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -700,7 +700,7 @@ public function getData(VariableReference $path, $data, $isForArrayIndex = false
700700

701701
if ($this->reducedVar instanceof Model) {
702702
$this->reducedVar = $this->reducedVar->{$pathItem->name};
703-
} elseif (is_object($this->reducedVar) && property_exists($this->reducedVar, $pathItem->name)) {
703+
} elseif (is_object($this->reducedVar) && property_exists($this->reducedVar, $pathItem->name) && (new \ReflectionProperty($this->reducedVar, $pathItem->name))->isPublic()) {
704704
$this->reducedVar = $this->reducedVar->{$pathItem->name};
705705
} else {
706706
$this->reduceVar($pathItem, $data);
@@ -861,10 +861,25 @@ private function reduceVar($path, $processorData = [])
861861
$this->unlockData();
862862
}
863863

864-
if (is_object($this->reducedVar) && method_exists($this->reducedVar, Str::camel($varPath))) {
865-
$this->reducedVar = call_user_func_array([$this->reducedVar, Str::camel($varPath)], []);
864+
if (is_object($this->reducedVar) && method_exists($this->reducedVar, $camelVar = Str::camel($varPath)) && (new \ReflectionMethod($this->reducedVar, $camelVar))->isPublic()) {
865+
$this->reducedVar = call_user_func_array([$this->reducedVar, $camelVar], []);
866866
$this->resolvedPath[] = '{method:'.$varPath.'}';
867867

868+
if ($doCompact) {
869+
$this->compact($path->isFinal);
870+
}
871+
} elseif (is_object($this->reducedVar) && property_exists($this->reducedVar, $camelVar = Str::camel($varPath)) && (new \ReflectionProperty($this->reducedVar, $camelVar))->isPublic()) {
872+
$this->reducedVar = $this->reducedVar->{$camelVar};
873+
$this->resolvedPath[] = '{property:'.$varPath.'}';
874+
875+
if ($doCompact) {
876+
$this->compact($path->isFinal);
877+
}
878+
} elseif (is_object($this->reducedVar)) {
879+
$this->reducedVar = null;
880+
$this->didFind = false;
881+
$this->doBreak = true;
882+
868883
if ($doCompact) {
869884
$this->compact($path->isFinal);
870885
}

tests/Antlers/Runtime/DataRetrieverTest.php

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace Tests\Antlers\Runtime;
44

5+
use Statamic\Facades\Antlers;
6+
use Statamic\Facades\Cascade;
57
use Statamic\View\Antlers\Language\Runtime\PathDataManager;
68
use Tests\Antlers\ParserTestCase;
79

@@ -14,6 +16,13 @@ private function getPathValue($path, $data)
1416
return $dataRetriever->getData($this->parsePath($path), $data);
1517
}
1618

19+
private function getPathValueWithExistence($path, $data)
20+
{
21+
$dataRetriever = new PathDataManager();
22+
23+
return $dataRetriever->getDataWithExistence($this->parsePath($path), $data);
24+
}
25+
1726
public function test_simple_data_is_retrieved()
1827
{
1928
$data = [
@@ -76,4 +85,218 @@ public function test_dynamic_keys_are_correctly_set()
7685
$value = $this->getPathValue('page[view:nested:nested1:nested2]', $data);
7786
$this->assertSame(12345, $value);
7887
}
88+
89+
public function test_object_properties_are_retrieved()
90+
{
91+
$data = [
92+
'view' => [
93+
'object' => new class
94+
{
95+
public string $publicProperty = 'Hello Public World!';
96+
97+
protected string $protectedProperty = 'Hello Protected World!';
98+
99+
private string $privateProperty = 'Hello Private World!';
100+
},
101+
],
102+
];
103+
104+
$value = $this->getPathValue('view.object.public_property', $data);
105+
$this->assertSame('Hello Public World!', $value);
106+
107+
$value = $this->getPathValue('view.object.protected_property', $data);
108+
$this->assertNull($value);
109+
110+
$value = $this->getPathValue('view.object.private_property', $data);
111+
$this->assertNull($value);
112+
}
113+
114+
public function test_object_properties_are_usable_in_antlers_conditions()
115+
{
116+
$object = new class
117+
{
118+
public string $truthyStringProperty = 'Hello Public World!';
119+
120+
public bool $truthyBoolProperty = true;
121+
122+
public string $falsyStringProperty = '';
123+
124+
public bool $falsyBoolProperty = false;
125+
126+
protected string $protectedProperty = 'Hello Protected World!';
127+
128+
private string $privateProperty = 'Hello Private World!';
129+
};
130+
131+
Cascade::set('object', $object);
132+
133+
$value = (string) Antlers::parse('{{ if object:truthy_string_property }}yes{{ else }}no{{ /if }}');
134+
$this->assertSame('yes', $value);
135+
136+
$value = (string) Antlers::parse('{{ if object:truthy_bool_property }}yes{{ else }}no{{ /if }}');
137+
$this->assertSame('yes', $value);
138+
139+
$value = (string) Antlers::parse('{{ if object:falsy_string_property }}yes{{ else }}no{{ /if }}');
140+
$this->assertSame('no', $value);
141+
142+
$value = (string) Antlers::parse('{{ if object:falsy_bool_property }}yes{{ else }}no{{ /if }}');
143+
$this->assertSame('no', $value);
144+
145+
$value = (string) Antlers::parse('{{ if object:protected_property }}yes{{ else }}no{{ /if }}');
146+
$this->assertSame('no', $value);
147+
148+
$value = (string) Antlers::parse('{{ if object:private_property }}yes{{ else }}no{{ /if }}');
149+
$this->assertSame('no', $value);
150+
}
151+
152+
public function test_non_public_methods_are_not_called()
153+
{
154+
$data = [
155+
'object' => new class
156+
{
157+
protected function protectedMethod()
158+
{
159+
return 'Protected Method';
160+
}
161+
162+
private function privateMethod()
163+
{
164+
return 'Private Method';
165+
}
166+
},
167+
];
168+
169+
$value = $this->getPathValue('object.protected_method', $data);
170+
$this->assertNull($value);
171+
172+
$value = $this->getPathValue('object.private_method', $data);
173+
$this->assertNull($value);
174+
}
175+
176+
public function test_public_property_is_used_when_non_public_method_shares_name()
177+
{
178+
$data = [
179+
'object' => new class
180+
{
181+
public string $label = 'My Widget';
182+
183+
protected function label()
184+
{
185+
return strtoupper($this->label);
186+
}
187+
},
188+
];
189+
190+
$value = $this->getPathValue('object.label', $data);
191+
$this->assertSame('My Widget', $value);
192+
}
193+
194+
public function test_objects_with_no_matching_property_or_method_are_returned_as_null()
195+
{
196+
$data = [
197+
'object' => new class
198+
{
199+
},
200+
];
201+
202+
$value = $this->getPathValue('object.no_existent', $data);
203+
$this->assertNull($value);
204+
205+
Cascade::set('object', $data['object']);
206+
207+
$value = (string) Antlers::parse('{{ if object:no_existent }}yes{{ else }}no{{ /if }}');
208+
$this->assertSame('no', $value);
209+
}
210+
211+
public function test_non_public_properties_report_not_found()
212+
{
213+
$data = [
214+
'view' => [
215+
'object' => new class
216+
{
217+
protected string $protectedProperty = 'Hello Protected World!';
218+
219+
private string $privateProperty = 'Hello Private World!';
220+
},
221+
],
222+
];
223+
224+
[$found, $value] = $this->getPathValueWithExistence('view.object.protected_property', $data);
225+
$this->assertFalse($found);
226+
$this->assertNull($value);
227+
228+
[$found, $value] = $this->getPathValueWithExistence('view.object.private_property', $data);
229+
$this->assertFalse($found);
230+
$this->assertNull($value);
231+
}
232+
233+
public function test_non_public_properties_short_circuit_deeper_paths()
234+
{
235+
$data = [
236+
'object' => new class
237+
{
238+
protected string $protectedProperty = 'Hello Protected World!';
239+
},
240+
];
241+
242+
$value = $this->getPathValue('object.protected_property.deeper.path', $data);
243+
$this->assertNull($value);
244+
245+
[$found, $value] = $this->getPathValueWithExistence('object.protected_property.deeper.path', $data);
246+
$this->assertFalse($found);
247+
$this->assertNull($value);
248+
}
249+
250+
public function test_objects_with_no_matching_property_or_method_report_not_found()
251+
{
252+
$data = [
253+
'object' => new class
254+
{
255+
},
256+
];
257+
258+
[$found, $value] = $this->getPathValueWithExistence('object.no_existent', $data);
259+
$this->assertFalse($found);
260+
$this->assertNull($value);
261+
}
262+
263+
public function test_objects_with_no_matching_property_short_circuit_deeper_paths()
264+
{
265+
$data = [
266+
'object' => new class
267+
{
268+
public string $name = 'Hello';
269+
},
270+
];
271+
272+
$value = $this->getPathValue('object.no_existent.deeper.path', $data);
273+
$this->assertNull($value);
274+
275+
[$found, $value] = $this->getPathValueWithExistence('object.no_existent.deeper.path', $data);
276+
$this->assertFalse($found);
277+
$this->assertNull($value);
278+
}
279+
280+
public function test_exact_name_non_public_properties_are_not_accessed()
281+
{
282+
$data = [
283+
'object' => new class
284+
{
285+
public string $name = 'Public';
286+
287+
protected string $secret = 'Protected';
288+
289+
private string $hidden = 'Private';
290+
},
291+
];
292+
293+
$value = $this->getPathValue('object.name', $data);
294+
$this->assertSame('Public', $value);
295+
296+
$value = $this->getPathValue('object.secret', $data);
297+
$this->assertNull($value);
298+
299+
$value = $this->getPathValue('object.hidden', $data);
300+
$this->assertNull($value);
301+
}
79302
}

0 commit comments

Comments
 (0)