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 LogicException;
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
@ -284,12 +285,15 @@ class GridFieldAddExistingAutocompleter extends AbstractGridFieldComponent imple
$json = [];
Config::nest();
SSViewer::config()->set('source_file_comments', false);
$viewer = SSViewer_FromString::create($this->resultsFormat);
$engine = new SSTemplateEngine();
foreach ($results as $result) {
if (!$result->canView()) {
continue;
}
$title = Convert::html2raw($viewer->process($result, cache: false));
$title = Convert::html2raw(
$engine->renderString($this->resultsFormat, ViewLayerData::create($result), cache: false)
);
$json[] = [
'label' => $title,
'value' => $title,

View File

@ -18,7 +18,6 @@ use stdClass;
*/
class ArrayData extends ModelData
{
/**
* @var array
* @see ArrayData::_construct()
@ -87,6 +86,7 @@ class ArrayData extends ModelData
*/
public function setField(string $fieldName, mixed $value): static
{
$this->objCacheClear();
$this->array[$fieldName] = $value;
return $this;
}
@ -102,6 +102,11 @@ class ArrayData extends ModelData
return isset($this->array[$fieldName]);
}
public function exists(): bool
{
return !empty($this->array);
}
/**
* 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
{
$this->objCacheClear();
$this->dynamicData[$field] = $value;
return $this;
}
@ -415,10 +416,8 @@ class ModelData implements Stringable
/**
* Clear object cache
*
* @return $this
*/
public function objCacheClear()
public function objCacheClear(): static
{
$this->objCache = [];
return $this;

View File

@ -1934,6 +1934,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
string $eagerLoadRelation,
EagerLoadedList|DataObject $eagerLoadedData
): void {
$this->objCacheClear();
$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.
*
* @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.
* @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
// for downstream checks to know there's "no value".
@ -42,6 +42,10 @@ class CastingService
$service = null;
if ($source instanceof ModelData) {
$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

View File

@ -5,6 +5,7 @@ namespace SilverStripe\View;
use InvalidArgumentException;
use Psr\SimpleCache\CacheInterface;
use SilverStripe\Control\Director;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Flushable;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Injector\Injector;
@ -32,6 +33,12 @@ use SilverStripe\View\Exception\MissingTemplateException;
class SSTemplateEngine implements TemplateEngine, Flushable
{
use Injectable;
use Configurable;
/**
* Default prepended cache key for partial caching
*/
private static string $global_key = '$CurrentReadingMode, $CurrentUser.ID';
/**
* 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.
$res['php'] .= '$keyExpression = function() use ($scope, $cache) {' . 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,
// 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.

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.
$res['php'] .= '$keyExpression = function() use ($scope, $cache) {' . 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,
// 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.

View File

@ -48,11 +48,6 @@ class SSViewer
*/
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.
* May not be supported for some rendering engines.
@ -71,14 +66,18 @@ class SSViewer
/**
* 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)
* 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.
@ -250,10 +249,8 @@ class SSViewer
/**
* 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) {
return $this->rewriteHashlinks;
@ -263,11 +260,8 @@ class SSViewer
/**
* 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;
return $this;
@ -275,10 +269,8 @@ class SSViewer
/**
* 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
if (static::$current_rewrite_hash_links !== null) {
@ -289,10 +281,8 @@ class SSViewer
/**
* 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;
}
@ -369,39 +359,6 @@ PHP;
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.
* 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;
use ArrayIterator;
use Countable;
use InvalidArgumentException;
use Iterator;
use LogicException;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\ORM\FieldType\DBField;
/**
* 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,
* 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)
*
* @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
*
* @var Iterator
*/
protected $itemIterator;
protected ?Iterator $itemIterator;
/**
* 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
*
* @var int
*/
private $popIndex;
private ?int $popIndex;
/**
* 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)
*
* @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
* (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
* stored in $localStack.
*
* @var int
*/
private $localIndex = 0;
private int $localIndex = 0;
/**
* List of global property providers
@ -128,35 +107,24 @@ class SSViewer_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
*
* @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
*
* @var array
*/
protected $underlay;
protected array $underlay;
/**
* @var object $item
* @var SSViewer_Scope $inheritedScope
*/
public function __construct(
$item,
array $overlay = null,
array $underlay = null,
SSViewer_Scope $inheritedScope = null
?ViewLayerData $item,
array $overlay = [],
array $underlay = [],
?SSViewer_Scope $inheritedScope = null
) {
$this->item = $item;
@ -164,8 +132,8 @@ class SSViewer_Scope
$this->itemIteratorTotal = ($inheritedScope) ? $inheritedScope->itemIteratorTotal : 0;
$this->itemStack[] = [$this->item, $this->itemIterator, $this->itemIteratorTotal, null, null, 0];
$this->overlay = $overlay ?: [];
$this->underlay = $underlay ?: [];
$this->overlay = $overlay;
$this->underlay = $underlay;
$this->cacheGlobalProperties();
$this->cacheIteratorProperties();
@ -351,29 +319,18 @@ class SSViewer_Scope
if (!$this->itemIterator) {
// 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
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();
// This will execute code in a generator up to the first yield. For example, this ensures that
// DataList::getIterator() is called before Datalist::count() which means we only run the query once
// instead of running a separate explicit count() query
$this->itemIterator->rewind();
// This will execute code in a generator up to the first yield. For example, this ensures that
// DataList::getIterator() is called before Datalist::count()
$this->itemIterator->rewind();
}
// 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();
}
// Get the number of items in the iterator.
// Don't just use iterator_count because that results in running through the list
// which causes some iterators to no longer be iterable for some reason
$this->itemIteratorTotal = $this->item->getIteratorCount();
$this->itemStack[$this->localIndex][SSViewer_Scope::ITEM_ITERATOR] = $this->itemIterator;
$this->itemStack[$this->localIndex][SSViewer_Scope::ITEM_ITERATOR_TOTAL] = $this->itemIteratorTotal;
@ -412,7 +369,7 @@ class SSViewer_Scope
} else {
$on = $this->getCurrentItem();
if ($on && isset($on->$name)) {
$retval = $on->getRawDataValue($name, $type, $arguments);
$retval = $on->getRawDataValue($name, $arguments, $type);
}
if ($retval === null) {
@ -431,7 +388,7 @@ class SSViewer_Scope
/**
* 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)
$retval = null;
@ -443,7 +400,7 @@ class SSViewer_Scope
if ($retval === null) {
$on = $this->getCurrentItem();
if ($on) {
$retval = $on->hasDataValue($name, $arguments);
$retval = $on->hasDataValue($name, $arguments, $type);
}
}

View File

@ -3,7 +3,6 @@
namespace SilverStripe\View;
use BadMethodCallException;
use Countable;
use InvalidArgumentException;
use IteratorAggregate;
use SilverStripe\Core\ClassInfo;
@ -13,7 +12,7 @@ use SilverStripe\Model\ModelDataCustomised;
use Stringable;
use Traversable;
class ViewLayerData implements IteratorAggregate, Stringable, Countable
class ViewLayerData implements IteratorAggregate, Stringable
{
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.
* @TODO see if we can remove the need for this
*/
public function count(): int
public function getIteratorCount(): int
{
$count = $this->getRawDataValue('count');
if ($count) {
if (is_numeric($count)) {
return $count;
}
if (is_countable($this->data)) {
return count($this->data);
}
if (ClassInfo::hasMethod($this->data, 'getIterator')) {
return count($this->data->getIterator());
return iterator_count($this->data->getIterator());
}
return 0;
}
@ -90,7 +88,7 @@ class ViewLayerData implements IteratorAggregate, Stringable, Countable
public function __get(string $name): ?ViewLayerData
{
$value = $this->getRawDataValue($name, ViewLayerData::TYPE_PROPERTY);
$value = $this->getRawDataValue($name, type: ViewLayerData::TYPE_PROPERTY);
if ($value === null) {
return null;
}
@ -100,7 +98,7 @@ class ViewLayerData implements IteratorAggregate, Stringable, Countable
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) {
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().
*/
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) {
// Ask the model if it has a value for that field
if ($this->data instanceof ModelData) {
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) {
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;
}
// @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
public function getRawDataValue(string $name, string $type = ViewLayerData::TYPE_ANY, array $arguments = []): mixed
/**
* Get the raw value of some field/property/method on the data, without wrapping it in ViewLayerData.
*/
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) {
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\View\SSViewer;
use PHPUnit\Framework\Attributes\DataProvider;
use SilverStripe\Control\Tests\ControllerTest\ControllerWithDummyEngine;
use SilverStripe\Control\Tests\ControllerTest\DummyTemplateEngine;
class ControllerTest extends FunctionalTest
{
@ -858,4 +860,12 @@ class ControllerTest extends FunctionalTest
$response = $this->post('HTTPMethodTestController', ['dummy' => 'example']);
$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 ThemeResourceLoader $origLoader;
protected function setUp(): void
{
parent::setUp();
@ -40,6 +42,7 @@ class SSTemplateEngineFindTemplateTest extends SapphireTest
$themeManifest->setProject('myproject');
$themeManifest->init();
// New Loader for that root
$this->origLoader = ThemeResourceLoader::inst();
$themeResourceLoader = new ThemeResourceLoader($this->base);
$themeResourceLoader->addSet('$default', $themeManifest);
ThemeResourceLoader::set_instance($themeResourceLoader);
@ -50,6 +53,7 @@ class SSTemplateEngineFindTemplateTest extends SapphireTest
protected function tearDown(): void
{
ThemeResourceLoader::set_instance($this->origLoader);
ModuleLoader::inst()->popManifest();
parent::tearDown();
}

View File

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

View File

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

View File

@ -5,10 +5,12 @@ namespace SilverStripe\View\Tests;
use ArrayIterator;
use BadMethodCallException;
use Error;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Model\ArrayData;
use SilverStripe\Model\List\ArrayList;
use SilverStripe\Model\ModelData;
use SilverStripe\ORM\FieldType\DBDate;
use SilverStripe\ORM\FieldType\DBHTMLText;
use SilverStripe\View\Exception\MissingTemplateException;
@ -88,7 +90,7 @@ class ViewLayerDataTest extends SapphireTest
}
}
public static function provideCount(): array
public static function provideGetIteratorCount(): array
{
return [
'uncountable object' => [
@ -100,8 +102,8 @@ class ViewLayerDataTest extends SapphireTest
'expected' => 12,
],
'uncountable object - has count field (non-int)' => [
'data' => new ArrayData(['count' => 'aahhh', 'Field2' => 'value2']), // @TODO fix this
'expected' => 12,
'data' => new ArrayData(['count' => 'aahhh', 'Field2' => 'value2']),
'expected' => 0,
],
'empty array' => [
'data' => [],
@ -130,11 +132,11 @@ class ViewLayerDataTest extends SapphireTest
];
}
#[DataProvider('provideCount')]
public function testCount(mixed $data, int $expected): void
#[DataProvider('provideGetIteratorCount')]
public function testGetIteratorCount(mixed $data, int $expected): void
{
$viewLayerData = new ViewLayerData($data);
$this->assertSame($expected, $viewLayerData->count());
$this->assertSame($expected, $viewLayerData->getIteratorCount());
}
public static function provideIsSet(): array
@ -290,6 +292,8 @@ class ViewLayerDataTest extends SapphireTest
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 [
'exception gets thrown if not __call() method' => [
'name' => 'badMethodCall',
@ -500,28 +504,225 @@ class ViewLayerDataTest extends SapphireTest
$this->assertSame($expected, (string) $viewLayerData);
}
// public function provideHasDataValue(): array
// {
// return [
// [
public static function provideHasDataValue(): array
{
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 function testHasDataValue(): void
// {
public static function provideGetRawDataValue(): array
{
$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\Model\ModelData;
use SilverStripe\View\SSViewer_Scope;
use SilverStripe\View\ViewLayerData;
use Symfony\Component\Translation\Loader\ArrayLoader;
use Symfony\Component\Translation\Translator;
@ -73,7 +74,7 @@ trait i18nTestManifest
{
// 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)
$presenter = new SSViewer_Scope(new ModelData());
$presenter = new SSViewer_Scope(new ViewLayerData([]));
unset($presenter);
// Switch to test manifest