Compare commits

..

2 Commits

Author SHA1 Message Date
Guy Sartorelli
0a971d5f87
Merge 7d5e854ae8 into 33929e2992 2024-10-09 23:33:17 +00:00
Guy Sartorelli
7d5e854ae8
API Refactor template layer into its own module
Includes the following large-scale changes:
- Impoved barrier between model and view layers
- Improved casting of scalar to relevant DBField types
- Improved capabilities for rendering arbitrary data in templates
2024-10-10 12:33:08 +13:00
22 changed files with 630 additions and 227 deletions

View File

@ -17,7 +17,8 @@ use SilverStripe\Model\ArrayData;
use SilverStripe\View\SSViewer; use SilverStripe\View\SSViewer;
use LogicException; use LogicException;
use SilverStripe\Control\HTTPResponse_Exception; use SilverStripe\Control\HTTPResponse_Exception;
use SilverStripe\View\SSViewer_FromString; use SilverStripe\View\SSTemplateEngine;
use SilverStripe\View\ViewLayerData;
/** /**
* This class is is responsible for adding objects to another object's has_many * This class is is responsible for adding objects to another object's has_many
@ -284,12 +285,15 @@ class GridFieldAddExistingAutocompleter extends AbstractGridFieldComponent imple
$json = []; $json = [];
Config::nest(); Config::nest();
SSViewer::config()->set('source_file_comments', false); SSViewer::config()->set('source_file_comments', false);
$viewer = SSViewer_FromString::create($this->resultsFormat);
$engine = new SSTemplateEngine();
foreach ($results as $result) { foreach ($results as $result) {
if (!$result->canView()) { if (!$result->canView()) {
continue; continue;
} }
$title = Convert::html2raw($viewer->process($result, cache: false)); $title = Convert::html2raw(
$engine->renderString($this->resultsFormat, ViewLayerData::create($result), cache: false)
);
$json[] = [ $json[] = [
'label' => $title, 'label' => $title,
'value' => $title, 'value' => $title,

View File

@ -18,7 +18,6 @@ use stdClass;
*/ */
class ArrayData extends ModelData class ArrayData extends ModelData
{ {
/** /**
* @var array * @var array
* @see ArrayData::_construct() * @see ArrayData::_construct()
@ -87,6 +86,7 @@ class ArrayData extends ModelData
*/ */
public function setField(string $fieldName, mixed $value): static public function setField(string $fieldName, mixed $value): static
{ {
$this->objCacheClear();
$this->array[$fieldName] = $value; $this->array[$fieldName] = $value;
return $this; return $this;
} }
@ -102,6 +102,11 @@ class ArrayData extends ModelData
return isset($this->array[$fieldName]); return isset($this->array[$fieldName]);
} }
public function exists(): bool
{
return !empty($this->array);
}
/** /**
* Converts an associative array to a simple object * Converts an associative array to a simple object
* *

View File

@ -206,6 +206,7 @@ class ModelData implements Stringable
public function setDynamicData(string $field, mixed $value): static public function setDynamicData(string $field, mixed $value): static
{ {
$this->objCacheClear();
$this->dynamicData[$field] = $value; $this->dynamicData[$field] = $value;
return $this; return $this;
} }
@ -415,10 +416,8 @@ class ModelData implements Stringable
/** /**
* Clear object cache * Clear object cache
*
* @return $this
*/ */
public function objCacheClear() public function objCacheClear(): static
{ {
$this->objCache = []; $this->objCache = [];
return $this; return $this;

View File

@ -1934,6 +1934,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
string $eagerLoadRelation, string $eagerLoadRelation,
EagerLoadedList|DataObject $eagerLoadedData EagerLoadedList|DataObject $eagerLoadedData
): void { ): void {
$this->objCacheClear();
$this->eagerLoadedData[$eagerLoadRelation] = $eagerLoadedData; $this->eagerLoadedData[$eagerLoadRelation] = $eagerLoadedData;
} }

View File

