Location: PHPKode > projects > ActiveRecord > php-activerecord/lib/Validations.php
<?php
/**
 * These two classes have been <i>heavily borrowed</i> from Ruby on Rails' ActiveRecord so much that
 * this piece can be considered a straight port. The reason for this is that the vaildation process is
 * tricky due to order of operations/events. The former combined with PHP's odd typecasting means
 * that it was easier to formulate this piece base on the rails code.
 *
 * @package ActiveRecord
 */

namespace ActiveRecord;
use ActiveRecord\Model;
use IteratorAggregate;
use ArrayIterator;

/**
 * Manages validations for a {@link Model}.
 *
 * This class isn't meant to be directly used. Instead you define
 * validators thru static variables in your {@link Model}. Example:
 *
 * <code>
 * class Person extends ActiveRecord\Model {
 *   static $validates_length_of = array(
 *     array('name', 'within' => array(30,100),
 *     array('state', 'is' => 2)
 *   );
 * }
 *
 * $person = new Person();
 * $person->name = 'Tito';
 * $person->state = 'this is not two characters';
 *
 * if (!$person->is_valid())
 *   print_r($person->errors);
 * </code>
 *
 * @package ActiveRecord
 * @see Errors
 * @link http://www.phpactiverecord.org/guides/validations
 */
class Validations
{
	private $model;
	private $options = array();
	private $validators = array();
	private $record;

	private static $VALIDATION_FUNCTIONS = array(
		'validates_presence_of',
		'validates_size_of',
		'validates_length_of',
		'validates_inclusion_of',
		'validates_exclusion_of',
		'validates_format_of',
		'validates_numericality_of',
		'validates_uniqueness_of'
	);

	private static $DEFAULT_VALIDATION_OPTIONS = array(
		'on' => 'save',
		'allow_null' => false,
		'allow_blank' => false,
		'message' => null,
	);

	private static  $ALL_RANGE_OPTIONS = array(
		'is' => null,
		'within' => null,
		'in' => null,
		'minimum' => null,
		'maximum' => null,
	);

	private static $ALL_NUMERICALITY_CHECKS = array(
		'greater_than' => null,
		'greater_than_or_equal_to'  => null,
		'equal_to' => null,
		'less_than' => null,
		'less_than_or_equal_to' => null,
		'odd' => null,
		'even' => null
	);

	/**
	 * Constructs a {@link Validations} object.
	 *
	 * @param Model $model The model to validate
	 * @return Validations
	 */
	public function __construct(Model $model)
	{
		$this->model = $model;
		$this->record = new Errors($this->model);
		$this->validators = array_intersect(array_keys(Reflections::instance()->get(get_class($this->model))->getStaticProperties()), self::$VALIDATION_FUNCTIONS);
	}

	/**
	 * Returns validator data.
	 *
	 * @return array
	 */
	public function rules()
	{
		$data = array();
		$reflection = Reflections::instance()->get(get_class($this->model));

		foreach ($this->validators as $validate)
		{
			$attrs = $reflection->getStaticPropertyValue($validate);

			foreach ($attrs as $attr)
			{
				$field = $attr[0];

				if (!isset($data[$field]) || !is_array($data[$field]))
					$data[$field] = array();

				$attr['validator'] = $validate;
				unset($attr[0]);
				array_push($data[$field],$attr);
			}
		}
		return $data;
	}

	/**
	 * Runs the validators.
	 *
	 * @return Errors the validation errors if any
	 */
	public function validate()
	{
		$reflection = Reflections::instance()->get(get_class($this->model));

		foreach ($this->validators as $validate)
			$this->$validate($reflection->getStaticPropertyValue($validate));

		$this->record->clear_model();
		return $this->record;
	}

