Skip to content
Permalink
master
Switch branches/tags

Name already in use

A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
Go to file
 
 
Cannot retrieve contributors at this time
<?php
/**
* Handles validation for fActiveRecord classes
*
* @copyright Copyright (c) 2007-2012 Will Bond, others
* @author Will Bond [wb] <will@flourishlib.com>
* @author Jeff Turcotte [jt] <jeff.turcotte@gmail.com>
* @license http://flourishlib.com/license
*
* @package Flourish
* @link http://flourishlib.com/fORMValidation
*
* @version 1.0.0b32
* @changes 1.0.0b32 Fixed an array to string conversion notice [wb, 2012-09-21]
* @changes 1.0.0b31 Fixed ::checkConditionalRule() to require columns that default to an empty string and are currently set to that value [wb, 2011-06-14]
* @changes 1.0.0b30 Fixed a bug with ::setMessageOrder() not accepting a variable number of parameters like fValidation::setMessageOrder() does [wb, 2011-03-07]
* @changes 1.0.0b29 Updated ::addManyToManyRule() and ::addOneToManyRule() to prefix any namespace from `$class` to `$related_class` if not already present [wb, 2010-11-24]
* @changes 1.0.0b28 Updated the class to work with the new nested array structure for validation messages [wb, 2010-10-03]
* @changes 1.0.0b27 Fixed ::hasValue() to properly detect zero-value floats, made ::hasValue() internal public [wb, 2010-07-26]
* @changes 1.0.0b26 Improved the error message for integers to say `whole number` instead of just `number` [wb, 2010-05-29]
* @changes 1.0.0b25 Added ::addRegexRule(), changed validation messages array to use column name keys [wb, 2010-05-26]
* @changes 1.0.0b24 Added ::addRequiredRule() for required columns that aren't automatically handled via schema detection [wb, 2010-04-06]
* @changes 1.0.0b23 Added support for checking integers and floats to ensure they fit within the range imposed by the database schema [wb, 2010-03-17]
* @changes 1.0.0b22 Made the value checking for one-or-more and only-one rules more robust when detecting the absence of a value [wb, 2009-12-17]
* @changes 1.0.0b21 Fixed a bug affecting where conditions with columns that are not null but have a default value [wb, 2009-11-03]
* @changes 1.0.0b20 Updated code for the new fORMDatabase and fORMSchema APIs [wb, 2009-10-28]
* @changes 1.0.0b19 Changed SQL statements to use value placeholders, identifier escaping and schema support [wb, 2009-10-22]
* @changes 1.0.0b18 Fixed ::checkOnlyOneRule() and ::checkOneOrMoreRule() to consider blank strings as NULL [wb, 2009-08-21]
* @changes 1.0.0b17 Added @internal methods ::removeStringReplacement() and ::removeRegexReplacement() [wb, 2009-07-29]
* @changes 1.0.0b16 Backwards Compatibility Break - renamed ::addConditionalValidationRule() to ::addConditionalRule(), ::addManyToManyValidationRule() to ::addManyToManyRule(), ::addOneOrMoreValidationRule() to ::addOneOrMoreRule(), ::addOneToManyValidationRule() to ::addOneToManyRule(), ::addOnlyOneValidationRule() to ::addOnlyOneRule(), ::addValidValuesValidationRule() to ::addValidValuesRule() [wb, 2009-07-13]
* @changes 1.0.0b15 Added ::addValidValuesValidationRule() [wb/jt, 2009-07-13]
* @changes 1.0.0b14 Added ::addStringReplacement() and ::addRegexReplacement() for simple validation message modification [wb, 2009-07-01]
* @changes 1.0.0b13 Changed ::reorderMessages() to compare string in a case-insensitive manner [wb, 2009-06-30]
* @changes 1.0.0b12 Updated ::addConditionalValidationRule() to allow any number of `$main_columns`, and if any of those have a matching value, the condtional columns will be required [wb, 2009-06-30]
* @changes 1.0.0b11 Fixed a couple of bugs with validating related records [wb, 2009-06-26]
* @changes 1.0.0b10 Fixed UNIQUE constraint checking so it is only done once per constraint, fixed some UTF-8 case sensitivity issues [wb, 2009-06-17]
* @changes 1.0.0b9 Updated code for new fORM API [wb, 2009-06-15]
* @changes 1.0.0b8 Updated code to use new fValidationException::formatField() method [wb, 2009-06-04]
* @changes 1.0.0b7 Updated ::validateRelated() to use new fORMRelated::validate() method and ::checkRelatedOneOrMoreRule() to use new `$related_records` structure [wb, 2009-06-02]
* @changes 1.0.0b6 Changed date/time/timestamp checking from `strtotime()` to fDate/fTime/fTimestamp for better localization support [wb, 2009-06-01]
* @changes 1.0.0b5 Fixed a bug in ::checkOnlyOneRule() where no values would not be flagged as an error [wb, 2009-04-23]
* @changes 1.0.0b4 Fixed a bug in ::checkUniqueConstraints() related to case-insensitive columns [wb, 2009-02-15]
* @changes 1.0.0b3 Implemented proper fix for ::addManyToManyValidationRule() [wb, 2008-12-12]
* @changes 1.0.0b2 Fixed a bug with ::addManyToManyValidationRule() [wb, 2008-12-08]
* @changes 1.0.0b The initial implementation [wb, 2007-08-04]
*/
class fORMValidation
{
// The following constants allow for nice looking callbacks to static methods
const addConditionalRule = 'fORMValidation::addConditionalRule';
const addManyToManyRule = 'fORMValidation::addManyToManyRule';
const addOneOrMoreRule = 'fORMValidation::addOneOrMoreRule';
const addOneToManyRule = 'fORMValidation::addOneToManyRule';
const addOnlyOneRule = 'fORMValidation::addOnlyOneRule';
const addRegexReplacement = 'fORMValidation::addRegexReplacement';
const addRegexRule = 'fORMValidation::addRegexRule';
const addRequiredRule = 'fORMValidation::addRequiredRule';
const addStringReplacement = 'fORMValidation::addStringReplacement';
const addValidValuesRule = 'fORMValidation::addValidValuesRule';
const hasValue = 'fORMValidation::hasValue';
const inspect = 'fORMValidation::inspect';
const removeStringReplacement = 'fORMValidation::removeStringReplacement';
const removeRegexReplacement = 'fORMValidation::removeRegexReplacement';
const reorderMessages = 'fORMValidation::reorderMessages';
const replaceMessages = 'fORMValidation::replaceMessages';
const reset = 'fORMValidation::reset';
const setColumnCaseInsensitive = 'fORMValidation::setColumnCaseInsensitive';
const setMessageOrder = 'fORMValidation::setMessageOrder';
const validate = 'fORMValidation::validate';
const validateRelated = 'fORMValidation::validateRelated';
/**
* Columns that should be treated as case insensitive when checking uniqueness
*
* @var array
*/
static private $case_insensitive_columns = array();
/**
* Conditional rules
*
* @var array
*/
static private $conditional_rules = array();
/**
* Ordering rules for messages
*
* @var array
*/
static private $message_orders = array();
/**
* One or more rules
*
* @var array
*/
static private $one_or_more_rules = array();
/**
* Only one rules
*
* @var array
*/
static private $only_one_rules = array();
/**
* Regular expression replacements performed on each message
*
* @var array
*/
static private $regex_replacements = array();
/**
* Rules that require at least one or more *-to-many related records to be associated
*
* @var array
*/
static private $related_one_or_more_rules = array();
/**
* Rules that require a value to match a regular expression
*
* @var array
*/
static private $regex_rules = array();
/**
* Rules that require a value be present in a column even if the database schema doesn't require it
*
* @var array
*/
static private $required_rules = array();
/**
* String replacements performed on each message
*
* @var array
*/
static private $string_replacements = array();
/**
* Valid values rules
*
* @var array
*/
static private $valid_values_rules = array();
/**
* Adds a conditional rule
*
* If a non-empty value is found in one of the `$main_columns`, or if
* specified, a value from the `$conditional_values` array, all of the
* `$conditional_columns` will also be required to have a value.
*
* @param mixed $class The class name or instance of the class this rule applies to
* @param string|array $main_columns The column(s) to check for a value
* @param mixed $conditional_values If `NULL`, any value in the main column will trigger the conditional column(s), otherwise the value must match this scalar value or be present in the array of values
* @param string|array $conditional_columns The column(s) that are to be required
* @return void
*/
static public function addConditionalRule($class, $main_columns, $conditional_values, $conditional_columns)
{
$class = fORM::getClass($class);
if (!isset(self::$conditional_rules[$class])) {
self::$conditional_rules[$class] = array();
}
settype($main_columns, 'array');
settype($conditional_columns, 'array');
if ($conditional_values !== NULL) {
settype($conditional_values, 'array');
}
$rule = array();
$rule['main_columns'] = $main_columns;
$rule['conditional_values'] = $conditional_values;
$rule['conditional_columns'] = $conditional_columns;
self::$conditional_rules[$class][] = $rule;
}
/**
* Add a many-to-many rule that requires at least one related record is associated with the current record
*
* @param mixed $class The class name or instance of the class to add the rule for
* @param string $related_class The name of the related class
* @param string $route The route to the related class
* @return void
*/
static public function addManyToManyRule($class, $related_class, $route=NULL)
{
$class = fORM::getClass($class);
$related_class = fORM::getRelatedClass($class, $related_class);
if (!isset(self::$related_one_or_more_rules[$class])) {
self::$related_one_or_more_rules[$class] = array();
}
if (!isset(self::$related_one_or_more_rules[$class][$related_class])) {
self::$related_one_or_more_rules[$class][$related_class] = array();
}
$route = fORMSchema::getRouteName(
fORMSchema::retrieve($class),
fORM::tablize($class),
fORM::tablize($related_class),
$route,
'many-to-many'
);
self::$related_one_or_more_rules[$class][$related_class][$route] = TRUE;
}
/**
* Adds a one-or-more rule that requires at least one of the columns specified has a value
*
* @param mixed $class The class name or instance of the class the columns exists in
* @param array $columns The columns to check
* @return void
*/
static public function addOneOrMoreRule($class, $columns)
{
$class = fORM::getClass($class);
settype($columns, 'array');
if (!isset(self::$one_or_more_rules[$class])) {
self::$one_or_more_rules[$class] = array();
}
$rule = array();
$rule['columns'] = $columns;
self::$one_or_more_rules[$class][] = $rule;
}
/**
* Add a one-to-many rule that requires at least one related record is associated with the current record
*
* @param mixed $class The class name or instance of the class to add the rule for
* @param string $related_class The name of the related class
* @param string $route The route to the related class
* @return void
*/
static public function addOneToManyRule($class, $related_class, $route=NULL)
{
$class = fORM::getClass($class);
$related_class = fORM::getRelatedClass($class, $related_class);
if (!isset(self::$related_one_or_more_rules[$class])) {
self::$related_one_or_more_rules[$class] = array();
}
if (!isset(self::$related_one_or_more_rules[$class][$related_class])) {
self::$related_one_or_more_rules[$class][$related_class] = array();
}
$route = fORMSchema::getRouteName(
fORMSchema::retrieve($class),
fORM::tablize($class),
fORM::tablize($related_class),
$route,
'one-to-many'
);
self::$related_one_or_more_rules[$class][$related_class][$route] = TRUE;
}
/**
* Add an only-one rule that requires exactly one of the columns must have a value
*
* @param mixed $class The class name or instance of the class the columns exists in
* @param array $columns The columns to check
* @return void
*/
static public function addOnlyOneRule($class, $columns)
{
$class = fORM::getClass($class);
settype($columns, 'array');
if (!isset(self::$only_one_rules[$class])) {
self::$only_one_rules[$class] = array();
}
$rule = array();
$rule['columns'] = $columns;
self::$only_one_rules[$class][] = $rule;
}
/**
* Adds a call to [http://php.net/preg_replace `preg_replace()`] for each message
*
* Regex replacement is done after the `post::validate()` hook, and right
* before the messages are reordered.
*
* If a message is an empty string after replacement, it will be
* removed from the list of messages.
*
* @param mixed $class The class name or instance of the class the columns exists in
* @param string $search The PCRE regex to search for - see http://php.net/pcre for details
* @param string $replace The string to replace with - all $ and \ are used in back references and must be escaped with a \ when meant literally
* @return void
*/
static public function addRegexReplacement($class, $search, $replace)
{
$class = fORM::getClass($class);
if (!isset(self::$regex_replacements[$class])) {
self::$regex_replacements[$class] = array(
'search' => array(),
'replace' => array()
);
}
self::$regex_replacements[$class]['search'][] = $search;
self::$regex_replacements[$class]['replace'][] = $replace;
}
/**
* Adds a rule to validate a column against a PCRE regular expression - the rule is not run if the value is `NULL`
*
* @param mixed $class The class name or instance of the class the columns exists in
* @param string $column The column to match with the regex
* @param string $regex The PCRE regex to match against - see http://php.net/pcre for details
* @param string $message The message to use if the value does not match the regular expression
* @return void
*/
static public function addRegexRule($class, $column, $regex, $message)
{
$class = fORM::getClass($class);
if (!isset(self::$regex_rules[$class])) {
self::$regex_rules[$class] = array();
}
self::$regex_rules[$class][$column] = array(
'regex' => $regex,
'message' => $message
);
}
/**
* Requires that a column have a non-`NULL` value
*
* Before using this method, try setting the database column to `NOT NULL`
* and remove any default value. Such a configuration will trigger the same
* functionality as this method, and will enforce the rule on the database
* level for any other code that queries it.
*
* @param mixed $class The class name or instance of the class the column(s) exists in
* @param array $columns The column or columns to check - each column will require a value
* @return void
*/
static public function addRequiredRule($class, $columns)
{
$class = fORM::getClass($class);
settype($columns, 'array');
if (!isset(self::$required_rules[$class])) {
self::$required_rules[$class] = array();
}
foreach ($columns as $column) {
self::$required_rules[$class][$column] = TRUE;
}
}
/**
* Adds a call to [http://php.net/str_replace `str_replace()`] for each message
*
* String replacement is done after the `post::validate()` hook, and right
* before the messages are reordered.
*
* If a message is an empty string after replacement, it will be
* removed from the list of messages.
*
* @param mixed $class The class name or instance of the class the columns exists in
* @param string $search The string to search for
* @param string $replace The string to replace with
* @return void
*/
static public function addStringReplacement($class, $search, $replace)
{
$class = fORM::getClass($class);
if (!isset(self::$string_replacements[$class])) {
self::$string_replacements[$class] = array(
'search' => array(),
'replace' => array()
);
}
self::$string_replacements[$class]['search'][] = $search;
self::$string_replacements[$class]['replace'][] = $replace;
}
/**
* Restricts a column to having only a value from the list of valid values
*
* Please note that `NULL` values are always allowed, even if not listed in
* the `$valid_values` array, if the column is not set as `NOT NULL`.
*
* This functionality can also be accomplished by added a `CHECK` constraint
* on the column in the database, or using a MySQL `ENUM` data type.
*
* @param mixed $class The class name or instance of the class this rule applies to
* @param string $column The column to validate
* @param array $valid_values The valid values to check - `NULL` values are always allows if the column is not set to `NOT NULL`
* @return void
*/
static public function addValidValuesRule($class, $column, $valid_values)
{
$class = fORM::getClass($class);
if (!isset(self::$valid_values_rules[$class])) {
self::$valid_values_rules[$class] = array();
}
settype($valid_values, 'array');
self::$valid_values_rules[$class][$column] = $valid_values;
fORM::registerInspectCallback($class, $column, self::inspect);
}
/**
* Validates a value against the database schema
*
* @param fSchema $schema The schema object for the object
* @param fActiveRecord $object The instance of the class the column is part of
* @param string $column The column to check
* @param array &$values An associative array of all values going into the row (needs all for multi-field unique constraint checking)
* @param array &$old_values The old values from the record
* @return string An error message for the column specified
*/
static private function checkAgainstSchema($schema, $object, $column, &$values, &$old_values)
{
$class = get_class($object);
$table = fORM::tablize($class);
$info = $schema->getColumnInfo($table, $column);
// Make sure a value is provided for required columns
$schema_not_null = $info['not_null'] && $info['default'] === NULL && $info['auto_increment'] === FALSE;
$rule_not_null = isset(self::$required_rules[$class][$column]);
if ($values[$column] === NULL && ($schema_not_null || $rule_not_null)) {
return self::compose(
'%sPlease enter a value',
fValidationException::formatField(fORM::getColumnName($class, $column))
);
}
$message = self::checkDataType($schema, $class, $column, $values[$column]);
if ($message) { return $message; }
// Make sure a valid value is chosen
if (isset($info['valid_values']) && $values[$column] !== NULL && !in_array($values[$column], $info['valid_values'])) {
return self::compose(
'%1$sPlease choose from one of the following: %2$s',
fValidationException::formatField(fORM::getColumnName($class, $column)),
join(', ', $info['valid_values'])
);
}
// Make sure the value isn't too long
if ($info['type'] == 'varchar' && isset($info['max_length']) && $values[$column] !== NULL && is_string($values[$column]) && fUTF8::len($values[$column]) > $info['max_length']) {
return self::compose(
'%1$sPlease enter a value no longer than %2$s characters',
fValidationException::formatField(fORM::getColumnName($class, $column)),
$info['max_length']
);
}
// Make sure the value is the proper length
if ($info['type'] == 'char' && isset($info['max_length']) && $values[$column] !== NULL && is_string($values[$column]) && fUTF8::len($values[$column]) != $info['max_length']) {
return self::compose(
'%1$sPlease enter exactly %2$s characters',
fValidationException::formatField(fORM::getColumnName($class, $column)),
$info['max_length']
);
}
// Make sure the value fits in the numeric range
if (self::stringlike($values[$column]) && in_array($info['type'], array('integer', 'float')) && $info['min_value'] && $info['max_value'] && ($info['min_value']->gt($values[$column]) || $info['max_value']->lt($values[$column]))) {
return self::compose(
'%1$sPlease enter a number between %2$s and %3$s',
fValidationException::formatField(fORM::getColumnName($class, $column)),
$info['min_value']->__toString(),
$info['max_value']->__toString()
);
}
$message = self::checkForeignKeyConstraints($schema, $class, $column, $values);
if ($message) { return $message; }
}
/**
* Validates against a conditional rule
*
* @param fSchema $schema The schema object for the class specified
* @param string $class The class this rule applies to
* @param array &$values An associative array of all values for the record
* @param array $main_columns The columns to check for a value
* @param array $conditional_values If `NULL`, any value in the main column will trigger the conditional columns, otherwise the value must match one of these
* @param array $conditional_columns The columns that are to be required
* @return array The error messages for the rule specified
*/
static private function checkConditionalRule($schema, $class, &$values, $main_columns, $conditional_values, $conditional_columns)
{
$check_for_missing_values = FALSE;
foreach ($main_columns as $main_column) {
$matches_conditional_value = $conditional_values !== NULL && in_array($values[$main_column], $conditional_values);
$has_some_value = $conditional_values === NULL && strlen((string) $values[$main_column]);
if ($matches_conditional_value || $has_some_value) {
$check_for_missing_values = TRUE;
break;
}
}
if (!$check_for_missing_values) {
return;
}
$table = fORM::tablize($class);
$messages = array();
foreach ($conditional_columns as $conditional_column) {
$default_is_space = $schema->getColumnInfo($table, $conditional_column, 'default') === '';
if ($values[$conditional_column] !== NULL && (!$default_is_space || ($default_is_space && $values[$conditional_column] !== ''))) { continue; }
$messages[$conditional_column] = self::compose(
'%sPlease enter a value',
fValidationException::formatField(fORM::getColumnName($class, $conditional_column))
);
}
if ($messages) {
return $messages;
}
}
/**
* Validates a value against the database data type
*
* @param fSchema $schema The schema object for the class
* @param string $class The class the column is part of
* @param string $column The column to check
* @param mixed $value The value to check
* @return string An error message for the column specified
*/
static private function checkDataType($schema, $class, $column, $value)
{
$table = fORM::tablize($class);
$column_info = $schema->getColumnInfo($table, $column);
if ($value !== NULL) {
switch ($column_info['type']) {
case 'varchar':
case 'char':
case 'text':
case 'blob':
if (!is_string($value) && !is_numeric($value)) {
return self::compose(
'%sPlease enter a string',
fValidationException::formatField(fORM::getColumnName($class, $column))
);
}
break;
case 'integer':
if (!is_numeric($value)) {
return self::compose(
'%sPlease enter a whole number',
fValidationException::formatField(fORM::getColumnName($class, $column))
);
}
break;
case 'float':
if (!is_numeric($value)) {
return self::compose(
'%sPlease enter a number',
fValidationException::formatField(fORM::getColumnName($class, $column))
);
}
break;
case 'timestamp':
try {
new fTimestamp($value);
} catch (fValidationException $e) {
return self::compose(
'%sPlease enter a date/time',
fValidationException::formatField(fORM::getColumnName($class, $column))
);
}
break;
case 'date':
try {
new fDate($value);
} catch (fValidationException $e) {
return self::compose(
'%sPlease enter a date',
fValidationException::formatField(fORM::getColumnName($class, $column))
);
}
break;
case 'time':
try {
new fTime($value);
} catch (fValidationException $e) {
return self::compose(
'%sPlease enter a time',
fValidationException::formatField(fORM::getColumnName($class, $column))
);
}
break;
}
}
}
/**
* Validates values against foreign key constraints
*
* @param fSchema $schema The schema object for the class
* @param string $class The class to check the foreign keys for
* @param string $column The column to check
* @param array &$values The values to check
* @return string An error message for the column specified
*/
static private function checkForeignKeyConstraints($schema, $class, $column, &$values)
{
if ($values[$column] === NULL) {
return;
}
$db = fORMDatabase::retrieve($class, 'read');
$table = fORM::tablize($class);
$foreign_keys = $schema->getKeys($table, 'foreign');
foreach ($foreign_keys AS $foreign_key) {
if ($foreign_key['column'] == $column) {
try {
$params = array(
"SELECT %r FROM %r WHERE " . fORMDatabase::makeCondition($schema, $table, $column, '=', $values[$column]),
$foreign_key['foreign_column'],
$foreign_key['foreign_table'],
$foreign_key['foreign_column'],
$values[$column]
);
$result = call_user_func_array($db->translatedQuery, $params);
$result->tossIfNoRows();
} catch (fNoRowsException $e) {
return self::compose(
'%sThe value specified is invalid',
fValidationException::formatField(fORM::getColumnName($class, $column))
);
}
}
}
}
/**
* Validates against a one-or-more rule
*
* @param fSchema $schema The schema object for the table
* @param string $class The class the columns are part of
* @param array &$values An associative array of all values for the record
* @param array $columns The columns to check
* @return string An error message for the rule
*/
static private function checkOneOrMoreRule($schema, $class, &$values, $columns)
{
settype($columns, 'array');
$found_value = FALSE;
foreach ($columns as $column) {
if (self::hasValue($schema, $class, $values, $column)) {
$found_value = TRUE;
}
}
if (!$found_value) {
$column_names = array();
foreach ($columns as $column) {
$column_names[] = fORM::getColumnName($class, $column);
}
return self::compose(
'%sPlease enter a value for at least one',
fValidationException::formatField(join(', ', $column_names))
);
}
}
/**
* Validates against an only-one rule
*
* @param fSchema $schema The schema object for the table
* @param string $class The class the columns are part of
* @param array &$values An associative array of all values for the record
* @param array $columns The columns to check
* @return string An error message for the rule
*/
static private function checkOnlyOneRule($schema, $class, &$values, $columns)
{
settype($columns, 'array');
$column_names = array();
foreach ($columns as $column) {
$column_names[] = fORM::getColumnName($class, $column);
}
$found_value = FALSE;
foreach ($columns as $column) {
if (self::hasValue($schema, $class, $values, $column)) {
if ($found_value) {
return self::compose(
'%sPlease enter a value for only one',
fValidationException::formatField(join(', ', $column_names))
);
}
$found_value = TRUE;
}
}
if (!$found_value) {
return self::compose(
'%sPlease enter a value for one',
fValidationException::formatField(join(', ', $column_names))
);
}
}
/**
* Makes sure a record with the same primary keys is not already in the database
*
* @param fSchema $schema The schema object for the object
* @param fActiveRecord $object The instance of the class to check
* @param array &$values An associative array of all values going into the row (needs all for multi-field unique constraint checking)
* @param array &$old_values The old values for the record
* @return array A single element associative array with the key being the primary keys joined by ,s and the value being the error message
*/
static private function checkPrimaryKeys($schema, $object, &$values, &$old_values)
{
$class = get_class($object);
$table = fORM::tablize($class);
$db = fORMDatabase::retrieve($class, 'read');
$pk_columns = $schema->getKeys($table, 'primary');
$columns = array();
$found_value = FALSE;
foreach ($pk_columns as $pk_column) {
$columns[] = fORM::getColumnName($class, $pk_column);
if ($values[$pk_column]) {
$found_value = TRUE;
}
}
if (!$found_value) {
return;
}
$different = FALSE;
foreach ($pk_columns as $pk_column) {
if (!fActiveRecord::hasOld($old_values, $pk_column)) {
continue;
}
$old_value = fActiveRecord::retrieveOld($old_values, $pk_column);
$value = $values[$pk_column];
if (self::isCaseInsensitive($class, $pk_column) && self::stringlike($value) && self::stringlike($old_value)) {
if (fUTF8::lower($value) != fUTF8::lower($old_value)) {
$different = TRUE;
}
} elseif ($old_value != $value) {
$different = TRUE;
}
}
if (!$different) {
return;
}
try {
$params = array(
"SELECT %r FROM %r WHERE ",
$pk_columns,
$table
);
$column_info = $schema->getColumnInfo($table);
$conditions = array();
foreach ($pk_columns as $pk_column) {
$value = $values[$pk_column];
// This makes sure the query performs the way an insert will
if ($value === NULL && $column_info[$pk_column]['not_null'] && $column_info[$pk_column]['default'] !== NULL) {
$value = $column_info[$pk_column]['default'];
}
if (self::isCaseInsensitive($class, $pk_column) && self::stringlike($value)) {
$condition = fORMDatabase::makeCondition($schema, $table, $pk_column, '=', $value);
$conditions[] = str_replace('%r', 'LOWER(%r)', $condition);
$params[] = $pk_column;
$params[] = fUTF8::lower($value);
} else {
$conditions[] = fORMDatabase::makeCondition($schema, $table, $pk_column, '=', $value);
$params[] = $pk_column;
$params[] = $value;
}
}
$params[0] .= join(' AND ', $conditions);
$result = call_user_func_array($db->translatedQuery, $params);
$result->tossIfNoRows();
return array(join(',', $pk_columns) => self::compose(
'Another %1$s with the same %2$s already exists',
fORM::getRecordName($class),
fGrammar::joinArray($columns, 'and')
));
} catch (fNoRowsException $e) { }
}
/**
* Validates against a regex rule
*
* @param string $class The class the column is part of
* @param array &$values An associative array of all values for the record
* @param string $column The column to check
* @param string $regex The PCRE regular expression
* @param string $message The message to use if the value does not match the regular expression
* @return string An error message for the rule
*/
static private function checkRegexRule($class, &$values, $column, $regex, $message)
{
if ($values[$column] === NULL) {
return;
}
if (preg_match($regex, $values[$column])) {
return;
}
return self::compose(
'%s' . str_replace('%', '%%', $message),
fValidationException::formatField(fORM::getColumnName($class, $column))
);
}
/**
* Validates against a *-to-many one or more rule
*
* @param fActiveRecord $object The object being checked
* @param array &$values The values for the object
* @param array &$related_records The related records for the object
* @param string $related_class The name of the related class
* @param string $route The name of the route from the class to the related class
* @return string An error message for the rule
*/
static private function checkRelatedOneOrMoreRule($object, &$values, &$related_records, $related_class, $route)
{
$related_table = fORM::tablize($related_class);
$class = get_class($object);
$exists = $object->exists();
$records_are_set = isset($related_records[$related_table][$route]);
$has_records = $records_are_set && $related_records[$related_table][$route]['count'];
if ($exists && (!$records_are_set || $has_records)) {
return;
}
if (!$exists && $has_records) {
return;
}
return self::compose(
'%sPlease select at least one',
fValidationException::formatField(fGrammar::pluralize(fORMRelated::getRelatedRecordName($class, $related_class, $route)))
);
}
/**
* Validates values against unique constraints
*
* @param fSchema $schema The schema object for the object
* @param fActiveRecord $object The instance of the class to check
* @param array &$values The values to check
* @param array &$old_values The old values for the record
* @return array An aray of error messages for the unique constraints
*/
static private function checkUniqueConstraints($schema, $object, &$values, &$old_values)
{
$class = get_class($object);
$table = fORM::tablize($class);
$db = fORMDatabase::retrieve($class, 'read');
$key_info = $schema->getKeys($table);
$pk_columns = $key_info['primary'];
$unique_keys = $key_info['unique'];
$messages = array();
foreach ($unique_keys AS $unique_columns) {
settype($unique_columns, 'array');
// NULL values are unique
$found_not_null = FALSE;
foreach ($unique_columns as $unique_column) {
if ($values[$unique_column] !== NULL) {
$found_not_null = TRUE;
}
}
if (!$found_not_null) {
continue;
}
$params = array(
"SELECT %r FROM %r WHERE ",
$key_info['primary'],
$table
);
$column_info = $schema->getColumnInfo($table);
$conditions = array();
foreach ($unique_columns as $unique_column) {
$value = $values[$unique_column];
// This makes sure the query performs the way an insert will
if ($value === NULL && $column_info[$unique_column]['not_null'] && $column_info[$unique_column]['default'] !== NULL) {
$value = $column_info[$unique_column]['default'];
}
if (self::isCaseInsensitive($class, $unique_column) && self::stringlike($value)) {
$condition = fORMDatabase::makeCondition($schema, $table, $unique_column, '=', $value);
$conditions[] = str_replace('%r', 'LOWER(%r)', $condition);
$params[] = $table . '.' . $unique_column;
$params[] = fUTF8::lower($value);
} else {
$conditions[] = fORMDatabase::makeCondition($schema, $table, $unique_column, '=', $value);
$params[] = $table . '.' . $unique_column;
$params[] = $value;
}
}
$params[0] .= join(' AND ', $conditions);
if ($object->exists()) {
foreach ($pk_columns as $pk_column) {
$value = fActiveRecord::retrieveOld($old_values, $pk_column, $values[$pk_column]);
$params[0] .= ' AND ' . fORMDatabase::makeCondition($schema, $table, $pk_column, '<>', $value);
$params[] = $table . '.' . $pk_column;
$params[] = $value;
}
}
try {
$result = call_user_func_array($db->translatedQuery, $params);
$result->tossIfNoRows();
// If an exception was not throw, we have existing values
$column_names = array();
foreach ($unique_columns as $unique_column) {
$column_names[] = fORM::getColumnName($class, $unique_column);
}
if (sizeof($column_names) == 1) {
$messages[join('', $unique_columns)] = self::compose(
'%sThe value specified must be unique, however it already exists',
fValidationException::formatField(join('', $column_names))
);
} else {
$messages[join(',', $unique_columns)] = self::compose(
'%sThe values specified must be a unique combination, however the specified combination already exists',
fValidationException::formatField(join(', ', $column_names))
);
}
} catch (fNoRowsException $e) { }
}
return $messages;
}
/**
* Validates against a valid values rule
*
* @param string $class The class this rule applies to
* @param array &$values An associative array of all values for the record
* @param string $column The column the rule applies to
* @param array $valid_values An array of valid values to check the column against
* @return string The error message for the rule specified
*/
static private function checkValidValuesRule($class, &$values, $column, $valid_values)
{
if ($values[$column] === NULL) {
return;
}
if (!in_array($values[$column], $valid_values)) {
return self::compose(
'%1$sPlease choose from one of the following: %2$s',
fValidationException::formatField(fORM::getColumnName($class, $column)),
join(', ', $valid_values)
);
}
}
/**
* Composes text using fText if loaded
*
* @param string $message The message to compose
* @param mixed $component A string or number to insert into the message
* @param mixed ...
* @return string The composed and possible translated message
*/
static private function compose($message)
{
$args = array_slice(func_get_args(), 1);
if (class_exists('fText', FALSE)) {
return call_user_func_array(
array('fText', 'compose'),
array($message, $args)
);
} else {
return vsprintf($message, $args);
}
}
/**
* Makes sure each rule array is set to at least an empty array
*
* @internal
*
* @param string $class The class to initilize the arrays for
* @return void
*/
static private function initializeRuleArrays($class)
{
self::$conditional_rules[$class] = (isset(self::$conditional_rules[$class])) ? self::$conditional_rules[$class] : array();
self::$one_or_more_rules[$class] = (isset(self::$one_or_more_rules[$class])) ? self::$one_or_more_rules[$class] : array();
self::$only_one_rules[$class] = (isset(self::$only_one_rules[$class])) ? self::$only_one_rules[$class] : array();
self::$regex_rules[$class] = (isset(self::$regex_rules[$class])) ? self::$regex_rules[$class] : array();
self::$related_one_or_more_rules[$class] = (isset(self::$related_one_or_more_rules[$class])) ? self::$related_one_or_more_rules[$class] : array();
self::$valid_values_rules[$class] = (isset(self::$valid_values_rules[$class])) ? self::$valid_values_rules[$class] : array();
}
/**
* Adds metadata about features added by this class
*
* @internal
*
* @param string $class The class being inspected
* @param string $column The column being inspected
* @param array &$metadata The array of metadata about a column
* @return void
*/
static public function inspect($class, $column, &$metadata)
{
if (!empty(self::$valid_values_rules[$class][$column])) {
$metadata['valid_values'] = self::$valid_values_rules[$class][$column];
}
}
/**
* Checks to see if a columns has a value, but based on the schema and if the column allows NULL
*
* If the columns allows NULL values, than anything other than NULL
* will be returned as TRUE. If the column does not allow NULL and
* the value is anything other than the "empty" value for that data type,
* then TRUE will be returned.
*
* The values that are considered "empty" for each data type are as follows.
* Please note that there is no "empty" value for dates, times or
* timestamps.
*
* - Blob: ''
* - Boolean: FALSE
* - Float: 0.0
* - Integer: 0
* - String: ''
*
* @internal
*
* @param fSchema $schema The schema object for the table
* @param string $class The class the column is part of
* @param array &$values An associative array of all values for the record
* @param array $columns The column to check
* @return string An error message for the rule
*/
static public function hasValue($schema, $class, &$values, $column)
{
$value = $values[$column];
if ($value === NULL) {
return FALSE;
}
$table = fORM::tablize($class);
$data_type = $schema->getColumnInfo($table, $column, 'type');
$allows_null = !$schema->getColumnInfo($table, $column, 'not_null');
if ($allows_null) {
return TRUE;
}
switch ($data_type) {
case 'blob':
case 'char':
case 'text':
case 'varchar':
if ($value === '') {
return FALSE;
}
break;
case 'boolean':
if ($value === FALSE) {
return FALSE;
}
break;
case 'integer':
if ($value === 0 || $value === '0') {
return FALSE;
}
break;
case 'float':
if (preg_match('#^0(\.0*)?$|^\.0+$#D', $value)) {
return FALSE;
}
break;
}
return TRUE;
}
/**
* Checks to see if a column has been set as case insensitive
*
* @internal
*
* @param string $class The class to check
* @param string $column The column to check
* @return boolean If the column is set to be case insensitive
*/
static private function isCaseInsensitive($class, $column)
{
return isset(self::$case_insensitive_columns[$class][$column]);
}
/**
* Returns FALSE if the string is empty - used for array filtering
*
* @param string $string The string to check
* @return boolean If the string is not blank
*/
static private function isNonBlankString($string)
{
if (is_array($string)) {
return TRUE;
}
return ((string) $string) !== '';
}
/**
* Removes a regex replacement
*
* @internal
*
* @param mixed $class The class name or instance of the class the columns exists in
* @param string $search The string to search for
* @param string $replace The string to replace with
* @return void
*/
static public function removeRegexReplacement($class, $search, $replace)
{
$class = fORM::getClass($class);
if (!isset(self::$regex_replacements[$class])) {
self::$regex_replacements[$class] = array(
'search' => array(),
'replace' => array()
);
}
$replacements = count(self::$regex_replacements[$class]['search']);
for ($i = 0; $i < $replacements; $i++) {
$match_search = self::$regex_replacements[$class]['search'][$i] == $search;
$match_replace = self::$regex_replacements[$class]['replace'][$i] == $replace;
if ($match_search && $match_replace) {
unset(self::$regex_replacements[$class]['search'][$i]);
unset(self::$regex_replacements[$class]['replace'][$i]);
}
}
// Remove the any gaps in the arrays
self::$regex_replacements[$class]['search'] = array_merge(self::$regex_replacements[$class]['search']);
self::$regex_replacements[$class]['replace'] = array_merge(self::$regex_replacements[$class]['replace']);
}
/**
* Removes a string replacement
*
* @internal
*
* @param mixed $class The class name or instance of the class the columns exists in
* @param string $search The string to search for
* @param string $replace The string to replace with
* @return void
*/
static public function removeStringReplacement($class, $search, $replace)
{
$class = fORM::getClass($class);
if (!isset(self::$string_replacements[$class])) {
self::$string_replacements[$class] = array(
'search' => array(),
'replace' => array()
);
}
$replacements = count(self::$string_replacements[$class]['search']);
for ($i = 0; $i < $replacements; $i++) {
$match_search = self::$string_replacements[$class]['search'][$i] == $search;
$match_replace = self::$string_replacements[$class]['replace'][$i] == $replace;
if ($match_search && $match_replace) {
unset(self::$string_replacements[$class]['search'][$i]);
unset(self::$string_replacements[$class]['replace'][$i]);
}
}
// Remove the any gaps in the arrays
self::$string_replacements[$class]['search'] = array_merge(self::$string_replacements[$class]['search']);
self::$string_replacements[$class]['replace'] = array_merge(self::$string_replacements[$class]['replace']);
}
/**
* Reorders list items in an html string based on their contents
*
* @internal
*
* @param string $class The class to reorder messages for
* @param array $messages An array of the messages
* @return array The reordered messages
*/
static public function reorderMessages($class, $messages)
{
if (!isset(self::$message_orders[$class])) {
return $messages;
}
$matches = self::$message_orders[$class];
$ordered_items = array_fill(0, sizeof($matches), array());
$other_items = array();
foreach ($messages as $key => $message) {
foreach ($matches as $num => $match_string) {
$string = is_array($message) ? $message['name'] : $message;
if (fUTF8::ipos($string, $match_string) !== FALSE) {
$ordered_items[$num][$key] = $message;
continue 2;
}
}
$other_items[$key] = $message;
}
$final_list = array();
foreach ($ordered_items as $ordered_item) {
$final_list = array_merge($final_list, $ordered_item);
}
return array_merge($final_list, $other_items);
}
/**
* Takes a list of messages and performs string and regex replacements on them
*
* @internal
*
* @param string $class The class to reorder messages for
* @param array $messages The array of messages
* @return array The new array of messages
*/
static public function replaceMessages($class, $messages)
{
if (isset(self::$string_replacements[$class])) {
foreach ($messages as $key => $message) {
if (is_array($message)) {
continue;
}
$messages[$key] = str_replace(
self::$string_replacements[$class]['search'],
self::$string_replacements[$class]['replace'],
$message
);
}
}
if (isset(self::$regex_replacements[$class])) {
foreach ($messages as $key => $message) {
if (is_array($message)) {
continue;
}
$messages[$key] = preg_replace(
self::$regex_replacements[$class]['search'],
self::$regex_replacements[$class]['replace'],
$message
);
}
}
return array_filter($messages, array('fORMValidation', 'isNonBlankString'));
}
/**
* Resets the configuration of the class
*
* @internal
*
* @return void
*/
static public function reset()
{
self::$case_insensitive_columns = array();
self::$conditional_rules = array();
self::$message_orders = array();
self::$one_or_more_rules = array();
self::$only_one_rules = array();
self::$regex_replacements = array();
self::$related_one_or_more_rules = array();
self::$regex_rules = array();
self::$required_rules = array();
self::$string_replacements = array();
self::$valid_values_rules = array();
}
/**
* Sets a column to be compared in a case-insensitive manner when checking `UNIQUE` and `PRIMARY KEY` constraints
*
* @param mixed $class The class name or instance of the class the column is located in
* @param string $column The column to set as case-insensitive
* @return void
*/
static public function setColumnCaseInsensitive($class, $column)
{
$class = fORM::getClass($class);
$table = fORM::tablize($class);
$schema = fORMSchema::retrieve($class);
$type = $schema->getColumnInfo($table, $column, 'type');
$valid_types = array('varchar', 'char', 'text');
if (!in_array($type, $valid_types)) {
throw new fProgrammerException(
'The column specified, %1$s, is of the data type %2$s. Must be one of %3$s to be treated as case insensitive.',
$column,
$type,
join(', ', $valid_types)
);
}
if (!isset(self::$case_insensitive_columns[$class])) {
self::$case_insensitive_columns[$class] = array();
}
self::$case_insensitive_columns[$class][$column] = TRUE;
}
/**
* Allows setting the order that the list items in a message will be displayed
*
* All string comparisons during the reordering process are done in a
* case-insensitive manner.
*
* @param mixed $class The class name or an instance of the class to set the message order for
* @param array $matches This should be an ordered array of strings. If a line contains the string it will be displayed in the relative order it occurs in this array.
* @return void
*/
static public function setMessageOrder($class, $matches)
{
$class = fORM::getClass($class);
// Handle the alternate form allowed with fValidation::setMessageOrder()
$args = func_get_args();
array_shift($args);
if (count($args) != 1) {
$matches = $args;
}
uasort($matches, array('self', 'sortMessageMatches'));
self::$message_orders[$class] = $matches;
}
/**
* Compares the message matching strings by longest first so that the longest matches are made first
*
* @param string $a The first string to compare
* @param string $b The second string to compare
* @return integer `-1` if `$a` is longer than `$b`, `0` if they are equal length, `1` if `$a` is shorter than `$b`
*/
static private function sortMessageMatches($a, $b)
{
if (strlen($a) == strlen($b)) {
return 0;
}
if (strlen($a) > strlen($b)) {
return -1;
}
return 1;
}
/**
* Returns `TRUE` for non-empty strings, numbers, objects, empty numbers and string-like numbers (such as `0`, `0.0`, `'0'`)
*
* @param mixed $value The value to check
* @return boolean If the value is string-like
*/
static private function stringlike($value)
{
if ((!is_string($value) && !is_object($value) && !is_numeric($value)) || !strlen(trim($value))) {
return FALSE;
}
return TRUE;
}
/**
* Validates values for an fActiveRecord object against the database schema and any additional rules that have been added
*
* @internal
*
* @param fActiveRecord $object The instance of the class to validate
* @param array $values The values to validate
* @param array $old_values The old values for the record
* @return array An array of messages
*/
static public function validate($object, $values, $old_values)
{
$class = get_class($object);
$table = fORM::tablize($class);
$schema = fORMSchema::retrieve($class);
self::initializeRuleArrays($class);
$validation_messages = array();
// Convert objects into values for validation
foreach ($values as $column => $value) {
$values[$column] = fORM::scalarize($class, $column, $value);
}
foreach ($old_values as $column => $column_values) {
foreach ($column_values as $key => $value) {
$old_values[$column][$key] = fORM::scalarize($class, $column, $value);
}
}
$message_array = self::checkPrimaryKeys($schema, $object, $values, $old_values);
if ($message_array) { $validation_messages[key($message_array)] = current($message_array); }
$column_info = $schema->getColumnInfo($table);
foreach ($column_info as $column => $info) {
$message = self::checkAgainstSchema($schema, $object, $column, $values, $old_values);
if ($message) { $validation_messages[$column] = $message; }
}
$messages = self::checkUniqueConstraints($schema, $object, $values, $old_values);
if ($messages) { $validation_messages = array_merge($validation_messages, $messages); }
foreach (self::$valid_values_rules[$class] as $column => $valid_values) {
$message = self::checkValidValuesRule($class, $values, $column, $valid_values);
if ($message) { $validation_messages[$column] = $message; }
}
foreach (self::$regex_rules[$class] as $column => $rule) {
$message = self::checkRegexRule($class, $values, $column, $rule['regex'], $rule['message']);
if ($message) { $validation_messages[$column] = $message; }
}
foreach (self::$conditional_rules[$class] as $rule) {
$messages = self::checkConditionalRule($schema, $class, $values, $rule['main_columns'], $rule['conditional_values'], $rule['conditional_columns']);
if ($messages) { $validation_messages = array_merge($validation_messages, $messages); }
}
foreach (self::$one_or_more_rules[$class] as $rule) {
$message = self::checkOneOrMoreRule($schema, $class, $values, $rule['columns']);
if ($message) { $validation_messages[join(',', $rule['columns'])] = $message; }
}
foreach (self::$only_one_rules[$class] as $rule) {
$message = self::checkOnlyOneRule($schema, $class, $values, $rule['columns']);
if ($message) { $validation_messages[join(',', $rule['columns'])] = $message; }
}
return $validation_messages;
}
/**
* Validates related records for an fActiveRecord object
*
* @internal
*
* @param fActiveRecord $object The object to validate
* @param array &$values The values for the object
* @param array &$related_records The related records for the object
* @return array An array of messages
*/
static public function validateRelated($object, &$values, &$related_records)
{
$class = get_class($object);
$table = fORM::tablize($class);
$validation_messages = array();
// Check related rules
foreach (self::$related_one_or_more_rules[$class] as $related_class => $routes) {
foreach ($routes as $route => $enabled) {
$message = self::checkRelatedOneOrMoreRule($object, $values, $related_records, $related_class, $route);
if ($message) { $validation_messages[fORM::tablize($related_class)] = $message; }
}
}
$related_messages = fORMRelated::validate($class, $values, $related_records);
$validation_messages = array_merge($validation_messages, $related_messages);
return $validation_messages;
}
}
/**
* Copyright (c) 2007-2012 Will Bond <will@flourishlib.com>, others
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/