@ -21,11 +21,11 @@ class CastingService
/** /**
* Cast a value to the relevant object (usually a DBField instance) for use in the view layer. * Cast a value to the relevant object (usually a DBField instance) for use in the view layer.
* *
* @param null|array|ModelData $source Where the data originates from. This is used both to check for casting helpers * @param null|array|object $source Where the data originates from. This is used both to check for casting helpers
* and to help set the value in cast DBField instances. * and to help set the value in cast DBField instances.
* @param bool $strict If true, an object will be returned even if $data is null. * @param bool $strict If true, an object will be returned even if $data is null.
*/ */
public function cast(mixed $data, null|array|ModelData $source = null, string $fieldName = '', bool $strict = false): ?object public function cast(mixed $data, null|array|object $source = null, string $fieldName = '', bool $strict = false): ?object
{ {
// null is null - we shouldn't cast it to an object, because that makes it harder // null is null - we shouldn't cast it to an object, because that makes it harder
// for downstream checks to know there's "no value". // for downstream checks to know there's "no value".
@ -42,6 +42,10 @@ class CastingService
$service = null; $service = null;
if ($source instanceof ModelData) { if ($source instanceof ModelData) {
$service = $source->castingHelper($fieldName); $service = $source->castingHelper($fieldName);
} elseif (is_object($source)) {
// $source is passed into setValue for the DBField instances, but those don't accept
// objects that aren't ModelData
$source = null;
} }
// Cast to object if there's an explicit casting for this field // Cast to object if there's an explicit casting for this field

View File

@ -5,6 +5,7 @@ namespace SilverStripe\View;
use InvalidArgumentException; use InvalidArgumentException;
use Psr\SimpleCache\CacheInterface; use Psr\SimpleCache\CacheInterface;
use SilverStripe\Control\Director; use SilverStripe\Control\Director;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Flushable; use SilverStripe\Core\Flushable;
use SilverStripe\Core\Injector\Injectable; use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Injector\Injector;
@ -32,6 +33,12 @@ use SilverStripe\View\Exception\MissingTemplateException;
class SSTemplateEngine implements TemplateEngine, Flushable class SSTemplateEngine implements TemplateEngine, Flushable
{ {
use Injectable; use Injectable;
use Configurable;
/**
* Default prepended cache key for partial caching
*/
private static string $global_key = '$CurrentReadingMode, $CurrentUser.ID';
/** /**
* List of models being processed * List of models being processed

View File

@ -781,7 +781,7 @@ class SSTemplateParser extends Parser implements TemplateParser
// the passed cache key, the block index, and the sha hash of the template. // the passed cache key, the block index, and the sha hash of the template.
$res['php'] .= '$keyExpression = function() use ($scope, $cache) {' . PHP_EOL; $res['php'] .= '$keyExpression = function() use ($scope, $cache) {' . PHP_EOL;
$res['php'] .= '$val = \'\';' . PHP_EOL; $res['php'] .= '$val = \'\';' . PHP_EOL;
if ($globalKey = SSViewer::config()->get('global_key')) { if ($globalKey = SSTemplateEngine::config()->get('global_key')) {
// Embed the code necessary to evaluate the globalKey directly into the template, // Embed the code necessary to evaluate the globalKey directly into the template,
// so that SSTemplateParser only needs to be called during template regeneration. // so that SSTemplateParser only needs to be called during template regeneration.
// Warning: If the global key is changed, it's necessary to flush the template cache. // Warning: If the global key is changed, it's necessary to flush the template cache.

View File

@ -3430,7 +3430,7 @@ class SSTemplateParser extends Parser implements TemplateParser
// the passed cache key, the block index, and the sha hash of the template. // the passed cache key, the block index, and the sha hash of the template.
$res['php'] .= '$keyExpression = function() use ($scope, $cache) {' . PHP_EOL; $res['php'] .= '$keyExpression = function() use ($scope, $cache) {' . PHP_EOL;
$res['php'] .= '$val = \'\';' . PHP_EOL; $res['php'] .= '$val = \'\';' . PHP_EOL;
if ($globalKey = SSViewer::config()->get('global_key')) { if ($globalKey = SSTemplateEngine::config()->get('global_key')) {
// Embed the code necessary to evaluate the globalKey directly into the template, // Embed the code necessary to evaluate the globalKey directly into the template,
// so that SSTemplateParser only needs to be called during template regeneration. // so that SSTemplateParser only needs to be called during template regeneration.
// Warning: If the global key is changed, it's necessary to flush the template cache. // Warning: If the global key is changed, it's necessary to flush the template cache.

View File

@ -48,11 +48,6 @@ class SSViewer
*/ */
private static bool $theme_enabled = true; private static bool $theme_enabled = true;
/**
* Default prepended cache key for partial caching
*/
private static string $global_key = '$CurrentReadingMode, $CurrentUser.ID';
/** /**
* If true, rendered templates will include comments indicating which template file was used. * If true, rendered templates will include comments indicating which template file was used.
* May not be supported for some rendering engines. * May not be supported for some rendering engines.
@ -71,14 +66,18 @@ class SSViewer
/** /**
* Overridden value of rewrite_hash_links config * Overridden value of rewrite_hash_links config
*
* Can be set to "php" to rewrite hash links with PHP executable code.
*/ */
protected static ?bool $current_rewrite_hash_links = null; protected static null|bool|string $current_rewrite_hash_links = null;
/** /**
* Instance variable to disable rewrite_hash_links (overrides global default) * Instance variable to disable rewrite_hash_links (overrides global default)
* Leave null to use global state. * Leave null to use global state.
*
* Can be set to "php" to rewrite hash links with PHP executable code.
*/ */
protected ?bool $rewriteHashlinks = null; protected null|bool|string $rewriteHashlinks = null;
/** /**
* Determines whether resources from the Requirements API are included in a processed result. * Determines whether resources from the Requirements API are included in a processed result.
@ -250,10 +249,8 @@ class SSViewer
/** /**
* Check if rewrite hash links are enabled on this instance * Check if rewrite hash links are enabled on this instance
*
* @return bool
*/ */
public function getRewriteHashLinks() public function getRewriteHashLinks(): null|bool|string
{ {
if ($this->rewriteHashlinks !== null) { if ($this->rewriteHashlinks !== null) {
return $this->rewriteHashlinks; return $this->rewriteHashlinks;
@ -263,11 +260,8 @@ class SSViewer
/** /**
* Set if hash links are rewritten for this instance * Set if hash links are rewritten for this instance
*
* @param bool $rewrite
* @return $this
*/ */
public function setRewriteHashLinks($rewrite) public function setRewriteHashLinks(null|bool|string $rewrite): static
{ {
$this->rewriteHashlinks = $rewrite; $this->rewriteHashlinks = $rewrite;
return $this; return $this;
@ -275,10 +269,8 @@ class SSViewer
/** /**
* Get default value for rewrite hash links for all modules * Get default value for rewrite hash links for all modules
*
* @return bool
*/ */
public static function getRewriteHashLinksDefault() public static function getRewriteHashLinksDefault(): null|bool|string
{ {
// Check if config overridden // Check if config overridden
if (static::$current_rewrite_hash_links !== null) { if (static::$current_rewrite_hash_links !== null) {
@ -289,10 +281,8 @@ class SSViewer
/** /**
* Set default rewrite hash links * Set default rewrite hash links
*
* @param bool $rewrite
*/ */
public static function setRewriteHashLinksDefault($rewrite) public static function setRewriteHashLinksDefault(null|bool|string $rewrite)
{ {
static::$current_rewrite_hash_links = $rewrite; static::$current_rewrite_hash_links = $rewrite;
} }
@ -369,39 +359,6 @@ PHP;
return $html; return $html;
} }
/**
* Execute the evaluated string, passing it the given data.
* Used by partial caching to evaluate custom cache keys expressed using
* template expressions
*
* @param string $content Input string
* @param mixed $data Data context
* @param array $arguments Additional arguments
* @param bool $globalRequirements
*
* @return string Evaluated result
*/
public static function execute_string($content, $data, $arguments = [], $globalRequirements = false)
{
// @TODO come back to this. Probably delete it but keeping for now due to $globalRequirements
$v = SSViewer_FromString::create($content);
if ($globalRequirements) {
$v->includeRequirements(false);
} else {
//nest a requirements backend for our template rendering
$origBackend = Requirements::backend();
Requirements::set_backend(Requirements_Backend::create());
}
try {
return $v->process($data, $arguments);
} finally {
if (!$globalRequirements) {
Requirements::set_backend($origBackend);
}
}
}
/** /**
* Return an appropriate base tag for the given template. * Return an appropriate base tag for the given template.
* It will be closed on an XHTML document, and unclosed on an HTML document. * It will be closed on an XHTML document, and unclosed on an HTML document.

View File

@ -1,45 +0,0 @@
<?php
namespace SilverStripe\View;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\ORM\FieldType\DBHTMLText;
/**
* Special SSViewer that will process a template passed as a string, rather than a filename.
*/
class SSViewer_FromString extends SSViewer
{
/**
* The template to use
*
* @var string
*/
protected $content;
/**
* @param string $content
* @param TemplateParser $parser
*/
public function __construct(string $content, ?TemplateEngine $templateEngine = null)
{
$this->content = $content;
if (!$templateEngine) {
$templateEngine = Injector::inst()->create(TemplateEngine::class);
}
$this->setTemplateEngine($templateEngine);
}
/**
* {@inheritdoc}
*/
public function process(mixed $item, array $overlay = [], bool $cache = true): DBHTMLText
{
$item = ViewLayerData::create($item);
$output = $this->getTemplateEngine()->renderString($this->content, $item, $overlay, $cache);
$html = DBField::create_field('HTMLFragment', $output);
return $html;
}
}

View File

@ -2,14 +2,11 @@
namespace SilverStripe\View; namespace SilverStripe\View;
use ArrayIterator;
use Countable;
use InvalidArgumentException; use InvalidArgumentException;
use Iterator; use Iterator;
use LogicException; use LogicException;
use SilverStripe\Core\ClassInfo; use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Injector\Injector;
use SilverStripe\ORM\FieldType\DBField;
/** /**
* This tracks the current scope for an SSViewer instance. It has three goals: * This tracks the current scope for an SSViewer instance. It has three goals:
@ -47,68 +44,50 @@ class SSViewer_Scope
/** /**
* The stack of previous items ("scopes") - an indexed array of: item, item iterator, item iterator total, * The stack of previous items ("scopes") - an indexed array of: item, item iterator, item iterator total,
* pop index, up index, current index & parent overlay * pop index, up index, current index & parent overlay
*
* @var array
*/ */
private $itemStack = []; private array $itemStack = [];
/** /**
* The current "global" item (the one any lookup starts from) * The current "global" item (the one any lookup starts from)
*
* @var object
*/ */
protected $item; protected ?ViewLayerData $item;
/** /**
* If we're looping over the current "global" item, here's the iterator that tracks with item we're up to * If we're looping over the current "global" item, here's the iterator that tracks with item we're up to
*
* @var Iterator
*/ */
protected $itemIterator; protected ?Iterator $itemIterator;
/** /**
* Total number of items in the iterator * Total number of items in the iterator
*
* @var int
*/ */
protected $itemIteratorTotal; protected int $itemIteratorTotal;
/** /**
* A pointer into the item stack for the item that will become the active scope on the next pop call * A pointer into the item stack for the item that will become the active scope on the next pop call
*
* @var int
*/ */
private $popIndex; private ?int $popIndex;
/** /**
* A pointer into the item stack for which item is "up" from this one * A pointer into the item stack for which item is "up" from this one
*
* @var int
*/ */
private $upIndex; private ?int $upIndex;
/** /**
* A pointer into the item stack for which the active item (or null if not in stack yet) * A pointer into the item stack for which the active item (or null if not in stack yet)
*
* @var int
*/ */
private $currentIndex; private int $currentIndex;
/** /**
* A store of copies of the main item stack, so it's preserved during a lookup from local scope * A store of copies of the main item stack, so it's preserved during a lookup from local scope
* (which may push/pop items to/from the main item stack) * (which may push/pop items to/from the main item stack)
*
* @var array
*/ */
private $localStack = []; private array $localStack = [];
/** /**
* The index of the current item in the main item stack, so we know where to restore the scope * The index of the current item in the main item stack, so we know where to restore the scope
* stored in $localStack. * stored in $localStack.
*
* @var int
*/ */
private $localIndex = 0; private int $localIndex = 0;
/** /**
* List of global property providers * List of global property providers
@ -128,35 +107,24 @@ class SSViewer_Scope
/** /**
* Overlay variables. Take precedence over anything from the current scope * Overlay variables. Take precedence over anything from the current scope
*
* @var array|null
*/ */
protected $overlay; protected array $overlay;
/** /**
* Flag for whether overlay should be preserved when pushing a new scope * Flag for whether overlay should be preserved when pushing a new scope
*
* @see SSViewer_Scope::pushScope()
* @var bool
*/ */
protected $preserveOverlay = false; protected bool $preserveOverlay = false;
/** /**
* Underlay variables. Concede precedence to overlay variables or anything from the current scope * Underlay variables. Concede precedence to overlay variables or anything from the current scope
*
* @var array
*/ */
protected $underlay; protected array $underlay;
/**
* @var object $item
* @var SSViewer_Scope $inheritedScope
*/
public function __construct( public function __construct(
$item, ?ViewLayerData $item,
array $overlay = null, array $overlay = [],
array $underlay = null, array $underlay = [],
SSViewer_Scope $inheritedScope = null ?SSViewer_Scope $inheritedScope = null
) { ) {
$this->item = $item; $this->item = $item;
@ -164,8 +132,8 @@ class SSViewer_Scope
$this->itemIteratorTotal = ($inheritedScope) ? $inheritedScope->itemIteratorTotal : 0; $this->itemIteratorTotal = ($inheritedScope) ? $inheritedScope->itemIteratorTotal : 0;
$this->itemStack[] = [$this->item, $this->itemIterator, $this->itemIteratorTotal, null, null, 0]; $this->itemStack[] = [$this->item, $this->itemIterator, $this->itemIteratorTotal, null, null, 0];
$this->overlay = $overlay ?: []; $this->overlay = $overlay;
$this->underlay = $underlay ?: []; $this->underlay = $underlay;
$this->cacheGlobalProperties(); $this->cacheGlobalProperties();
$this->cacheIteratorProperties(); $this->cacheIteratorProperties();
@ -351,29 +319,18 @@ class SSViewer_Scope
if (!$this->itemIterator) { if (!$this->itemIterator) {
// Note: it is important that getIterator() is called before count() as implemenations may rely on // Note: it is important that getIterator() is called before count() as implemenations may rely on
// this to efficiency get both the number of records and an iterator (e.g. DataList does this) // this to efficiently get both the number of records and an iterator (e.g. DataList does this)
$this->itemIterator = $this->item->getIterator();
// Item may be an array or a regular IteratorAggregate // This will execute code in a generator up to the first yield. For example, this ensures that
if (is_array($this->item)) { // DataList::getIterator() is called before Datalist::count() which means we only run the query once
$this->itemIterator = new ArrayIterator($this->item); // instead of running a separate explicit count() query
} elseif ($this->item instanceof Iterator) { $this->itemIterator->rewind();
$this->itemIterator = $this->item;
} else {
$this->itemIterator = $this->item->getIterator();
// This will execute code in a generator up to the first yield. For example, this ensures that // Get the number of items in the iterator.
// DataList::getIterator() is called before Datalist::count() // Don't just use iterator_count because that results in running through the list
$this->itemIterator->rewind(); // which causes some iterators to no longer be iterable for some reason
} $this->itemIteratorTotal = $this->item->getIteratorCount();
// If the item implements Countable, use that to fetch the count, otherwise we have to inspect the
// iterator and then rewind it.
if ($this->item instanceof Countable) {
$this->itemIteratorTotal = count($this->item);
} else {
$this->itemIteratorTotal = iterator_count($this->itemIterator);
$this->itemIterator->rewind();
}
$this->itemStack[$this->localIndex][SSViewer_Scope::ITEM_ITERATOR] = $this->itemIterator; $this->itemStack[$this->localIndex][SSViewer_Scope::ITEM_ITERATOR] = $this->itemIterator;
$this->itemStack[$this->localIndex][SSViewer_Scope::ITEM_ITERATOR_TOTAL] = $this->itemIteratorTotal; $this->itemStack[$this->localIndex][SSViewer_Scope::ITEM_ITERATOR_TOTAL] = $this->itemIteratorTotal;
@ -412,7 +369,7 @@ class SSViewer_Scope
} else { } else {
$on = $this->getCurrentItem(); $on = $this->getCurrentItem();
if ($on && isset($on->$name)) { if ($on && isset($on->$name)) {
$retval = $on->getRawDataValue($name, $type, $arguments); $retval = $on->getRawDataValue($name, $arguments, $type);
} }
if ($retval === null) { if ($retval === null) {
@ -431,7 +388,7 @@ class SSViewer_Scope
/** /**
* Check if the current item in scope has a value for the named field. * Check if the current item in scope has a value for the named field.
*/ */
public function hasValue(string $name, array $arguments): bool public function hasValue(string $name, array $arguments, string $type): bool
{ {
// @TODO: look for ways to remove the need to call hasValue (e.g. using isset($this->getCurrentItem()->$name) and an equivalent for over/underlays) // @TODO: look for ways to remove the need to call hasValue (e.g. using isset($this->getCurrentItem()->$name) and an equivalent for over/underlays)
$retval = null; $retval = null;
@ -443,7 +400,7 @@ class SSViewer_Scope
if ($retval === null) { if ($retval === null) {
$on = $this->getCurrentItem(); $on = $this->getCurrentItem();
if ($on) { if ($on) {
$retval = $on->hasDataValue($name, $arguments); $retval = $on->hasDataValue($name, $arguments, $type);
} }
} }

View File

@ -3,7 +3,6 @@
namespace SilverStripe\View; namespace SilverStripe\View;
use BadMethodCallException; use BadMethodCallException;
use Countable;
use InvalidArgumentException; use InvalidArgumentException;
use IteratorAggregate; use IteratorAggregate;
use SilverStripe\Core\ClassInfo; use SilverStripe\Core\ClassInfo;
@ -13,7 +12,7 @@ use SilverStripe\Model\ModelDataCustomised;
use Stringable; use Stringable;
use Traversable; use Traversable;
class ViewLayerData implements IteratorAggregate, Stringable, Countable class ViewLayerData implements IteratorAggregate, Stringable
{ {
use Injectable; use Injectable;
@ -40,19 +39,18 @@ class ViewLayerData implements IteratorAggregate, Stringable, Countable
/** /**
* Needed so we can rewind in SSViewer_Scope::next() after getting itemIteratorTotal without throwing an exception. * Needed so we can rewind in SSViewer_Scope::next() after getting itemIteratorTotal without throwing an exception.
* @TODO see if we can remove the need for this
*/ */
public function count(): int public function getIteratorCount(): int
{ {
$count = $this->getRawDataValue('count'); $count = $this->getRawDataValue('count');
if ($count) { if (is_numeric($count)) {
return $count; return $count;
} }
if (is_countable($this->data)) { if (is_countable($this->data)) {
return count($this->data); return count($this->data);
} }
if (ClassInfo::hasMethod($this->data, 'getIterator')) { if (ClassInfo::hasMethod($this->data, 'getIterator')) {
return count($this->data->getIterator()); return iterator_count($this->data->getIterator());
} }
return 0; return 0;
} }
@ -90,7 +88,7 @@ class ViewLayerData implements IteratorAggregate, Stringable, Countable
public function __get(string $name): ?ViewLayerData public function __get(string $name): ?ViewLayerData
{ {
$value = $this->getRawDataValue($name, ViewLayerData::TYPE_PROPERTY); $value = $this->getRawDataValue($name, type: ViewLayerData::TYPE_PROPERTY);
if ($value === null) { if ($value === null) {
return null; return null;
} }
@ -100,7 +98,7 @@ class ViewLayerData implements IteratorAggregate, Stringable, Countable
public function __call(string $name, array $arguments = []): ?ViewLayerData public function __call(string $name, array $arguments = []): ?ViewLayerData
{ {
$value = $this->getRawDataValue($name, ViewLayerData::TYPE_METHOD, $arguments); $value = $this->getRawDataValue($name, $arguments, ViewLayerData::TYPE_METHOD);
if ($value === null) { if ($value === null) {
return null; return null;
} }
@ -119,23 +117,38 @@ class ViewLayerData implements IteratorAggregate, Stringable, Countable
/** /**
* Check if there is a truthy value or (for ModelData) if the data exists(). * Check if there is a truthy value or (for ModelData) if the data exists().
*/ */
public function hasDataValue(?string $name = null, array $arguments = []): bool public function hasDataValue(?string $name = null, array $arguments = [], string $type = ViewLayerData::TYPE_ANY): bool
{ {
if ($name) { if ($name) {
// Ask the model if it has a value for that field
if ($this->data instanceof ModelData) { if ($this->data instanceof ModelData) {
return $this->data->hasValue($name, $arguments); return $this->data->hasValue($name, $arguments);
} }
return ViewLayerData::create($this->getRawDataValue($name, arguments: $arguments))->hasDataValue(); // Check for ourselves if there's a value for that field
// This mimics what ModelData does, which provides consistency
$value = $this->getRawDataValue($name, $arguments, $type);
if ($value === null) {
return false;
}
return ViewLayerData::create($value, $this->data, $name)->hasDataValue();
} }
// Ask the model if it "exists"
if ($this->data instanceof ModelData) { if ($this->data instanceof ModelData) {
return $this->data->exists(); return $this->data->exists();
} }
// Mimics ModelData checks on lists
if (is_countable($this->data)) {
return count($this->data) > 0;
}
// Check for truthiness (which is effectively `return true` since data is an object)
// We do this to mimic ModelData->hasValue() for consistency
return (bool) $this->data; return (bool) $this->data;
} }
// @TODO We need this publicly right now for the ss template engine method args, but need to check if /**
// we can rely on it, since twig won't be calling this at all * Get the raw value of some field/property/method on the data, without wrapping it in ViewLayerData.
public function getRawDataValue(string $name, string $type = ViewLayerData::TYPE_ANY, array $arguments = []): mixed */
public function getRawDataValue(string $name, array $arguments = [], string $type = ViewLayerData::TYPE_ANY): mixed
{ {
if ($type !== ViewLayerData::TYPE_ANY && $type !== ViewLayerData::TYPE_METHOD && $type !== ViewLayerData::TYPE_PROPERTY) { if ($type !== ViewLayerData::TYPE_ANY && $type !== ViewLayerData::TYPE_METHOD && $type !== ViewLayerData::TYPE_PROPERTY) {
throw new InvalidArgumentException('$type must be one of the TYPE_* constant values'); throw new InvalidArgumentException('$type must be one of the TYPE_* constant values');

View File

@ -19,6 +19,8 @@ use SilverStripe\Dev\FunctionalTest;
use SilverStripe\Security\Member; use SilverStripe\Security\Member;
use SilverStripe\View\SSViewer; use SilverStripe\View\SSViewer;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use SilverStripe\Control\Tests\ControllerTest\ControllerWithDummyEngine;
use SilverStripe\Control\Tests\ControllerTest\DummyTemplateEngine;
class ControllerTest extends FunctionalTest class ControllerTest extends FunctionalTest
{ {
@ -858,4 +860,12 @@ class ControllerTest extends FunctionalTest
$response = $this->post('HTTPMethodTestController', ['dummy' => 'example']); $response = $this->post('HTTPMethodTestController', ['dummy' => 'example']);
$this->assertEquals('Routed to postLegacyRoot', $response->getBody()); $this->assertEquals('Routed to postLegacyRoot', $response->getBody());
} }
public function testTemplateEngineUsed()
{
$controller = new ControllerWithDummyEngine();
$this->assertSame('This is my controller', $controller->render()->getValue());
$this->assertSame('This is my controller', $controller->renderWith('literally-any-template')->getValue());
$this->assertInstanceOf(DummyTemplateEngine::class, $controller->getViewer('')->getTemplateEngine());
}
} }

View File

@ -0,0 +1,15 @@
<?php
namespace SilverStripe\Control\Tests\ControllerTest;
use SilverStripe\Control\Controller;
use SilverStripe\Dev\TestOnly;
use SilverStripe\View\TemplateEngine;
class ControllerWithDummyEngine extends Controller implements TestOnly
{
protected function getTemplateEngine(): TemplateEngine
{
return new DummyTemplateEngine();
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace SilverStripe\Control\Tests\ControllerTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\View\TemplateEngine;
use SilverStripe\View\ViewLayerData;
/**
* A dummy template renderer that doesn't actually render any templates.
*/
class DummyTemplateEngine implements TemplateEngine, TestOnly
{
private string $output = 'This is my controller';
public function __construct(string|array $templateCandidates = [])
{
// no-op
}
public function setTemplate(string|array $templateCandidates): static
{
return $this;
}
public function hasTemplate(string|array $templateCandidates): bool
{
return true;
}
public function renderString(string $template, ViewLayerData $model, array $overlay = [], bool $cache = true): string
{
return $this->output;
}
public function render(ViewLayerData $model, array $overlay = []): string
{
return $this->output;
}
}

View File

@ -0,0 +1,206 @@
<?php
namespace SilverStripe\View\Tests;
use PHPUnit\Framework\Attributes\DataProvider;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Model\ArrayData;
use SilverStripe\Model\List\ArrayList;
use SilverStripe\ORM\FieldType\DBBoolean;
use SilverStripe\ORM\FieldType\DBCurrency;
use SilverStripe\ORM\FieldType\DBDate;
use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\ORM\FieldType\DBFloat;
use SilverStripe\ORM\FieldType\DBHTMLText;
use SilverStripe\ORM\FieldType\DBInt;
use SilverStripe\ORM\FieldType\DBText;
use SilverStripe\ORM\FieldType\DBTime;
use SilverStripe\View\CastingService;
use SilverStripe\View\Tests\CastingServiceTest\TestDataObject;
use stdClass;
class CastingServiceTest extends SapphireTest
{
// protected static $extra_dataobjects = [
// TestDataObject::class,
// ];
protected $usesDatabase = false;
public static function provideCast(): array
{
return [
[
'data' => null,
'source' => null,
'fieldName' => '',
'expected' => null,
],
[
'data' => new stdClass(),
'source' => null,
'fieldName' => '',
'expected' => stdClass::class,
],
[
'data' => new stdClass(),
'source' => TestDataObject::class,
'fieldName' => 'DateField',
'expected' => stdClass::class,
],
[
'data' => new DBText(),
'source' => TestDataObject::class,
'fieldName' => 'DateField',
'expected' => stdClass::class,
],
[
'data' => '2024-10-10',
'source' => TestDataObject::class,
'fieldName' => 'DateField',
'expected' => DBDate::class,
],
[
'data' => 'some value',
'source' => TestDataObject::class,
'fieldName' => 'HtmlField',
'expected' => DBHTMLText::class,
],
[
'data' => '12.35',
'source' => TestDataObject::class,
'fieldName' => 'OverrideCastingHelper',
'expected' => DBCurrency::class,
],
[
'data' => '10:17:36',
'source' => TestDataObject::class,
'fieldName' => 'TimeField',
'expected' => DBTime::class,
],
[
'data' => 123456,
'source' => TestDataObject::class,
'fieldName' => 'RandomField',
'expected' => DBInt::class,
],
[
'data' => '<body>some text</body>',
'source' => TestDataObject::class,
'fieldName' => 'RandomField',
'expected' => DBText::class,
],
[
'data' => '12.35',
'source' => null,
'fieldName' => 'OverrideCastingHelper',
'expected' => DBText::class,
],
[
'data' => 123456,
'source' => null,
'fieldName' => 'RandomField',
'expected' => DBInt::class,
],
[
'data' => '10:17:36',
'source' => null,
'fieldName' => 'TimeField',
'expected' => DBText::class,
],
[
'data' => '<body>some text</body>',
'source' => null,
'fieldName' => '',
'expected' => DBText::class,
],
[
'data' => true,
'source' => null,
'fieldName' => '',
'expected' => DBBoolean::class,
],
[
'data' => false,
'source' => null,
'fieldName' => '',
'expected' => DBBoolean::class,
],
[
'data' => 1.234,
'source' => null,
'fieldName' => '',
'expected' => DBFloat::class,
],
[
'data' => [],
'source' => null,
'fieldName' => '',
'expected' => ArrayList::class,
],
[
'data' => [1,2,3,4],
'source' => null,
'fieldName' => '',
'expected' => ArrayList::class,
],
[
'data' => ['one' => 1, 'two' => 2],
'source' => null,
'fieldName' => '',
'expected' => ArrayData::class,
],
[
'data' => ['one' => 1, 'two' => 2],
'source' => TestDataObject::class,
'fieldName' => 'AnyField',
'expected' => ArrayData::class,
],
[
'data' => ['one' => 1, 'two' => 2],
'source' => TestDataObject::class,
'fieldName' => 'ArrayAsText',
'expected' => DBText::class,
],
];
}
#[DataProvider('provideCast')]
public function testCast(mixed $data, ?string $source, string $fieldName, ?string $expected): void
{
// Can't instantiate DataObject in a data provider
if (is_string($source)) {
$source = new $source();
}
$service = new CastingService();
$value = $service->cast($data, $source, $fieldName);
// Check the cast object is the correct type
if ($expected === null) {
$this->assertNull($value);
} elseif (is_object($data)) {
$this->assertSame($data, $value);
} else {
$this->assertInstanceOf($expected, $value);
}
// Check the value is retained
if ($value instanceof DBField && !is_object($data)) {
$this->assertSame($data, $value->getValue());
}
if ($value instanceof ArrayData && !is_object($data)) {
$this->assertSame($data, $value->toMap());
}
if ($value instanceof ArrayList && !is_object($data)) {
$this->assertSame($data, $value->toArray());
}
}
public function testCastStrict(): void
{
$service = new CastingService();
$value = $service->cast(null, strict: true);
$this->assertInstanceOf(DBText::class, $value);
$this->assertNull($value->getValue());
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace SilverStripe\View\Tests\CastingServiceTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
class TestDataObject extends DataObject implements TestOnly
{
private static string $table_name = 'CastingServiceTest_TestDataObject';
private static array $db = [
'HtmlField' => 'HTMLText',
'DateField' => 'Date',
];
private static array $casting = [
'DateField' => 'Text', // won't override
'TimeField' => 'Time',
'ArrayAsText' => 'Text',
];
public function castingHelper(string $field): ?string
{
if ($field === 'OverrideCastingHelper') {
return 'Currency';
}
return parent::castingHelper($field);
}
}

View File

@ -20,6 +20,8 @@ class SSTemplateEngineFindTemplateTest extends SapphireTest
{ {
private string $base; private string $base;
private ThemeResourceLoader $origLoader;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
@ -40,6 +42,7 @@ class SSTemplateEngineFindTemplateTest extends SapphireTest
$themeManifest->setProject('myproject'); $themeManifest->setProject('myproject');
$themeManifest->init(); $themeManifest->init();
// New Loader for that root // New Loader for that root
$this->origLoader = ThemeResourceLoader::inst();
$themeResourceLoader = new ThemeResourceLoader($this->base); $themeResourceLoader = new ThemeResourceLoader($this->base);
$themeResourceLoader->addSet('$default', $themeManifest); $themeResourceLoader->addSet('$default', $themeManifest);
ThemeResourceLoader::set_instance($themeResourceLoader); ThemeResourceLoader::set_instance($themeResourceLoader);
@ -50,6 +53,7 @@ class SSTemplateEngineFindTemplateTest extends SapphireTest
protected function tearDown(): void protected function tearDown(): void
{ {
ThemeResourceLoader::set_instance($this->origLoader);
ModuleLoader::inst()->popManifest(); ModuleLoader::inst()->popManifest();
parent::tearDown(); parent::tearDown();
} }

View File

@ -27,7 +27,6 @@ use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions;
use SilverStripe\View\Exception\MissingTemplateException; use SilverStripe\View\Exception\MissingTemplateException;
use SilverStripe\View\SSTemplateEngine; use SilverStripe\View\SSTemplateEngine;
use SilverStripe\View\ThemeResourceLoader;
use SilverStripe\View\ViewLayerData; use SilverStripe\View\ViewLayerData;
class SSTemplateEngineTest extends SapphireTest class SSTemplateEngineTest extends SapphireTest
@ -2076,9 +2075,6 @@ after'
$this->assertEquals(1, $count); $this->assertEquals(1, $count);
} }
/**
* Tests if caching for SSViewer_FromString is working
*/
public function testFromStringCaching() public function testFromStringCaching()
{ {
$content = 'Test content'; $content = 'Test content';
@ -2177,7 +2173,7 @@ after'
private function render(string $templateString, mixed $data = null, bool $cacheTemplate = false): string private function render(string $templateString, mixed $data = null, bool $cacheTemplate = false): string
{ {
$engine = new SSTemplateEngine(); $engine = new SSTemplateEngine();
if (!$data) { if ($data === null) {
$data = new SSTemplateEngineTest\TestFixture(); $data = new SSTemplateEngineTest\TestFixture();
} }
$data = new ViewLayerData($data); $data = new ViewLayerData($data);

View File

@ -162,7 +162,6 @@ class SSViewerTest extends SapphireTest
<body> <body>
</html>' </html>'
); );
// Note: SSViewer_FromString doesn't rewrite hash links.
$tmpl = new SSViewer([], $engine); $tmpl = new SSViewer([], $engine);
$result = $tmpl->process('pretend this is a model'); $result = $tmpl->process('pretend this is a model');
$this->assertStringContainsString( $this->assertStringContainsString(
@ -206,7 +205,6 @@ class SSViewerTest extends SapphireTest
<body> <body>
</html>' </html>'
); );
// Note: SSViewer_FromString doesn't rewrite hash links.
$tmpl = new SSViewer([], $engine); $tmpl = new SSViewer([], $engine);
$result = $tmpl->process('pretend this is a model'); $result = $tmpl->process('pretend this is a model');

View File

@ -5,10 +5,12 @@ namespace SilverStripe\View\Tests;
use ArrayIterator; use ArrayIterator;
use BadMethodCallException; use BadMethodCallException;
use Error; use Error;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use SilverStripe\Dev\SapphireTest; use SilverStripe\Dev\SapphireTest;
use SilverStripe\Model\ArrayData; use SilverStripe\Model\ArrayData;
use SilverStripe\Model\List\ArrayList; use SilverStripe\Model\List\ArrayList;
use SilverStripe\Model\ModelData;
use SilverStripe\ORM\FieldType\DBDate; use SilverStripe\ORM\FieldType\DBDate;
use SilverStripe\ORM\FieldType\DBHTMLText; use SilverStripe\ORM\FieldType\DBHTMLText;
use SilverStripe\View\Exception\MissingTemplateException; use SilverStripe\View\Exception\MissingTemplateException;
@ -88,7 +90,7 @@ class ViewLayerDataTest extends SapphireTest
} }
} }
public static function provideCount(): array public static function provideGetIteratorCount(): array
{ {
return [ return [
'uncountable object' => [ 'uncountable object' => [
@ -100,8 +102,8 @@ class ViewLayerDataTest extends SapphireTest
'expected' => 12, 'expected' => 12,
], ],
'uncountable object - has count field (non-int)' => [ 'uncountable object - has count field (non-int)' => [
'data' => new ArrayData(['count' => 'aahhh', 'Field2' => 'value2']), // @TODO fix this 'data' => new ArrayData(['count' => 'aahhh', 'Field2' => 'value2']),
'expected' => 12, 'expected' => 0,
], ],
'empty array' => [ 'empty array' => [
'data' => [], 'data' => [],
@ -130,11 +132,11 @@ class ViewLayerDataTest extends SapphireTest
]; ];
} }
#[DataProvider('provideCount')] #[DataProvider('provideGetIteratorCount')]
public function testCount(mixed $data, int $expected): void public function testGetIteratorCount(mixed $data, int $expected): void
{ {
$viewLayerData = new ViewLayerData($data); $viewLayerData = new ViewLayerData($data);
$this->assertSame($expected, $viewLayerData->count()); $this->assertSame($expected, $viewLayerData->getIteratorCount());
} }
public static function provideIsSet(): array public static function provideIsSet(): array
@ -290,6 +292,8 @@ class ViewLayerDataTest extends SapphireTest
public static function provideGetComplex(): array public static function provideGetComplex(): array
{ {
// Note the actual value checks aren't very comprehensive here because that's done
// in more detail in testGetRawDataValue
return [ return [
'exception gets thrown if not __call() method' => [ 'exception gets thrown if not __call() method' => [
'name' => 'badMethodCall', 'name' => 'badMethodCall',
@ -500,28 +504,225 @@ class ViewLayerDataTest extends SapphireTest
$this->assertSame($expected, (string) $viewLayerData); $this->assertSame($expected, (string) $viewLayerData);
} }
// public function provideHasDataValue(): array public static function provideHasDataValue(): array
// { {
// return [ return [
// [ 'empty array' => [
'data' => [],
'name' => null,
'expected' => false,
],
'empty ArrayList' => [
'data' => new ArrayList(),
'name' => null,
'expected' => false,
],
'empty ArrayData' => [
'data' => new ArrayData(),
'name' => null,
'expected' => false,
],
'empty ArrayIterator' => [
'data' => new ArrayIterator(),
'name' => null,
'expected' => false,
],
'empty ModelData' => [
'data' => new ModelData(),
'name' => null,
'expected' => true,
],
'non-countable object' => [
'data' => new ExtensibleObject(),
'name' => null,
'expected' => true,
],
'array with data' => [
'data' => [1,2,3],
'name' => null,
'expected' => true,
],
'associative array' => [
'data' => ['one' => 1, 'two' => 2],
'name' => null,
'expected' => true,
],
'ArrayList with data' => [
'data' => new ArrayList([1,2,3]),
'name' => null,
'expected' => true,
],
'ArrayData with data' => [
'data' => new ArrayData(['one' => 1, 'two' => 2]),
'name' => null,
'expected' => true,
],
'ArrayIterator with data' => [
'data' => new ArrayIterator([1,2,3]),
'name' => null,
'expected' => true,
],
'ArrayData missing value' => [
'data' => new ArrayData(['one' => 1, 'two' => 2]),
'name' => 'three',
'expected' => false,
],
'ArrayData with truthy value' => [
'data' => new ArrayData(['one' => 1, 'two' => 2]),
'name' => 'one',
'expected' => true,
],
'ArrayData with null value' => [
'data' => new ArrayData(['nullVal' => null, 'two' => 2]),
'name' => 'nullVal',
'expected' => false,
],
'ArrayData with falsy value' => [
'data' => new ArrayData(['zero' => 0, 'two' => 2]),
'name' => 'zero',
'expected' => false,
],
'Empty string' => [
'data' => '',
'name' => null,
'expected' => false,
],
'Truthy string' => [
'data' => 'has a value',
'name' => null,
'expected' => true,
],
'Field on a string' => [
'data' => 'has a value',
'name' => 'SomeField',
'expected' => false,
],
];
}
// ], #[DataProvider('provideHasDataValue')]
// ]; public function testHasDataValue(mixed $data, ?string $name, bool $expected): void
// } {
$viewLayerData = new ViewLayerData($data);
$this->assertSame($expected, $viewLayerData->hasDataValue($name)); // @TODO call methods with(out) args
}
// #[DataProvider('provideHasDataValue')] public static function provideGetRawDataValue(): array
// public function testHasDataValue(): void {
// { $dbHtml = (new DBHTMLText())->setValue('Some html text');
// Note we're not checking the fetch order or passing args here - see testGet and testCall for that.
return [
[
'data' => ['MyField' => 'some value'],
'name' => 'MissingField',
'expected' => null,
],
[
'data' => ['MyField' => null],
'name' => 'MyField',
'expected' => null,
],
[
'data' => ['MyField' => 'some value'],
'name' => 'MyField',
'expected' => 'some value',
],
[
'data' => ['MyField' => 123],
'name' => 'MyField',
'expected' => 123,
],
[
'data' => ['MyField' => true],
'name' => 'MyField',
'expected' => true,
],
[
'data' => ['MyField' => false],
'name' => 'MyField',
'expected' => false,
],
[
'data' => ['MyField' => $dbHtml],
'name' => 'MyField',
'expected' => $dbHtml,
],
[
'data' => (new ArrayData(['MyField' => 1234]))->customise(new ArrayData(['MyField' => 'overridden value'])),
'name' => 'MyField',
'expected' => 'overridden value',
],
[
'data' => (new ArrayData(['MyField' => 1234]))->customise(new ArrayData(['FieldTwo' => 'checks here'])),
'name' => 'FieldTwo',
'expected' => 'checks here',
],
[
'data' => (new ArrayData(['MyField' => 1234]))->customise(new ArrayData(['FieldTwo' => 'not here'])),
'name' => 'MyField',
'expected' => 1234,
],
];
}
// } #[DataProvider('provideGetRawDataValue')]
public function testGetRawDataValue(mixed $data, string $name, mixed $expected): void
{
$viewLayerData = new ViewLayerData($data);
$this->assertSame($expected, $viewLayerData->getRawDataValue($name));
}
// public function testGetRawDataValue(): void public static function provideGetRawDataValueType(): array
// { {
// The types aren't currently used, but are passed in so we can use them later
// if we find the distinction useful. We should test they do what we expect
// in the meantime.
return [
[
'type' => 'property',
'shouldThrow' => false,
],
[
'type' => 'method',
'shouldThrow' => false,
],
[
'type' => 'any',
'shouldThrow' => false,
],
[
'type' => 'constant',
'shouldThrow' => true,
],
[
'type' => 'randomtext',
'shouldThrow' => true,
],
];
}
// } #[DataProvider('provideGetRawDataValueType')]
public function testGetRawDataValueType(string $type, bool $shouldThrow): void
{
$viewLayerData = new ViewLayerData([]);
if ($shouldThrow) {
$this->expectException(InvalidArgumentException::class);
} else {
$this->expectNotToPerformAssertions();
}
$viewLayerData->getRawDataValue('something', type: $type);
}
// public function testCache(): void public function testCache(): void
// { {
$data = new ArrayData(['MyField' => 'some value']);
$viewLayerData = new ViewLayerData($data);
// } // No cache because we haven't fetched anything
$this->assertNull($data->objCacheGet('MyField'));
// Fetching the value caches it
$viewLayerData->MyField;
$this->assertSame('some value', $data->objCacheGet('MyField'));
}
} }

View File

@ -21,6 +21,7 @@ use SilverStripe\View\ThemeResourceLoader;
use SilverStripe\View\ThemeManifest; use SilverStripe\View\ThemeManifest;
use SilverStripe\Model\ModelData; use SilverStripe\Model\ModelData;
use SilverStripe\View\SSViewer_Scope; use SilverStripe\View\SSViewer_Scope;
use SilverStripe\View\ViewLayerData;
use Symfony\Component\Translation\Loader\ArrayLoader; use Symfony\Component\Translation\Loader\ArrayLoader;
use Symfony\Component\Translation\Translator; use Symfony\Component\Translation\Translator;
@ -73,7 +74,7 @@ trait i18nTestManifest
{ {
// force SSViewer_Scope to cache global template vars before we switch to the // force SSViewer_Scope to cache global template vars before we switch to the
// test-project class manifest (since it will lose visibility of core classes) // test-project class manifest (since it will lose visibility of core classes)
$presenter = new SSViewer_Scope(new ModelData()); $presenter = new SSViewer_Scope(new ViewLayerData([]));
unset($presenter); unset($presenter);
// Switch to test manifest // Switch to test manifest