	/**
	 * Validates a field is not null and not blank.
	 *
	 * <code>
	 * class Person extends ActiveRecord\Model {
	 *   static $validates_presence_of = array(
	 *     array('first_name'),
	 *     array('last_name')
	 *   );
	 * }
	 * </code>
	 *
	 * Available options:
	 *
	 * <ul>
	 * <li><b>message:</b> custom error message</li>
	 * </ul>
	 *
	 * @param array $attrs Validation definition
	 */
	public function validates_presence_of($attrs)
	{
		$configuration = array_merge(self::$DEFAULT_VALIDATION_OPTIONS, array('message' => Errors::$DEFAULT_ERROR_MESSAGES['blank'], 'on' => 'save'));

		foreach ($attrs as $attr)
		{
			$options = array_merge($configuration, $attr);
			$this->record->add_on_blank($options[0], $options['message']);
		}
	}

	/**
	 * Validates that a value is included the specified array.
	 *
	 * <code>
	 * class Car extends ActiveRecord\Model {
	 *   static $validates_inclusion_of = array(
	 *     array('fuel_type', 'in' => array('hyrdogen', 'petroleum', 'electric')),
	 *   );
	 * }
	 * </code>
	 *
	 * Available options:
	 *
	 * <ul>
	 * <li><b>in/within:</b> attribute should/shouldn't be a value within an array</li>
	 * <li><b>message:</b> custome error message</li>
	 * </ul>
	 *
	 * @param array $attrs Validation definition
	 */
	public function validates_inclusion_of($attrs)
	{
		$this->validates_inclusion_or_exclusion_of('inclusion', $attrs);
	}

	/**
	 * This is the opposite of {@link validates_include_of}.
	 *
	 * @param array $attrs Validation definition
	 * @see validates_inclusion_of
	 */
	public function validates_exclusion_of($attrs)
	{
		$this->validates_inclusion_or_exclusion_of('exclusion', $attrs);
	}

	/**
	 * Validates that a value is in or out of a specified list of values.
	 *
	 * @see validates_inclusion_of
	 * @see validates_exclusion_of
	 * @param string $type Either inclusion or exclusion
	 * @param $attrs Validation definition
	 */
	public function validates_inclusion_or_exclusion_of($type, $attrs)
	{
		$configuration = array_merge(self::$DEFAULT_VALIDATION_OPTIONS, array('message' => Errors::$DEFAULT_ERROR_MESSAGES[$type], 'on' => 'save'));

		foreach ($attrs as $attr)
		{
			$options = array_merge($configuration, $attr);
			$attribute = $options[0];
			$var = $this->model->$attribute;

			if (isset($options['in']))
				$enum = $options['in'];
			elseif (isset($options['within']))
				$enum = $options['within'];

			if (!is_array($enum))
				array($enum);

			$message = str_replace('%s', $var, $options['message']);

			if ($this->is_null_with_option($var, $options) || $this->is_blank_with_option($var, $options))
				continue;

			if (('inclusion' == $type && !in_array($var, $enum)) || ('exclusion' == $type && in_array($var, $enum)))
				$this->record->add($attribute, $message);
		}
	}

