This bugtracker is archived (announcement). New tickets are created on github.com. See all framework issues, cms issues, and search the module listings for more specific bugtrackers.
wiki:development/validation-new
Last modified 3 years ago Last modified on 08/03/11 14:25:56

Validation development plan

Introduction

This is a development plan to overhaul the current validation system in Sapphire, which is lacking in flexibility, and ability to express different validation rules such as length.

There is also no model validation, so it's possible to write inconsistent data.

Note that the actual implementation may slightly differ to what is shown here, especially code examples. Dependency injection would be used where possible so that a developer can replace validation functionality with their own custom logic where they see fit.

Model validation

Definition of validation rules

Model validation is defined directly on a model class.

Let's take a model definition, it has a few database fields, and a validations definition on what values should only be accepted when it is written to the database:

class Foo extends DataObject {

	public static $db = array(
		'Name' => 'Varchar(50)',
		'Age' => 'Varchar(2)',
		'Email' => 'Varchar(50)',
	);

	public static $validations = array(
		'Name' => array(
			'Required' => array(
				'unless' => 'MemberIsAdmin' // only required to be filled out if MemberIsAdmin() returns true!
			),
			'Length' => array(
				'unless' => 'MemberIsAdmin',
				'value' => '20..50'
			),
		),
		'Age' => array(
			'Required' => array(true),
			'Numeric' => array(true),
			'Length' => array('2')
		),
		'Email' => array(
			'Required' => array(true),
			'Unique' => array(true),
			'Email' => array(
				'unless' => 'MemberIsAdmin',
				'message' => 'Please enter a valid email address'
			),
			'Length' => array('10..50')
		),
	);

}

Evaluation of validation rules

DataObject::validate()

DataObject::validate() will take the rules applied in $validations and give them to the DataObjectValidator class which performs the evaluation of in-memory field values to those rules.

Here is how it *may* look. Dependency injection may make this slightly different in the actual implementation:

public function validate() {
	$validator = DataObjectValidator::get($this);
	$validator->evaluate();
}

DataObjectValidator class

DataObjectValidator is responsible for taking a reference of a model instance and evaluating $validations rules.

To do specific validation checks like "Unique", "Email" and "Length", as shown in the $validations rules above, Validator will call upon an implementations of the *Validator* interface which performs the specific validation rule. Examples of these:

  • Validator_Required
  • Validator_Email
  • Validator_Numeric
  • Validator_Currency
  • Validator_Unique
  • Validator_Length

If there is an *unless* rule on the "Required" key of the $validations, it will skip all validation, including whether it actually has a value or not.

This means silly messages like this won't be shown:

  • Email is required to be filled out
  • Email should be a valid address
  • Email should be between 10 and 50 characters in length

In the case where the field is required, and there is no value, only one message would be shown:

  • Email is required to be filled out

In the case where a field has a value, but doesn't meet other requirements, then this message would be shown:

  • Email should be a valid address
  • Email should be between 10 and 50 characters in length

Here's a rough idea how the DataObjectValidator evaluate() method would work:

class DataObjectValidator {
	protected $model;

	public function __construct($model) {
		$this->model = $model;
	}

	public function evaluate() {
		// evaluate model rules and raise error messages on invalid fields
		$trans = ValidationTransaction::get();
		$model = $this->model;
	
		foreach($model->stat('validations') as $field => $rules) {
			// skip validation if the field is not required at all (special case: use of "unless" rule)
			$requiredUnlessCallback = isset($rules['Required']['unless']) ? $rules['Required']['unless'] : '';
			if(method_exists($model, $requiredUnlessCallback)) {
				if($model->$requiredUnlessCallback()) continue;
			}
	
			// continue evaluating other rules
			foreach($rules as $name => $rule) {
				if(!$rule) continue; // false rules will be skipped
	
				// skip this rule if there's an unless callback and it returns true
				$unlessCallback = isset($rule['unless']) ? $rule['unless'] : '';
				if(method_exists($mode, $unlessCallback)) {
					if($model->$unlessCallback()) continue;
				}
	
				// rule can be array('10..50'), or array('value' => '10..50')
				$ruleValue = isset($rule['value']) ? $rule['value'] : $rule[0];
	
				// find a Validator_* class for the rule
				$validatorClass = sprintf('Validator_%s', $name);
	
				// add an error message if the field value is not valid
				if(class_exists($validatorClass)) {
					$validator = new $validatorClass($ruleValue);
					$message = isset($rule['message']) ? $rule['message'] : $validator->getDefaultMessage($field);
					if(!$validator->validate($model->$field)) {
						$trans->addError(get_class($model), $field, $message);
					}
				}
			}
		}
	
		// loop through validate*() methods on the model
	}
}

The above is a very rough example. Additional validation would take into account any validate*() methods on the model as well. These add their own messages to ValidationTransaction and can be used to customise validation further.

More details on ValidationTransaction below.

Validator_* classes

These perform the actual evaluation logic for specific requirements such as email, length, numeric or currency.

Here's a very simple email validator:

class Validator_Email implements Validator {
	public function validate($value) {
		return Email::is_valid($value);
	}
}

Any rule could be defined in the $validations definitions, provided that a class exists for it. In the case of "Foo", for example, Validator_Foo must exist and have the validate() method implemented.

Let's take the example of Foo above.

Forms and validation

ValidationTransaction class

ValidationTransaction is responsible for holding in-memory any validation errors that that have been raised when model validation has occurred. Messages can also be manually added.

How ValidationTransaction works with forms

ValidationTransaction will start a database transaction when Form::httpSubmission() is called. This means any database writes in the action are not completed until validation passes.

Here's a form action, which inserts all form data into a new Foo instance and writes it.

