silverstripe / silverstripe-framework

Silverstripe Framework, the MVC framework that powers Silverstripe CMS
https://www.silverstripe.org
BSD 3-Clause "New" or "Revised" License
721 stars 821 forks source link

Improvements to the validation #2118

Closed stojg closed 7 years ago

stojg commented 11 years ago

I could not find a reference to the validation improvements for the Framework in issues.

The only information I've been able to find are:

http://open.silverstripe.org/wiki/development/validation http://open.silverstripe.org/wiki/development/validation-new

There were a GSOC 2012 project, but I'm not sure where that ended up. @chillu, @halkyon, @mateusz I think you been involved in this before. If you have any information to share that would be great.

sminnee commented 11 years ago

The GSOC project kind of died on the vine. Here was my view of the necessary work:

sminnee commented 11 years ago

Aha: https://github.com/sminnee/sapphire/commit/8074970928565d2a35ca20b8e328f6fe89d15282

sminnee commented 11 years ago

OK - I spent a couple of hours getting that over the line and I've pushed it here: https://github.com/silverstripe/silverstripe-framework/pull/2119

This PR addresses the first of my bullet points above.

sminnee commented 11 years ago

Following on from #2119 I would see the following changes make sense:

That would basically get you to the "let's do this properly" state that I know has been a desire for a long while.

But what about Constraints? Implicit in this is the idea is that a "constraint" is nothing but a validator that only bothers looking at one field. So, if you did this:

$dataObject->addConstraint(new RequiredField("Title"));

Then RequiredField would be a Validator that would execute like so:

function validateData($data, $result) {
  // $data would be the actual DataObject, but it implements ArrayAccess.
  if(!$data['Title']) $result->addFieldError("Title", "Title is required");
}

Of course, in this case, Title would be a parameter passed to the constructor of RequiredField and then referenced, but you get the idea. You would probably have a base class for field-specific validators, and you might even call that base-class "Constraint". But I would probably call it FieldValidator, as that seems more consistent. Something like this:

abstract class FieldValidator extends Validator {
   protected $field;
   function __construct($field) {
      $this->field = $field;
   }

   function validateData($data, $result) {
       $this->validateField($this->field, $data[$this->field], $data, $result);
   }

   abstract function validateField($fieldName, $fieldValue, $data, $result);
}

class RequiredField extends FieldValidator {
    function validateField($fieldName, $fieldValue, $data, $result) {
      if(!$fieldValue) $result->addFieldError($fieldName, "$fieldName is required");
    }
 }

Given all of this, you could define constraints on a DataObject by:

One thing I'm less clear on is the format of the config static for defining the constraints.

sminnee commented 11 years ago

This begs the question "should we rely on Symfony constraint classes", as referenced here: http://symfony.com/doc/current/book/validation.html

My view on this is that while the volume of constraints they have is nice, trying to weld their system to be a part of ours is too much work to be feasible, and I don't think it's critical.

If someone has the desire, they could plumb between Symfony validators or constraints, but I would see that as an done with an optional adapter class rather than being a part of the core validator scheme in SilverStripe.

They have a lot of constraints objects; although for simple things like required fields pre-existing objects are helpful, at a pretty simple level of complexity it becomes easier just to do this:

function validate($result) {
   parent::validate($result);
   if($this->Type == "Child" && $this->Age > 15) $result->addFieldError("Children shouldn't be older than 14");
 }

I would focus the core validators/constraints on

chillu commented 11 years ago

Sounds great :) I agree that Symfony constraints as such won't offer us much benefits given how simple most of them are. It would make more sense if used together with the Symfony Form and Validator components, but that's probably a too disruptive change for SilverStripe.

I like that we keep validators stateless through passing in ValidationResult, and the composition powers this allows.

Presumably you want to keep the static DataObject::$validations approach outlined in the wiki, which generates a model-specific validator on request? We'd need to cache or register these validators somewhere, so we don't create a new one for each model instance.

Do you see model binding in forms as a next step, and have FieldValidator specified manually for now? The wiki already outlines an approach for this. I want to make sure the validator holds up to more complex scenarios: How would you validate a form submission with a list of new users for example? (in the format User[0]['FirstName], User[1]['FirstName], ...). The wiki suggests using CompositeField with a bindToModel() method. Looking at your approach, I guess you'd do this in a custom Form->validate() method, where you call Validator->validateData() on the specific parts of the submitted data?

sminnee commented 11 years ago

We'd need to cache or register these validators somewhere, so we don't create a new one for each model instance.

We could potentially use the Injector system for this, and treat the DataObject::$validations as a special kind of DI configuration. To be honest, this is the part of the system that I am least clear on, and I would probably start by writing some test apps just by instantiating validators inside DataObject::validate() or something.

Do you see model binding in forms as a next step, and have FieldValidator specified manually for now?

The current system doesn't so much bind models to forms, as it does assume that the fieldnames on field-specific errors are the same between forms and dataobjects.

So, on a DataObject's validator you could do:

 $result->addFieldError("FirstName", "I don't like your name");

And it would show up as an error on the FirstName field of the relevant form.

Certainly for this default case this works well; it's not clear what we'd need to do for non-default cases. I guess it basically comes down to re-mapping -- "an error on field X of the model should be shown on field Y of the form". Something like:

 try {
    $model->write();
 } catch(ValidationException $e) {
    $e->getResult()->remapFieldErrors(array("Title" => "MyTitleFormField"));
    throw $e;
 }

A little bit verbose, but it's a pretty rare use-case. The only extra piece of code you would need is to implement remapFieldErrors(), which is trivial.

tractorcow commented 7 years ago

Form validation has been rewritten in 4.x, and includes the above discussed enhancement around using exception handling to trigger and safely display errors. I'm closing this, as any future enhancements to validation will likely need to be implemented from scratch and in a react-form supporting way.