diff --git a/src/Control/RSS/RSSFeed_Entry.php b/src/Control/RSS/RSSFeed_Entry.php
index 1ebaae7e7..1bb729721 100644
--- a/src/Control/RSS/RSSFeed_Entry.php
+++ b/src/Control/RSS/RSSFeed_Entry.php
@@ -58,7 +58,7 @@ class RSSFeed_Entry extends ModelData
/**
* Get the description of this entry
*
- * @return DBField Returns the description of the entry.
+ * @return DBField|null Returns the description of the entry.
*/
public function Title()
{
@@ -68,7 +68,7 @@ class RSSFeed_Entry extends ModelData
/**
* Get the description of this entry
*
- * @return DBField Returns the description of the entry.
+ * @return DBField|null Returns the description of the entry.
*/
public function Description()
{
@@ -85,7 +85,7 @@ class RSSFeed_Entry extends ModelData
/**
* Get the author of this entry
*
- * @return DBField Returns the author of the entry.
+ * @return DBField|null Returns the author of the entry.
*/
public function Author()
{
@@ -96,7 +96,7 @@ class RSSFeed_Entry extends ModelData
* Return the safely casted field
*
* @param string $fieldName Name of field
- * @return DBField
+ * @return DBField|null
*/
public function rssField($fieldName)
{
diff --git a/src/Dev/Backtrace.php b/src/Dev/Backtrace.php
index 62d402efc..9aa7b85ad 100644
--- a/src/Dev/Backtrace.php
+++ b/src/Dev/Backtrace.php
@@ -149,11 +149,11 @@ class Backtrace
if ($showArgs && isset($item['args'])) {
$args = [];
foreach ($item['args'] as $arg) {
- if (!is_object($arg) || method_exists($arg, '__toString')) {
+ if (is_object($arg)) {
+ $args[] = get_class($arg);
+ } else {
$sarg = is_array($arg) ? 'Array' : strval($arg);
$args[] = (strlen($sarg ?? '') > $argCharLimit) ? substr($sarg, 0, $argCharLimit) . '...' : $sarg;
- } else {
- $args[] = get_class($arg);
}
}
diff --git a/src/Forms/DropdownField.php b/src/Forms/DropdownField.php
index ed5da3000..9e3124525 100644
--- a/src/Forms/DropdownField.php
+++ b/src/Forms/DropdownField.php
@@ -68,7 +68,7 @@ use SilverStripe\Model\ArrayData;
* DropdownField::create(
* 'Country',
* 'Country',
- * singleton(MyObject::class)->dbObject('Country')->enumValues()
+ * singleton(MyObject::class)->dbObject('Country')?->enumValues()
* );
*
*
diff --git a/src/Forms/FieldGroup.php b/src/Forms/FieldGroup.php
index 9a0d6c675..c61de2136 100644
--- a/src/Forms/FieldGroup.php
+++ b/src/Forms/FieldGroup.php
@@ -154,7 +154,7 @@ class FieldGroup extends CompositeField
/** @var FormField $subfield */
$messages = [];
foreach ($dataFields as $subfield) {
- $message = $subfield->obj('Message')->forTemplate();
+ $message = $subfield->obj('Message')?->forTemplate();
if ($message) {
$messages[] = rtrim($message ?? '', ".");
}
diff --git a/src/Forms/FormField.php b/src/Forms/FormField.php
index 0d210436b..264090010 100644
--- a/src/Forms/FormField.php
+++ b/src/Forms/FormField.php
@@ -15,6 +15,7 @@ use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\View\AttributesHTML;
use SilverStripe\View\SSViewer;
use SilverStripe\Model\ModelData;
+use SilverStripe\ORM\DataObject;
/**
* Represents a field in a form.
@@ -458,7 +459,7 @@ class FormField extends RequestHandler
*
* By default, makes use of $this->dataValue()
*
- * @param ModelData|DataObjectInterface $record Record to save data into
+ * @param DataObjectInterface $record Record to save data into
*/
public function saveInto(DataObjectInterface $record)
{
@@ -469,7 +470,9 @@ class FormField extends RequestHandler
if (($pos = strrpos($this->name ?? '', '.')) !== false) {
$relation = substr($this->name ?? '', 0, $pos);
$fieldName = substr($this->name ?? '', $pos + 1);
- $component = $record->relObject($relation);
+ if ($record instanceof DataObject) {
+ $component = $record->relObject($relation);
+ }
}
if ($fieldName && $component) {
@@ -1469,12 +1472,12 @@ class FormField extends RequestHandler
'schemaType' => $this->getSchemaDataType(),
'component' => $this->getSchemaComponent(),
'holderId' => $this->HolderID(),
- 'title' => $this->obj('Title')->getSchemaValue(),
+ 'title' => $this->obj('Title')?->getSchemaValue(),
'source' => null,
'extraClass' => $this->extraClass(),
- 'description' => $this->obj('Description')->getSchemaValue(),
- 'rightTitle' => $this->obj('RightTitle')->getSchemaValue(),
- 'leftTitle' => $this->obj('LeftTitle')->getSchemaValue(),
+ 'description' => $this->obj('Description')?->getSchemaValue(),
+ 'rightTitle' => $this->obj('RightTitle')?->getSchemaValue(),
+ 'leftTitle' => $this->obj('LeftTitle')?->getSchemaValue(),
'readOnly' => $this->isReadonly(),
'disabled' => $this->isDisabled(),
'customValidationMessage' => $this->getCustomValidationMessage(),
diff --git a/src/Forms/FormScaffolder.php b/src/Forms/FormScaffolder.php
index 099dabf5d..db43a88e8 100644
--- a/src/Forms/FormScaffolder.php
+++ b/src/Forms/FormScaffolder.php
@@ -115,7 +115,7 @@ class FormScaffolder
$fieldObject = $this
->obj
->dbObject($fieldName)
- ->scaffoldFormField(null, $this->getParamsArray());
+ ?->scaffoldFormField(null, $this->getParamsArray());
}
// Allow fields to opt-out of scaffolding
if (!$fieldObject) {
@@ -145,7 +145,7 @@ class FormScaffolder
$fieldClass = $this->fieldClasses[$fieldName];
$hasOneField = new $fieldClass($fieldName);
} else {
- $hasOneField = $this->obj->dbObject($fieldName)->scaffoldFormField(null, $this->getParamsArray());
+ $hasOneField = $this->obj->dbObject($fieldName)?->scaffoldFormField(null, $this->getParamsArray());
}
if (empty($hasOneField)) {
continue; // Allow fields to opt out of scaffolding
diff --git a/src/Forms/HTMLEditor/HTMLEditorField.php b/src/Forms/HTMLEditor/HTMLEditorField.php
index 90c3fad75..4527dd1de 100644
--- a/src/Forms/HTMLEditor/HTMLEditorField.php
+++ b/src/Forms/HTMLEditor/HTMLEditorField.php
@@ -5,9 +5,11 @@ namespace SilverStripe\Forms\HTMLEditor;
use SilverStripe\Assets\Shortcodes\ImageShortcodeProvider;
use SilverStripe\Forms\FormField;
use SilverStripe\Forms\TextareaField;
-use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataObjectInterface;
use Exception;
+use SilverStripe\Model\ModelData;
+use SilverStripe\ORM\FieldType\DBField;
+use SilverStripe\View\CastingService;
use SilverStripe\View\Parsers\HTMLValue;
/**
@@ -123,13 +125,9 @@ class HTMLEditorField extends TextareaField
);
}
- /**
- * @param DataObject|DataObjectInterface $record
- * @throws Exception
- */
public function saveInto(DataObjectInterface $record)
{
- if ($record->hasField($this->name) && $record->escapeTypeForField($this->name) != 'xml') {
+ if (!$this->usesXmlFriendlyField($record)) {
throw new Exception(
'HTMLEditorField->saveInto(): This field should save into a HTMLText or HTMLVarchar field.'
);
@@ -225,4 +223,15 @@ class HTMLEditorField extends TextareaField
return $config;
}
+
+ private function usesXmlFriendlyField(DataObjectInterface $record): bool
+ {
+ if ($record instanceof ModelData && !$record->hasField($this->getName())) {
+ return true;
+ }
+
+ $castingService = CastingService::singleton();
+ $castValue = $castingService->cast($this->Value(), $record, $this->getName());
+ return $castValue instanceof DBField && $castValue::config()->get('escape_type') === 'xml';
+ }
}
diff --git a/src/Forms/TreeDropdownField.php b/src/Forms/TreeDropdownField.php
index c503c591a..1019736ff 100644
--- a/src/Forms/TreeDropdownField.php
+++ b/src/Forms/TreeDropdownField.php
@@ -870,14 +870,14 @@ class TreeDropdownField extends FormField implements HasOneRelationFieldInterfac
$ancestors = $record->getAncestors(true)->reverse();
foreach ($ancestors as $parent) {
- $title = $parent->obj($this->getTitleField())->getValue();
+ $title = $parent->obj($this->getTitleField())?->getValue();
$titlePath .= $title . '/';
}
}
$data['data']['valueObject'] = [
- 'id' => $record->obj($this->getKeyField())->getValue(),
- 'title' => $record->obj($this->getTitleField())->getValue(),
- 'treetitle' => $record->obj($this->getLabelField())->getSchemaValue(),
+ 'id' => $record->obj($this->getKeyField())?->getValue(),
+ 'title' => $record->obj($this->getTitleField())?->getValue(),
+ 'treetitle' => $record->obj($this->getLabelField())?->getSchemaValue(),
'titlePath' => $titlePath,
];
}
diff --git a/src/Forms/TreeMultiselectField.php b/src/Forms/TreeMultiselectField.php
index a1362f247..449a275fe 100644
--- a/src/Forms/TreeMultiselectField.php
+++ b/src/Forms/TreeMultiselectField.php
@@ -92,10 +92,10 @@ class TreeMultiselectField extends TreeDropdownField
foreach ($items as $item) {
if ($item instanceof DataObject) {
$values[] = [
- 'id' => $item->obj($this->getKeyField())->getValue(),
- 'title' => $item->obj($this->getTitleField())->getValue(),
+ 'id' => $item->obj($this->getKeyField())?->getValue(),
+ 'title' => $item->obj($this->getTitleField())?->getValue(),
'parentid' => $item->ParentID,
- 'treetitle' => $item->obj($this->getLabelField())->getSchemaValue(),
+ 'treetitle' => $item->obj($this->getLabelField())?->getSchemaValue(),
];
} else {
$values[] = $item;
@@ -212,7 +212,7 @@ class TreeMultiselectField extends TreeDropdownField
foreach ($items as $item) {
$idArray[] = $item->ID;
$titleArray[] = ($item instanceof ModelData)
- ? $item->obj($this->getLabelField())->forTemplate()
+ ? $item->obj($this->getLabelField())?->forTemplate()
: Convert::raw2xml($item->{$this->getLabelField()});
}
diff --git a/src/Model/ModelData.php b/src/Model/ModelData.php
index 04d5a1fc0..ab21ef932 100644
--- a/src/Model/ModelData.php
+++ b/src/Model/ModelData.php
@@ -12,14 +12,14 @@ use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Convert;
use SilverStripe\Core\Extensible;
use SilverStripe\Core\Injector\Injectable;
-use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Debug;
use SilverStripe\Core\ArrayLib;
-use SilverStripe\Model\List\ArrayList;
use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\ORM\FieldType\DBHTMLText;
use SilverStripe\Model\ArrayData;
+use SilverStripe\View\CastingService;
use SilverStripe\View\SSViewer;
+use Stringable;
use UnexpectedValueException;
/**
@@ -29,7 +29,7 @@ use UnexpectedValueException;
* is provided and automatically escaped by ModelData. Any class that needs to be available to a view (controllers,
* {@link DataObject}s, page controls) should inherit from this class.
*/
-class ModelData
+class ModelData implements Stringable
{
use Extensible {
defineMethods as extensibleDefineMethods;
@@ -38,7 +38,7 @@ class ModelData
use Configurable;
/**
- * An array of objects to cast certain fields to. This is set up as an array in the format:
+ * An array of DBField classes to cast certain fields to. This is set up as an array in the format:
*
*
* public static $casting = array (
@@ -47,16 +47,18 @@ class ModelData
*
*/
private static array $casting = [
- 'CSSClasses' => 'Varchar'
+ 'CSSClasses' => 'Varchar',
+ 'forTemplate' => 'HTMLText',
];
/**
- * The default object to cast scalar fields to if casting information is not specified, and casting to an object
+ * The default class to cast scalar fields to if casting information is not specified, and casting to an object
* is required.
+ * This can be any injectable service name but must resolve to a DBField subclass.
+ *
+ * If null, casting will be determined based on the type of value (e.g. integers will be cast to DBInt)
*/
- private static string $default_cast = 'Text';
-
- private static array $casting_cache = [];
+ private static ?string $default_cast = null;
/**
* Acts as a PHP 8.2+ compliant replacement for dynamic properties
@@ -305,12 +307,18 @@ class ModelData
return true;
}
- /**
- * Return the class name (though subclasses may return something else)
- */
public function __toString(): string
{
- return static::class;
+ return $this->forTemplate();
+ }
+
+ /**
+ * Return the HTML markup that represents this model when it is directly injected into a template (e.g. using $Me).
+ * By default this attempts to render the model using templates based on the class hierarchy.
+ */
+ public function forTemplate(): string
+ {
+ return $this->renderWith($this->getViewerTemplates());
}
public function getCustomisedObj(): ?ModelData
@@ -326,14 +334,10 @@ class ModelData
// CASTING ---------------------------------------------------------------------------------------------------------
/**
- * Return the "casting helper" (a piece of PHP code that when evaluated creates a casted value object)
+ * Return the "casting helper" (an injectable service name)
* for a field on this object. This helper will be a subclass of DBField.
- *
- * @param bool $useFallback If true, fall back on the default casting helper if there isn't an explicit one.
- * @return string|null Casting helper As a constructor pattern, and may include arguments.
- * @throws Exception
*/
- public function castingHelper(string $field, bool $useFallback = true): ?string
+ public function castingHelper(string $field): ?string
{
// Get casting if it has been configured.
// DB fields and PHP methods are all case insensitive so we normalise casing before checking.
@@ -346,67 +350,15 @@ class ModelData
// If no specific cast is declared, fall back to failover.
$failover = $this->getFailover();
if ($failover) {
- $cast = $failover->castingHelper($field, $useFallback);
+ $cast = $failover->castingHelper($field);
if ($cast) {
return $cast;
}
}
- if ($useFallback) {
- return $this->defaultCastingHelper($field);
- }
-
return null;
}
- /**
- * Return the default "casting helper" for use when no explicit casting helper is defined.
- * This helper will be a subclass of DBField. See castingHelper()
- */
- protected function defaultCastingHelper(string $field): string
- {
- // If there is a failover, the default_cast will always
- // be drawn from this object instead of the top level object.
- $failover = $this->getFailover();
- if ($failover) {
- $cast = $failover->defaultCastingHelper($field);
- if ($cast) {
- return $cast;
- }
- }
-
- // Fall back to raw default_cast
- $default = $this->config()->get('default_cast');
- if (empty($default)) {
- throw new Exception('No default_cast');
- }
- return $default;
- }
-
- /**
- * Get the class name a field on this object will be casted to.
- */
- public function castingClass(string $field): string
- {
- // Strip arguments
- $spec = $this->castingHelper($field);
- return trim(strtok($spec ?? '', '(') ?? '');
- }
-
- /**
- * Return the string-format type for the given field.
- *
- * @return string 'xml'|'raw'
- */
- public function escapeTypeForField(string $field): string
- {
- $class = $this->castingClass($field) ?: $this->config()->get('default_cast');
-
- /** @var DBField $type */
- $type = Injector::inst()->get($class, true);
- return $type->config()->get('escape_type');
- }
-
// TEMPLATE ACCESS LAYER -------------------------------------------------------------------------------------------
/**
@@ -497,8 +449,6 @@ class ModelData
*
* @return object|DBField|null The specific object representing the field, or null if there is no
* property, method, or dynamic data available for that field.
- * Note that if there is a property or method that returns null, a relevant DBField instance will
- * be returned.
*/
public function obj(
string $fieldName,
@@ -532,29 +482,7 @@ class ModelData
return null;
}
- // Try to cast object if we have an explicit cast set
- if (!is_object($value)) {
- $castingHelper = $this->castingHelper($fieldName, false);
- if ($castingHelper !== null) {
- $valueObject = Injector::inst()->create($castingHelper, $fieldName);
- $valueObject->setValue($value, $this);
- $value = $valueObject;
- }
- }
-
- // Wrap list arrays in ModelData so templates can handle them
- if (is_array($value) && array_is_list($value)) {
- $value = ArrayList::create($value);
- }
-
- // Fallback on default casting
- if (!is_object($value)) {
- // Force cast
- $castingHelper = $this->defaultCastingHelper($fieldName);
- $valueObject = Injector::inst()->create($castingHelper, $fieldName);
- $valueObject->setValue($value, $this);
- $value = $valueObject;
- }
+ $value = CastingService::singleton()->cast($value, $this, $fieldName, true);
// Record in cache
if ($cache) {
@@ -567,10 +495,8 @@ class ModelData
/**
* A simple wrapper around {@link ModelData::obj()} that automatically caches the result so it can be used again
* without re-running the method.
- *
- * @return Object|DBField
*/
- public function cachedCall(string $fieldName, array $arguments = [], ?string $cacheName = null): object
+ public function cachedCall(string $fieldName, array $arguments = [], ?string $cacheName = null): ?object
{
return $this->obj($fieldName, $arguments, true, $cacheName);
}
diff --git a/src/Model/ModelDataCustomised.php b/src/Model/ModelDataCustomised.php
index 6ae73be21..f7d44b8a8 100644
--- a/src/Model/ModelDataCustomised.php
+++ b/src/Model/ModelDataCustomised.php
@@ -49,6 +49,11 @@ class ModelDataCustomised extends ModelData
return isset($this->customised->$property) || isset($this->original->$property) || parent::__isset($property);
}
+ public function forTemplate(): string
+ {
+ return $this->original->forTemplate();
+ }
+
public function hasMethod($method)
{
return $this->customised->hasMethod($method) || $this->original->hasMethod($method);
diff --git a/src/ORM/DataList.php b/src/ORM/DataList.php
index d703d3b90..e8d69f27f 100644
--- a/src/ORM/DataList.php
+++ b/src/ORM/DataList.php
@@ -19,6 +19,7 @@ use SilverStripe\Model\List\Limitable;
use SilverStripe\Model\List\Map;
use SilverStripe\Model\List\Sortable;
use SilverStripe\Model\List\SS_List;
+use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\ORM\Filters\SearchFilterable;
/**
@@ -1852,7 +1853,7 @@ class DataList extends ModelData implements SS_List, Filterable, Sortable, Limit
return $relation;
}
- public function dbObject($fieldName)
+ public function dbObject(string $fieldName): ?DBField
{
return singleton($this->dataClass)->dbObject($fieldName);
}
diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php
index 2b6bed1da..f791be9d7 100644
--- a/src/ORM/DataObject.php
+++ b/src/ORM/DataObject.php
@@ -3033,7 +3033,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
/**
* {@inheritdoc}
*/
- public function castingHelper(string $field, bool $useFallback = true): ?string
+ public function castingHelper(string $field): ?string
{
$fieldSpec = static::getSchema()->fieldSpec(static::class, $field);
if ($fieldSpec) {
@@ -3051,7 +3051,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
}
}
- return parent::castingHelper($field, $useFallback);
+ return parent::castingHelper($field);
}
/**
@@ -3234,11 +3234,11 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
* - it still returns an object even when the field has no value.
* - it only matches fields and not methods
* - it matches foreign keys generated by has_one relationships, eg, "ParentID"
+ * - if the field exists, the return value is ALWAYS a DBField instance
*
- * @param string $fieldName Name of the field
- * @return DBField The field as a DBField object
+ * Returns null if the field doesn't exist
*/
- public function dbObject($fieldName)
+ public function dbObject(string $fieldName): ?DBField
{
// Check for field in DB
$schema = static::getSchema();
@@ -3306,7 +3306,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
} elseif ($component instanceof Relation || $component instanceof DataList) {
// $relation could either be a field (aggregate), or another relation
$singleton = DataObject::singleton($component->dataClass());
- $component = $singleton->dbObject($relation) ?: $component->relation($relation);
+ $component = $singleton->dbObject($relation) ?? $component->relation($relation);
} elseif ($component instanceof DataObject && ($dbObject = $component->dbObject($relation))) {
$component = $dbObject;
} elseif ($component instanceof ModelData && $component->hasField($relation)) {
@@ -4399,7 +4399,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
// has_one fields should not use dbObject to check if a value is given
$hasOne = static::getSchema()->hasOneComponent(static::class, $field);
if (!$hasOne && ($obj = $this->dbObject($field))) {
- return $obj->exists();
+ return $obj && $obj->exists();
} else {
return parent::hasValue($field, $arguments, $cache);
}
diff --git a/src/ORM/EagerLoadedList.php b/src/ORM/EagerLoadedList.php
index d65a49d37..ad53ad42e 100644
--- a/src/ORM/EagerLoadedList.php
+++ b/src/ORM/EagerLoadedList.php
@@ -171,7 +171,7 @@ class EagerLoadedList extends ModelData implements Relation, SS_List, Filterable
return $this->dataClass;
}
- public function dbObject($fieldName): ?DBField
+ public function dbObject(string $fieldName): ?DBField
{
return singleton($this->dataClass)->dbObject($fieldName);
}
diff --git a/src/ORM/FieldType/DBComposite.php b/src/ORM/FieldType/DBComposite.php
index 7060417ea..6c9ea2a05 100644
--- a/src/ORM/FieldType/DBComposite.php
+++ b/src/ORM/FieldType/DBComposite.php
@@ -73,7 +73,7 @@ abstract class DBComposite extends DBField
foreach ($this->compositeDatabaseFields() as $field => $spec) {
// Write sub-manipulation
$fieldObject = $this->dbObject($field);
- $fieldObject->writeToManipulation($manipulation);
+ $fieldObject?->writeToManipulation($manipulation);
}
}
@@ -137,7 +137,7 @@ abstract class DBComposite extends DBField
// By default all fields
foreach ($this->compositeDatabaseFields() as $field => $spec) {
$fieldObject = $this->dbObject($field);
- if (!$fieldObject->exists()) {
+ if (!$fieldObject?->exists()) {
return false;
}
}
diff --git a/src/ORM/FieldType/DBField.php b/src/ORM/FieldType/DBField.php
index 38efb5758..8d7254aa3 100644
--- a/src/ORM/FieldType/DBField.php
+++ b/src/ORM/FieldType/DBField.php
@@ -520,11 +520,6 @@ abstract class DBField extends ModelData implements DBIndexable
DBG;
}
- public function __toString(): string
- {
- return (string)$this->forTemplate();
- }
-
public function getArrayValue()
{
return $this->arrayValue;
diff --git a/src/ORM/FieldType/DBVarchar.php b/src/ORM/FieldType/DBVarchar.php
index 3081ad34b..86608a197 100644
--- a/src/ORM/FieldType/DBVarchar.php
+++ b/src/ORM/FieldType/DBVarchar.php
@@ -47,7 +47,7 @@ class DBVarchar extends DBString
* can be useful if you want to have text fields with a length limit that
* is dictated by the DB field.
*
- * TextField::create('Title')->setMaxLength(singleton('SiteTree')->dbObject('Title')->getSize())
+ * TextField::create('Title')->setMaxLength(singleton('SiteTree')->dbObject('Title')?->getSize())
*
* @return int The size of the field
*/
diff --git a/src/ORM/Filters/SearchFilter.php b/src/ORM/Filters/SearchFilter.php
index f622252fb..bc70ec5d4 100644
--- a/src/ORM/Filters/SearchFilter.php
+++ b/src/ORM/Filters/SearchFilter.php
@@ -339,7 +339,7 @@ abstract class SearchFilter
/** @var DBField $dbField */
$dbField = singleton($this->model)->dbObject($this->name);
- $dbField->setValue($this->value);
+ $dbField?->setValue($this->value);
return $dbField->RAW();
}
diff --git a/src/ORM/Relation.php b/src/ORM/Relation.php
index 62b2b266c..93c63e961 100644
--- a/src/ORM/Relation.php
+++ b/src/ORM/Relation.php
@@ -45,9 +45,6 @@ interface Relation extends SS_List, Filterable, Sortable, Limitable
/**
* Return the DBField object that represents the given field on the related class.
- *
- * @param string $fieldName Name of the field
- * @return DBField The field as a DBField object
*/
- public function dbObject($fieldName);
+ public function dbObject(string $fieldName): ?DBField;
}
diff --git a/src/ORM/UnsavedRelationList.php b/src/ORM/UnsavedRelationList.php
index e01ff241e..ab2780288 100644
--- a/src/ORM/UnsavedRelationList.php
+++ b/src/ORM/UnsavedRelationList.php
@@ -307,11 +307,8 @@ class UnsavedRelationList extends ArrayList implements Relation
/**
* Return the DBField object that represents the given field on the related class.
- *
- * @param string $fieldName Name of the field
- * @return DBField The field as a DBField object
*/
- public function dbObject($fieldName)
+ public function dbObject(string $fieldName): ?DBField
{
return DataObject::singleton($this->dataClass)->dbObject($fieldName);
}
diff --git a/src/Security/Member.php b/src/Security/Member.php
index cfcee6b78..328c8ebf0 100644
--- a/src/Security/Member.php
+++ b/src/Security/Member.php
@@ -342,7 +342,7 @@ class Member extends DataObject
{
/** @var DBDatetime $lockedOutUntilObj */
$lockedOutUntilObj = $this->dbObject('LockedOutUntil');
- if ($lockedOutUntilObj->InFuture()) {
+ if ($lockedOutUntilObj?->InFuture()) {
return true;
}
@@ -369,7 +369,7 @@ class Member extends DataObject
/** @var DBDatetime $firstFailureDate */
$firstFailureDate = $attempts->first()->dbObject('Created');
$maxAgeSeconds = $this->config()->get('lock_out_delay_mins') * 60;
- $lockedOutUntil = $firstFailureDate->getTimestamp() + $maxAgeSeconds;
+ $lockedOutUntil = $firstFailureDate?->getTimestamp() + $maxAgeSeconds;
$now = DBDatetime::now()->getTimestamp();
if ($now < $lockedOutUntil) {
return true;
@@ -429,7 +429,7 @@ class Member extends DataObject
$currentValue = $this->PasswordExpiry;
$currentDate = $this->dbObject('PasswordExpiry');
- if ($dataValue && (!$currentValue || $currentDate->inFuture())) {
+ if ($dataValue && (!$currentValue || $currentDate?->inFuture())) {
// Only alter future expiries - this way an admin could see how long ago a password expired still
$this->PasswordExpiry = DBDatetime::now()->Rfc2822();
} elseif (!$dataValue && $this->isPasswordExpired()) {
diff --git a/src/Security/PermissionCheckboxSetField.php b/src/Security/PermissionCheckboxSetField.php
index bad09fa4f..7592dc681 100644
--- a/src/Security/PermissionCheckboxSetField.php
+++ b/src/Security/PermissionCheckboxSetField.php
@@ -117,7 +117,7 @@ class PermissionCheckboxSetField extends FormField
$uninheritedCodes[$permission->Code][] = _t(
'SilverStripe\\Security\\PermissionCheckboxSetField.AssignedTo',
'assigned to "{title}"',
- ['title' => $record->dbObject('Title')->forTemplate()]
+ ['title' => $record->dbObject('Title')?->forTemplate()]
);
}
@@ -135,7 +135,7 @@ class PermissionCheckboxSetField extends FormField
'SilverStripe\\Security\\PermissionCheckboxSetField.FromRole',
'inherited from role "{title}"',
'A permission inherited from a certain permission role',
- ['title' => $role->dbObject('Title')->forTemplate()]
+ ['title' => $role->dbObject('Title')?->forTemplate()]
);
}
}
@@ -159,8 +159,8 @@ class PermissionCheckboxSetField extends FormField
'inherited from role "{roletitle}" on group "{grouptitle}"',
'A permission inherited from a role on a certain group',
[
- 'roletitle' => $role->dbObject('Title')->forTemplate(),
- 'grouptitle' => $parent->dbObject('Title')->forTemplate()
+ 'roletitle' => $role->dbObject('Title')?->forTemplate(),
+ 'grouptitle' => $parent->dbObject('Title')?->forTemplate()
]
);
}
@@ -176,7 +176,7 @@ class PermissionCheckboxSetField extends FormField
'SilverStripe\\Security\\PermissionCheckboxSetField.FromGroup',
'inherited from group "{title}"',
'A permission inherited from a certain group',
- ['title' => $parent->dbObject('Title')->forTemplate()]
+ ['title' => $parent->dbObject('Title')?->forTemplate()]
);
}
}
diff --git a/src/View/CastingService.php b/src/View/CastingService.php
new file mode 100644
index 000000000..0d6f7510b
--- /dev/null
+++ b/src/View/CastingService.php
@@ -0,0 +1,100 @@
+castingHelper($fieldName);
+ }
+
+ // Cast to object if there's an explicit casting for this field
+ // Explicit casts take precedence over array casting
+ if ($service) {
+ $castObject = Injector::inst()->create($service, $fieldName);
+ if (!ClassInfo::hasMethod($castObject, 'setValue')) {
+ throw new LogicException('Explicit casting service must have a setValue method.');
+ }
+ $castObject->setValue($data, $source);
+ return $castObject;
+ }
+
+ // Wrap arrays in ModelData so templates can handle them
+ if (is_array($data)) {
+ return array_is_list($data) ? ArrayList::create($data) : ArrayData::create($data);
+ }
+
+ // Fall back to default casting
+ $service = $this->defaultService($data, $source, $fieldName);
+ $castObject = Injector::inst()->create($service, $fieldName);
+ if (!ClassInfo::hasMethod($castObject, 'setValue')) {
+ throw new LogicException('Default service must have a setValue method.');
+ }
+ $castObject->setValue($data, $source);
+ return $castObject;
+ }
+
+ /**
+ * Get the default service to use if no explicit service is declared for this field on the source model.
+ */
+ private function defaultService(mixed $data, mixed $source = null, string $fieldName = ''): ?string
+ {
+ $default = null;
+ if ($source instanceof ModelData) {
+ $default = $source::config()->get('default_cast');
+ if ($default === null) {
+ $failover = $source->getFailover();
+ if ($failover) {
+ $default = $this->defaultService($data, $failover, $fieldName);
+ }
+ }
+ }
+ if ($default !== null) {
+ return $default;
+ }
+
+ return match (gettype($data)) {
+ 'boolean' => DBBoolean::class,
+ 'string' => DBText::class,
+ 'double' => DBFloat::class,
+ 'integer' => DBInt::class,
+ default => DBText::class,
+ };
+ }
+}
diff --git a/src/View/SSTemplateParser.peg b/src/View/SSTemplateParser.peg
index b893ef4ae..abec26c6d 100644
--- a/src/View/SSTemplateParser.peg
+++ b/src/View/SSTemplateParser.peg
@@ -247,7 +247,7 @@ class SSTemplateParser extends Parser implements TemplateParser
}
$res['php'] .= ($sub['ArgumentMode'] == 'default') ? $sub['string_php'] :
- str_replace('$$FINAL', 'XML_val', $sub['php'] ?? '');
+ str_replace('$$FINAL', 'getValueAsArgument', $sub['php'] ?? '');
}
/*!*
@@ -274,8 +274,8 @@ class SSTemplateParser extends Parser implements TemplateParser
}
/**
- * The basic generated PHP of LookupStep and LastLookupStep is the same, except that LookupStep calls 'obj' to
- * get the next ModelData in the sequence, and LastLookupStep calls different methods (XML_val, hasValue, obj)
+ * The basic generated PHP of LookupStep and LastLookupStep is the same, except that LookupStep calls 'scopeToIntermediateValue' to
+ * get the next ModelData in the sequence, and LastLookupStep calls different methods (getOutputValue, hasValue, scopeToIntermediateValue)
* depending on the context the lookup is used in.
*/
function Lookup_AddLookupStep(&$res, $sub, $method)
@@ -286,15 +286,17 @@ 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)";
+ $type = ViewLayerData::TYPE_METHOD;
+ $res['php'] .= "->$method('$property', [$arguments], '$type', true)";
} else {
- $res['php'] .= "->$method('$property', [], true)";
+ $type = ViewLayerData::TYPE_PROPERTY;
+ $res['php'] .= "->$method('$property', [], '$type', true)";
}
}
function Lookup_LookupStep(&$res, $sub)
{
- $this->Lookup_AddLookupStep($res, $sub, 'obj');
+ $this->Lookup_AddLookupStep($res, $sub, 'scopeToIntermediateValue');
}
function Lookup_LastLookupStep(&$res, $sub)
@@ -357,7 +359,7 @@ class SSTemplateParser extends Parser implements TemplateParser
function InjectionVariables_Argument(&$res, $sub)
{
- $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php'] ?? '') . ',';
+ $res['php'] .= str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? '') . ',';
}
function InjectionVariables__finalise(&$res)
@@ -392,7 +394,7 @@ class SSTemplateParser extends Parser implements TemplateParser
*/
function Injection_STR(&$res, $sub)
{
- $res['php'] = '$val .= '. str_replace('$$FINAL', 'XML_val', $sub['Lookup']['php'] ?? '') . ';';
+ $res['php'] = '$val .= '. str_replace('$$FINAL', 'getOutputValue', $sub['Lookup']['php'] ?? '') . ';';
}
/*!*
@@ -535,10 +537,10 @@ class SSTemplateParser extends Parser implements TemplateParser
if (!empty($res['php'])) {
$res['php'] .= $sub['string_php'];
} else {
- $res['php'] = str_replace('$$FINAL', 'XML_val', $sub['lookup_php'] ?? '');
+ $res['php'] = str_replace('$$FINAL', 'getOutputValue', $sub['lookup_php'] ?? '');
}
} else {
- $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php'] ?? '');
+ $res['php'] .= str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? '');
}
}
@@ -567,7 +569,7 @@ class SSTemplateParser extends Parser implements TemplateParser
} 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
+ // Lookup_LastLookupStep and Argument_BareWord can produce hasValue instead of getOutputValue
$res['php'] .= str_replace('$$FINAL', 'hasValue', $php ?? '');
}
}
@@ -697,7 +699,7 @@ class SSTemplateParser extends Parser implements TemplateParser
$res['php'] = '';
}
- $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php'] ?? '');
+ $res['php'] .= str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? '');
}
/*!*
@@ -827,7 +829,7 @@ class SSTemplateParser extends Parser implements TemplateParser
{
$entity = $sub['String']['text'];
if (strpos($entity ?? '', '.') === false) {
- $res['php'] .= "\$scope->XML_val('I18NNamespace').'.$entity'";
+ $res['php'] .= "\$scope->getOutputValue('I18NNamespace').'.$entity'";
} else {
$res['php'] .= "'$entity'";
}
@@ -915,7 +917,7 @@ class SSTemplateParser extends Parser implements TemplateParser
break;
default:
- $res['php'] .= str_replace('$$FINAL', 'obj', $sub['php'] ?? '') . '->self()';
+ $res['php'] .= str_replace('$$FINAL', 'scopeToIntermediateValue', $sub['php'] ?? '') . '->self()';
break;
}
}
@@ -948,7 +950,7 @@ class SSTemplateParser extends Parser implements TemplateParser
$arguments = $res['arguments'];
// Note: 'type' here is important to disable subTemplates in SSViewer::getSubtemplateFor()
- $res['php'] = '$val .= \\SilverStripe\\View\\SSViewer::execute_template([["type" => "Includes", '.$template.'], '.$template.'], $scope->getItem(), [' .
+ $res['php'] = '$val .= \\SilverStripe\\View\\SSViewer::execute_template([["type" => "Includes", '.$template.'], '.$template.'], $scope->getCurrentItem(), [' .
implode(',', $arguments)."], \$scope, true);\n";
if ($this->includeDebuggingComments) { // Add include filename comments on dev sites
@@ -1037,7 +1039,7 @@ class SSTemplateParser extends Parser implements TemplateParser
//loop without arguments loops on the current scope
if ($res['ArgumentCount'] == 0) {
- $on = '$scope->locally()->obj(\'Me\', [], true)';
+ $on = '$scope->locally()->scopeToIntermediateValue(\'Me\', [], true)';
} else { //loop in the normal way
$arg = $res['Arguments'][0];
if ($arg['ArgumentMode'] == 'string') {
@@ -1045,13 +1047,13 @@ class SSTemplateParser extends Parser implements TemplateParser
}
$on = str_replace(
'$$FINAL',
- 'obj',
+ 'scopeToIntermediateValue',
($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']
);
}
return
- $on . '; $scope->pushScope(); while (($key = $scope->next()) !== false) {' . PHP_EOL .
+ $on . '; $scope->pushScope(); while ($scope->next() !== false) {' . PHP_EOL .
$res['Template']['php'] . PHP_EOL .
'}; $scope->popScope(); ';
}
@@ -1071,7 +1073,7 @@ class SSTemplateParser extends Parser implements TemplateParser
throw new SSTemplateParseException('Control block cant take string as argument.', $this);
}
- $on = str_replace('$$FINAL', 'obj', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']);
+ $on = str_replace('$$FINAL', 'scopeToIntermediateValue', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']);
return
$on . '; $scope->pushScope();' . PHP_EOL .
$res['Template']['php'] . PHP_EOL .
diff --git a/src/View/SSTemplateParser.php b/src/View/SSTemplateParser.php
index 4e4842489..187cf331d 100644
--- a/src/View/SSTemplateParser.php
+++ b/src/View/SSTemplateParser.php
@@ -572,7 +572,7 @@ class SSTemplateParser extends Parser implements TemplateParser
}
$res['php'] .= ($sub['ArgumentMode'] == 'default') ? $sub['string_php'] :
- str_replace('$$FINAL', 'XML_val', $sub['php'] ?? '');
+ str_replace('$$FINAL', 'getValueAsArgument', $sub['php'] ?? '');
}
/* Call: Method:Word ( "(" < :CallArguments? > ")" )? */
@@ -765,8 +765,8 @@ class SSTemplateParser extends Parser implements TemplateParser
}
/**
- * The basic generated PHP of LookupStep and LastLookupStep is the same, except that LookupStep calls 'obj' to
- * get the next ModelData in the sequence, and LastLookupStep calls different methods (XML_val, hasValue, obj)
+ * The basic generated PHP of LookupStep and LastLookupStep is the same, except that LookupStep calls 'scopeToIntermediateValue' to
+ * get the next ModelData in the sequence, and LastLookupStep calls different methods (getOutputValue, hasValue, scopeToIntermediateValue)
* depending on the context the lookup is used in.
*/
function Lookup_AddLookupStep(&$res, $sub, $method)
@@ -777,15 +777,17 @@ 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)";
+ $type = ViewLayerData::TYPE_METHOD;
+ $res['php'] .= "->$method('$property', [$arguments], '$type', true)";
} else {
- $res['php'] .= "->$method('$property', [], true)";
+ $type = ViewLayerData::TYPE_PROPERTY;
+ $res['php'] .= "->$method('$property', [], '$type', true)";
}
}
function Lookup_LookupStep(&$res, $sub)
{
- $this->Lookup_AddLookupStep($res, $sub, 'obj');
+ $this->Lookup_AddLookupStep($res, $sub, 'scopeToIntermediateValue');
}
function Lookup_LastLookupStep(&$res, $sub)
@@ -1009,7 +1011,7 @@ class SSTemplateParser extends Parser implements TemplateParser
function InjectionVariables_Argument(&$res, $sub)
{
- $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php'] ?? '') . ',';
+ $res['php'] .= str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? '') . ',';
}
function InjectionVariables__finalise(&$res)
@@ -1158,7 +1160,7 @@ class SSTemplateParser extends Parser implements TemplateParser
function Injection_STR(&$res, $sub)
{
- $res['php'] = '$val .= '. str_replace('$$FINAL', 'XML_val', $sub['Lookup']['php'] ?? '') . ';';
+ $res['php'] = '$val .= '. str_replace('$$FINAL', 'getOutputValue', $sub['Lookup']['php'] ?? '') . ';';
}
/* DollarMarkedLookup: SimpleInjection */
@@ -1187,7 +1189,7 @@ class SSTemplateParser extends Parser implements TemplateParser
$matchrule = "QuotedString"; $result = $this->construct($matchrule, $matchrule, null);
$_154 = NULL;
do {
- $stack[] = $result; $result = $this->construct( $matchrule, "q" );
+ $stack[] = $result; $result = $this->construct( $matchrule, "q" );
if (( $subres = $this->rx( '/[\'"]/' ) ) !== FALSE) {
$result["text"] .= $subres;
$subres = $result; $result = array_pop($stack);
@@ -1197,7 +1199,7 @@ class SSTemplateParser extends Parser implements TemplateParser
$result = array_pop($stack);
$_154 = FALSE; break;
}
- $stack[] = $result; $result = $this->construct( $matchrule, "String" );
+ $stack[] = $result; $result = $this->construct( $matchrule, "String" );
if (( $subres = $this->rx( '/ (\\\\\\\\ | \\\\. | [^'.$this->expression($result, $stack, 'q').'\\\\])* /' ) ) !== FALSE) {
$result["text"] .= $subres;
$subres = $result; $result = array_pop($stack);
@@ -1818,10 +1820,10 @@ class SSTemplateParser extends Parser implements TemplateParser
if (!empty($res['php'])) {
$res['php'] .= $sub['string_php'];
} else {
- $res['php'] = str_replace('$$FINAL', 'XML_val', $sub['lookup_php'] ?? '');
+ $res['php'] = str_replace('$$FINAL', 'getOutputValue', $sub['lookup_php'] ?? '');
}
} else {
- $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php'] ?? '');
+ $res['php'] .= str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? '');
}
}
@@ -1840,7 +1842,7 @@ class SSTemplateParser extends Parser implements TemplateParser
$pos_255 = $this->pos;
$_254 = NULL;
do {
- $stack[] = $result; $result = $this->construct( $matchrule, "Not" );
+ $stack[] = $result; $result = $this->construct( $matchrule, "Not" );
if (( $subres = $this->literal( 'not' ) ) !== FALSE) {
$result["text"] .= $subres;
$subres = $result; $result = array_pop($stack);
@@ -1887,7 +1889,7 @@ class SSTemplateParser extends Parser implements TemplateParser
} 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
+ // Lookup_LastLookupStep and Argument_BareWord can produce hasValue instead of getOutputValue
$res['php'] .= str_replace('$$FINAL', 'hasValue', $php ?? '');
}
}
@@ -2235,7 +2237,7 @@ class SSTemplateParser extends Parser implements TemplateParser
else { $_330 = FALSE; break; }
if (( $subres = $this->whitespace( ) ) !== FALSE) { $result["text"] .= $subres; }
else { $_330 = FALSE; break; }
- $stack[] = $result; $result = $this->construct( $matchrule, "Call" );
+ $stack[] = $result; $result = $this->construct( $matchrule, "Call" );
$_326 = NULL;
do {
$matcher = 'match_'.'Word'; $key = $matcher; $pos = $this->pos;
@@ -2470,7 +2472,7 @@ class SSTemplateParser extends Parser implements TemplateParser
$res['php'] = '';
}
- $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php'] ?? '');
+ $res['php'] .= str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? '');
}
/* CacheBlockTemplate: (Comment | Translate | If | Require | OldI18NTag | Include | ClosedBlock |
@@ -2740,7 +2742,7 @@ class SSTemplateParser extends Parser implements TemplateParser
$_423 = NULL;
do {
if (( $subres = $this->whitespace( ) ) !== FALSE) { $result["text"] .= $subres; }
- $stack[] = $result; $result = $this->construct( $matchrule, "Conditional" );
+ $stack[] = $result; $result = $this->construct( $matchrule, "Conditional" );
$_419 = NULL;
do {
$_417 = NULL;
@@ -3166,7 +3168,7 @@ class SSTemplateParser extends Parser implements TemplateParser
if (( $subres = $this->literal( '<%' ) ) !== FALSE) { $result["text"] .= $subres; }
else { $_555 = FALSE; break; }
if (( $subres = $this->whitespace( ) ) !== FALSE) { $result["text"] .= $subres; }
- $stack[] = $result; $result = $this->construct( $matchrule, "CacheTag" );
+ $stack[] = $result; $result = $this->construct( $matchrule, "CacheTag" );
$_508 = NULL;
do {
$_506 = NULL;
@@ -3225,7 +3227,7 @@ class SSTemplateParser extends Parser implements TemplateParser
$_524 = NULL;
do {
if (( $subres = $this->whitespace( ) ) !== FALSE) { $result["text"] .= $subres; }
- $stack[] = $result; $result = $this->construct( $matchrule, "Conditional" );
+ $stack[] = $result; $result = $this->construct( $matchrule, "Conditional" );
$_520 = NULL;
do {
$_518 = NULL;
@@ -3587,7 +3589,7 @@ class SSTemplateParser extends Parser implements TemplateParser
{
$entity = $sub['String']['text'];
if (strpos($entity ?? '', '.') === false) {
- $res['php'] .= "\$scope->XML_val('I18NNamespace').'.$entity'";
+ $res['php'] .= "\$scope->getOutputValue('I18NNamespace').'.$entity'";
} else {
$res['php'] .= "'$entity'";
}
@@ -3792,7 +3794,7 @@ class SSTemplateParser extends Parser implements TemplateParser
break;
default:
- $res['php'] .= str_replace('$$FINAL', 'obj', $sub['php'] ?? '') . '->self()';
+ $res['php'] .= str_replace('$$FINAL', 'scopeToIntermediateValue', $sub['php'] ?? '') . '->self()';
break;
}
}
@@ -3897,7 +3899,7 @@ class SSTemplateParser extends Parser implements TemplateParser
$arguments = $res['arguments'];
// Note: 'type' here is important to disable subTemplates in SSViewer::getSubtemplateFor()
- $res['php'] = '$val .= \\SilverStripe\\View\\SSViewer::execute_template([["type" => "Includes", '.$template.'], '.$template.'], $scope->getItem(), [' .
+ $res['php'] = '$val .= \\SilverStripe\\View\\SSViewer::execute_template([["type" => "Includes", '.$template.'], '.$template.'], $scope->getCurrentItem(), [' .
implode(',', $arguments)."], \$scope, true);\n";
if ($this->includeDebuggingComments) { // Add include filename comments on dev sites
@@ -4165,7 +4167,7 @@ class SSTemplateParser extends Parser implements TemplateParser
unset( $pos_685 );
}
if (( $subres = $this->whitespace( ) ) !== FALSE) { $result["text"] .= $subres; }
- $stack[] = $result; $result = $this->construct( $matchrule, "Zap" );
+ $stack[] = $result; $result = $this->construct( $matchrule, "Zap" );
if (( $subres = $this->literal( '%>' ) ) !== FALSE) {
$result["text"] .= $subres;
$subres = $result; $result = array_pop($stack);
@@ -4265,7 +4267,7 @@ class SSTemplateParser extends Parser implements TemplateParser
//loop without arguments loops on the current scope
if ($res['ArgumentCount'] == 0) {
- $on = '$scope->locally()->obj(\'Me\', [], true)';
+ $on = '$scope->locally()->scopeToIntermediateValue(\'Me\', [], true)';
} else { //loop in the normal way
$arg = $res['Arguments'][0];
if ($arg['ArgumentMode'] == 'string') {
@@ -4273,13 +4275,13 @@ class SSTemplateParser extends Parser implements TemplateParser
}
$on = str_replace(
'$$FINAL',
- 'obj',
+ 'scopeToIntermediateValue',
($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']
);
}
return
- $on . '; $scope->pushScope(); while (($key = $scope->next()) !== false) {' . PHP_EOL .
+ $on . '; $scope->pushScope(); while ($scope->next() !== false) {' . PHP_EOL .
$res['Template']['php'] . PHP_EOL .
'}; $scope->popScope(); ';
}
@@ -4299,7 +4301,7 @@ class SSTemplateParser extends Parser implements TemplateParser
throw new SSTemplateParseException('Control block cant take string as argument.', $this);
}
- $on = str_replace('$$FINAL', 'obj', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']);
+ $on = str_replace('$$FINAL', 'scopeToIntermediateValue', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']);
return
$on . '; $scope->pushScope();' . PHP_EOL .
$res['Template']['php'] . PHP_EOL .
@@ -4575,7 +4577,7 @@ class SSTemplateParser extends Parser implements TemplateParser
if (( $subres = $this->literal( '<%' ) ) !== FALSE) { $result["text"] .= $subres; }
else { $_743 = FALSE; break; }
if (( $subres = $this->whitespace( ) ) !== FALSE) { $result["text"] .= $subres; }
- $stack[] = $result; $result = $this->construct( $matchrule, "Tag" );
+ $stack[] = $result; $result = $this->construct( $matchrule, "Tag" );
$_737 = NULL;
do {
if (( $subres = $this->literal( 'end_' ) ) !== FALSE) { $result["text"] .= $subres; }
diff --git a/src/View/SSViewer.php b/src/View/SSViewer.php
index 63f0edc34..987ad5c43 100644
--- a/src/View/SSViewer.php
+++ b/src/View/SSViewer.php
@@ -15,6 +15,7 @@ use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\ORM\FieldType\DBHTMLText;
use SilverStripe\Security\Permission;
use InvalidArgumentException;
+use RuntimeException;
use SilverStripe\Model\ModelData;
/**
@@ -550,10 +551,10 @@ class SSViewer implements Flushable
* Effectively this is the common code that both SSViewer#process and SSViewer_FromString#process call
*
* @param string $cacheFile The path to the file that contains the template compiled to PHP
- * @param ModelData $item The item to use as the root scope for the template
+ * @param ViewLayerData $item The item to use as the root scope for the template
* @param array $overlay Any variables to layer on top of the scope
* @param array $underlay Any variables to layer underneath the scope
- * @param ModelData $inheritedScope The current scope of a parent template including a sub-template
+ * @param SSViewer_Scope $inheritedScope The current scope of a parent template including a sub-template
* @return string The result of executing the template
*/
protected function includeGeneratedTemplate($cacheFile, $item, $overlay, $underlay, $inheritedScope = null)
@@ -569,7 +570,7 @@ class SSViewer implements Flushable
}
$cache = $this->getPartialCacheStore();
- $scope = new SSViewer_DataPresenter($item, $overlay, $underlay, $inheritedScope);
+ $scope = new SSViewer_Scope($item, $overlay, $underlay, $inheritedScope);
$val = '';
// Placeholder for values exposed to $cacheFile
@@ -597,6 +598,7 @@ class SSViewer implements Flushable
*/
public function process($item, $arguments = null, $inheritedScope = null)
{
+ $item = ViewLayerData::create($item);
// Set hashlinks and temporarily modify global state
$rewrite = $this->getRewriteHashLinks();
$origRewriteDefault = static::getRewriteHashLinksDefault();
@@ -606,6 +608,10 @@ class SSViewer implements Flushable
$template = $this->chosen;
+ if (!$template) {
+ throw new RuntimeException('No template to render');
+ }
+
$cacheFile = TEMP_PATH . DIRECTORY_SEPARATOR . '.cache'
. str_replace(['\\','/',':'], '.', Director::makeRelative(realpath($template ?? '')) ?? '');
$lastEdited = filemtime($template ?? '');
diff --git a/src/View/SSViewer_DataPresenter.php b/src/View/SSViewer_DataPresenter.php
deleted file mode 100644
index 0729584a2..000000000
--- a/src/View/SSViewer_DataPresenter.php
+++ /dev/null
@@ -1,449 +0,0 @@
-overlay = $overlay ?: [];
- $this->underlay = $underlay ?: [];
-
- $this->cacheGlobalProperties();
- $this->cacheIteratorProperties();
- }
-
- /**
- * Build cache of global properties
- */
- protected function cacheGlobalProperties()
- {
- if (SSViewer_DataPresenter::$globalProperties !== null) {
- return;
- }
-
- SSViewer_DataPresenter::$globalProperties = $this->getPropertiesFromProvider(
- TemplateGlobalProvider::class,
- 'get_template_global_variables'
- );
- }
-
- /**
- * Build cache of global iterator properties
- */
- protected function cacheIteratorProperties()
- {
- if (SSViewer_DataPresenter::$iteratorProperties !== null) {
- return;
- }
-
- SSViewer_DataPresenter::$iteratorProperties = $this->getPropertiesFromProvider(
- TemplateIteratorProvider::class,
- 'get_template_iterator_variables',
- true // Call non-statically
- );
- }
-
- /**
- * @var string $interfaceToQuery
- * @var string $variableMethod
- * @var boolean $createObject
- * @return array
- */
- protected function getPropertiesFromProvider($interfaceToQuery, $variableMethod, $createObject = false)
- {
- $methods = [];
-
- $implementors = ClassInfo::implementorsOf($interfaceToQuery);
- if ($implementors) {
- foreach ($implementors as $implementor) {
- // Create a new instance of the object for method calls
- if ($createObject) {
- $implementor = new $implementor();
- $exposedVariables = $implementor->$variableMethod();
- } else {
- $exposedVariables = $implementor::$variableMethod();
- }
-
- foreach ($exposedVariables as $varName => $details) {
- if (!is_array($details)) {
- $details = [
- 'method' => $details,
- 'casting' => ModelData::config()->uninherited('default_cast')
- ];
- }
-
- // If just a value (and not a key => value pair), use method name for both key and value
- if (is_numeric($varName)) {
- $varName = $details['method'];
- }
-
- // Add in a reference to the implementing class (might be a string class name or an instance)
- $details['implementor'] = $implementor;
-
- // And a callable array
- if (isset($details['method'])) {
- $details['callable'] = [$implementor, $details['method']];
- }
-
- // Save with both uppercase & lowercase first letter, so either works
- $lcFirst = strtolower($varName[0] ?? '') . substr($varName ?? '', 1);
- $result[$lcFirst] = $details;
- $result[ucfirst($varName)] = $details;
- }
- }
- }
-
- return $result;
- }
-
- /**
- * Look up injected value - it may be part of an "overlay" (arguments passed to <% include %>),
- * set on the current item, part of an "underlay" ($Layout or $Content), or an iterator/global property
- *
- * @param string $property Name of property
- * @param array $params
- * @param bool $cast If true, an object is always returned even if not an object.
- * @return array|null
- */
- public function getInjectedValue($property, array $params, $cast = true)
- {
- // Get source for this value
- $result = $this->getValueSource($property);
- if (!array_key_exists('source', $result)) {
- return null;
- }
-
- // Look up the value - either from a callable, or from a directly provided value
- $source = $result['source'];
- $res = [];
- if (isset($source['callable'])) {
- $res['value'] = $source['callable'](...$params);
- } elseif (array_key_exists('value', $source)) {
- $res['value'] = $source['value'];
- } else {
- throw new InvalidArgumentException(
- "Injected property $property doesn't have a value or callable value source provided"
- );
- }
-
- // If we want to provide a casted object, look up what type object to use
- if ($cast) {
- $res['obj'] = $this->castValue($res['value'], $source);
- }
-
- return $res;
- }
-
- /**
- * Store the current overlay (as it doesn't directly apply to the new scope
- * that's being pushed). We want to store the overlay against the next item
- * "up" in the stack (hence upIndex), rather than the current item, because
- * SSViewer_Scope::obj() has already been called and pushed the new item to
- * the stack by this point
- *
- * @return SSViewer_Scope
- */
- public function pushScope()
- {
- $scope = parent::pushScope();
- $upIndex = $this->getUpIndex() ?: 0;
-
- $itemStack = $this->getItemStack();
- $itemStack[$upIndex][SSViewer_Scope::ITEM_OVERLAY] = $this->overlay;
- $this->setItemStack($itemStack);
-
- // Remove the overlay when we're changing to a new scope, as values in
- // that scope take priority. The exceptions that set this flag are $Up
- // and $Top as they require that the new scope inherits the overlay
- if (!$this->preserveOverlay) {
- $this->overlay = [];
- }
-
- return $scope;
- }
-
- /**
- * Now that we're going to jump up an item in the item stack, we need to
- * restore the overlay that was previously stored against the next item "up"
- * in the stack from the current one
- *
- * @return SSViewer_Scope
- */
- public function popScope()
- {
- $upIndex = $this->getUpIndex();
-
- if ($upIndex !== null) {
- $itemStack = $this->getItemStack();
- $this->overlay = $itemStack[$upIndex][SSViewer_Scope::ITEM_OVERLAY];
- }
-
- return parent::popScope();
- }
-
- /**
- * $Up and $Top need to restore the overlay from the parent and top-level
- * scope respectively.
- *
- * @param string $name
- * @param array $arguments
- * @param bool $cache
- * @param string $cacheName
- * @return $this
- */
- public function obj($name, $arguments = [], $cache = false, $cacheName = null)
- {
- $overlayIndex = false;
-
- switch ($name) {
- case 'Up':
- $upIndex = $this->getUpIndex();
- if ($upIndex === null) {
- throw new \LogicException('Up called when we\'re already at the top of the scope');
- }
- $overlayIndex = $upIndex; // Parent scope
- $this->preserveOverlay = true; // Preserve overlay
- break;
- case 'Top':
- $overlayIndex = 0; // Top-level scope
- $this->preserveOverlay = true; // Preserve overlay
- break;
- default:
- $this->preserveOverlay = false;
- break;
- }
-
- if ($overlayIndex !== false) {
- $itemStack = $this->getItemStack();
- if (!$this->overlay && isset($itemStack[$overlayIndex][SSViewer_Scope::ITEM_OVERLAY])) {
- $this->overlay = $itemStack[$overlayIndex][SSViewer_Scope::ITEM_OVERLAY];
- }
- }
-
- parent::obj($name, $arguments, $cache, $cacheName);
- return $this;
- }
-
- /**
- * {@inheritdoc}
- */
- public function getObj($name, $arguments = [], $cache = false, $cacheName = null)
- {
- $result = $this->getInjectedValue($name, (array)$arguments);
- if ($result) {
- return $result['obj'];
- }
- return parent::getObj($name, $arguments, $cache, $cacheName);
- }
-
- /**
- * {@inheritdoc}
- */
- public function __call($name, $arguments)
- {
- // Extract the method name and parameters
- $property = $arguments[0]; // The name of the public function being called
-
- // The public function parameters in an array
- $params = (isset($arguments[1])) ? (array)$arguments[1] : [];
-
- $val = $this->getInjectedValue($property, $params);
- if ($val) {
- $obj = $val['obj'];
- if ($name === 'hasValue') {
- $result = ($obj instanceof ModelData) ? $obj->exists() : (bool)$obj;
- } elseif (is_null($obj) || (is_scalar($obj) && !is_string($obj))) {
- $result = $obj; // Nulls and non-string scalars don't need casting
- } else {
- $result = $obj->forTemplate(); // XML_val
- }
-
- $this->resetLocalScope();
- return $result;
- }
-
- return parent::__call($name, $arguments);
- }
-
- /**
- * Evaluate a template override. Returns an array where the presence of
- * a 'value' key indiciates whether an override was successfully found,
- * as null is a valid override value
- *
- * @param string $property Name of override requested
- * @param array $overrides List of overrides available
- * @return array An array with a 'value' key if a value has been found, or empty if not
- */
- protected function processTemplateOverride($property, $overrides)
- {
- if (!array_key_exists($property, $overrides)) {
- return [];
- }
-
- // Detect override type
- $override = $overrides[$property];
-
- // Late-evaluate this value
- if (!is_string($override) && is_callable($override)) {
- $override = $override();
-
- // Late override may yet return null
- if (!isset($override)) {
- return [];
- }
- }
-
- return ['value' => $override];
- }
-
- /**
- * Determine source to use for getInjectedValue. Returns an array where the presence of
- * a 'source' key indiciates whether a value source was successfully found, as a source
- * may be a null value returned from an override
- *
- * @param string $property
- * @return array An array with a 'source' key if a value source has been found, or empty if not
- */
- protected function getValueSource($property)
- {
- // Check for a presenter-specific override
- $result = $this->processTemplateOverride($property, $this->overlay);
- if (array_key_exists('value', $result)) {
- return ['source' => $result];
- }
-
- // Check if the method to-be-called exists on the target object - if so, don't check any further
- // injection locations
- $on = $this->getItem();
- if (is_object($on) && (isset($on->$property) || method_exists($on, $property ?? ''))) {
- return [];
- }
-
- // Check for a presenter-specific override
- $result = $this->processTemplateOverride($property, $this->underlay);
- if (array_key_exists('value', $result)) {
- return ['source' => $result];
- }
-
- // Then for iterator-specific overrides
- if (array_key_exists($property, SSViewer_DataPresenter::$iteratorProperties)) {
- $source = SSViewer_DataPresenter::$iteratorProperties[$property];
- /** @var TemplateIteratorProvider $implementor */
- $implementor = $source['implementor'];
- if ($this->itemIterator) {
- // Set the current iterator position and total (the object instance is the first item in
- // the callable array)
- $implementor->iteratorProperties(
- $this->itemIterator->key(),
- $this->itemIteratorTotal
- );
- } else {
- // If we don't actually have an iterator at the moment, act like a list of length 1
- $implementor->iteratorProperties(0, 1);
- }
-
- return ($source) ? ['source' => $source] : [];
- }
-
- // And finally for global overrides
- if (array_key_exists($property, SSViewer_DataPresenter::$globalProperties)) {
- return [
- 'source' => SSViewer_DataPresenter::$globalProperties[$property] // get the method call
- ];
- }
-
- // No value
- return [];
- }
-
- /**
- * Ensure the value is cast safely
- *
- * @param mixed $value
- * @param array $source
- * @return DBField
- */
- protected function castValue($value, $source)
- {
- // If the value has already been cast, is null, or is a non-string scalar
- if (is_object($value) || is_null($value) || (is_scalar($value) && !is_string($value))) {
- return $value;
- }
-
- // Wrap list arrays in ModelData so templates can handle them
- if (is_array($value) && array_is_list($value)) {
- return ArrayList::create($value);
- }
-
- // Get provided or default cast
- $casting = empty($source['casting'])
- ? ModelData::config()->uninherited('default_cast')
- : $source['casting'];
-
- return DBField::create_field($casting, $value);
- }
-}
diff --git a/src/View/SSViewer_FromString.php b/src/View/SSViewer_FromString.php
index c7712a9f2..407c6343d 100644
--- a/src/View/SSViewer_FromString.php
+++ b/src/View/SSViewer_FromString.php
@@ -50,6 +50,7 @@ class SSViewer_FromString extends SSViewer
*/
public function process($item, $arguments = null, $scope = null)
{
+ $item = ViewLayerData::create($item);
$hash = sha1($this->content ?? '');
$cacheFile = TEMP_PATH . DIRECTORY_SEPARATOR . ".cache.$hash";
diff --git a/src/View/SSViewer_Scope.php b/src/View/SSViewer_Scope.php
index 928b7b4a3..7688f6edf 100644
--- a/src/View/SSViewer_Scope.php
+++ b/src/View/SSViewer_Scope.php
@@ -4,12 +4,11 @@ namespace SilverStripe\View;
use ArrayIterator;
use Countable;
+use InvalidArgumentException;
use Iterator;
-use SilverStripe\Model\List\ArrayList;
-use SilverStripe\ORM\FieldType\DBBoolean;
-use SilverStripe\ORM\FieldType\DBText;
-use SilverStripe\ORM\FieldType\DBFloat;
-use SilverStripe\ORM\FieldType\DBInt;
+use LogicException;
+use SilverStripe\Core\ClassInfo;
+use SilverStripe\Core\Injector\Injector;
use SilverStripe\ORM\FieldType\DBField;
/**
@@ -18,6 +17,10 @@ use SilverStripe\ORM\FieldType\DBField;
* - Track Up and Top
* - (As a side effect) Inject data that needs to be available globally (used to live in ModelData)
*
+ * It is also responsible for mixing in data on top of what the item provides. This can be "global"
+ * data that is scope-independant (like BaseURL), or type-specific data that is layered on top cross-cut like
+ * (like $FirstLast etc).
+ *
* In order to handle up, rather than tracking it using a tree, which would involve constructing new objects
* for each step, we use indexes into the itemStack (which already has to exist).
*
@@ -107,37 +110,73 @@ class SSViewer_Scope
*/
private $localIndex = 0;
+ /**
+ * List of global property providers
+ *
+ * @internal
+ * @var TemplateGlobalProvider[]|null
+ */
+ private static $globalProperties = null;
+
+ /**
+ * List of global iterator providers
+ *
+ * @internal
+ * @var TemplateIteratorProvider[]|null
+ */
+ private static $iteratorProperties = null;
+
+ /**
+ * Overlay variables. Take precedence over anything from the current scope
+ *
+ * @var array|null
+ */
+ protected $overlay;
+
+ /**
+ * Flag for whether overlay should be preserved when pushing a new scope
+ *
+ * @see SSViewer_Scope::pushScope()
+ * @var bool
+ */
+ protected $preserveOverlay = false;
+
+ /**
+ * Underlay variables. Concede precedence to overlay variables or anything from the current scope
+ *
+ * @var array
+ */
+ protected $underlay;
+
/**
* @var object $item
* @var SSViewer_Scope $inheritedScope
*/
- public function __construct($item, SSViewer_Scope $inheritedScope = null)
- {
+ public function __construct(
+ $item,
+ array $overlay = null,
+ array $underlay = null,
+ SSViewer_Scope $inheritedScope = null
+ ) {
$this->item = $item;
$this->itemIterator = ($inheritedScope) ? $inheritedScope->itemIterator : null;
$this->itemIteratorTotal = ($inheritedScope) ? $inheritedScope->itemIteratorTotal : 0;
$this->itemStack[] = [$this->item, $this->itemIterator, $this->itemIteratorTotal, null, null, 0];
+
+ $this->overlay = $overlay ?: [];
+ $this->underlay = $underlay ?: [];
+
+ $this->cacheGlobalProperties();
+ $this->cacheIteratorProperties();
}
/**
- * Returns the current "active" item
- *
- * @return object
+ * Returns the current "current" item in scope
*/
- public function getItem()
+ public function getCurrentItem(): ?ViewLayerData
{
- $item = $this->itemIterator ? $this->itemIterator->current() : $this->item;
- if (is_scalar($item)) {
- $item = $this->convertScalarToDBField($item);
- }
-
- // Wrap list arrays in ModelData so templates can handle them
- if (is_array($item) && array_is_list($item)) {
- $item = ArrayList::create($item);
- }
-
- return $item;
+ return $this->itemIterator ? $this->itemIterator->current() : $this->item;
}
/**
@@ -184,36 +223,21 @@ class SSViewer_Scope
}
/**
- * @param string $name
- * @param array $arguments
- * @param bool $cache
- * @param string $cacheName
- * @return mixed
+ * Set scope to an intermediate value, which will be used for getting output later on.
*/
- public function getObj($name, $arguments = [], $cache = false, $cacheName = null)
+ public function scopeToIntermediateValue(string $name, array $arguments = [], string $type = '', bool $cache = false, ?string $cacheName = null): static
{
- $on = $this->getItem();
- if ($on === null) {
- return null;
- }
- return $on->obj($name, $arguments, $cache, $cacheName);
- }
+ $overlayIndex = false;
- /**
- * @param string $name
- * @param array $arguments
- * @param bool $cache
- * @param string $cacheName
- * @return $this
- */
- public function obj($name, $arguments = [], $cache = false, $cacheName = null)
- {
+ // $Up and $Top need to restore the overlay from the parent and top-level scope respectively.
switch ($name) {
case 'Up':
- if ($this->upIndex === null) {
+ $upIndex = $this->getUpIndex();
+ if ($upIndex === null) {
throw new \LogicException('Up called when we\'re already at the top of the scope');
}
-
+ $overlayIndex = $upIndex; // Parent scope
+ $this->preserveOverlay = true; // Preserve overlay
list(
$this->item,
$this->itemIterator,
@@ -224,6 +248,8 @@ class SSViewer_Scope
) = $this->itemStack[$this->upIndex];
break;
case 'Top':
+ $overlayIndex = 0; // Top-level scope
+ $this->preserveOverlay = true; // Preserve overlay
list(
$this->item,
$this->itemIterator,
@@ -234,13 +260,21 @@ class SSViewer_Scope
) = $this->itemStack[0];
break;
default:
- $this->item = $this->getObj($name, $arguments, $cache, $cacheName);
+ $this->preserveOverlay = false;
+ $this->item = $this->getObj($name, $arguments, $type, $cache, $cacheName);
$this->itemIterator = null;
$this->upIndex = $this->currentIndex ? $this->currentIndex : count($this->itemStack) - 1;
$this->currentIndex = count($this->itemStack);
break;
}
+ if ($overlayIndex !== false) {
+ $itemStack = $this->getItemStack();
+ if (!$this->overlay && isset($itemStack[$overlayIndex][SSViewer_Scope::ITEM_OVERLAY])) {
+ $this->overlay = $itemStack[$overlayIndex][SSViewer_Scope::ITEM_OVERLAY];
+ }
+ }
+
$this->itemStack[] = [
$this->item,
$this->itemIterator,
@@ -254,12 +288,11 @@ class SSViewer_Scope
/**
* Gets the current object and resets the scope.
- *
- * @return object
+ * @TODO: Replace with $Me
*/
- public function self()
+ public function self(): ?ViewLayerData
{
- $result = $this->getItem();
+ $result = $this->getCurrentItem();
$this->resetLocalScope();
return $result;
@@ -268,9 +301,13 @@ class SSViewer_Scope
/**
* Jump to the last item in the stack, called when a new item is added before a loop/with
*
- * @return SSViewer_Scope
+ * Store the current overlay (as it doesn't directly apply to the new scope
+ * that's being pushed). We want to store the overlay against the next item
+ * "up" in the stack (hence upIndex), rather than the current item, because
+ * SSViewer_Scope::obj() has already been called and pushed the new item to
+ * the stack by this point
*/
- public function pushScope()
+ public function pushScope(): static
{
$newLocalIndex = count($this->itemStack ?? []) - 1;
@@ -284,16 +321,38 @@ class SSViewer_Scope
// once we enter a new global scope, we need to make sure we use a new one
$this->itemIterator = $this->itemStack[$newLocalIndex][SSViewer_Scope::ITEM_ITERATOR] = null;
+ $upIndex = $this->getUpIndex() ?: 0;
+
+ $itemStack = $this->getItemStack();
+ $itemStack[$upIndex][SSViewer_Scope::ITEM_OVERLAY] = $this->overlay;
+ $this->setItemStack($itemStack);
+
+ // Remove the overlay when we're changing to a new scope, as values in
+ // that scope take priority. The exceptions that set this flag are $Up
+ // and $Top as they require that the new scope inherits the overlay
+ if (!$this->preserveOverlay) {
+ $this->overlay = [];
+ }
+
return $this;
}
/**
* Jump back to "previous" item in the stack, called after a loop/with block
*
- * @return SSViewer_Scope
+ * Now that we're going to jump up an item in the item stack, we need to
+ * restore the overlay that was previously stored against the next item "up"
+ * in the stack from the current one
*/
- public function popScope()
+ public function popScope(): static
{
+ $upIndex = $this->getUpIndex();
+
+ if ($upIndex !== null) {
+ $itemStack = $this->getItemStack();
+ $this->overlay = $itemStack[$upIndex][SSViewer_Scope::ITEM_OVERLAY];
+ }
+
$this->localIndex = $this->popIndex;
$this->resetLocalScope();
@@ -301,11 +360,10 @@ class SSViewer_Scope
}
/**
- * Fast-forwards the current iterator to the next item
- *
- * @return mixed
+ * Fast-forwards the current iterator to the next item.
+ * @return bool True if there's an item, false if not.
*/
- public function next()
+ public function next(): bool
{
if (!$this->item) {
return false;
@@ -349,23 +407,124 @@ class SSViewer_Scope
return false;
}
- return $this->itemIterator->key();
+ return true;
}
/**
- * @param string $name
- * @param array $arguments
- * @return mixed
+ * Get the value that will be directly rendered in the template.
*/
- public function __call($name, $arguments)
+ public function getOutputValue(string $name, array $arguments = [], string $type = '', bool $cache = false, ?string $cacheName = null): string
{
- $on = $this->getItem();
- $retval = $on ? $on->$name(...$arguments) : null;
+ $retval = $this->getObj($name, $arguments, $type, $cache, $cacheName);
+ $this->resetLocalScope();
+ return $retval === null ? '' : $retval->__toString();
+ }
+
+ /**
+ * Get the value to pass as an argument to a method.
+ */
+ public function getValueAsArgument(string $name, array $arguments = [], string $type = '', bool $cache = false, ?string $cacheName = null): mixed
+ {
+ $retval = null;
+
+ if ($this->hasOverlay($name)) {
+ $retval = $this->getOverlay($name, $arguments, true);
+ } else {
+ // @TODO caching
+ $on = $this->getCurrentItem();
+ if ($on && isset($on->$name)) {
+ $retval = $on->getRawDataValue($name, $type, $arguments);
+ }
+
+ if ($retval === null) {
+ $retval = $this->getUnderlay($name, $arguments, true);
+ }
+ }
+
+ if ($retval instanceof DBField) {
+ $retval = $retval->getValue(); // Workaround because we're still calling obj in ViewLayerData
+ }
$this->resetLocalScope();
return $retval;
}
+ /**
+ * Check if the current item in scope has a value for the named field.
+ */
+ public function hasValue(string $name, array $arguments = [], string $type = '', bool $cache = false, ?string $cacheName = null): 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;
+ $overlay = $this->getOverlay($name, $arguments);
+ if ($overlay && $overlay->hasDataValue()) {
+ $retval = true;
+ }
+
+ if ($retval === null) {
+ $on = $this->getCurrentItem();
+ if ($on) {
+ $retval = $on->hasDataValue($name, $arguments);
+ }
+ }
+
+ if (!$retval) {
+ $underlay = $this->getUnderlay($name, $arguments);
+ $retval = $underlay && $underlay->hasDataValue();
+ }
+
+ $this->resetLocalScope();
+ return $retval;
+ }
+
+ /**
+ * @var string $interfaceToQuery
+ * @var string $variableMethod
+ * @var boolean $createObject
+ * @return array
+ */
+ protected function getPropertiesFromProvider($interfaceToQuery, $variableMethod, $createObject = false)
+ {
+ $implementors = ClassInfo::implementorsOf($interfaceToQuery);
+ if ($implementors) {
+ foreach ($implementors as $implementor) {
+ // Create a new instance of the object for method calls
+ if ($createObject) {
+ $implementor = new $implementor();
+ $exposedVariables = $implementor->$variableMethod();
+ } else {
+ $exposedVariables = $implementor::$variableMethod();
+ }
+
+ foreach ($exposedVariables as $varName => $details) {
+ if (!is_array($details)) {
+ $details = ['method' => $details];
+ }
+
+ // If just a value (and not a key => value pair), use method name for both key and value
+ if (is_numeric($varName)) {
+ $varName = $details['method'];
+ }
+
+ // Add in a reference to the implementing class (might be a string class name or an instance)
+ $details['implementor'] = $implementor;
+
+ // And a callable array
+ if (isset($details['method'])) {
+ $details['callable'] = [$implementor, $details['method']];
+ }
+
+ // Save with both uppercase & lowercase first letter, so either works
+ $lcFirst = strtolower($varName[0] ?? '') . substr($varName ?? '', 1);
+ $result[$lcFirst] = $details;
+ $result[ucfirst($varName)] = $details;
+ }
+ }
+ }
+
+ return $result;
+ }
+
/**
* @return array
*/
@@ -390,13 +549,175 @@ class SSViewer_Scope
return $this->upIndex;
}
- private function convertScalarToDBField(bool|string|float|int $value): DBField
+ /**
+ * Evaluate a template override. Returns an array where the presence of
+ * a 'value' key indiciates whether an override was successfully found,
+ * as null is a valid override value
+ *
+ * @param string $property Name of override requested
+ * @param array $overrides List of overrides available
+ * @return array An array with a 'value' key if a value has been found, or empty if not
+ */
+ protected function processTemplateOverride($property, $overrides)
{
- return match (gettype($value)) {
- 'boolean' => DBBoolean::create()->setValue($value),
- 'string' => DBText::create()->setValue($value),
- 'double' => DBFloat::create()->setValue($value),
- 'integer' => DBInt::create()->setValue($value),
- };
+ if (!array_key_exists($property, $overrides)) {
+ return [];
+ }
+
+ // Detect override type
+ $override = $overrides[$property];
+
+ // Late-evaluate this value
+ if (!is_string($override) && is_callable($override)) {
+ $override = $override();
+
+ // Late override may yet return null
+ if (!isset($override)) {
+ return [];
+ }
+ }
+
+ return ['value' => $override];
+ }
+
+ /**
+ * Build cache of global properties
+ */
+ protected function cacheGlobalProperties()
+ {
+ if (SSViewer_Scope::$globalProperties !== null) {
+ return;
+ }
+
+ SSViewer_Scope::$globalProperties = $this->getPropertiesFromProvider(
+ TemplateGlobalProvider::class,
+ 'get_template_global_variables'
+ );
+ }
+
+ /**
+ * Build cache of global iterator properties
+ */
+ protected function cacheIteratorProperties()
+ {
+ if (SSViewer_Scope::$iteratorProperties !== null) {
+ return;
+ }
+
+ SSViewer_Scope::$iteratorProperties = $this->getPropertiesFromProvider(
+ TemplateIteratorProvider::class,
+ 'get_template_iterator_variables',
+ true // Call non-statically
+ );
+ }
+
+ private function getObj(string $name, array $arguments = [], string $type = '', bool $cache = false, ?string $cacheName = null): ?ViewLayerData
+ {
+ if ($this->hasOverlay($name)) {
+ return $this->getOverlay($name, $arguments);
+ }
+
+ // @TODO caching
+ $on = $this->getCurrentItem();
+ if ($on && isset($on->$name)) {
+ if ($type === ViewLayerData::TYPE_METHOD) {
+ return $on->$name(...$arguments);
+ }
+ // property
+ return $on->$name;
+ }
+
+ return $this->getUnderlay($name, $arguments);
+ }
+
+ private function hasOverlay(string $property): bool
+ {
+ $result = $this->processTemplateOverride($property, $this->overlay);
+ return array_key_exists('value', $result);
+ }
+
+ private function getOverlay(string $property, array $args, bool $getRaw = false): mixed
+ {
+ $result = $this->processTemplateOverride($property, $this->overlay);
+ if (array_key_exists('value', $result)) {
+ return $this->getInjectedValue($result, $property, $args, $getRaw);
+ }
+ return null;
+ }
+
+ private function getUnderlay(string $property, array $args, bool $getRaw = false): mixed
+ {
+ // Check for a presenter-specific override
+ $result = $this->processTemplateOverride($property, $this->underlay);
+ if (array_key_exists('value', $result)) {
+ return $this->getInjectedValue($result, $property, $args, $getRaw);
+ }
+
+ // Then for iterator-specific overrides
+ if (array_key_exists($property, SSViewer_Scope::$iteratorProperties)) {
+ $source = SSViewer_Scope::$iteratorProperties[$property];
+ /** @var TemplateIteratorProvider $implementor */
+ $implementor = $source['implementor'];
+ if ($this->itemIterator) {
+ // Set the current iterator position and total (the object instance is the first item in
+ // the callable array)
+ $implementor->iteratorProperties(
+ $this->itemIterator->key(),
+ $this->itemIteratorTotal
+ );
+ } else {
+ // If we don't actually have an iterator at the moment, act like a list of length 1
+ $implementor->iteratorProperties(0, 1);
+ }
+
+ return $this->getInjectedValue($source, $property, $args, $getRaw);
+ }
+
+ // And finally for global overrides
+ if (array_key_exists($property, SSViewer_Scope::$globalProperties)) {
+ return $this->getInjectedValue(
+ SSViewer_Scope::$globalProperties[$property],
+ $property,
+ $args,
+ $getRaw
+ );
+ }
+
+ return null;
+ }
+
+ private function getInjectedValue(
+ array|TemplateGlobalProvider|TemplateIteratorProvider $source,
+ string $property,
+ array $params,
+ bool $getRaw = false
+ ) {
+ // Look up the value - either from a callable, or from a directly provided value
+ $value = null;
+ if (isset($source['callable'])) {
+ $value = $source['callable'](...$params);
+ } elseif (array_key_exists('value', $source)) {
+ $value = $source['value'];
+ } else {
+ throw new InvalidArgumentException(
+ "Injected property $property doesn't have a value or callable value source provided"
+ );
+ }
+
+ if ($value === null) {
+ return null;
+ }
+
+ // TemplateGlobalProviders can provide an explicit service to cast to which works outside of the regular cast flow
+ if (!$getRaw && isset($source['casting'])) {
+ $castObject = Injector::inst()->create($source['casting'], $property);
+ if (!ClassInfo::hasMethod($castObject, 'setValue')) {
+ throw new LogicException('Explicit cast from template global provider must have a setValue method.');
+ }
+ $castObject->setValue($value);
+ $value = $castObject;
+ }
+
+ return $getRaw ? $value : ViewLayerData::create($value);
}
}
diff --git a/src/View/ViewLayerData.php b/src/View/ViewLayerData.php
new file mode 100644
index 000000000..b903de47f
--- /dev/null
+++ b/src/View/ViewLayerData.php
@@ -0,0 +1,147 @@
+data;
+ } else {
+ $data = CastingService::singleton()->cast($data, $source, $name);
+ }
+ $this->data = $data;
+ }
+
+ /**
+ * 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
+ {
+ if (is_countable($this->data)) {
+ return count($this->data);
+ }
+ if (ClassInfo::hasMethod($this->data, 'getIterator')) {
+ return count($this->data->getIterator());
+ }
+ if (ClassInfo::hasMethod($this->data, 'count')) {
+ return $this->data->count();
+ }
+ if (isset($this->data->count)) {
+ return $this->data->count;
+ }
+ return 0;
+ }
+
+ public function getIterator(): Traversable
+ {
+ if (!is_iterable($this->data) && !ClassInfo::hasMethod($this->data, 'getIterator')) {
+ $type = is_object($this->data) ? get_class($this->data) : gettype($this->data);
+ throw new BadMethodCallException("$type is not iterable.");
+ }
+
+ $iterator = $this->data;
+ if (!is_iterable($iterator)) {
+ $iterator = $this->data->getIterator();
+ }
+ $source = $this->data instanceof ModelData ? $this->data : null;
+ foreach ($iterator as $item) {
+ yield $item === null ? null : ViewLayerData::create($item, $source);
+ }
+ }
+
+ public function __isset(string $name): bool
+ {
+ // Might be worth reintroducing the way ss template engine checks if lists/countables "exist" here,
+ // i.e. if ($this->data->__isset($name) && is_countable($this->data->{$name})) { return count($this->data->{$name}) > 0; }
+ // In worst-case scenarios that would result in lazy-loading a value when we don't need to, but we already do that with the current system.
+
+ // The SS template system uses `ModelData::hasValue()` rather than isset(), but using that doesn't check for methods and we can't use
+ // method_exists on ViewLayerData because the method just simply DOESN'T exist.... so. Hmm.
+ // UPDATE: Added ClassInfo::hasMethod here to simulate what ModelData does... will still have to check if it works with twig
+ // Removing method_exists check in scope for now.
+ return isset($this->data->$name) || ClassInfo::hasMethod($this->data, $name);
+ }
+
+ public function __get(string $name): ?ViewLayerData
+ {
+ $value = $this->getRawDataValue($name, ViewLayerData::TYPE_PROPERTY);
+ $source = $this->data instanceof ModelData ? $this->data : null;
+ return ViewLayerData::create($value, $source, $name); // @TODO maybe not return this here, but wrap it again in the next layer? This may not play nicely with twig when passing values into args?
+ }
+
+ public function __call(string $name, array $arguments = []): ?ViewLayerData
+ {
+ $value = $this->getRawDataValue($name, ViewLayerData::TYPE_METHOD, $arguments);
+ $source = $this->data instanceof ModelData ? $this->data : null;
+ return ViewLayerData::create($value, $source, $name); // @TODO maybe not return this here, but wrap it again in the next layer? This may not play nicely with twig when passing values into args?
+ }
+
+ public function __toString(): string
+ {
+ if ($this->data instanceof ModelData) {
+ return $this->data->forTemplate();
+ }
+ return (string) $this->data;
+ }
+
+ // @TODO We need this right now for the ss template engine, but need to check if
+ // we can rely on it, since twig won't be calling this at all
+ public function hasDataValue(?string $name = null, array $arguments = []): bool
+ {
+ if ($name) {
+ if ($this->data instanceof ModelData) {
+ return $this->data->hasValue($name, $arguments);
+ }
+ return isset($this->$name);
+ }
+ if ($this->data instanceof ModelData) {
+ return $this->data->exists();
+ }
+ return (bool) $this->data;
+ }
+
+ // @TODO We need this right now for the ss template engine, 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, array $arguments = []): mixed
+ {
+ if ($type === ViewLayerData::TYPE_PROPERTY) {
+ if ($this->data instanceof ModelData) { // temporary while I move things across.
+ return $this->data->obj($name);
+ }
+ return isset($this->data->$name) ? $this->data->$name : null;
+ }
+ if ($type === ViewLayerData::TYPE_METHOD) {
+ // If it's not a property it's a method
+ if ($this->data instanceof ModelData) { // temporary while I move things across.
+ return $this->data->obj($name, $arguments);
+ } elseif (ClassInfo::hasMethod($this->data, $name) || method_exists($this->data, '__call')) {
+ return $this->data->$name(...$arguments);
+ }
+ }
+
+ throw new InvalidArgumentException('$type must be one of the TYPE_* constant values');
+ }
+}
diff --git a/tests/php/Forms/TreeDropdownFieldTest.php b/tests/php/Forms/TreeDropdownFieldTest.php
index 83e091f89..012b22b9a 100644
--- a/tests/php/Forms/TreeDropdownFieldTest.php
+++ b/tests/php/Forms/TreeDropdownFieldTest.php
@@ -314,7 +314,7 @@ class TreeDropdownFieldTest extends SapphireTest
$noResult = $parser->getBySelector($cssPath);
$this->assertEmpty(
$noResult,
- $subObject2 . ' is not found'
+ get_class($subObject2) . ' is not found'
);
}
diff --git a/tests/php/Model/ModelDataTest.php b/tests/php/Model/ModelDataTest.php
index 33f4b171d..f3e910b77 100644
--- a/tests/php/Model/ModelDataTest.php
+++ b/tests/php/Model/ModelDataTest.php
@@ -122,7 +122,7 @@ class ModelDataTest extends SapphireTest
$this->assertEquals('casted', $newModelData->XML_val('alwaysCasted'));
$this->assertEquals('castable', $modelData->forTemplate());
- $this->assertEquals('casted', $newModelData->forTemplate());
+ $this->assertEquals('castable', $newModelData->forTemplate());
}
public function testDefaultValueWrapping()
@@ -139,25 +139,6 @@ class ModelDataTest extends SapphireTest
$this->assertEquals('SomeTitleValue', $obj->forTemplate());
}
- public function testCastingClass()
- {
- $expected = [
- //'NonExistant' => null,
- 'Field' => 'CastingType',
- 'Argument' => 'ArgumentType',
- 'ArrayArgument' => 'ArrayArgumentType'
- ];
- $obj = new ModelDataTest\CastingClass();
-
- foreach ($expected as $field => $class) {
- $this->assertEquals(
- $class,
- $obj->castingClass($field),
- "castingClass() returns correct results for ::\$$field"
- );
- }
- }
-
public function testObjWithCachedStringValueReturnsValidObject()
{
$obj = new ModelDataTest\NoCastingInformation();
diff --git a/tests/php/ORM/Filters/EndsWithFilterTest.php b/tests/php/ORM/Filters/EndsWithFilterTest.php
index 40c69c0e2..907715d07 100644
--- a/tests/php/ORM/Filters/EndsWithFilterTest.php
+++ b/tests/php/ORM/Filters/EndsWithFilterTest.php
@@ -197,20 +197,6 @@ class EndsWithFilterTest extends SapphireTest
'modifiers' => [],
'matches' => false,
],
- // These will both evaluate to true because the __toString() method just returns the class name.
- // We're testing this scenario because ArrayList might contain arbitrary values
- [
- 'filterValue' => new ArrayData(['SomeField' => 'some value']),
- 'matchValue' => new ArrayData(['SomeField' => 'some value']),
- 'modifiers' => [],
- 'matches' => true,
- ],
- [
- 'filterValue' => new ArrayData(['SomeField' => 'SoMe VaLuE']),
- 'matchValue' => new ArrayData(['SomeField' => 'some value']),
- 'modifiers' => [],
- 'matches' => true,
- ],
// case insensitive
[
'filterValue' => 'somevalue',
diff --git a/tests/php/ORM/Filters/PartialMatchFilterTest.php b/tests/php/ORM/Filters/PartialMatchFilterTest.php
index 7d11ebe7c..8a3d5fdaf 100644
--- a/tests/php/ORM/Filters/PartialMatchFilterTest.php
+++ b/tests/php/ORM/Filters/PartialMatchFilterTest.php
@@ -197,20 +197,6 @@ class PartialMatchFilterTest extends SapphireTest
'modifiers' => [],
'matches' => false,
],
- // These will both evaluate to true because the __toString() method just returns the class name.
- // We're testing this scenario because ArrayList might contain arbitrary values
- [
- 'filterValue' => new ArrayData(['SomeField' => 'some value']),
- 'matchValue' => new ArrayData(['SomeField' => 'some value']),
- 'modifiers' => [],
- 'matches' => true,
- ],
- [
- 'filterValue' => new ArrayData(['SomeField' => 'SoMe VaLuE']),
- 'matchValue' => new ArrayData(['SomeField' => 'some value']),
- 'modifiers' => [],
- 'matches' => true,
- ],
// case insensitive
[
'filterValue' => 'somevalue',
diff --git a/tests/php/ORM/Filters/StartsWithFilterTest.php b/tests/php/ORM/Filters/StartsWithFilterTest.php
index 32e2050ff..66a2d8b16 100644
--- a/tests/php/ORM/Filters/StartsWithFilterTest.php
+++ b/tests/php/ORM/Filters/StartsWithFilterTest.php
@@ -197,20 +197,6 @@ class StartsWithFilterTest extends SapphireTest
'modifiers' => [],
'matches' => false,
],
- // These will both evaluate to true because the __toString() method just returns the class name.
- // We're testing this scenario because ArrayList might contain arbitrary values
- [
- 'filterValue' => new ArrayData(['SomeField' => 'some value']),
- 'matchValue' => new ArrayData(['SomeField' => 'some value']),
- 'modifiers' => [],
- 'matches' => true,
- ],
- [
- 'filterValue' => new ArrayData(['SomeField' => 'SoMe VaLuE']),
- 'matchValue' => new ArrayData(['SomeField' => 'some value']),
- 'modifiers' => [],
- 'matches' => true,
- ],
// case insensitive
[
'filterValue' => 'somevalue',
diff --git a/tests/php/View/SSViewerTest.php b/tests/php/View/SSViewerTest.php
index d9de2385f..881010f5a 100644
--- a/tests/php/View/SSViewerTest.php
+++ b/tests/php/View/SSViewerTest.php
@@ -360,8 +360,9 @@ SS;
'z
[out:Arg1]
[out:Arg2]
[out:Arg2.Count]
' + '[out:Arg1]
[out:Arg2]
[out:Arg2.Count]
', + $this->render('<% include SSViewerTestIncludeWithArguments %>') ); $this->assertEquals( - $this->render('<% include SSViewerTestIncludeWithArguments Arg1=A %>'), - 'A
[out:Arg2]
[out:Arg2.Count]
' + 'A
[out:Arg2]
[out:Arg2.Count]
', + $this->render('<% include SSViewerTestIncludeWithArguments Arg1=A %>') ); $this->assertEquals( - $this->render('<% include SSViewerTestIncludeWithArguments Arg1=A, Arg2=B %>'), - 'A
B
' + 'A
B
', + $this->render('<% include SSViewerTestIncludeWithArguments Arg1=A, Arg2=B %>') ); $this->assertEquals( - $this->render('<% include SSViewerTestIncludeWithArguments Arg1=A Bare String, Arg2=B Bare String %>'), - 'A Bare String
B Bare String
' + 'A Bare String
B Bare String
', + $this->render('<% include SSViewerTestIncludeWithArguments Arg1=A Bare String, Arg2=B Bare String %>') ); $this->assertEquals( + 'A
Bar
', $this->render( '<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=$B %>', new ArrayData(['B' => 'Bar']) - ), - 'A
Bar
' + ) ); $this->assertEquals( + 'A
Bar
', $this->render( '<% include SSViewerTestIncludeWithArguments Arg1="A" %>', new ArrayData(['Arg1' => 'Foo', 'Arg2' => 'Bar']) - ), - 'A
Bar
' + ) ); $this->assertEquals( - $this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=0 %>'), - 'A
0
' + 'A
0
', + $this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=0 %>') ); $this->assertEquals( - $this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=false %>'), - 'A
' + 'A
', + $this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=false %>') ); $this->assertEquals( - $this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=null %>'), - 'A
' + 'A
', + // Note Arg2 is explicitly overridden with null + $this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=null %>') ); $this->assertEquals( + 'SomeArg - Foo - Bar - SomeArg', $this->render( '<% include SSViewerTestIncludeScopeInheritanceWithArgsInLoop Title="SomeArg" %>', new ArrayData( @@ -1179,19 +1182,19 @@ after' ] )] ) - ), - 'SomeArg - Foo - Bar - SomeArg' + ) ); $this->assertEquals( + 'A - B - A', $this->render( '<% include SSViewerTestIncludeScopeInheritanceWithArgsInWith Title="A" %>', new ArrayData(['Item' => new ArrayData(['Title' =>'B'])]) - ), - 'A - B - A' + ) ); $this->assertEquals( + 'A - B - C - B - A', $this->render( '<% include SSViewerTestIncludeScopeInheritanceWithArgsInNestedWith Title="A" %>', new ArrayData( @@ -1202,11 +1205,11 @@ after' ] )] ) - ), - 'A - B - C - B - A' + ) ); $this->assertEquals( + 'A - A - A', $this->render( '<% include SSViewerTestIncludeScopeInheritanceWithUpAndTop Title="A" %>', new ArrayData( @@ -1217,8 +1220,7 @@ after' ] )] ) - ), - 'A - A - A' + ) ); $data = new ArrayData( @@ -2202,7 +2204,66 @@ EOC; } } - public function testCallsWithArguments() + public static function provideCallsWithArguments(): array + { + return [ + [ + 'template' => '$Level.output(1)', + 'expected' => '1-1', + ], + [ + 'template' => '$Nest.Level.output($Set.First.Number)', + 'expected' => '2-1', + ], + [ + 'template' => '<% with $Set %>$Up.Level.output($First.Number)<% end_with %>', + 'expected' => '1-1', + ], + [ + 'template' => '<% with $Set %>$Top.Nest.Level.output($First.Number)<% end_with %>', + 'expected' => '2-1', + ], + [ + 'template' => '<% loop $Set %>$Up.Nest.Level.output($Number)<% end_loop %>', + 'expected' => '2-12-22-32-42-5', + ], + [ + 'template' => '<% loop $Set %>$Top.Level.output($Number)<% end_loop %>', + 'expected' => '1-11-21-31-41-5', + ], + [ + 'template' => '<% with $Nest %>$Level.output($Top.Set.First.Number)<% end_with %>', + 'expected' => '2-1', + ], + [ + 'template' => '<% with $Level %>$output($Up.Set.Last.Number)<% end_with %>', + 'expected' => '1-5', + ], + [ + 'template' => '<% with $Level.forWith($Set.Last.Number) %>$output("hi")<% end_with %>', + 'expected' => '5-hi', + ], + [ + 'template' => '<% loop $Level.forLoop($Set.First.Number) %>$Number<% end_loop %>', + 'expected' => '!0', + ], + [ + 'template' => '<% with $Nest %> + <% with $Level.forWith($Up.Set.First.Number) %>$output("hi")<% end_with %> + <% end_with %>', + 'expected' => '1-hi', + ], + [ + 'template' => '<% with $Nest %> + <% loop $Level.forLoop($Top.Set.Last.Number) %>$Number<% end_loop %> + <% end_with %>', + 'expected' => '!0!1!2!3!4', + ], + ]; + } + + #[DataProvider('provideCallsWithArguments')] + public function testCallsWithArguments(string $template, string $expected): void { $data = new ArrayData( [ @@ -2222,28 +2283,7 @@ EOC; ] ); - $tests = [ - '$Level.output(1)' => '1-1', - '$Nest.Level.output($Set.First.Number)' => '2-1', - '<% with $Set %>$Up.Level.output($First.Number)<% end_with %>' => '1-1', - '<% with $Set %>$Top.Nest.Level.output($First.Number)<% end_with %>' => '2-1', - '<% loop $Set %>$Up.Nest.Level.output($Number)<% end_loop %>' => '2-12-22-32-42-5', - '<% loop $Set %>$Top.Level.output($Number)<% end_loop %>' => '1-11-21-31-41-5', - '<% with $Nest %>$Level.output($Top.Set.First.Number)<% end_with %>' => '2-1', - '<% with $Level %>$output($Up.Set.Last.Number)<% end_with %>' => '1-5', - '<% with $Level.forWith($Set.Last.Number) %>$output("hi")<% end_with %>' => '5-hi', - '<% loop $Level.forLoop($Set.First.Number) %>$Number<% end_loop %>' => '!0', - '<% with $Nest %> - <% with $Level.forWith($Up.Set.First.Number) %>$output("hi")<% end_with %> - <% end_with %>' => '1-hi', - '<% with $Nest %> - <% loop $Level.forLoop($Top.Set.Last.Number) %>$Number<% end_loop %> - <% end_with %>' => '!0!1!2!3!4', - ]; - - foreach ($tests as $template => $expected) { - $this->assertEquals($expected, trim($this->render($template, $data) ?? '')); - } + $this->assertEquals($expected, trim($this->render($template, $data) ?? '')); } public function testRepeatedCallsAreCached() @@ -2360,7 +2400,7 @@ EOC; public function testMe(): void { $myArrayData = new class extends ArrayData { - public function forTemplate() + public function forTemplate(): string { return ''; } diff --git a/tests/php/View/SSViewerTest/TestFixture.php b/tests/php/View/SSViewerTest/TestFixture.php index f0abb39bc..bdb97fe3b 100644 --- a/tests/php/View/SSViewerTest/TestFixture.php +++ b/tests/php/View/SSViewerTest/TestFixture.php @@ -2,23 +2,78 @@ namespace SilverStripe\View\Tests\SSViewerTest; -use SilverStripe\Model\List\ArrayList; -use SilverStripe\Model\ModelData; +use ReflectionClass; +use SilverStripe\Dev\TestOnly; +use SilverStripe\View\SSViewer_Scope; +use Stringable; /** * A test fixture that will echo back the template item */ -class TestFixture extends ModelData +class TestFixture implements TestOnly, Stringable { - protected $name; + private ?string $name; public function __construct($name = null) { $this->name = $name; - parent::__construct(); } - private function argedName($fieldName, $arguments) + public function __call(string $name, array $arguments = []): static|array|null + { + return $this->getValue($name, $arguments); + } + + public function __get(string $name): static|array|null + { + return $this->getValue($name); + } + + public function __isset(string $name): bool + { + if (preg_match('/NotSet/i', $name)) { + return false; + } + $reflectionScope = new ReflectionClass(SSViewer_Scope::class); + $globalProperties = $reflectionScope->getStaticPropertyValue('globalProperties'); + if (array_key_exists($name, $globalProperties)) { + return false; + } + return true; + } + + public function __toString(): string + { + if (preg_match('/NotSet/i', $this->name ?? '')) { + return ''; + } + if (preg_match('/Raw/i', $this->name ?? '')) { + return $this->name ?? ''; + } + return '[out:' . $this->name . ']'; + } + + private function getValue(string $name, array $arguments = []): static|array|null + { + $childName = $this->argedName($name, $arguments); + + // Special field name Loop### to create a list + if (preg_match('/^Loop([0-9]+)$/', $name ?? '', $matches)) { + $output = []; + for ($i = 0; $i < $matches[1]; $i++) { + $output[] = new TestFixture($childName); + } + return $output; + } + + if (preg_match('/NotSet/i', $name)) { + return null; + } + + return new TestFixture($childName); + } + + private function argedName(string $fieldName, array $arguments): string { $childName = $this->name ? "$this->name.$fieldName" : $fieldName; if ($arguments) { @@ -27,46 +82,4 @@ class TestFixture extends ModelData return $childName; } } - - public function obj( - string $fieldName, - array $arguments = [], - bool $cache = false, - ?string $cacheName = null - ): ?object { - $childName = $this->argedName($fieldName, $arguments); - - // Special field name Loop### to create a list - if (preg_match('/^Loop([0-9]+)$/', $fieldName ?? '', $matches)) { - $output = new ArrayList(); - for ($i = 0; $i < $matches[1]; $i++) { - $output->push(new TestFixture($childName)); - } - return $output; - } else { - if (preg_match('/NotSet/i', $fieldName ?? '')) { - return new ModelData(); - } else { - return new TestFixture($childName); - } - } - } - - public function XML_val(string $fieldName, array $arguments = [], bool $cache = false): string - { - if (preg_match('/NotSet/i', $fieldName ?? '')) { - return ''; - } else { - if (preg_match('/Raw/i', $fieldName ?? '')) { - return $fieldName; - } else { - return '[out:' . $this->argedName($fieldName, $arguments) . ']'; - } - } - } - - public function hasValue(string $fieldName, array $arguments = [], bool $cache = true): bool - { - return (bool)$this->XML_val($fieldName, $arguments); - } } diff --git a/tests/php/i18n/i18nTestManifest.php b/tests/php/i18n/i18nTestManifest.php index d2fd62a13..2a8bcfb9e 100644 --- a/tests/php/i18n/i18nTestManifest.php +++ b/tests/php/i18n/i18nTestManifest.php @@ -17,10 +17,10 @@ use SilverStripe\i18n\Tests\i18nTest\MyObject; use SilverStripe\i18n\Tests\i18nTest\MySubObject; use SilverStripe\i18n\Tests\i18nTest\TestDataObject; use SilverStripe\View\SSViewer; -use SilverStripe\View\SSViewer_DataPresenter; use SilverStripe\View\ThemeResourceLoader; use SilverStripe\View\ThemeManifest; use SilverStripe\Model\ModelData; +use SilverStripe\View\SSViewer_Scope; use Symfony\Component\Translation\Loader\ArrayLoader; use Symfony\Component\Translation\Translator; @@ -71,9 +71,9 @@ trait i18nTestManifest public function setupManifest() { - // force SSViewer_DataPresenter 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) - $presenter = new SSViewer_DataPresenter(new ModelData()); + $presenter = new SSViewer_Scope(new ModelData()); unset($presenter); // Switch to test manifest