public function submit($data, $form, $request) {
	$foo = new Foo();
	$form->saveInto($foo);
	$foo->write();
}

After the action has returned, Form::httpSubmission() will check ValidationTransaction::hasErrors() to see if there are any validation errors raised during $foo->write().

In the case that hasErrors() is true, the database transaction is rolled back and considered incomplete. Any writes that occurred in the action are rolled back. At this point the user would be taken back to the form, and observe error messages at the top of the form.

In the case where hasErrors() is false (no validation errors were raised), Form::httpSubmission() completes the transaction, so the data is then written to the database.

Database transactions

ValidationTransaction requires a transactional database.

This is generally not an issue on PostgreSQL, SQL Server and SQLite. MySQL, however, has some limitations.

Sapphire by default will create MyISAM tables by default, but transactions only work on InnoDB tables. The engine can be changed on a per-model basis like this:

class MyModel extends DataObject {
	static $create_table_options = array('MySQLDatabase' => 'ENGINE=InnoDB');
}

WARNING: Fulltext searches will stop working on tables you change to InnoDB.

Manually adding form errors in actions

Doing so allows you to define your own custom error messages, separate of model validation.

There may be cases where you want to validate fields that aren't associated with a model. In the example below, an error is manually added to the form for the "Email" field.

public function submit($data, $form) {
	$trans = ValidationTransaction::get();
	if(!Email::is_email($data['Email'])) {
		$trans->addError(null, 'Email', 'Please enter a valid email address');
	}

	$foo = new Foo();
	$form->saveInto($foo);
	$foo->write();
}

Forcing a transaction to commit or rollback

public function submit($data, $form, $request) {
	$foo = new Foo();
	$form->saveInto($foo);
	$foo->write();

	// forcibly commit the transaction
	$trans = ValidationTransaction::get();
	$trans->commit();
}

Doing this will write Foo to the database, even if validation errors may occur.

Binding models to forms

Because you may be dealing with multiple models on a single form, it is possible to bind multiple models to a form using CompositeField to group model fields which can be bound to a model class. Let's take a more complex and useful example:

class Submission extends DataObject {

	public static $db = array(
		'Name' => 'Varchar(50)',
		'Address' => 'Varchar(200)',
		'CouponCode' => 'Int'
	);

	public static $validations = array(
		'Name' => array(
			'Required' => array(true),
			'Length' => array('0..50')
		),
		'Address' => array(
			'Required' => array(true),
			'Length' => array('0..200')
		),
		'CouponCode' => array(
			'Required' => array(true),
			'Numeric' => array(true)
		)
	);

}
class Member_Extension extends DataObjectDecorator {

	public function extraStatics() {
		return array(
			'validations' => array(
				'FirstName' => array(
					'Required' => array(true)
				),
				'Surname' => array(
					'Required' => array(true)
				),
				'Email' => array(
					'Required' => array(
						'value' => true,
						'unless' => 'IsAdmin'
					),
					'Email' => array(true)
				)
			)
		);
	}

}
class Foo_Controller extends Controller {

	public function Form() {
		$submissionFields = new CompositeField(
			new TextField('Name'),
			new TextareaField('Address'),
			new NumericField('CouponCode', 'Coupon code')
		);
		$submissionFields->bindToModel('Submission');

		$memberFields = new CompositeField(
			new TextField('FirstName', 'First name'),
			new TextField('Surname', 'Surname'),
			new EmailField('Email', 'Email address')
		);
		$memberFields->bindToModel('Member')

		return new Form($this, 'Form', new FieldSet(
			$submissionFields,
			$memberFields
		), new FieldSet(
			new FormAction('submit', 'Submit')
		));
	}

	public function submit($data, $form, $request) {
		$submission = new Submission();
		$form->saveInto($submission);
		$submission->write();
	
		$member = new Member();
		$form->saveInto($member);
		$member->write();
	}

}

In this case, we have bound two CompositeField field groups to two specific models. "SubmissionFields" goes to Submission, "MemberFields" to Member. To bind the CompositeField to the model, you would call bindToModel() on the CompositeField with the model you want to bind.

bindToModel() can also be called on a Form instance to bind the entire form to a model.

Once this has been done, at the time the form is rendered, model validation rules will be applies to the form HTML inputs, using HTML5 data attributes. These data attributes will store each of the rules for the given field, so that a developer can use these to construct client side validation.

Here's an example of how a form input for an email field might look with validation rules:

<input type="text" class="email-field" data-validation-length="20..50" data-validation-message="Please enter a valid email address">

As far as server side validation is concerned, the form action would be called immediately on form submit, however ValidationTransaction would have started a database transaction just before the action is called. Just like the example above showing how ValidationTransaction works, the transaction would be rolled back if there are validation errors after the action has returned inside Form::httpSubmission().

In the case there are no validation errors, the transaction is completed and the database writes are committed, finalizing the form submission.

TODO

  • How to handle existing FormField::validate() methods with ValidationTransaction?

Tasks

  • Deprecate Validator class
  • Remove all existing javascript validation in Validator.js, Validator and FormField instances
  • Create DataObjectValidator for evaluating $validators definitions on DataObject
  • Set up DataObject::validate() and DataObject::write() with model validation
  • Set up DataObjectDecorator to allow $validations to be extended
  • Set up FormField templates with data attributes of validation rules
  • Modify CompositeField and Form to allow binding of a model class $form->bindToModel('Foo')
  • Create ValidationTransaction
  • Create basic Validators
    • Validator_Required
    • Validator_Email
    • Validator_Numeric
    • Validator_Currency
    • Validator_Unique
    • Validator_Length
  • Modify Form::httpSubmission() to work with ValidationTransaction
  • Document API and example usage