	/**
	 * Validates that a value is numeric.
	 *
	 * <code>
	 * class Person extends ActiveRecord\Model {
	 *   static $validates_numericality_of = array(
	 *     array('salary', 'greater_than' => 19.99, 'less_than' => 99.99)
	 *   );
	 * }
	 * </code>
	 *
	 * Available options:
	 *
	 * <ul>
	 * <li><b>only_integer:</b> value must be an integer (e.g. not a float)</li>
	 * <li><b>even:</b> must be even</li>
	 * <li><b>odd:</b> must be odd"</li>
	 * <li><b>greater_than:</b> must be greater than specified number</li>
	 * <li><b>greater_than_or_equal_to:</b> must be greater than or equal to specified number</li>
	 * <li><b>equal_to:</b> ...</li>
	 * <li><b>less_than:</b> ...</li>
	 * <li><b>less_than_or_equal_to:</b> ...</li>
	 * </ul>
	 *
	 * @param array $attrs Validation definition
	 */
	public function validates_numericality_of($attrs)
	{
		$configuration = array_merge(self::$DEFAULT_VALIDATION_OPTIONS, array('only_integer' => false));

		// Notice that for fixnum and float columns empty strings are converted to nil.
		// Validates whether the value of the specified attribute is numeric by trying to convert it to a float with Kernel.Float
		// (if only_integer is false) or applying it to the regular expression /\A[+\-]?\d+\Z/ (if only_integer is set to true).
		foreach ($attrs as $attr)
		{
			$options = array_merge($configuration, $attr);
			$attribute = $options[0];
			$var = $this->model->$attribute;

			$numericalityOptions = array_intersect_key(self::$ALL_NUMERICALITY_CHECKS, $options);

			if ($this->is_null_with_option($var, $options))
				continue;

			if (true === $options['only_integer'] && !is_integer($var))
			{
				if (!preg_match('/\A[+-]?\d+\Z/', (string)($var)))
				{
					if (isset($options['message']))
						$message = $options['message'];
					else
						$message = Errors::$DEFAULT_ERROR_MESSAGES['not_a_number'];

					$this->record->add($attribute, $message);
					continue;
				}
			}
			else
			{
				if (!is_numeric($var))
				{
					$this->record->add($attribute, Errors::$DEFAULT_ERROR_MESSAGES['not_a_number']);
					continue;
				}

				$var = (float)$var;
			}

			foreach ($numericalityOptions as $option => $check)
			{
				$option_value = $options[$option];

				if ('odd' != $option && 'even' != $option)
				{
					$option_value = (float)$options[$option];

					if (!is_numeric($option_value))
						throw new  ValidationsArgumentError("$option must be a number");

					if (isset($options['message']))
						$message = $options['message'];
					else
						$message = Errors::$DEFAULT_ERROR_MESSAGES[$option];

					$message = str_replace('%d', $option_value, $message);

					if ('greater_than' == $option && !($var > $option_value))
						$this->record->add($attribute, $message);

					elseif ('greater_than_or_equal_to' == $option && !($var >= $option_value))
						$this->record->add($attribute, $message);

					elseif ('equal_to' == $option && !($var == $option_value))
						$this->record->add($attribute, $message);

					elseif ('less_than' == $option && !($var < $option_value))
						$this->record->add($attribute, $message);

					elseif ('less_than_or_equal_to' == $option && !($var <= $option_value))
						$this->record->add($attribute, $message);
				}
				else
				{
					if (isset($options['message']))
						$message = $options['message'];
					else
						$message = Errors::$DEFAULT_ERROR_MESSAGES[$option];

					if ( ('odd' == $option && !( Utils::is_odd($var))) || ('even' == $option && ( Utils::is_odd($var))))
						$this->record->add($attribute, $message);
				}
			}
		}
	}

	/**
	 * Alias of {@link validates_length_of}
	 *
	 * @param array $attrs Validation definition
	 */
	public function validates_size_of($attrs)
	{
		$this->validates_length_of($attrs);
	}

	/**
	 * Validates that a value is matches a regex.
	 *
	 * <code>
	 * class Person extends ActiveRecord\Model {
	 *   static $validates_format_of = array(
	 *     array('email', 'with' => '/^.*?@.*$/')
	 *   );
	 * }
	 * </code>
	 *
	 * Available options:
	 *
	 * <ul>
	 * <li><b>with:</b> a regular expression</li>
	 * <li><b>message:</b> custom error message</li>
	 * </ul>
	 *
	 * @param array $attrs Validation definition
	 */
	public function validates_format_of($attrs)
	{
		$configuration = array_merge(self::$DEFAULT_VALIDATION_OPTIONS, array('message' => Errors::$DEFAULT_ERROR_MESSAGES['invalid'], 'on' => 'save', 'with' => null));

		foreach ($attrs as $attr)
		{
			$options = array_merge($configuration, $attr);
			$attribute = $options[0];
			$var = $this->model->$attribute;

			if (is_null($options['with']) || !is_string($options['with']) || !is_string($options['with']))
				throw new ValidationsArgumentError('A regular expression must be supplied as the [with] option of the configuration array.');
			else
				$expression = $options['with'];

			if ($this->is_null_with_option($var, $options) || $this->is_blank_with_option($var, $options))
				continue;

			if (!@preg_match($expression, $var))
			$this->record->add($attribute, $options['message']);
		}
	}

