From 98198a5983fde390272ac96fab8b141a183a5b5c Mon Sep 17 00:00:00 2001 From: Steve Boyd Date: Tue, 24 Sep 2024 18:14:32 +1200 Subject: [PATCH] NEW DBField validation --- _config/model.yml | 2 + src/Forms/EmailField.php | 25 +- src/Forms/FormField.php | 17 +- src/Forms/FormRequestHandler.php | 8 +- src/Forms/GridField/GridField.php | 2 + src/Forms/TextField.php | 4 + src/Model/ModelData.php | 31 ++- src/Model/ModelFields/ModelField.php | 242 ++++++++++++++++ src/Model/ModelFields/ModelFieldTrait.php | 37 +++ src/Model/ModelFields/StringModelField.php | 142 ++++++++++ src/ORM/DataObject.php | 48 +++- src/ORM/FieldType/DBDate.php | 1 + src/ORM/FieldType/DBEmail.php | 43 +++ src/ORM/FieldType/DBField.php | 306 +++++---------------- src/ORM/FieldType/DBString.php | 134 +-------- 15 files changed, 634 insertions(+), 408 deletions(-) create mode 100644 src/Model/ModelFields/ModelField.php create mode 100644 src/Model/ModelFields/ModelFieldTrait.php create mode 100644 src/Model/ModelFields/StringModelField.php create mode 100644 src/ORM/FieldType/DBEmail.php diff --git a/_config/model.yml b/_config/model.yml index 4046feddb65..8e6840244c6 100644 --- a/_config/model.yml +++ b/_config/model.yml @@ -20,6 +20,8 @@ SilverStripe\Core\Injector\Injector: class: SilverStripe\ORM\FieldType\DBDecimal Double: class: SilverStripe\ORM\FieldType\DBDouble + Email: + class: SilverStripe\ORM\FieldType\DBEmail Enum: class: SilverStripe\ORM\FieldType\DBEnum Float: diff --git a/src/Forms/EmailField.php b/src/Forms/EmailField.php index 68ea66d4199..46efac9f777 100644 --- a/src/Forms/EmailField.php +++ b/src/Forms/EmailField.php @@ -2,6 +2,9 @@ namespace SilverStripe\Forms; +use SilverStripe\Core\Validation\ConstraintValidator; +use Symfony\Component\Validator\Constraints; + /** * Text input field with validation for correct email format according to RFC 2822. */ @@ -18,32 +21,26 @@ public function Type() } /** - * Validates for RFC 2822 compliant email addresses. - * - * @see http://www.regular-expressions.info/email.html - * @see http://www.ietf.org/rfc/rfc2822.txt - * * @param Validator $validator * * @return string */ public function validate($validator) { - $result = true; $this->value = trim($this->value ?? ''); - $pattern = '^[a-z0-9!#$%&\'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&\'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$'; - - // Escape delimiter characters. - $safePattern = str_replace('/', '\\/', $pattern ?? ''); + $result = true; + $message = _t('SilverStripe\\Forms\\EmailField.VALIDATION', 'Please enter an email address'); + $validationResult = ConstraintValidator::validate( + $this->value, + new Constraints\Email(message: $message) + ); - if ($this->value && !preg_match('/' . $safePattern . '/i', $this->value ?? '')) { + if (!$validationResult->isValid()) { $validator->validationError( $this->name, - _t('SilverStripe\\Forms\\EmailField.VALIDATION', 'Please enter an email address'), - 'validation' + $validationResult->getMessages()[0]['message'], ); - $result = false; } diff --git a/src/Forms/FormField.php b/src/Forms/FormField.php index 0d210436b0f..97a613697b1 100644 --- a/src/Forms/FormField.php +++ b/src/Forms/FormField.php @@ -15,6 +15,9 @@ use SilverStripe\View\AttributesHTML; use SilverStripe\View\SSViewer; use SilverStripe\Model\ModelData; +use SilverStripe\Model\ModelFields\ModelField; +use SilverStripe\Model\ModelFields\ModelFieldTrait; +use SilverStripe\Model\ModelFields\StringModelField; /** * Represents a field in a form. @@ -40,10 +43,11 @@ * including both structure (name, id, attributes, etc.) and state (field value). * Can be used by for JSON data which is consumed by a front-end application. */ -class FormField extends RequestHandler +abstract class FormField extends RequestHandler { use AttributesHTML; use FormMessage; + use ModelFieldTrait; /** @see $schemaDataType */ const SCHEMA_DATA_TYPE_STRING = 'String'; @@ -87,6 +91,9 @@ class FormField extends RequestHandler /** @see $schemaDataType */ const SCHEMA_DATA_TYPE_STRUCTURAL = 'Structural'; + // TODO: this is wrong, though haven't yet created things like DateTimeModelField + protected string $modelFieldClass = StringModelField::class; + /** * @var Form */ @@ -344,6 +351,7 @@ public function __construct($name, $title = null, $value = null) parent::__construct(); + $this->initModelField($name); $this->setupDefaultClasses(); } @@ -929,7 +937,7 @@ public function Field($properties = []) // Trim whitespace from the result, so that trailing newlines are suppressed. Works for strings and HTMLText values if (is_string($result)) { $result = trim($result ?? ''); - } elseif ($result instanceof DBField) { + } elseif ($result instanceof ModelField) { $result->setValue(trim($result->getValue() ?? '')); } @@ -1231,9 +1239,12 @@ protected function extendValidationResult(bool $result, Validator $validator): b } /** - * Abstract method each {@link FormField} subclass must implement, determines whether the field + * Method each {@link FormField} subclass can implement, determines whether the field * is valid or not based on the value. * + * Subclass methods should call $this->extendValidationResult(true, $validator) + * at the end of the method + * * @param Validator $validator * @return bool */ diff --git a/src/Forms/FormRequestHandler.php b/src/Forms/FormRequestHandler.php index d2125881116..0ad7d60c24c 100644 --- a/src/Forms/FormRequestHandler.php +++ b/src/Forms/FormRequestHandler.php @@ -216,10 +216,10 @@ public function httpSubmission($request) // Action handlers may throw ValidationExceptions. try { // Or we can use the Validator attached to the form - $result = $this->form->validationResult(); - if (!$result->isValid()) { - return $this->getValidationErrorResponse($result); - } + // $result = $this->form->validationResult(); + // if (!$result->isValid()) { + // return $this->getValidationErrorResponse($result); + // } // First, try a handler method on the controller (has been checked for allowed_actions above already) $controller = $this->form->getController(); diff --git a/src/Forms/GridField/GridField.php b/src/Forms/GridField/GridField.php index 12b2522c087..1021770e3df 100644 --- a/src/Forms/GridField/GridField.php +++ b/src/Forms/GridField/GridField.php @@ -543,6 +543,8 @@ public function FieldHolder($properties = []) 'footer' => '', ]; + $_c = $this->getComponents(); + foreach ($this->getComponents() as $item) { if ($item instanceof GridField_HTMLProvider) { $fragments = $item->getHTMLFragments($this); diff --git a/src/Forms/TextField.php b/src/Forms/TextField.php index 49aa40b2c14..08ef5c26ef2 100644 --- a/src/Forms/TextField.php +++ b/src/Forms/TextField.php @@ -2,11 +2,15 @@ namespace SilverStripe\Forms; +use SilverStripe\Model\ModelFields\StringModelField; + /** * Text input field. */ class TextField extends FormField implements TippableFieldInterface { + protected string $modelFieldClass = StringModelField::class; + /** * @var int */ diff --git a/src/Model/ModelData.php b/src/Model/ModelData.php index 04d5a1fc027..e29776eb284 100644 --- a/src/Model/ModelData.php +++ b/src/Model/ModelData.php @@ -16,11 +16,12 @@ 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\SSViewer; use UnexpectedValueException; +use SilverStripe\Model\ModelField; +use SilverStripe\Core\Validation\ValidationResult; /** * A ModelData object is any object that can be rendered into a template/view. @@ -327,7 +328,7 @@ public function setCustomisedObj(ModelData $object) /** * Return the "casting helper" (a piece of PHP code that when evaluated creates a casted value object) - * for a field on this object. This helper will be a subclass of DBField. + * for a field on this object. This helper will be a subclass of ModelField. * * @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. @@ -361,7 +362,7 @@ public function castingHelper(string $field, bool $useFallback = true): ?string /** * Return the default "casting helper" for use when no explicit casting helper is defined. - * This helper will be a subclass of DBField. See castingHelper() + * This helper will be a subclass of ModelField. See castingHelper() */ protected function defaultCastingHelper(string $field): string { @@ -402,7 +403,7 @@ public function escapeTypeForField(string $field): string { $class = $this->castingClass($field) ?: $this->config()->get('default_cast'); - /** @var DBField $type */ + /** @var ModelField $type */ $type = Injector::inst()->get($class, true); return $type->config()->get('escape_type'); } @@ -495,9 +496,9 @@ protected function objCacheClear() * Get the value of a field on this object, automatically inserting the value into any available casting objects * that have been specified. * - * @return object|DBField|null The specific object representing the field, or null if there is no + * @return object|ModelField|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 + * Note that if there is a property or method that returns null, a relevant ModelField instance will * be returned. */ public function obj( @@ -520,7 +521,13 @@ public function obj( // Load value from record if ($this->hasMethod($fieldName)) { $hasObj = true; - $value = call_user_func_array([$this, $fieldName], $arguments ?: []); + // todo: remove try catch block + try { + $value = call_user_func_array([$this, $fieldName], $arguments ?: []); + } catch (Exception $e) { + $x=1; + throw $e; + } } else { $hasObj = $this->hasField($fieldName) || ($this->hasMethod("get{$fieldName}") && $this->isAccessibleMethod("get{$fieldName}")); $value = $this->$fieldName; @@ -568,7 +575,7 @@ public function obj( * 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 + * @return Object|ModelField */ public function cachedCall(string $fieldName, array $arguments = [], ?string $cacheName = null): object { @@ -599,7 +606,13 @@ public function XML_val(string $field, array $arguments = [], bool $cache = fals return ''; } // Might contain additional formatting over ->XML(). E.g. parse shortcodes, nl2br() - return $result->forTemplate(); + // todo: remove try catch block + try { + return $result->forTemplate(); + } catch (Exception $e) { + $x=1; + throw $e; + } } /** diff --git a/src/Model/ModelFields/ModelField.php b/src/Model/ModelFields/ModelField.php new file mode 100644 index 00000000000..e0bd291dc1f --- /dev/null +++ b/src/Model/ModelFields/ModelField.php @@ -0,0 +1,242 @@ + 'HTMLFragment', + 'CDATA' => 'HTMLFragment', + 'HTML' => 'HTMLFragment', + 'HTMLATT' => 'HTMLFragment', + 'JS' => 'HTMLFragment', + 'RAW' => 'HTMLFragment', + 'RAWURLATT' => 'HTMLFragment', + 'URLATT' => 'HTMLFragment', + 'XML' => 'HTMLFragment', + 'ProcessedRAW' => 'HTMLFragment', + ]; + + public function __construct(?string $name = null) + { + $this->name = $name; + parent::__construct(); + } + + public function setName(?string $name): static + { + $this->name = $name; + return $this; + } + + /** + * Returns the name of this field. + */ + public function getName(): string + { + return $this->name ?? ''; + } + + /** + * Returns the value of this field. + */ + public function getValue(): mixed + { + return $this->value; + } + + public function setValue(mixed $value): static + { + $this->value = $value; + return $this; + } + + /** + * Determine 'default' casting for this field. + */ + public function forTemplate(): string + { + // Default to XML encoding + return $this->XML(); + } + + /** + * Gets the value appropriate for a HTML attribute string + */ + public function HTMLATT(): string + { + return Convert::raw2htmlatt($this->RAW()); + } + + /** + * urlencode this string + */ + public function URLATT(): string + { + return urlencode($this->RAW() ?? ''); + } + + /** + * rawurlencode this string + */ + public function RAWURLATT(): string + { + return rawurlencode($this->RAW() ?? ''); + } + + /** + * Gets the value appropriate for a HTML attribute string + */ + public function ATT(): string + { + return Convert::raw2att($this->RAW()); + } + + /** + * Gets the raw value for this field. + * Note: Skips processors implemented via forTemplate() + */ + public function RAW(): mixed + { + return $this->getValue(); + } + + /** + * Gets javascript string literal value + */ + public function JS(): string + { + return Convert::raw2js($this->RAW()); + } + + /** + * Return JSON encoded value + */ + public function JSON(): string + { + return json_encode($this->RAW()); + } + + /** + * Alias for {@see XML()} + */ + public function HTML(): string + { + return $this->XML(); + } + + /** + * XML encode this value + */ + public function XML(): string + { + return Convert::raw2xml($this->RAW()); + } + + /** + * Safely escape for XML string + */ + public function CDATA(): string + { + return $this->XML(); + } + + /** + * Saves this field to the given data object. + * + * TODO: probably rename, it's just setting a field on a model + */ + public function saveInto(ModelData $model): void + { + $fieldName = $this->name; + if (empty($fieldName)) { + throw new \BadMethodCallException( + "ModelField::saveInto() Called on a nameless '" . static::class . "' object" + ); + } + if ($this->value instanceof ModelField) { + $this->value->saveInto($model); + } else { + $model->__set($fieldName, $this->value); + } + } + + public function validate(): ValidationResult + { + return ValidationResult::create(); + } + + /** + * Returns a FormField instance used as a default + * for form scaffolding. + * + * Used by {@link SearchContext}, {@link ModelAdmin}, {@link DataObject::scaffoldFormFields()} + * + * @param string $title Optional. Localized title of the generated instance + */ + public function scaffoldFormField(?string $title = null, array $params = []): ?FormField + { + return TextField::create($this->name, $title); + } + + /** + * Returns a FormField instance used as a default + * for searchform scaffolding. + * + * Used by {@link SearchContext}, {@link ModelAdmin}, {@link DataObject::scaffoldFormFields()}. + * + * @param string $title Optional. Localized title of the generated instance + */ + public function scaffoldSearchField(?string $title = null): ?FormField + { + return $this->scaffoldFormField($title); + } + + public function debug(): string + { + return << +
  • Name:{$this->name}
  • +
  • Table:{$this->tableName}
  • +
  • Value:{$this->value}
  • + +DBG; + } + + public function __toString(): string + { + return (string)$this->forTemplate(); + } + + /** + * Whether or not this ModelField only accepts scalar values. + * + * Composite ModelFields can override this method and return `false` so they can accept arrays of values. + */ + public function scalarValueOnly(): bool + { + return true; + } +} diff --git a/src/Model/ModelFields/ModelFieldTrait.php b/src/Model/ModelFields/ModelFieldTrait.php new file mode 100644 index 00000000000..4f23d0029a4 --- /dev/null +++ b/src/Model/ModelFields/ModelFieldTrait.php @@ -0,0 +1,37 @@ +modelFieldClass)) { + throw new LogicException('You must define the $modelFieldClass property on ' . static::class); + } + $this->modelField = Injector::inst()->createWithArgs($this->modelFieldClass, [$name]); + } + + public function getModelField(): ModelField + { + return $this->modelField; + } + + public function setModelField(ModelField $modelField): static + { + $this->modelField = $modelField; + return $this; + } +} diff --git a/src/Model/ModelFields/StringModelField.php b/src/Model/ModelFields/StringModelField.php new file mode 100644 index 00000000000..34f0441a939 --- /dev/null +++ b/src/Model/ModelFields/StringModelField.php @@ -0,0 +1,142 @@ + 'Text', + 'LimitCharactersToClosestWord' => 'Text', + 'LimitWordCount' => 'Text', + 'LowerCase' => 'Text', + 'UpperCase' => 'Text', + 'Plain' => 'Text', + ]; + + public function forTemplate(): string + { + return nl2br(parent::forTemplate() ?? ''); + } + + /** + * Limit this field's content by a number of characters. + * This makes use of strip_tags() to avoid malforming the + * HTML tags in the string of text. + * + * @param int $limit Number of characters to limit by + * @param string|false $add Ellipsis to add to the end of truncated string + */ + public function LimitCharacters(int $limit = 20, string|false $add = false): string + { + $value = $this->Plain(); + if (mb_strlen($value ?? '') <= $limit) { + return $value; + } + return $this->addEllipsis(mb_substr($value ?? '', 0, $limit), $add); + } + + /** + * Limit this field's content by a number of characters and truncate + * the field to the closest complete word. All HTML tags are stripped + * from the field. + * + * @param int $limit Number of characters to limit by + * @param string|false $add Ellipsis to add to the end of truncated string + * @return string Plain text value with limited characters + */ + public function LimitCharactersToClosestWord(int $limit = 20, string|false $add = false): string + { + // Safely convert to plain text + $value = $this->Plain(); + + // Determine if value exceeds limit before limiting characters + if (mb_strlen($value ?? '') <= $limit) { + return $value; + } + + // Limit to character limit + $value = mb_substr($value ?? '', 0, $limit); + + // If value exceeds limit, strip punctuation off the end to the last space and apply ellipsis + $value = $this->addEllipsis( + preg_replace( + '/[^\w_]+$/', + '', + mb_substr($value ?? '', 0, mb_strrpos($value ?? '', " ")) + ), + $add + ); + return $value; + } + + /** + * Limit this field's content by a number of words. + * + * @param int $numWords Number of words to limit by. + * @param string|false $add Ellipsis to add to the end of truncated string. + */ + public function LimitWordCount(int $numWords = 26, string|false $add = false): string + { + $value = $this->Plain(); + $words = explode(' ', $value ?? ''); + if (count($words ?? []) <= $numWords) { + return $value; + } + + // Limit + $words = array_slice($words ?? [], 0, $numWords); + return $this->addEllipsis(implode(' ', $words), $add); + } + + /** + * Converts the current value for this StringField to lowercase. + * + * @return string Text with lowercase (HTML for some subclasses) + */ + public function LowerCase(): string + { + return mb_strtolower($this->RAW() ?? ''); + } + + /** + * Converts the current value for this StringField to uppercase. + * + * @return string Text with uppercase (HTML for some subclasses) + */ + public function UpperCase(): string + { + return mb_strtoupper($this->RAW() ?? ''); + } + + /** + * Plain text version of this string + */ + public function Plain(): string + { + return trim($this->RAW() ?? ''); + } + + /** + * Swap add for defaultEllipsis if need be + */ + private function addEllipsis(string $string, string|false $add): string + { + if ($add === false) { + $add = $this->defaultEllipsis(); + } + + return $string . $add; + } + + /** + * Get the default string to indicate that a string was cut off. + */ + public function defaultEllipsis(): string + { + // TODO: Change to translation key defined on StringModelField + return _t(DBString::class . '.ELLIPSIS', '…'); + } +} diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index 178db68e59d..ad3e9649c46 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -20,6 +20,7 @@ use SilverStripe\Forms\FormScaffolder; use SilverStripe\Forms\CompositeValidator; use SilverStripe\Forms\FieldsValidator; +use SilverStripe\Forms\Form; use SilverStripe\Forms\GridField\GridField; use SilverStripe\Forms\GridField\GridFieldConfig_RelationEditor; use SilverStripe\Forms\HiddenField; @@ -45,7 +46,13 @@ use SilverStripe\Security\Security; use SilverStripe\View\SSViewer; use SilverStripe\Model\ModelData; +use SilverStripe\ORM\FieldType\DBText; +use SilverStripe\ORM\FieldType\DBVarchar; use stdClass; +use SilverStripe\Control\Controller; +use SilverStripe\Control\HTTPRequest; +use SilverStripe\Control\Session; +use Symfony\Component\Validator\Constraints\Uuid; /** * A single database record & abstract class for the data-access-model. @@ -1230,10 +1237,44 @@ public function forceChange() public function validate() { $result = ValidationResult::create(); + // Call validate() on every DBField + $specs = static::getSchema()->fieldSpecs(static::class); + foreach (array_keys($specs) as $fieldName) { + // Call DBField::validate() on every DBField + $dbField = $this->dbObject($fieldName); + $result->combineAnd($dbField->validate()); + } + // If it passed DBField::validate(), then call FormField::validate() + // on every field that would be scaffolded + // This isn't ideal and can lead to double validation, however it's to preserve + // any legacy FormField::validate() logic on projects that has not been + // migrated to use DBField::validate() + // TODO: don't call this when in FormRequestHandler::httpSubmission() + // as it means that calling FormField::validate() is called twice + if ($result->isValid()) { + $form = $this->getFormForValidation(); + $result->combineAnd($form->validationResult()); + } + // Call extension hook and return $this->extend('updateValidate', $result); return $result; } + private function getFormForValidation(): Form + { + $session = new Session([]); + $controller = new Controller(); + $request = new HTTPRequest('GET', '/'); + $request->setSession($session); + $controller->setRequest($request); + // Use getCMSFields rather than $dbField->scaffoldFormField() + // to ensure we get any CMS fields that were replaced by other fields + $fields = $this->getCMSFields(); + $form = new Form($controller, sprintf('temp-%s', uniqid()), $fields, null, new FieldsValidator); + $form->loadDataFrom($this); + return $form; + } + /** * Event handler called before writing to the database. * You can overload this to clean up or otherwise process data before writing it to the @@ -2422,16 +2463,15 @@ public function scaffoldSearchFields($_params = null) } // Otherwise, use the database field's scaffolder - } elseif ($object = $this->relObject($fieldName)) { - if (is_object($object) && $object->hasMethod('scaffoldSearchField')) { - $field = $object->scaffoldSearchField(); - } else { + } elseif ($dbField = $this->relObject($fieldName)) { + if (!is_a($dbField, DBField::class)) { throw new Exception(sprintf( "SearchField '%s' on '%s' does not return a valid DBField instance.", $fieldName, get_class($this) )); } + $field = $dbField->getModelField()->scaffoldSearchField(); } // Allow fields to opt out of search diff --git a/src/ORM/FieldType/DBDate.php b/src/ORM/FieldType/DBDate.php index d8a271d7878..397bedb3074 100644 --- a/src/ORM/FieldType/DBDate.php +++ b/src/ORM/FieldType/DBDate.php @@ -12,6 +12,7 @@ use SilverStripe\Security\Member; use SilverStripe\Security\Security; use SilverStripe\Model\ModelData; +use SilverStripe\Model\ModelFields\StringModelField; /** * Represents a date field. diff --git a/src/ORM/FieldType/DBEmail.php b/src/ORM/FieldType/DBEmail.php new file mode 100644 index 00000000000..8b12bd71252 --- /dev/null +++ b/src/ORM/FieldType/DBEmail.php @@ -0,0 +1,43 @@ +combineAnd( + ConstraintValidator::validate( + $this->getValue(), + new Constraints\Email(message: $message), + $this->getName() + ) + ); + $this->extend('updateValidate', $result); + return $result; + } + + public function scaffoldFormField(?string $title = null, array $params = []): ?FormField + { + // Set field with appropriate size + $field = EmailField::create($this->name, $title); + $field->setMaxLength($this->getSize()); + + // Allow the user to select if it's null instead of automatically assuming empty string is + if (!$this->getNullifyEmpty()) { + return NullableField::create($field); + } + return $field; + } +} diff --git a/src/ORM/FieldType/DBField.php b/src/ORM/FieldType/DBField.php index 38efb57583f..1d8328da9df 100644 --- a/src/ORM/FieldType/DBField.php +++ b/src/ORM/FieldType/DBField.php @@ -3,13 +3,13 @@ namespace SilverStripe\ORM\FieldType; use InvalidArgumentException; -use SilverStripe\Core\Convert; use SilverStripe\Core\Injector\Injector; -use SilverStripe\Forms\FormField; -use SilverStripe\Forms\TextField; -use SilverStripe\ORM\Filters\SearchFilter; -use SilverStripe\ORM\Queries\SQLSelect; use SilverStripe\Model\ModelData; +use SilverStripe\ORM\Queries\SQLSelect; +use SilverStripe\ORM\Filters\SearchFilter; +use SilverStripe\Model\ModelFields\ModelField; +use SilverStripe\Model\ModelFields\ModelFieldTrait; +use SilverStripe\Model\ModelFields\StringModelField; /** * Single field in the database. @@ -43,6 +43,10 @@ */ abstract class DBField extends ModelData implements DBIndexable { + use ModelFieldTrait; + + // TODO: this is wrong, though haven't yet created things like DateTimeModelField + protected string $modelFieldClass = StringModelField::class; /** * Raw value of this field @@ -50,14 +54,14 @@ abstract class DBField extends ModelData implements DBIndexable protected mixed $value = null; /** - * Table this field belongs to + * Name of this field */ - protected ?string $tableName = null; + protected ?string $name = null; /** - * Name of this field + * Table this field belongs to */ - protected ?string $name = null; + protected ?string $tableName = null; /** * Used for generating DB schema. {@see DBSchemaManager} @@ -71,34 +75,16 @@ abstract class DBField extends ModelData implements DBIndexable protected array $options = []; /** - * The escape type for this field when inserted into a template - either "xml" or "raw". + * The type of index to use for this field. Can either be a string (one of the DBIndexable type options) or a + * boolean. When a boolean is given, false will not index the field, and true will use the default index type. */ - private static string $escape_type = 'raw'; + private static string|bool $index = false; /** * Subclass of {@link SearchFilter} for usage in {@link defaultSearchFilter()}. */ private static string $default_search_filter_class = 'PartialMatchFilter'; - /** - * The type of index to use for this field. Can either be a string (one of the DBIndexable type options) or a - * boolean. When a boolean is given, false will not index the field, and true will use the default index type. - */ - private static string|bool $index = false; - - private static array $casting = [ - 'ATT' => 'HTMLFragment', - 'CDATA' => 'HTMLFragment', - 'HTML' => 'HTMLFragment', - 'HTMLATT' => 'HTMLFragment', - 'JS' => 'HTMLFragment', - 'RAW' => 'HTMLFragment', - 'RAWURLATT' => 'HTMLFragment', - 'URLATT' => 'HTMLFragment', - 'XML' => 'HTMLFragment', - 'ProcessedRAW' => 'HTMLFragment', - ]; - /** * Default value in the database. * Might be overridden on DataObject-level, but still useful for setting defaults on @@ -114,45 +100,17 @@ abstract class DBField extends ModelData implements DBIndexable public function __construct(?string $name = null, array $options = []) { $this->name = $name; - if ($options) { if (!is_array($options)) { throw new InvalidArgumentException("Invalid options $options"); } $this->setOptions($options); } + $this->initModelField($name); parent::__construct(); } - /** - * Create a DBField object that's not bound to any particular field. - * - * Useful for accessing the classes behaviour for other parts of your code. - * - * @param string $spec Class specification to construct. May include both service name and additional - * constructor arguments in the same format as DataObject.db config. - * @param mixed $value value of field - * @param null|string $name Name of field - * @param mixed $args Additional arguments to pass to constructor if not using args in service $spec - * Note: Will raise a warning if using both - */ - public static function create_field(string $spec, mixed $value, ?string $name = null, mixed ...$args): static - { - // Raise warning if inconsistent with DataObject::dbObject() behaviour - // This will cause spec args to be shifted down by the number of provided $args - if ($args && strpos($spec ?? '', '(') !== false) { - trigger_error('Additional args provided in both $spec and $args', E_USER_WARNING); - } - // Ensure name is always first argument - array_unshift($args, $name); - - /** @var DBField $dbField */ - $dbField = Injector::inst()->createWithArgs($spec, $args); - $dbField->setValue($value, null, false); - return $dbField; - } - /** * Set the name of this field. * @@ -164,31 +122,16 @@ public static function create_field(string $spec, mixed $value, ?string $name = public function setName(?string $name): static { if ($this->name && $this->name !== $name) { - user_error("DBField::setName() shouldn't be called once a DBField already has a name." + user_error("ModelField::setName() shouldn't be called once a ModelField already has a name." . "It's partially immutable - it shouldn't be altered after it's given a value.", E_USER_WARNING); } $this->name = $name; + $this->getModelField()->setName($name); return $this; } - /** - * Returns the name of this field. - */ - public function getName(): string - { - return $this->name ?? ''; - } - - /** - * Returns the value of this field. - */ - public function getValue(): mixed - { - return $this->value; - } - /** * Set the value of this field in various formats. * Used by {@link DataObject->getField()}, {@link DataObject->setCastedField()} @@ -206,9 +149,31 @@ public function getValue(): mixed public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static { $this->value = $value; + $this->getModelField()->setValue($value); + return $this; } + /** + * Magic method to call methods on the ModelField now that bunch of DBField API has been moved to ModelField + * + * TODO: get rid of this and call ->getModelField()->$method() directly + */ + public function __call($method, $args) + { + return $this->getModelField()->$method(...$args); + } + + public function __toString(): string + { + return (string) $this->getModelField(); + } + + /** + * Add the field to the underlying database. + */ + abstract public function requireField(): void; + /** * Get default value assigned at the DB level */ @@ -351,96 +316,6 @@ public function getTable(): ?string return $this->tableName; } - /** - * Determine 'default' casting for this field. - */ - public function forTemplate(): string - { - // Default to XML encoding - return $this->XML(); - } - - /** - * Gets the value appropriate for a HTML attribute string - */ - public function HTMLATT(): string - { - return Convert::raw2htmlatt($this->RAW()); - } - - /** - * urlencode this string - */ - public function URLATT(): string - { - return urlencode($this->RAW() ?? ''); - } - - /** - * rawurlencode this string - */ - public function RAWURLATT(): string - { - return rawurlencode($this->RAW() ?? ''); - } - - /** - * Gets the value appropriate for a HTML attribute string - */ - public function ATT(): string - { - return Convert::raw2att($this->RAW()); - } - - /** - * Gets the raw value for this field. - * Note: Skips processors implemented via forTemplate() - */ - public function RAW(): mixed - { - return $this->getValue(); - } - - /** - * Gets javascript string literal value - */ - public function JS(): string - { - return Convert::raw2js($this->RAW()); - } - - /** - * Return JSON encoded value - */ - public function JSON(): string - { - return json_encode($this->RAW()); - } - - /** - * Alias for {@see XML()} - */ - public function HTML(): string - { - return $this->XML(); - } - - /** - * XML encode this value - */ - public function XML(): string - { - return Convert::raw2xml($this->RAW()); - } - - /** - * Safely escape for XML string - */ - public function CDATA(): string - { - return $this->XML(); - } - /** * Returns the value to be set in the database to blank this field. * Usually it's a choice between null, 0, and '' @@ -450,50 +325,6 @@ public function nullValue(): mixed return null; } - /** - * Saves this field to the given data object. - */ - public function saveInto(ModelData $model): void - { - $fieldName = $this->name; - if (empty($fieldName)) { - throw new \BadMethodCallException( - "DBField::saveInto() Called on a nameless '" . static::class . "' object" - ); - } - if ($this->value instanceof DBField) { - $this->value->saveInto($model); - } else { - $model->__set($fieldName, $this->value); - } - } - - /** - * Returns a FormField instance used as a default - * for form scaffolding. - * - * Used by {@link SearchContext}, {@link ModelAdmin}, {@link DataObject::scaffoldFormFields()} - * - * @param string $title Optional. Localized title of the generated instance - */ - public function scaffoldFormField(?string $title = null, array $params = []): ?FormField - { - return TextField::create($this->name, $title); - } - - /** - * Returns a FormField instance used as a default - * for searchform scaffolding. - * - * Used by {@link SearchContext}, {@link ModelAdmin}, {@link DataObject::scaffoldFormFields()}. - * - * @param string $title Optional. Localized title of the generated instance - */ - public function scaffoldSearchField(?string $title = null): ?FormField - { - return $this->scaffoldFormField($title); - } - /** * @param string $name Override name of this field */ @@ -504,27 +335,6 @@ public function defaultSearchFilter(?string $name = null): SearchFilter return Injector::inst()->create($filterClass, $name); } - /** - * Add the field to the underlying database. - */ - abstract public function requireField(): void; - - public function debug(): string - { - return << -
  • Name:{$this->name}
  • -
  • Table:{$this->tableName}
  • -
  • Value:{$this->value}
  • - -DBG; - } - - public function __toString(): string - { - return (string)$this->forTemplate(); - } - public function getArrayValue() { return $this->arrayValue; @@ -536,14 +346,6 @@ public function setArrayValue($value): static return $this; } - /** - * Get formfield schema value for use in formschema response - */ - public function getSchemaValue(): mixed - { - return $this->RAW(); - } - public function getIndexSpecs(): ?array { $type = $this->getIndexType(); @@ -557,12 +359,30 @@ public function getIndexSpecs(): ?array } /** - * Whether or not this DBField only accepts scalar values. + * Create a DBField object that's not bound to any particular field. * - * Composite DBFields can override this method and return `false` so they can accept arrays of values. + * Useful for accessing the classes behaviour for other parts of your code. + * + * @param string $spec Class specification to construct. May include both service name and additional + * constructor arguments in the same format as DataObject.db config. + * @param mixed $value value of field + * @param null|string $name Name of field + * @param mixed $args Additional arguments to pass to constructor if not using args in service $spec + * Note: Will raise a warning if using both */ - public function scalarValueOnly(): bool + public static function create_field(string $spec, mixed $value, ?string $name = null, mixed ...$args): static { - return true; + // Raise warning if inconsistent with DataObject::dbObject() behaviour + // This will cause spec args to be shifted down by the number of provided $args + if ($args && strpos($spec ?? '', '(') !== false) { + trigger_error('Additional args provided in both $spec and $args', E_USER_WARNING); + } + // Ensure name is always first argument + array_unshift($args, $name); + + /** @var DBField $dbField */ + $dbField = Injector::inst()->createWithArgs($spec, $args); + $dbField->setValue($value, null, false); + return $dbField; } } diff --git a/src/ORM/FieldType/DBString.php b/src/ORM/FieldType/DBString.php index 99d597aa9c5..8072519cd70 100644 --- a/src/ORM/FieldType/DBString.php +++ b/src/ORM/FieldType/DBString.php @@ -2,19 +2,14 @@ namespace SilverStripe\ORM\FieldType; +use SilverStripe\Model\ModelFields\StringModelField; + /** * An abstract base class for the string field types (i.e. Varchar and Text) */ abstract class DBString extends DBField { - private static array $casting = [ - 'LimitCharacters' => 'Text', - 'LimitCharactersToClosestWord' => 'Text', - 'LimitWordCount' => 'Text', - 'LowerCase' => 'Text', - 'UpperCase' => 'Text', - 'Plain' => 'Text', - ]; + protected string $modelFieldClass = StringModelField::class; /** * Set the default value for "nullify empty" @@ -95,127 +90,4 @@ public function prepValueForDB(mixed $value): array|string|null } return ''; } - - public function forTemplate(): string - { - return nl2br(parent::forTemplate() ?? ''); - } - - /** - * Limit this field's content by a number of characters. - * This makes use of strip_tags() to avoid malforming the - * HTML tags in the string of text. - * - * @param int $limit Number of characters to limit by - * @param string|false $add Ellipsis to add to the end of truncated string - */ - public function LimitCharacters(int $limit = 20, string|false $add = false): string - { - $value = $this->Plain(); - if (mb_strlen($value ?? '') <= $limit) { - return $value; - } - return $this->addEllipsis(mb_substr($value ?? '', 0, $limit), $add); - } - - /** - * Limit this field's content by a number of characters and truncate - * the field to the closest complete word. All HTML tags are stripped - * from the field. - * - * @param int $limit Number of characters to limit by - * @param string|false $add Ellipsis to add to the end of truncated string - * @return string Plain text value with limited characters - */ - public function LimitCharactersToClosestWord(int $limit = 20, string|false $add = false): string - { - // Safely convert to plain text - $value = $this->Plain(); - - // Determine if value exceeds limit before limiting characters - if (mb_strlen($value ?? '') <= $limit) { - return $value; - } - - // Limit to character limit - $value = mb_substr($value ?? '', 0, $limit); - - // If value exceeds limit, strip punctuation off the end to the last space and apply ellipsis - $value = $this->addEllipsis( - preg_replace( - '/[^\w_]+$/', - '', - mb_substr($value ?? '', 0, mb_strrpos($value ?? '', " ")) - ), - $add - ); - return $value; - } - - /** - * Limit this field's content by a number of words. - * - * @param int $numWords Number of words to limit by. - * @param string|false $add Ellipsis to add to the end of truncated string. - */ - public function LimitWordCount(int $numWords = 26, string|false $add = false): string - { - $value = $this->Plain(); - $words = explode(' ', $value ?? ''); - if (count($words ?? []) <= $numWords) { - return $value; - } - - // Limit - $words = array_slice($words ?? [], 0, $numWords); - return $this->addEllipsis(implode(' ', $words), $add); - } - - /** - * Converts the current value for this StringField to lowercase. - * - * @return string Text with lowercase (HTML for some subclasses) - */ - public function LowerCase(): string - { - return mb_strtolower($this->RAW() ?? ''); - } - - /** - * Converts the current value for this StringField to uppercase. - * - * @return string Text with uppercase (HTML for some subclasses) - */ - public function UpperCase(): string - { - return mb_strtoupper($this->RAW() ?? ''); - } - - /** - * Plain text version of this string - */ - public function Plain(): string - { - return trim($this->RAW() ?? ''); - } - - /** - * Swap add for defaultEllipsis if need be - */ - private function addEllipsis(string $string, string|false $add): string - { - if ($add === false) { - $add = $this->defaultEllipsis(); - } - - return $string . $add; - } - - /** - * Get the default string to indicate that a string was cut off. - */ - public function defaultEllipsis(): string - { - return _t(DBString::class . '.ELLIPSIS', '…'); - } }