PageRenderTime 50ms CodeModel.GetById 18ms RepoModel.GetById 0ms app.codeStats 0ms

/classphp/flourish/fORMValidation.php

https://github.com/jsuarez/Lexer
PHP | 1301 lines | 700 code | 186 blank | 415 comment | 123 complexity | 216cc909cf7e4b452907a8b644af5663 MD5 | raw file
  1. <?php
  2. /**
  3. * Handles validation for fActiveRecord classes
  4. *
  5. * @copyright Copyright (c) 2007-2009 Will Bond, others
  6. * @author Will Bond [wb] <will@flourishlib.com>
  7. * @author Jeff Turcotte [jt] <jeff.turcotte@gmail.com>
  8. * @license http://flourishlib.com/license
  9. *
  10. * @package Flourish
  11. * @link http://flourishlib.com/fORMValidation
  12. *
  13. * @version 1.0.0b18
  14. * @changes 1.0.0b18 Fixed ::checkOnlyOneRule() and ::checkOneOrMoreRule() to consider blank strings as NULL [wb, 2009-08-21]
  15. * @changes 1.0.0b17 Added @internal methods ::removeStringReplacement() and ::removeRegexReplacement() [wb, 2009-07-29]
  16. * @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]
  17. * @changes 1.0.0b15 Added ::addValidValuesValidationRule() [wb/jt, 2009-07-13]
  18. * @changes 1.0.0b14 Added ::addStringReplacement() and ::addRegexReplacement() for simple validation message modification [wb, 2009-07-01]
  19. * @changes 1.0.0b13 Changed ::reorderMessages() to compare string in a case-insensitive manner [wb, 2009-06-30]
  20. * @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]
  21. * @changes 1.0.0b11 Fixed a couple of bugs with validating related records [wb, 2009-06-26]
  22. * @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]
  23. * @changes 1.0.0b9 Updated code for new fORM API [wb, 2009-06-15]
  24. * @changes 1.0.0b8 Updated code to use new fValidationException::formatField() method [wb, 2009-06-04]
  25. * @changes 1.0.0b7 Updated ::validateRelated() to use new fORMRelated::validate() method and ::checkRelatedOneOrMoreRule() to use new `$related_records` structure [wb, 2009-06-02]
  26. * @changes 1.0.0b6 Changed date/time/timestamp checking from `strtotime()` to fDate/fTime/fTimestamp for better localization support [wb, 2009-06-01]
  27. * @changes 1.0.0b5 Fixed a bug in ::checkOnlyOneRule() where no values would not be flagged as an error [wb, 2009-04-23]
  28. * @changes 1.0.0b4 Fixed a bug in ::checkUniqueConstraints() related to case-insensitive columns [wb, 2009-02-15]
  29. * @changes 1.0.0b3 Implemented proper fix for ::addManyToManyValidationRule() [wb, 2008-12-12]
  30. * @changes 1.0.0b2 Fixed a bug with ::addManyToManyValidationRule() [wb, 2008-12-08]
  31. * @changes 1.0.0b The initial implementation [wb, 2007-08-04]
  32. */
  33. class fORMValidation
  34. {
  35. // The following constants allow for nice looking callbacks to static methods
  36. const addConditionalRule = 'fORMValidation::addConditionalRule';
  37. const addManyToManyRule = 'fORMValidation::addManyToManyRule';
  38. const addOneOrMoreRule = 'fORMValidation::addOneOrMoreRule';
  39. const addOneToManyRule = 'fORMValidation::addOneToManyRule';
  40. const addOnlyOneRule = 'fORMValidation::addOnlyOneRule';
  41. const addRegexReplacement = 'fORMValidation::addRegexReplacement';
  42. const addStringReplacement = 'fORMValidation::addStringReplacement';
  43. const addValidValuesRule = 'fORMValidation::addValidValuesRule';
  44. const inspect = 'fORMValidation::inspect';
  45. const removeStringReplacement = 'fORMValidation::removeStringReplacement';
  46. const removeRegexReplacement = 'fORMValidation::removeRegexReplacement';
  47. const reorderMessages = 'fORMValidation::reorderMessages';
  48. const replaceMessages = 'fORMValidation::replaceMessages';
  49. const reset = 'fORMValidation::reset';
  50. const setColumnCaseInsensitive = 'fORMValidation::setColumnCaseInsensitive';
  51. const setMessageOrder = 'fORMValidation::setMessageOrder';
  52. const validate = 'fORMValidation::validate';
  53. const validateRelated = 'fORMValidation::validateRelated';
  54. /**
  55. * Columns that should be treated as case insensitive when checking uniqueness
  56. *
  57. * @var array
  58. */
  59. static private $case_insensitive_columns = array();
  60. /**
  61. * Conditional rules
  62. *
  63. * @var array
  64. */
  65. static private $conditional_rules = array();
  66. /**
  67. * Ordering rules for messages
  68. *
  69. * @var array
  70. */
  71. static private $message_orders = array();
  72. /**
  73. * One or more rules
  74. *
  75. * @var array
  76. */
  77. static private $one_or_more_rules = array();
  78. /**
  79. * Only one rules
  80. *
  81. * @var array
  82. */
  83. static private $only_one_rules = array();
  84. /**
  85. * Regular expression replacements performed on each message
  86. *
  87. * @var array
  88. */
  89. static private $regex_replacements = array();
  90. /**
  91. * Rules that require at least one or more *-to-many related records to be associated
  92. *
  93. * @var array
  94. */
  95. static private $related_one_or_more_rules = array();
  96. /**
  97. * String replacements performed on each message
  98. *
  99. * @var array
  100. */
  101. static private $string_replacements = array();
  102. /**
  103. * Valid values rules
  104. *
  105. * @var array
  106. */
  107. static private $valid_values_rules = array();
  108. /**
  109. * Adds a conditional rule
  110. *
  111. * If a non-empty value is found in one of the `$main_columns`, or if
  112. * specified, a value from the `$conditional_values` array, all of the
  113. * `$conditional_columns` will also be required to have a value.
  114. *
  115. * @param mixed $class The class name or instance of the class this rule applies to
  116. * @param string|array $main_columns The column(s) to check for a value
  117. * @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
  118. * @param string|array $conditional_columns The column(s) that are to be required
  119. * @return void
  120. */
  121. static public function addConditionalRule($class, $main_columns, $conditional_values, $conditional_columns)
  122. {
  123. $class = fORM::getClass($class);
  124. if (!isset(self::$conditional_rules[$class])) {
  125. self::$conditional_rules[$class] = array();
  126. }
  127. settype($main_columns, 'array');
  128. settype($conditional_columns, 'array');
  129. if ($conditional_values !== NULL) {
  130. settype($conditional_values, 'array');
  131. }
  132. $rule = array();
  133. $rule['main_columns'] = $main_columns;
  134. $rule['conditional_values'] = $conditional_values;
  135. $rule['conditional_columns'] = $conditional_columns;
  136. self::$conditional_rules[$class][] = $rule;
  137. }
  138. /**
  139. * Add a many-to-many rule that requires at least one related record is associated with the current record
  140. *
  141. * @param mixed $class The class name or instance of the class to add the rule for
  142. * @param string $related_class The name of the related class
  143. * @param string $route The route to the related class
  144. * @return void
  145. */
  146. static public function addManyToManyRule($class, $related_class, $route=NULL)
  147. {
  148. $class = fORM::getClass($class);
  149. if (!isset(self::$related_one_or_more_rules[$class])) {
  150. self::$related_one_or_more_rules[$class] = array();
  151. }
  152. if (!isset(self::$related_one_or_more_rules[$class][$related_class])) {
  153. self::$related_one_or_more_rules[$class][$related_class] = array();
  154. }
  155. $route = fORMSchema::getRouteName(
  156. fORM::tablize($class),
  157. fORM::tablize($related_class),
  158. $route,
  159. 'many-to-many'
  160. );
  161. self::$related_one_or_more_rules[$class][$related_class][$route] = TRUE;
  162. }
  163. /**
  164. * Adds a one-or-more rule that requires at least one of the columns specified has a value
  165. *
  166. * @param mixed $class The class name or instance of the class the columns exists in
  167. * @param array $columns The columns to check
  168. * @return void
  169. */
  170. static public function addOneOrMoreRule($class, $columns)
  171. {
  172. $class = fORM::getClass($class);
  173. settype($columns, 'array');
  174. if (!isset(self::$one_or_more_rules[$class])) {
  175. self::$one_or_more_rules[$class] = array();
  176. }
  177. $rule = array();
  178. $rule['columns'] = $columns;
  179. self::$one_or_more_rules[$class][] = $rule;
  180. }
  181. /**
  182. * Add a one-to-many rule that requires at least one related record is associated with the current record
  183. *
  184. * @param mixed $class The class name or instance of the class to add the rule for
  185. * @param string $related_class The name of the related class
  186. * @param string $route The route to the related class
  187. * @return void
  188. */
  189. static public function addOneToManyRule($class, $related_class, $route=NULL)
  190. {
  191. $class = fORM::getClass($class);
  192. if (!isset(self::$related_one_or_more_rules[$class])) {
  193. self::$related_one_or_more_rules[$class] = array();
  194. }
  195. if (!isset(self::$related_one_or_more_rules[$class][$related_class])) {
  196. self::$related_one_or_more_rules[$class][$related_class] = array();
  197. }
  198. $route = fORMSchema::getRouteName(
  199. fORM::tablize($class),
  200. fORM::tablize($related_class),
  201. $route,
  202. 'one-to-many'
  203. );
  204. self::$related_one_or_more_rules[$class][$related_class][$route] = TRUE;
  205. }
  206. /**
  207. * Add an only-one rule that requires exactly one of the columns must have a value
  208. *
  209. * @param mixed $class The class name or instance of the class the columns exists in
  210. * @param array $columns The columns to check
  211. * @return void
  212. */
  213. static public function addOnlyOneRule($class, $columns)
  214. {
  215. $class = fORM::getClass($class);
  216. settype($columns, 'array');
  217. if (!isset(self::$only_one_rules[$class])) {
  218. self::$only_one_rules[$class] = array();
  219. }
  220. $rule = array();
  221. $rule['columns'] = $columns;
  222. self::$only_one_rules[$class][] = $rule;
  223. }
  224. /**
  225. * Adds a call to [http://php.net/oreg_replace `preg_replace()`] for each message
  226. *
  227. * Regex replacement is done after the `post::validate()` hook, and right
  228. * before the messages are reordered.
  229. *
  230. * If a message is an empty string after replacement, it will be
  231. * removed from the list of messages.
  232. *
  233. * @param mixed $class The class name or instance of the class the columns exists in
  234. * @param string $search The PCRE regex to search for - see http://php.net/pcre for details
  235. * @param string $replace The string to replace with - all $ and \ are used in back references and must be escaped with a \ when meant literally
  236. * @return void
  237. */
  238. static public function addRegexReplacement($class, $search, $replace)
  239. {
  240. $class = fORM::getClass($class);
  241. if (!isset(self::$regex_replacements[$class])) {
  242. self::$regex_replacements[$class] = array(
  243. 'search' => array(),
  244. 'replace' => array()
  245. );
  246. }
  247. self::$regex_replacements[$class]['search'][] = $search;
  248. self::$regex_replacements[$class]['replace'][] = $replace;
  249. }
  250. /**
  251. * Adds a call to [http://php.net/str_replace `str_replace()`] for each message
  252. *
  253. * String replacement is done after the `post::validate()` hook, and right
  254. * before the messages are reordered.
  255. *
  256. * If a message is an empty string after replacement, it will be
  257. * removed from the list of messages.
  258. *
  259. * @param mixed $class The class name or instance of the class the columns exists in
  260. * @param string $search The string to search for
  261. * @param string $replace The string to replace with
  262. * @return void
  263. */
  264. static public function addStringReplacement($class, $search, $replace)
  265. {
  266. $class = fORM::getClass($class);
  267. if (!isset(self::$string_replacements[$class])) {
  268. self::$string_replacements[$class] = array(
  269. 'search' => array(),
  270. 'replace' => array()
  271. );
  272. }
  273. self::$string_replacements[$class]['search'][] = $search;
  274. self::$string_replacements[$class]['replace'][] = $replace;
  275. }
  276. /**
  277. * Restricts a column to having only a value from the list of valid values
  278. *
  279. * Please note that `NULL` values are always allowed, even if not listed in
  280. * the `$valid_values` array, if the column is not set as `NOT NULL`.
  281. *
  282. * This functionality can also be accomplished by added a `CHECK` constraint
  283. * on the column in the database, or using a MySQL `ENUM` data type.
  284. *
  285. * @param mixed $class The class name or instance of the class this rule applies to
  286. * @param string $column The column to validate
  287. * @param array $valid_values The valid values to check - `NULL` values are always allows if the column is not set to `NOT NULL`
  288. * @return void
  289. */
  290. static public function addValidValuesRule($class, $column, $valid_values)
  291. {
  292. $class = fORM::getClass($class);
  293. if (!isset(self::$valid_values_rules[$class])) {
  294. self::$valid_values_rules[$class] = array();
  295. }
  296. settype($valid_values, 'array');
  297. self::$valid_values_rules[$class][$column] = $valid_values;
  298. fORM::registerInspectCallback($class, $column, self::inspect);
  299. }
  300. /**
  301. * Validates a value against the database schema
  302. *
  303. * @param fActiveRecord $object The instance of the class the column is part of
  304. * @param string $column The column to check
  305. * @param array &$values An associative array of all values going into the row (needs all for multi-field unique constraint checking)
  306. * @param array &$old_values The old values from the record
  307. * @return string An error message for the column specified
  308. */
  309. static private function checkAgainstSchema($object, $column, &$values, &$old_values)
  310. {
  311. $class = get_class($object);
  312. $table = fORM::tablize($class);
  313. $column_info = fORMSchema::retrieve()->getColumnInfo($table, $column);
  314. // Make sure a value is provided for required columns
  315. if ($values[$column] === NULL && $column_info['not_null'] && $column_info['default'] === NULL && $column_info['auto_increment'] === FALSE) {
  316. return self::compose(
  317. '%sPlease enter a value',
  318. fValidationException::formatField(fORM::getColumnName($class, $column))
  319. );
  320. }
  321. $message = self::checkDataType($class, $column, $values[$column]);
  322. if ($message) { return $message; }
  323. // Make sure a valid value is chosen
  324. if (isset($column_info['valid_values']) && $values[$column] !== NULL && !in_array($values[$column], $column_info['valid_values'])) {
  325. return self::compose(
  326. '%1$sPlease choose from one of the following: %2$s',
  327. fValidationException::formatField(fORM::getColumnName($class, $column)),
  328. join(', ', $column_info['valid_values'])
  329. );
  330. }
  331. // Make sure the value isn't too long
  332. if ($column_info['type'] == 'varchar' && isset($column_info['max_length']) && $values[$column] !== NULL && is_string($values[$column]) && fUTF8::len($values[$column]) > $column_info['max_length']) {
  333. return self::compose(
  334. '%1$sPlease enter a value no longer than %2$s characters',
  335. fValidationException::formatField(fORM::getColumnName($class, $column)),
  336. $column_info['max_length']
  337. );
  338. }
  339. // Make sure the value is the proper length
  340. if ($column_info['type'] == 'char' && isset($column_info['max_length']) && $values[$column] !== NULL && is_string($values[$column]) && fUTF8::len($values[$column]) != $column_info['max_length']) {
  341. return self::compose(
  342. '%1$sPlease enter exactly %2$s characters',
  343. fValidationException::formatField(fORM::getColumnName($class, $column)),
  344. $column_info['max_length']
  345. );
  346. }
  347. $message = self::checkForeignKeyConstraints($class, $column, $values);
  348. if ($message) { return $message; }
  349. }
  350. /**
  351. * Validates against a conditional rule
  352. *
  353. * @param string $class The class this rule applies to
  354. * @param array &$values An associative array of all values for the record
  355. * @param array $main_columns The columns to check for a value
  356. * @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
  357. * @param array $conditional_columns The columns that are to be required
  358. * @return array The error messages for the rule specified
  359. */
  360. static private function checkConditionalRule($class, &$values, $main_columns, $conditional_values, $conditional_columns)
  361. {
  362. $check_for_missing_values = FALSE;
  363. foreach ($main_columns as $main_column) {
  364. $matches_conditional_value = $conditional_values !== NULL && in_array($values[$main_column], $conditional_values);
  365. $has_some_value = $conditional_values === NULL && strlen((string) $values[$main_column]);
  366. if ($matches_conditional_value || $has_some_value) {
  367. $check_for_missing_values = TRUE;
  368. break;
  369. }
  370. }
  371. if (!$check_for_missing_values) {
  372. return;
  373. }
  374. $messages = array();
  375. foreach ($conditional_columns as $conditional_column) {
  376. if ($values[$conditional_column] !== NULL) { continue; }
  377. $messages[] = self::compose(
  378. '%sPlease enter a value',
  379. fValidationException::formatField(fORM::getColumnName($class, $conditional_column))
  380. );
  381. }
  382. if ($messages) {
  383. return $messages;
  384. }
  385. }
  386. /**
  387. * Validates a value against the database data type
  388. *
  389. * @param string $class The class the column is part of
  390. * @param string $column The column to check
  391. * @param mixed $value The value to check
  392. * @return string An error message for the column specified
  393. */
  394. static private function checkDataType($class, $column, $value)
  395. {
  396. $table = fORM::tablize($class);
  397. $column_info = fORMSchema::retrieve()->getColumnInfo($table, $column);
  398. if ($value !== NULL) {
  399. switch ($column_info['type']) {
  400. case 'varchar':
  401. case 'char':
  402. case 'text':
  403. case 'blob':
  404. if (!is_string($value) && !is_numeric($value)) {
  405. return self::compose(
  406. '%sPlease enter a string',
  407. fValidationException::formatField(fORM::getColumnName($class, $column))
  408. );
  409. }
  410. break;
  411. case 'integer':
  412. case 'float':
  413. if (!is_numeric($value)) {
  414. return self::compose(
  415. '%sPlease enter a number',
  416. fValidationException::formatField(fORM::getColumnName($class, $column))
  417. );
  418. }
  419. break;
  420. case 'timestamp':
  421. try {
  422. new fTimestamp($value);
  423. } catch (fValidationException $e) {
  424. return self::compose(
  425. '%sPlease enter a date/time',
  426. fValidationException::formatField(fORM::getColumnName($class, $column))
  427. );
  428. }
  429. break;
  430. case 'date':
  431. try {
  432. new fDate($value);
  433. } catch (fValidationException $e) {
  434. return self::compose(
  435. '%sPlease enter a date',
  436. fValidationException::formatField(fORM::getColumnName($class, $column))
  437. );
  438. }
  439. break;
  440. case 'time':
  441. try {
  442. new fTime($value);
  443. } catch (fValidationException $e) {
  444. return self::compose(
  445. '%sPlease enter a time',
  446. fValidationException::formatField(fORM::getColumnName($class, $column))
  447. );
  448. }
  449. break;
  450. }
  451. }
  452. }
  453. /**
  454. * Validates values against foreign key constraints
  455. *
  456. * @param string $class The class to check the foreign keys for
  457. * @param string $column The column to check
  458. * @param array &$values The values to check
  459. * @return string An error message for the column specified
  460. */
  461. static private function checkForeignKeyConstraints($class, $column, &$values)
  462. {
  463. if ($values[$column] === NULL) {
  464. return;
  465. }
  466. $table = fORM::tablize($class);
  467. $foreign_keys = fORMSchema::retrieve()->getKeys($table, 'foreign');
  468. foreach ($foreign_keys AS $foreign_key) {
  469. if ($foreign_key['column'] == $column) {
  470. try {
  471. $sql = "SELECT " . $foreign_key['foreign_column'];
  472. $sql .= " FROM " . $foreign_key['foreign_table'];
  473. $sql .= " WHERE ";
  474. $sql .= $column . fORMDatabase::escapeBySchema($table, $column, $values[$column], '=');
  475. $sql = str_replace('WHERE ' . $column, 'WHERE ' . $foreign_key['foreign_column'], $sql);
  476. $result = fORMDatabase::retrieve()->translatedQuery($sql);
  477. $result->tossIfNoRows();
  478. } catch (fNoRowsException $e) {
  479. return self::compose(
  480. '%sThe value specified is invalid',
  481. fValidationException::formatField(fORM::getColumnName($class, $column))
  482. );
  483. }
  484. }
  485. }
  486. }
  487. /**
  488. * Validates against a one-or-more rule
  489. *
  490. * @param string $class The class the columns are part of
  491. * @param array &$values An associative array of all values for the record
  492. * @param array $columns The columns to check
  493. * @return string An error message for the rule
  494. */
  495. static private function checkOneOrMoreRule($class, &$values, $columns)
  496. {
  497. settype($columns, 'array');
  498. $found_value = FALSE;
  499. foreach ($columns as $column) {
  500. if ($values[$column] !== NULL && $values[$column] !== '') {
  501. $found_value = TRUE;
  502. }
  503. }
  504. if (!$found_value) {
  505. $column_names = array();
  506. foreach ($columns as $column) {
  507. $column_names[] = fORM::getColumnName($class, $column);
  508. }
  509. return self::compose(
  510. '%sPlease enter a value for at least one',
  511. fValidationException::formatField(join(', ', $column_names))
  512. );
  513. }
  514. }
  515. /**
  516. * Validates against an only-one rule
  517. *
  518. * @param string $class The class the columns are part of
  519. * @param array &$values An associative array of all values for the record
  520. * @param array $columns The columns to check
  521. * @return string An error message for the rule
  522. */
  523. static private function checkOnlyOneRule($class, &$values, $columns)
  524. {
  525. settype($columns, 'array');
  526. $column_names = array();
  527. foreach ($columns as $column) {
  528. $column_names[] = fORM::getColumnName($class, $column);
  529. }
  530. $found_value = FALSE;
  531. foreach ($columns as $column) {
  532. if ($values[$column] !== NULL && $values[$column] !== '') {
  533. if ($found_value) {
  534. return self::compose(
  535. '%sPlease enter a value for only one',
  536. fValidationException::formatField(join(', ', $column_names))
  537. );
  538. }
  539. $found_value = TRUE;
  540. }
  541. }
  542. if (!$found_value) {
  543. return self::compose(
  544. '%sPlease enter a value for one',
  545. fValidationException::formatField(join(', ', $column_names))
  546. );
  547. }
  548. }
  549. /**
  550. * Makes sure a record with the same primary keys is not already in the database
  551. *
  552. * @param fActiveRecord $object The instance of the class to check
  553. * @param array &$values An associative array of all values going into the row (needs all for multi-field unique constraint checking)
  554. * @param array &$old_values The old values for the record
  555. * @return string An error message
  556. */
  557. static private function checkPrimaryKeys($object, &$values, &$old_values)
  558. {
  559. $class = get_class($object);
  560. $table = fORM::tablize($class);
  561. $primary_keys = fORMSchema::retrieve()->getKeys($table, 'primary');
  562. $columns = array();
  563. $found_value = FALSE;
  564. foreach ($primary_keys as $primary_key) {
  565. $columns[] = fORM::getColumnName($class, $primary_key);
  566. if ($values[$primary_key]) {
  567. $found_value = TRUE;
  568. }
  569. }
  570. if (!$found_value) {
  571. return;
  572. }
  573. $different = FALSE;
  574. foreach ($primary_keys as $primary_key) {
  575. if (!fActiveRecord::hasOld($old_values, $primary_key)) {
  576. continue;
  577. }
  578. $old_value = fActiveRecord::retrieveOld($old_values, $primary_key);
  579. $value = $values[$primary_key];
  580. if (self::isCaseInsensitive($class, $primary_key) && self::stringlike($value) && self::stringlike($old_value)) {
  581. if (fUTF8::lower($value) != fUTF8::lower($old_value)) {
  582. $different = TRUE;
  583. }
  584. } elseif ($old_value != $value) {
  585. $different = TRUE;
  586. }
  587. }
  588. if (!$different) {
  589. return;
  590. }
  591. try {
  592. $sql = "SELECT " . join(', ', $primary_keys) . " FROM " . $table . " WHERE ";
  593. $conditions = array();
  594. foreach ($primary_keys as $primary_key) {
  595. if (self::isCaseInsensitive($class, $primary_key) && self::stringlike($values[$primary_key])) {
  596. $conditions[] = 'LOWER(' . $primary_key . ')' . fORMDatabase::escapeBySchema($table, $primary_key, fUTF8::lower($values[$primary_key]), '=');
  597. } else {
  598. $conditions[] = $primary_key . fORMDatabase::escapeBySchema($table, $primary_key, $values[$primary_key], '=');
  599. }
  600. }
  601. $sql .= join(' AND ', $conditions);
  602. $result = fORMDatabase::retrieve()->translatedQuery($sql);
  603. $result->tossIfNoRows();
  604. return self::compose(
  605. 'Another %1$s with the same %2$s already exists',
  606. fORM::getRecordName($class),
  607. fGrammar::joinArray($columns, 'and')
  608. );
  609. } catch (fNoRowsException $e) { }
  610. }
  611. /**
  612. * Validates against a *-to-many one or more rule
  613. *
  614. * @param fActiveRecord $object The object being checked
  615. * @param array &$values The values for the object
  616. * @param array &$related_records The related records for the object
  617. * @param string $related_class The name of the related class
  618. * @param string $route The name of the route from the class to the related class
  619. * @return string An error message for the rule
  620. */
  621. static private function checkRelatedOneOrMoreRule($object, &$values, &$related_records, $related_class, $route)
  622. {
  623. $related_table = fORM::tablize($related_class);
  624. $class = get_class($object);
  625. $exists = $object->exists();
  626. $records_are_set = isset($related_records[$related_table][$route]);
  627. $has_records = $records_are_set && $related_records[$related_table][$route]['count'];
  628. if ($exists && (!$records_are_set || $has_records)) {
  629. return;
  630. }
  631. if (!$exists && $has_records) {
  632. return;
  633. }
  634. return self::compose(
  635. '%sPlease select at least one',
  636. fValidationException::formatField(fGrammar::pluralize(fORMRelated::getRelatedRecordName($class, $related_class, $route)))
  637. );
  638. }
  639. /**
  640. * Validates values against unique constraints
  641. *
  642. * @param fActiveRecord $object The instance of the class to check
  643. * @param array &$values The values to check
  644. * @param array &$old_values The old values for the record
  645. * @return string An error message for the unique constraints
  646. */
  647. static private function checkUniqueConstraints($object, &$values, &$old_values)
  648. {
  649. $class = get_class($object);
  650. $table = fORM::tablize($class);
  651. $key_info = fORMSchema::retrieve()->getKeys($table);
  652. $primary_keys = $key_info['primary'];
  653. $unique_keys = $key_info['unique'];
  654. foreach ($unique_keys AS $unique_columns) {
  655. settype($unique_columns, 'array');
  656. // NULL values are unique
  657. $found_not_null = FALSE;
  658. foreach ($unique_columns as $unique_column) {
  659. if ($values[$unique_column] !== NULL) {
  660. $found_not_null = TRUE;
  661. }
  662. }
  663. if (!$found_not_null) {
  664. continue;
  665. }
  666. $sql = "SELECT " . join(', ', $key_info['primary']) . " FROM " . $table . " WHERE ";
  667. $first = TRUE;
  668. foreach ($unique_columns as $unique_column) {
  669. if ($first) { $first = FALSE; } else { $sql .= " AND "; }
  670. $value = $values[$unique_column];
  671. if (self::isCaseInsensitive($class, $unique_column) && self::stringlike($value)) {
  672. $sql .= 'LOWER(' . $unique_column . ')' . fORMDatabase::escapeBySchema($table, $unique_column, fUTF8::lower($value), '=');
  673. } else {
  674. $sql .= $unique_column . fORMDatabase::escapeBySchema($table, $unique_column, $value, '=');
  675. }
  676. }
  677. if ($object->exists()) {
  678. foreach ($primary_keys as $primary_key) {
  679. $value = fActiveRecord::retrieveOld($old_values, $primary_key, $values[$primary_key]);
  680. $sql .= ' AND ' . $primary_key . fORMDatabase::escapeBySchema($table, $primary_key, $value, '<>');
  681. }
  682. }
  683. try {
  684. $result = fORMDatabase::retrieve()->translatedQuery($sql);
  685. $result->tossIfNoRows();
  686. // If an exception was not throw, we have existing values
  687. $column_names = array();
  688. foreach ($unique_columns as $unique_column) {
  689. $column_names[] = fORM::getColumnName($class, $unique_column);
  690. }
  691. if (sizeof($column_names) == 1) {
  692. return self::compose(
  693. '%sThe value specified must be unique, however it already exists',
  694. fValidationException::formatField(join('', $column_names))
  695. );
  696. } else {
  697. return self::compose(
  698. '%sThe values specified must be a unique combination, however the specified combination already exists',
  699. fValidationException::formatField(join(', ', $column_names))
  700. );
  701. }
  702. } catch (fNoRowsException $e) { }
  703. }
  704. }
  705. /**
  706. * Validates against a valid values rule
  707. *
  708. * @param string $class The class this rule applies to
  709. * @param array &$values An associative array of all values for the record
  710. * @param string $column The column the rule applies to
  711. * @param array $valid_values An array of valid values to check the column against
  712. * @return string The error message for the rule specified
  713. */
  714. static private function checkValidValuesRule($class, &$values, $column, $valid_values)
  715. {
  716. if ($values[$column] === NULL) {
  717. return;
  718. }
  719. if (!in_array($values[$column], $valid_values)) {
  720. return self::compose(
  721. '%1$sPlease choose from one of the following: %2$s',
  722. fValidationException::formatField(fORM::getColumnName($class, $column)),
  723. join(', ', $valid_values)
  724. );
  725. }
  726. }
  727. /**
  728. * Composes text using fText if loaded
  729. *
  730. * @param string $message The message to compose
  731. * @param mixed $component A string or number to insert into the message
  732. * @param mixed ...
  733. * @return string The composed and possible translated message
  734. */
  735. static private function compose($message)
  736. {
  737. $args = array_slice(func_get_args(), 1);
  738. if (class_exists('fText', FALSE)) {
  739. return call_user_func_array(
  740. array('fText', 'compose'),
  741. array($message, $args)
  742. );
  743. } else {
  744. return vsprintf($message, $args);
  745. }
  746. }
  747. /**
  748. * Makes sure each rule array is set to at least an empty array
  749. *
  750. * @internal
  751. *
  752. * @param string $class The class to initilize the arrays for
  753. * @return void
  754. */
  755. static private function initializeRuleArrays($class)
  756. {
  757. self::$conditional_rules[$class] = (isset(self::$conditional_rules[$class])) ? self::$conditional_rules[$class] : array();
  758. self::$one_or_more_rules[$class] = (isset(self::$one_or_more_rules[$class])) ? self::$one_or_more_rules[$class] : array();
  759. self::$only_one_rules[$class] = (isset(self::$only_one_rules[$class])) ? self::$only_one_rules[$class] : array();
  760. self::$related_one_or_more_rules[$class] = (isset(self::$related_one_or_more_rules[$class])) ? self::$related_one_or_more_rules[$class] : array();
  761. self::$valid_values_rules[$class] = (isset(self::$valid_values_rules[$class])) ? self::$valid_values_rules[$class] : array();
  762. }
  763. /**
  764. * Adds metadata about features added by this class
  765. *
  766. * @internal
  767. *
  768. * @param string $class The class being inspected
  769. * @param string $column The column being inspected
  770. * @param array &$metadata The array of metadata about a column
  771. * @return void
  772. */
  773. static public function inspect($class, $column, &$metadata)
  774. {
  775. if (!empty(self::$valid_values_rules[$class][$column])) {
  776. $metadata['valid_values'] = self::$valid_values_rules[$class][$column];
  777. }
  778. }
  779. /**
  780. * Checks to see if a column has been set as case insensitive
  781. *
  782. * @internal
  783. *
  784. * @param string $class The class to check
  785. * @param string $column The column to check
  786. * @return boolean If the column is set to be case insensitive
  787. */
  788. static private function isCaseInsensitive($class, $column)
  789. {
  790. return isset(self::$case_insensitive_columns[$class][$column]);
  791. }
  792. /**
  793. * Returns FALSE if the string is empty - used for array filtering
  794. *
  795. * @param string $string The string to check
  796. * @return boolean If the string is not blank
  797. */
  798. static private function isNonBlankString($string)
  799. {
  800. return ((string) $string) !== '';
  801. }
  802. /**
  803. * Removes a regex replacement
  804. *
  805. * @internal
  806. *
  807. * @param mixed $class The class name or instance of the class the columns exists in
  808. * @param string $search The string to search for
  809. * @param string $replace The string to replace with
  810. * @return void
  811. */
  812. static public function removeRegexReplacement($class, $search, $replace)
  813. {
  814. $class = fORM::getClass($class);
  815. if (!isset(self::$regex_replacements[$class])) {
  816. self::$regex_replacements[$class] = array(
  817. 'search' => array(),
  818. 'replace' => array()
  819. );
  820. }
  821. $replacements = count(self::$regex_replacements[$class]['search']);
  822. for ($i = 0; $i < $replacements; $i++) {
  823. $match_search = self::$regex_replacements[$class]['search'][$i] == $search;
  824. $match_replace = self::$regex_replacements[$class]['replace'][$i] == $replace;
  825. if ($match_search && $match_replace) {
  826. unset(self::$regex_replacements[$class]['search'][$i]);
  827. unset(self::$regex_replacements[$class]['replace'][$i]);
  828. }
  829. }
  830. // Remove the any gaps in the arrays
  831. self::$regex_replacements[$class]['search'] = array_merge(self::$regex_replacements[$class]['search']);
  832. self::$regex_replacements[$class]['replace'] = array_merge(self::$regex_replacements[$class]['replace']);
  833. }
  834. /**
  835. * Removes a string replacement
  836. *
  837. * @internal
  838. *
  839. * @param mixed $class The class name or instance of the class the columns exists in
  840. * @param string $search The string to search for
  841. * @param string $replace The string to replace with
  842. * @return void
  843. */
  844. static public function removeStringReplacement($class, $search, $replace)
  845. {
  846. $class = fORM::getClass($class);
  847. if (!isset(self::$string_replacements[$class])) {
  848. self::$string_replacements[$class] = array(
  849. 'search' => array(),
  850. 'replace' => array()
  851. );
  852. }
  853. $replacements = count(self::$string_replacements[$class]['search']);
  854. for ($i = 0; $i < $replacements; $i++) {
  855. $match_search = self::$string_replacements[$class]['search'][$i] == $search;
  856. $match_replace = self::$string_replacements[$class]['replace'][$i] == $replace;
  857. if ($match_search && $match_replace) {
  858. unset(self::$string_replacements[$class]['search'][$i]);
  859. unset(self::$string_replacements[$class]['replace'][$i]);
  860. }
  861. }
  862. // Remove the any gaps in the arrays
  863. self::$string_replacements[$class]['search'] = array_merge(self::$string_replacements[$class]['search']);
  864. self::$string_replacements[$class]['replace'] = array_merge(self::$string_replacements[$class]['replace']);
  865. }
  866. /**
  867. * Reorders list items in an html string based on their contents
  868. *
  869. * @internal
  870. *
  871. * @param string $class The class to reorder messages for
  872. * @param array $messages An array of the messages
  873. * @return array The reordered messages
  874. */
  875. static public function reorderMessages($class, $messages)
  876. {
  877. if (!isset(self::$message_orders[$class])) {
  878. return $messages;
  879. }
  880. $matches = self::$message_orders[$class];
  881. $ordered_items = array_fill(0, sizeof($matches), array());
  882. $other_items = array();
  883. foreach ($messages as $message) {
  884. foreach ($matches as $num => $match_string) {
  885. if (fUTF8::ipos($message, $match_string) !== FALSE) {
  886. $ordered_items[$num][] = $message;
  887. continue 2;
  888. }
  889. }
  890. $other_items[] = $message;
  891. }
  892. $final_list = array();
  893. foreach ($ordered_items as $ordered_item) {
  894. $final_list = array_merge($final_list, $ordered_item);
  895. }
  896. return array_merge($final_list, $other_items);
  897. }
  898. /**
  899. * Takes a list of messages and performs string and regex replacements on them
  900. *
  901. * @internal
  902. *
  903. * @param string $class The class to reorder messages for
  904. * @param array $messages The array of messages
  905. * @return array The new array of messages
  906. */
  907. static public function replaceMessages($class, $messages)
  908. {
  909. if (isset(self::$string_replacements[$class])) {
  910. $messages = str_replace(
  911. self::$string_replacements[$class]['search'],
  912. self::$string_replacements[$class]['replace'],
  913. $messages
  914. );
  915. }
  916. if (isset(self::$regex_replacements[$class])) {
  917. $messages = preg_replace(
  918. self::$regex_replacements[$class]['search'],
  919. self::$regex_replacements[$class]['replace'],
  920. $messages
  921. );
  922. }
  923. return array_filter($messages, array('fORMValidation', 'isNonBlankString'));
  924. }
  925. /**
  926. * Resets the configuration of the class
  927. *
  928. * @internal
  929. *
  930. * @return void
  931. */
  932. static public function reset()
  933. {
  934. self::$case_insensitive_columns = array();
  935. self::$conditional_rules = array();
  936. self::$message_orders = array();
  937. self::$one_or_more_rules = array();
  938. self::$only_one_rules = array();
  939. self::$regex_replacements = array();
  940. self::$related_one_or_more_rules = array();
  941. self::$string_replacements = array();
  942. self::$valid_values_rules = array();
  943. }
  944. /**
  945. * Sets a column to be compared in a case-insensitive manner when checking `UNIQUE` and `PRIMARY KEY` constraints
  946. *
  947. * @param mixed $class The class name or instance of the class the column is located in
  948. * @param string $column The column to set as case-insensitive
  949. * @return void
  950. */
  951. static public function setColumnCaseInsensitive($class, $column)
  952. {
  953. $class = fORM::getClass($class);
  954. $table = fORM::tablize($class);
  955. $type = fORMSchema::retrieve()->getColumnInfo($table, $column, 'type');
  956. $valid_types = array('varchar', 'char', 'text');
  957. if (!in_array($type, $valid_types)) {
  958. throw new fProgrammerException(
  959. 'The column specified, %1$s, is of the data type %2$s. Must be one of %3$s to be treated as case insensitive.',
  960. $column,
  961. $type,
  962. join(', ', $valid_types)
  963. );
  964. }
  965. if (!isset(self::$case_insensitive_columns[$class])) {
  966. self::$case_insensitive_columns[$class] = array();
  967. }
  968. self::$case_insensitive_columns[$class][$column] = TRUE;
  969. }
  970. /**
  971. * Allows setting the order that the list items in a message will be displayed
  972. *
  973. * All string comparisons during the reordering process are done in a
  974. * case-insensitive manner.
  975. *
  976. * @param mixed $class The class name or an instance of the class to set the message order for
  977. * @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.
  978. * @return void
  979. */
  980. static public function setMessageOrder($class, $matches)
  981. {
  982. $class = fORM::getClass($class);
  983. uasort($matches, array('self', 'sortMessageMatches'));
  984. self::$message_orders[$class] = $matches;
  985. }
  986. /**
  987. * Compares the message matching strings by longest first so that the longest matches are made first
  988. *
  989. * @param string $a The first string to compare
  990. * @param string $b The second string to compare
  991. * @return integer `-1` if `$a` is longer than `$b`, `0` if they are equal length, `1` if `$a` is shorter than `$b`
  992. */
  993. static private function sortMessageMatches($a, $b)
  994. {
  995. if (strlen($a) == strlen($b)) {
  996. return 0;
  997. }
  998. if (strlen($a) > strlen($b)) {
  999. return -1;
  1000. }
  1001. return 1;
  1002. }
  1003. /**
  1004. * Returns `TRUE` for non-empty strings, numbers, objects, empty numbers and string-like numbers (such as `0`, `0.0`, `'0'`)
  1005. *
  1006. * @param mixed $value The value to check
  1007. * @return boolean If the value is string-like
  1008. */
  1009. static private function stringlike($value)
  1010. {
  1011. if ((!is_string($value) && !is_object($value) && !is_numeric($value)) || !strlen(trim($value))) {
  1012. return FALSE;
  1013. }
  1014. return TRUE;
  1015. }
  1016. /**
  1017. * Validates values for an fActiveRecord object against the database schema and any additional rules that have been added
  1018. *
  1019. * @internal
  1020. *
  1021. * @param fActiveRecord $object The instance of the class to validate
  1022. * @param array $values The values to validate
  1023. * @param array $old_values The old values for the record
  1024. * @return array An array of messages
  1025. */
  1026. static public function validate($object, $values, $old_values)
  1027. {
  1028. $class = get_class($object);
  1029. $table = fORM::tablize($class);
  1030. self::initializeRuleArrays($class);
  1031. $validation_messages = array();
  1032. // Convert objects into values for validation
  1033. foreach ($values as $column => $value) {
  1034. $values[$column] = fORM::scalarize($class, $column, $value);
  1035. }
  1036. foreach ($old_values as $column => $column_values) {
  1037. foreach ($column_values as $key => $value) {
  1038. $old_values[$column][$key] = fORM::scalarize($class, $column, $value);
  1039. }
  1040. }
  1041. $message = self::checkPrimaryKeys($object, $values, $old_values);
  1042. if ($message) { $validation_messages[] = $message; }
  1043. $column_info = fORMSchema::retrieve()->getColumnInfo($table);
  1044. foreach ($column_info as $column => $info) {
  1045. $message = self::checkAgainstSchema($object, $column, $values, $old_values);
  1046. if ($message) { $validation_messages[] = $message; }
  1047. }
  1048. $message = self::checkUniqueConstraints($object, $values, $old_values);
  1049. if ($message) { $validation_messages[] = $message; }
  1050. foreach (self::$valid_values_rules[$class] as $column => $valid_values) {
  1051. $message = self::checkValidValuesRule($class, $values, $column, $valid_values);
  1052. if ($message) { $validation_messages[] = $message; }
  1053. }
  1054. foreach (self::$conditional_rules[$class] as $rule) {
  1055. $messages = self::checkConditionalRule($class, $values, $rule['main_columns'], $rule['conditional_values'], $rule['conditional_columns']);
  1056. if ($messages) { $validation_messages = array_merge($validation_messages, $messages); }
  1057. }
  1058. foreach (self::$one_or_more_rules[$class] as $rule) {
  1059. $message = self::checkOneOrMoreRule($class, $values, $rule['columns']);
  1060. if ($message) { $validation_messages[] = $message; }
  1061. }
  1062. foreach (self::$only_one_rules[$class] as $rule) {
  1063. $message = self::checkOnlyOneRule($class, $values, $rule['columns']);
  1064. if ($message) { $validation_messages[] = $message; }
  1065. }
  1066. return $validation_messages;
  1067. }
  1068. /**
  1069. * Validates related records for an fActiveRecord object
  1070. *
  1071. * @internal
  1072. *
  1073. * @param fActiveRecord $object The object to validate
  1074. * @param array &$values The values for the object
  1075. * @param array &$related_records The related records for the object
  1076. * @return array An array of messages
  1077. */
  1078. static public function validateRelated($object, &$values, &$related_records)
  1079. {
  1080. $class = get_class($object);
  1081. $table = fORM::tablize($class);
  1082. $validation_messages = array();
  1083. // Check related rules
  1084. foreach (self::$related_one_or_more_rules[$class] as $related_class => $routes) {
  1085. foreach ($routes as $route => $enabled) {
  1086. $message = self::checkRelatedOneOrMoreRule($object, $values, $related_records, $related_class, $route);
  1087. if ($message) { $validation_messages[] = $message; }
  1088. }
  1089. }
  1090. $related_messages = fORMRelated::validate($class, $values, $related_records);
  1091. $validation_messages = array_merge($validation_messages, $related_messages);
  1092. return $validation_messages;
  1093. }
  1094. }
  1095. /**
  1096. * Copyright (c) 2007-2009 Will Bond <will@flourishlib.com>, others
  1097. *
  1098. * Permission is hereby granted, free of charge, to any person obtaining a copy
  1099. * of this software and associated documentation files (the "Software"), to deal
  1100. * in the Software without restriction, including without limitation the rights
  1101. * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  1102. * copies of the Software, and to permit persons to whom the Software is
  1103. * furnished to do so, subject to the following conditions:
  1104. *
  1105. * The above copyright notice and this permission notice shall be included in
  1106. * all copies or substantial portions of the Software.
  1107. *
  1108. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  1109. * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  1110. * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  1111. * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  1112. * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  1113. * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  1114. * THE SOFTWARE.
  1115. */