	/**
	 * Validates the length of a value.
	 *
	 * <code>
	 * class Person extends ActiveRecord\Model {
	 *   static $validates_length_of = array(
	 *     array('name', 'within' => array(1,50))
	 *   );
	 * }
	 * </code>
	 *
	 * Available options:
	 *
	 * <ul>
	 * <li><b>is:</b> attribute should be exactly n characters long</li>
	 * <li><b>in/within:</b> attribute should be within an range array(min,max)</li>
	 * <li><b>maximum/minimum:</b> attribute should not be above/below respectively</li>
	 * </ul>
	 *
	 * @param array $attrs Validation definition
	 */
	public function validates_length_of($attrs)
	{
		$configuration = array_merge(self::$DEFAULT_VALIDATION_OPTIONS, array(
			'too_long'     => Errors::$DEFAULT_ERROR_MESSAGES['too_long'],
			'too_short'    => Errors::$DEFAULT_ERROR_MESSAGES['too_short'],
			'wrong_length' => Errors::$DEFAULT_ERROR_MESSAGES['wrong_length']
		));

		foreach ($attrs as $attr)
		{
			$options = array_merge($configuration, $attr);
			$range_options = array_intersect(array_keys(self::$ALL_RANGE_OPTIONS), array_keys($attr));
			sort($range_options);

			switch (sizeof($range_options))
			{
				case 0:
					throw new  ValidationsArgumentError('Range unspecified.  Specify the [within], [maximum], or [is] option.');

				case 1:
					break;

				default:
					throw new  ValidationsArgumentError('Too many range options specified.  Choose only one.');
			}

			$attribute = $options[0];
			$var = $this->model->$attribute;
			$range_option = $range_options[0];

			if ($this->is_null_with_option($var, $options) || $this->is_blank_with_option($var, $options))
				continue;

			if ('within' == $range_option || 'in' == $range_option)
			{
				$range = $options[$range_options[0]];

				if (!(Utils::is_a('range', $range)))
					throw new  ValidationsArgumentError("$range_option must be an array composing a range of numbers with key [0] being less than key [1]");

				if (is_float($range[0]) || is_float($range[1]))
					throw new  ValidationsArgumentError("Range values cannot use floats for length.");

				if ((int)$range[0] <= 0 || (int)$range[1] <= 0)
					throw new  ValidationsArgumentError("Range values cannot use signed integers.");

				$too_short = isset($options['message']) ? $options['message'] : $options['too_short'];
				$too_long =  isset($options['message']) ? $options['message'] : $options['too_long'];

				$too_short = str_replace('%d', $range[0], $too_short);
				$too_long = str_replace('%d', $range[1], $too_long);

				if (strlen($this->model->$attribute) < (int)$range[0])
					$this->record->add($attribute, $too_short);
				elseif (strlen($this->model->$attribute) > (int)$range[1])
					$this->record->add($attribute, $too_long);
			}

			elseif ('is' == $range_option || 'minimum' == $range_option || 'maximum' == $range_option)
			{
				$option = $options[$range_option];

				if ((int)$option <= 0)
					throw new  ValidationsArgumentError("$range_option value cannot use a signed integer.");

				if (is_float($option))
					throw new  ValidationsArgumentError("$range_option value cannot use a float for length.");

				if (!is_null($this->model->$attribute))
				{
					$messageOptions = array('is' => 'wrong_length', 'minimum' => 'too_short', 'maximum' => 'too_long');

					if (isset($options[$messageOptions[$range_option]]))
						$message = $options[$messageOptions[$range_option]];
					else
						$message = $options['message'];

					$message = str_replace('%d', $option, $message);
					$attribute_value = $this->model->$attribute;
					$len = strlen($attribute_value);
					$value = (int)$attr[$range_option];

					if ('maximum' == $range_option && $len > $value)
						$this->record->add($attribute, $message);

					if ('minimum' == $range_option && $len < $value)
						$this->record->add($attribute, $message);

					if ('is' == $range_option && $len !== $value)
						$this->record->add($attribute, $message);
				}
			}
		}
	}

