diff --git a/src/View/ArrayData.php b/src/View/ArrayData.php index 1d166de5305..819ad8f75ac 100644 --- a/src/View/ArrayData.php +++ b/src/View/ArrayData.php @@ -77,7 +77,7 @@ public function toMap() public function getField($field) { $value = $this->array[$field]; - if (is_object($value) && !$value instanceof ViewableData) { + if (is_object($value) && !($value instanceof ViewableData) && !is_iterable($value)) { return new ArrayData($value); } elseif (ArrayLib::is_associative($value)) { return new ArrayData($value); diff --git a/src/View/SSTemplateParser.peg b/src/View/SSTemplateParser.peg index 2061c1879a5..2ae672b39be 100644 --- a/src/View/SSTemplateParser.peg +++ b/src/View/SSTemplateParser.peg @@ -287,6 +287,8 @@ class SSTemplateParser extends Parser implements TemplateParser if (isset($sub['Call']['CallArguments']) && isset($sub['Call']['CallArguments']['php'])) { $arguments = $sub['Call']['CallArguments']['php']; $res['php'] .= "->$method('$property', [$arguments], true)"; + } elseif ($property === 'Count') { + $res['php'] .= "->$property()"; } else { $res['php'] .= "->$method('$property', null, true)"; } diff --git a/src/View/SSTemplateParser.php b/src/View/SSTemplateParser.php index db9f0a6f342..ed1b8b54c88 100644 --- a/src/View/SSTemplateParser.php +++ b/src/View/SSTemplateParser.php @@ -778,6 +778,8 @@ function Lookup_AddLookupStep(&$res, $sub, $method) if (isset($sub['Call']['CallArguments']) && isset($sub['Call']['CallArguments']['php'])) { $arguments = $sub['Call']['CallArguments']['php']; $res['php'] .= "->$method('$property', [$arguments], true)"; + } elseif ($property === 'Count') { + $res['php'] .= "->$property()"; } else { $res['php'] .= "->$method('$property', null, true)"; } @@ -1886,6 +1888,8 @@ function PresenceCheck_Argument(&$res, $sub) $res['php'] .= '((bool)'.$sub['php'].')'; } else { $php = ($sub['ArgumentMode'] == 'default' ? $sub['lookup_php'] : $sub['php']); + // TODO: kinda hacky - maybe we need a way to pass state down the parse chain so + // Lookup_LastLookupStep and Argument_BareWord can produce hasValue instead of XML_val $res['php'] .= str_replace('$$FINAL', 'hasValue', $php ?? ''); } } @@ -5290,6 +5294,8 @@ function Text__finalise(&$res) $text = stripslashes($text ?? ''); $text = addcslashes($text ?? '', '\'\\'); + // TODO: This is pretty ugly & gets applied on all files not just html. I wonder if we can make this + // non-dynamically calculated $code = <<<'EOC' (\SilverStripe\View\SSViewer::getRewriteHashLinksDefault() ? \SilverStripe\Core\Convert::raw2att( preg_replace("/^(\\/)+/", "/", $_SERVER['REQUEST_URI'] ) ) @@ -5328,7 +5334,8 @@ public function compileString($string, $templateName = "", $includeDebuggingComm $this->includeDebuggingComments = $includeDebuggingComments; - // Ignore UTF8 BOM at beginning of string. + // Ignore UTF8 BOM at beginning of string. TODO: Confirm this is needed, make sure SSViewer handles UTF + // (and other encodings) properly if (substr($string ?? '', 0, 3) == pack("CCC", 0xef, 0xbb, 0xbf)) { $this->pos = 3; } diff --git a/src/View/SSViewer_DataPresenter.php b/src/View/SSViewer_DataPresenter.php index 4735bba01a2..3c836937d07 100644 --- a/src/View/SSViewer_DataPresenter.php +++ b/src/View/SSViewer_DataPresenter.php @@ -172,6 +172,11 @@ public function getInjectedValue($property, array $params, $cast = true) // Get source for this value $result = $this->getValueSource($property); if (!array_key_exists('source', $result)) { + $obj = $this->getItem(); + // $Me is a special property. If nothing is providing an override, return the current item. + if ($property === 'Me' && !isset($obj->$property)) { + return ['obj' => $this->getItem()]; + } return null; } @@ -303,6 +308,29 @@ public function getObj($name, $arguments = [], $cache = false, $cacheName = null */ public function __call($name, $arguments) { + // $Count should be handled specially, so we can count raw arrays and iterables + // which aren't ViewableData. + if (empty($arguments) && ($name === 'Count' || $name === 'count')) { + $item = $this->getItem(); + $result = null; + if ($item instanceof ViewableData) { + // Respect ViewableData casting + $result = $item->XML_val($name, [], true); + } elseif (is_object($item)) { + // Get the method or property from objects, if there is one + if (ClassInfo::hasMethod($item, $name)) { + $result = $item->$name(); + } elseif (isset($item->$name)) { + $result = $item->$name; + } + } elseif (is_countable($item)) { + // Count countables + $result = count($item); + } + $this->resetLocalScope(); + return $result; + } + // Extract the method name and parameters $property = $arguments[0]; // The name of the public function being called @@ -313,7 +341,14 @@ public function __call($name, $arguments) if ($val) { $obj = $val['obj']; if ($name === 'hasValue') { - $result = ($obj instanceof ViewableData) ? $obj->exists() : (bool)$obj; + // Check if a value exists, e.g. <% if $obj %> + if ($obj instanceof ViewableData) { + $result = $obj->exists(); + } elseif (is_countable($obj)) { + $result = count($obj) > 0; + } else { + $result = (bool) $obj; + } } elseif (is_null($obj) || (is_scalar($obj) && !is_string($obj))) { $result = $obj; // Nulls and non-string scalars don't need casting } else { diff --git a/src/View/SSViewer_Scope.php b/src/View/SSViewer_Scope.php index 3b0fe1a5efe..6f4af08c308 100644 --- a/src/View/SSViewer_Scope.php +++ b/src/View/SSViewer_Scope.php @@ -130,6 +130,16 @@ public function getItem() if (is_scalar($item)) { $item = $this->convertScalarToDBField($item); } + // Wrap arrays + if (is_array($item)) { + if (array_is_list($item)) { + // Wrap in ArrayIterator to respect method signature + $item = new ArrayIterator($item); + } else { + // Wrap in ArrayData so values can be accessed by key in templates + $item = ArrayData::create($item); + } + } return $item; } @@ -308,6 +318,8 @@ public function next() // Item may be an array or a regular IteratorAggregate if (is_array($this->item)) { $this->itemIterator = new ArrayIterator($this->item); + } elseif ($this->item instanceof Iterator) { + $this->itemIterator = $this->item; } else { $this->itemIterator = $this->item->getIterator(); diff --git a/src/View/ViewableData.php b/src/View/ViewableData.php index f5f19ec8c0f..e694bd8e5fb 100644 --- a/src/View/ViewableData.php +++ b/src/View/ViewableData.php @@ -537,7 +537,7 @@ protected function objCacheClear() * @param array $arguments * @param bool $cache Cache this object * @param string $cacheName a custom cache name - * @return Object|DBField + * @return object|DBField */ public function obj($fieldName, $arguments = [], $cache = false, $cacheName = null) { @@ -558,6 +558,17 @@ public function obj($fieldName, $arguments = [], $cache = false, $cacheName = nu $value = $this->$fieldName; } + // Wrap arrays + if (is_array($value)) { + if (array_is_list($value)) { + // Wrap in ArrayIterator to respect method signature + $value = new ArrayIterator($value); + } else { + // Wrap in ArrayData so values can be accessed by key in templates + $value = ArrayData::create($value); + } + } + // Cast object if (!is_object($value)) { // Force cast @@ -601,7 +612,13 @@ public function cachedCall($fieldName, $arguments = [], $identifier = null) public function hasValue($field, $arguments = [], $cache = true) { $result = $this->obj($field, $arguments, $cache); - return $result->exists(); + if ($result instanceof ViewableData) { + return $result->exists(); + } + if (is_countable($result)) { + return count($result) > 0; + } + return (bool) $result; } /** @@ -668,17 +685,6 @@ public function getViewerTemplates($suffix = '') return SSViewer::get_templates_by_class(static::class, $suffix, self::class); } - /** - * When rendering some objects it is necessary to iterate over the object being rendered, to do this, you need - * access to itself. - * - * @return ViewableData - */ - public function Me() - { - return $this; - } - /** * Get part of the current classes ancestry to be used as a CSS class. * diff --git a/tests/php/View/SSViewerTest.php b/tests/php/View/SSViewerTest.php index e5307a7c13e..ac42398fdce 100644 --- a/tests/php/View/SSViewerTest.php +++ b/tests/php/View/SSViewerTest.php @@ -2,6 +2,7 @@ namespace SilverStripe\View\Tests; +use ArrayIterator; use Exception; use InvalidArgumentException; use LogicException; @@ -1016,6 +1017,48 @@ public function testIfBlocks() ); } + public function provideIfBlockWithIterable(): array + { + $scenarios = [ + 'empty array' => [ + 'iterable' => [], + 'inScope' => false, + ], + 'array' => [ + 'iterable' => [1, 2, 3], + 'inScope' => false, + ], + 'iterator' => [ + 'iterable' => new ArrayIterator([1, 2, 3]), + 'inScope' => false, + ], + 'ArrayList' => [ + 'iterable' => new ArrayList([['Val' => 1], ['Val' => 2], ['Val' => 3]]), + 'inScope' => false, + ], + ]; + foreach ($scenarios as $name => $scenario) { + $scenario['inScope'] = true; + $scenarios[$name . ' in scope'] = $scenario; + } + return $scenarios; + } + + /** + * @dataProvider provideIfBlockWithIterable + */ + public function testIfBlockWithIterable(iterable $iterable, bool $inScope): void + { + $expected = count($iterable) ? 'has value' : 'no value'; + $data = new ArrayData(['Iterable' => $iterable]); + if ($inScope) { + $template = '<% with $Iterable %><% if $Me %>has value<% else %>no value<% end_if %><% end_with %>'; + } else { + $template = '<% if $Iterable %>has value<% else %>no value<% end_if %>'; + } + $this->assertEqualIgnoringWhitespace($expected, $this->render($template, $data)); + } + public function testBaseTagGeneration() { // XHTML will have a closed base tag @@ -1331,6 +1374,92 @@ public function testCastingHelpers() ); } + public function provideLoop(): array + { + return [ + 'nested array and iterator' => [ + 'iterable' => [ + [ + 'value 1', + 'value 2', + ], + new ArrayIterator([ + 'value 3', + 'value 4', + ]), + ], + 'template' => '<% loop $Iterable %><% loop $Me %>$Me<% end_loop %><% end_loop %>', + 'expected' => 'value 1 value 2 value 3 value 4', + ], + 'nested associative arrays' => [ + 'iterable' => [ + [ + 'Foo' => 'one', + ], + [ + 'Foo' => 'two', + ], + [ + 'Foo' => 'three', + ], + ], + 'template' => '<% loop $Iterable %>$Foo<% end_loop %>', + 'expected' => 'one two three', + ], + ]; + } + + /** + * @dataProvider provideLoop + */ + public function testLoop(iterable $iterable, string $template, string $expected): void + { + $data = new ArrayData(['Iterable' => $iterable]); + $this->assertEqualIgnoringWhitespace($expected, $this->render($template, $data)); + } + + public function provideCountIterable(): array + { + $scenarios = [ + 'empty array' => [ + 'iterable' => [], + 'inScope' => false, + ], + 'array' => [ + 'iterable' => [1, 2, 3], + 'inScope' => false, + ], + 'iterator' => [ + 'iterable' => new ArrayIterator([1, 2, 3]), + 'inScope' => false, + ], + 'ArrayList' => [ + 'iterable' => new ArrayList([['Val' => 1], ['Val' => 2], ['Val' => 3]]), + 'inScope' => false, + ], + ]; + foreach ($scenarios as $name => $scenario) { + $scenario['inScope'] = true; + $scenarios[$name . ' in scope'] = $scenario; + } + return $scenarios; + } + + /** + * @dataProvider provideCountIterable + */ + public function testCountIterable(iterable $iterable, bool $inScope): void + { + $expected = count($iterable); + $data = new ArrayData(['Iterable' => $iterable]); + if ($inScope) { + $template = '<% with $Iterable %>$Count<% end_with %>'; + } else { + $template = '$Iterable.Count'; + } + $this->assertEqualIgnoringWhitespace($expected, $this->render($template, $data)); + } + public function testSSViewerBasicIteratorSupport() { $data = new ArrayData( @@ -2230,4 +2359,34 @@ public function testPrimitivesConvertedToDBFields() $this->render('<% loop $Foo %>$Me<% end_loop %>', $data) ); } + + public function testMe(): void + { + $mockArrayData = $this->getMockBuilder(ArrayData::class)->addMethods(['forTemplate'])->getMock(); + $mockArrayData->expects($this->once())->method('forTemplate'); + $this->render('$Me', $mockArrayData); + } + + public function provideAccessAssociativeArrayValues(): array + { + return [ + 'in scope' => [ + true, + ], + 'not in scope' => [ + false, + ], + ]; + } + + /** + * @dataProvider provideAccessAssociativeArrayValues + */ + public function testAccessAssociativeArrayValues(bool $inScope): void + { + $data = new ViewableData(); + $data->Foo = ['Value1' => '1', 'Value2' => 'two']; + $template = $inScope ? '<% with $Foo %>$Value1 $Value2<% end_with %>' : '$Foo.Value1 $Foo.Value2'; + $this->assertEqualIgnoringWhitespace('1 two', $this->render($template, $data)); + } } diff --git a/tests/php/View/ViewableDataTest.php b/tests/php/View/ViewableDataTest.php index 15ff7425290..3403fb2b9e2 100644 --- a/tests/php/View/ViewableDataTest.php +++ b/tests/php/View/ViewableDataTest.php @@ -2,6 +2,7 @@ namespace SilverStripe\View\Tests; +use ArrayIterator; use ReflectionMethod; use SilverStripe\ORM\FieldType\DBField; use SilverStripe\Dev\SapphireTest; @@ -278,4 +279,45 @@ public function testDynamicData() $this->assertSame($obj, $viewableData->getDynamicData('abc')); $this->assertSame($obj, $viewableData->abc); } + + public function provideWrapArrayInObj(): array + { + return [ + 'empty array' => [ + 'arr' => [], + 'expectedClass' => ArrayIterator::class, + ], + 'fully indexed array' => [ + 'arr' => [ + 'value1', + 'value2', + ], + 'expectedClass' => ArrayIterator::class, + ], + 'fully associative array' => [ + 'arr' => [ + 'v1' => 'value1', + 'v2' => 'value2', + ], + 'expectedClass' => ArrayData::class, + ], + 'partially associative array' => [ + 'arr' => [ + 'value1', + 'v2' => 'value2', + ], + 'expectedClass' => ArrayData::class, + ], + ]; + } + + /** + * @dataProvider provideWrapArrayInObj + */ + public function testWrapArrayInObj(array $arr, string $expectedClass): void + { + $viewableData = new ViewableData(); + $viewableData->arr = $arr; + $this->assertInstanceOf($expectedClass, $viewableData->obj('arr')); + } }