	/**
	 * Validates the uniqueness of a value.
	 *
	 * <code>
	 * class Person extends ActiveRecord\Model {
	 *   static $validates_uniqueness_of = array(
	 *     array('name'),
	 *     array(array('blah','bleh'), 'message' => 'blech')
	 *   );
	 * }
	 * </code>
	 *
	 * @param array $attrs Validation definition
	 */
	public function validates_uniqueness_of($attrs)
	{
		$configuration = array_merge(self::$DEFAULT_VALIDATION_OPTIONS, array(
			'message' => Errors::$DEFAULT_ERROR_MESSAGES['unique']
		));

		foreach ($attrs as $attr)
		{
			$options = array_merge($configuration, $attr);
			$pk = $this->model->get_primary_key();
			$pk_value = $this->model->$pk[0];

			if (is_array($options[0]))
			{
				$add_record = join("_and_", $options[0]);
				$fields = $options[0];
			}
			else
			{
				$add_record = $options[0];
				$fields = array($options[0]);
			}

			$sql = "";
			$conditions = array("");

			if ($pk_value === null)
				$sql = "{$pk[0]} is not null";
			else
			{
				$sql = "{$pk[0]}!=?";
				array_push($conditions,$pk_value);
			}

			foreach ($fields as $field)
			{
				$field = $this->model->get_real_attribute_name($field);
				$sql .= " and {$field}=?";
				array_push($conditions,$this->model->$field);
			}

			$conditions[0] = $sql;

			if ($this->model->exists(array('conditions' => $conditions)))
				$this->record->add($add_record, $options['message']);
		}
	}

	private function is_null_with_option($var, &$options)
	{
		return (is_null($var) && (isset($options['allow_null']) && $options['allow_null']));
	}

	private function is_blank_with_option($var, &$options)
	{
		return (Utils::is_blank($var) && (isset($options['allow_blank']) && $options['allow_blank']));
	}
}

/**
 * Class that holds {@link Validations} errors.
 *
 * @package ActiveRecord
 */
class Errors implements IteratorAggregate
{
	private $model;
	private $errors;

	public static $DEFAULT_ERROR_MESSAGES = array(
   		'inclusion'		=> "is not included in the list",
     	'exclusion'		=> "is reserved",
      	'invalid'		=> "is invalid",
      	'confirmation'	=> "doesn't match confirmation",
      	'accepted'		=> "must be accepted",
      	'empty'			=> "can't be empty",
      	'blank'			=> "can't be blank",
      	'too_long'		=> "is too long (maximum is %d characters)",
      	'too_short'		=> "is too short (minimum is %d characters)",
      	'wrong_length'	=> "is the wrong length (should be %d characters)",
      	'taken'			=> "has already been taken",
      	'not_a_number'	=> "is not a number",
      	'greater_than'	=> "must be greater than %d",
      	'equal_to'		=> "must be equal to %d",
      	'less_than'		=> "must be less than %d",
      	'odd'			=> "must be odd",
      	'even'			=> "must be even",
		'unique'		=> "must be unique",
      	'less_than_or_equal_to' => "must be less than or equal to %d",
      	'greater_than_or_equal_to' => "must be greater than or equal to %d"
   	);

   	/**
	 * Constructs an {@link Errors} object.
	 *
	 * @param Model $model The model the error is for
	 * @return Errors
   	 */
	public function __construct(Model $model)
	{
		$this->model = $model;
	}

	/**
	 * Nulls $model so we don't get pesky circular references. $model is only needed during the
	 * validation process and so can be safely cleared once that is done.
	 */
	public function clear_model()
	{
		$this->model = null;
	}

	/**
	 * Add an error message.
	 *
	 * @param string $attribute Name of an attribute on the model
	 * @param string $msg The error message
	 */
	public function add($attribute, $msg)
	{
		if (is_null($msg))
			$msg = self :: $DEFAULT_ERROR_MESSAGES['invalid'];

		if (!isset($this->errors[$attribute]))
			$this->errors[$attribute] = array($msg);
		else
			$this->errors[$attribute][] = $msg;
	}

	/**
	 * Adds an error message only if the attribute value is {@link http://www.php.net/empty empty}.
	 *
	 * @param string $attribute Name of an attribute on the model
	 * @param string $msg The error message
	 */
	public function add_on_empty($attribute, $msg)
	{
		if (empty($msg))
			$msg = self::$DEFAULT_ERROR_MESSAGES['empty'];

		if (empty($this->model->$attribute))
			$this->add($attribute, $msg);
	}

	/**
	 * Retrieve error message for an attribute.
	 *
	 * @param string $attribute Name of an attribute on the model
	 * @return string
	 */
	public function __get($attribute)
	{
		if (!isset($this->errors[$attribute]))
			return null;

		return $this->errors[$attribute];
	}

	/**
	 * Adds the error message only if the attribute value was null or an empty string.
	 *
	 * @param string $attribute Name of an attribute on the model
	 * @param string $msg The error message
	 */
	public function add_on_blank($attribute, $msg)
	{
		if (!$msg)
			$msg = self::$DEFAULT_ERROR_MESSAGES['blank'];

		if (($value = $this->model->$attribute) === '' || $value === null)
			$this->add($attribute, $msg);
	}

	/**
	 * Returns true if the specified attribute had any error messages.
	 *
	 * @param string $attribute Name of an attribute on the model
	 * @return boolean
	 */
	public function is_invalid($attribute)
	{
		return isset($this->errors[$attribute]);
	}

	/**
	 * Returns the error message for the specified attribute or null if none.
	 *
	 * @param string $attribute Name of an attribute on the model
	 * @return string
	 */
	public function on($attribute)
	{
		if (!isset($this->errors[$attribute]))
			return null;

		$errors = $this->errors[$attribute];

		if (null === $errors)
			return null;
		else
			return count($errors) == 1 ? $errors[0] : $errors;
	}

	/**
	 * Returns all the error messages as an array.
	 *
	 * <code>
	 * $model->errors->full_messages();
	 *
	 * # array(
	 * #  "Name can't be blank",
	 * #  "State is the wrong length (should be 2 chars)"
	 * # )
	 * </code>
	 *
	 * @param array $options Options for messages
	 * @return array
	 */
	public function full_messages()
	{
		$full_messages = array();

		if ($this->errors)
		{
			foreach ($this->errors as $attribute => $messages)
			{
				foreach ($messages as $msg)
				{
					if (is_null($msg))
						continue;

					$full_messages[] = Utils::human_attribute($attribute) . ' ' . $msg;
				}
			}
		}
		return $full_messages;
	}

	/**
	 * Returns true if there are no error messages.
	 * @return boolean
	 */
	public function is_empty()
	{
		return empty($this->errors);
	}

	/**
	 * Clears out all error messages.
	 */
	public function clear()
	{
		$this->errors = array();
	}

	/**
	 * Returns the number of error messages there are.
	 * @return int
	 */
	public function size()
	{
		if ($this->is_empty())
			return 0;

		$count = 0;

		foreach ($this->errors as $attribute => $error)
			$count += count($error);

		return $count;
	}

	/**
	 * Returns an iterator to the error messages.
	 *
	 * This will allow you to iterate over the {@link Errors} object using foreach.
	 *
	 * <code>
	 * foreach ($model->errors as $msg)
	 *   echo "$msg\n";
	 * </code>
	 *
	 * @return ArrayIterator
	 */
	public function getIterator()
	{
		return new ArrayIterator($this->full_messages());
	}
};
?>
Return current item: ActiveRecord