PageRenderTime 50ms CodeModel.GetById 15ms RepoModel.GetById 0ms app.codeStats 1ms

/include/lib/BaseRow.php

https://github.com/daybreaker/chitin
PHP | 1188 lines | 524 code | 167 blank | 497 comment | 129 complexity | b195eb99ed05e13086719e9760dfa52a MD5 | raw file
Possible License(s): MIT
  1. <?php
  2. /**
  3. * @author Gabe Martin-Dempesy <gabe@mudbugmedia.com>
  4. * @version $Id: BaseRow.php 5212 2009-10-05 21:05:25Z gabebug $
  5. * @copyright Mudbug Media, 2007-10-15
  6. * @package Chitin
  7. * @subpackage Models
  8. */
  9. include_once 'DBSingleton.php';
  10. class BaseRowException extends Exception { }
  11. class BaseRowQueryException extends BaseRowException { }
  12. class BaseRowRecordNotFoundException extends BaseRowException { }
  13. /**
  14. * BaseRow is an abstract class implementing an ORM
  15. *
  16. * @link https://wiki.mudbugmedia.com/index.php/BaseRow
  17. * @package Chitin
  18. * @subpackage Models
  19. */
  20. abstract class BaseRow {
  21. /**
  22. * @var PEAR::DB
  23. */
  24. protected $dao;
  25. /**
  26. * @var string Optional database name used to override the default selected database supplied by DBSingleton
  27. */
  28. protected $database_name;
  29. /**
  30. * @var string Name of the table whose rows we are modelling
  31. */
  32. protected $table_name;
  33. /**
  34. * @var string field name of the primary key. Defaults to 'id'
  35. */
  36. protected $primary_key_field = 'id';
  37. /**
  38. * @var array field names
  39. */
  40. protected $fields;
  41. /**
  42. * @var array
  43. */
  44. protected $protected_fields = array('updated', 'updated_on', 'updated_at', 'created', 'created_on', 'created_at');
  45. protected $updated_fields = array('updated', 'updated_on', 'updated_at');
  46. protected $created_fields = array('created', 'created_on', 'created_at');
  47. /**
  48. * @var mixed String or array of strings of field names to apply to find() operations if no order_by or sort_ parameter were provided.
  49. */
  50. protected $default_sort_fields;
  51. /**
  52. * @var mixed String or array of strings of field directions (ASC or DESC) to compliment $default_sort_fields
  53. */
  54. protected $default_sort_directions;
  55. /**
  56. * @var string Default 'order_by' parameter to apply to find() operations if no order_by or sort_ parameter were provided.
  57. */
  58. protected $default_order_by;
  59. /**
  60. * @var integer When save() or validateRecursive(), the number of table associations deep to process
  61. */
  62. protected $default_recurse = 1;
  63. /**
  64. * @var array The currently represented row in an associative array
  65. */
  66. protected $row;
  67. /**
  68. * @var array List of errors
  69. */
  70. protected $errors;
  71. /**
  72. * @var boolean Is the data in the current row saved?
  73. */
  74. protected $saved;
  75. /**
  76. * @var boolean Does this instance already exist in the database?
  77. */
  78. protected $new_record;
  79. /**
  80. * @param mixed Hash of BaseRow associations, where the key is the public field name by which we will reference the data
  81. */
  82. protected $associations = array();
  83. /**
  84. * @param mixed Hash of named scope rules, where the key is the name and the value is a find parameter
  85. *
  86. * Example:
  87. * array('available' => array('where' => 'status = 1'));
  88. * FooTable()->scope('available')->find();
  89. */
  90. protected $scopes;
  91. /**
  92. * @param mixed Array of find() rules or named scope strings. DO NOT MODIFY (this should be private, but unserialize complains about private vars)
  93. * @ignore
  94. */
  95. protected $scope_stack = array();
  96. /**
  97. * Instantiates a new row from an array of data
  98. */
  99. function __construct ($row = null) {
  100. // Instance variables
  101. $this->row = (is_null($row) ? array() : $row);
  102. if (!is_array($this->row)) throw new BaseRowException("Unexpected input provided to BaseRow::__construct. Expected: array or null, received: " . gettype($this->row));
  103. $this->saved = false;
  104. $this->new_record = true;
  105. // Call the 'setup' method if it exists. This method should act as a constructor that doesn't have to super or deal with argument passing
  106. if (method_exists($this, 'setup')) $this->setup();
  107. }
  108. /*
  109. *
  110. * STATIC METHODS
  111. *
  112. */
  113. /**
  114. * Run a query and return all the results as an array of rows
  115. * @todo Log errors instead of sending a notice
  116. * @access protected
  117. * @param string $query SQL query to run
  118. * @param array $params Optional prepare() parameters. If null or not passed, it will not be sent
  119. * @return array of rows
  120. */
  121. protected function _query ($query, $params = null) {
  122. if (!isset($this->dao))
  123. $this->dao = DBSingleton::getInstance();
  124. if (class_exists('ChitinLogger')) {
  125. ChitinLogger::log(get_class($this) . ": " . $query);
  126. if (!is_null($params) && count($params) > 0)
  127. ChitinLogger::log('Params: ' . var_export($params, true));
  128. }
  129. $result = (is_null($params) ?
  130. $this->dao->getAll($query) :
  131. $this->dao->getAll($query, $params));
  132. if (PEAR::isError($result))
  133. throw new BaseRowQueryException($result->getUserInfo());
  134. return $result;
  135. }
  136. /**
  137. * Return the last inserted ID
  138. * @access protected
  139. * @return PRIMARY_KEY
  140. */
  141. protected function _insertID () {
  142. $query = "SELECT LAST_INSERT_ID() AS id";
  143. $result = $this->_query($query);
  144. return $result[0]['id'];
  145. }
  146. /**
  147. * Return an escaped table name, including database name if set
  148. *
  149. * Example: `pureftpd`.`users`
  150. *
  151. * @access protected
  152. * @return string
  153. */
  154. protected function _tableStatement () {
  155. return (isset($this->database_name) ?
  156. '`'.$this->database_name.'`.`'.$this->table_name.'`' :
  157. '`'.$this->table_name.'`');
  158. }
  159. /**
  160. * Fetches and caches a list of fields/columns from the database
  161. * @access protected
  162. */
  163. protected function _fetchfields () {
  164. // If something is already set or cached, don't re-fetch
  165. if (!is_null($this->fields))
  166. return;
  167. if (!isset($this->dao))
  168. $this->dao = DBSingleton::getInstance();
  169. // PEAR DB's tableInfo() does not properly escape table names,
  170. // so you can run into some problems when a table name is a
  171. // reserved word in MySQL (e.g. "group").
  172. //
  173. // The PEAR DB maintainers does not intend to change this
  174. // behavior as is evidenced from the comments on Bugzilla:
  175. //
  176. // @see http://pear.php.net/bugs/bug.php?id=8336
  177. // [2006-08-01 13:06 UTC] lsmith (Lukas Smith)
  178. // IIRC Daniel decided that in PEAR::DB it will be left to the user to
  179. // quote identifiers passed to tableInfo()....
  180. //
  181. // To get around this issue, we will escape our table name. However,
  182. // we do consider it bad practice at Mudbug Media to name tables using
  183. // reserved words.
  184. $table_info = $this->dao->tableInfo($this->_tableStatement());
  185. if (PEAR::isError($table_info))
  186. throw new BaseRowQueryException($table_info->getUserInfo());
  187. foreach ($table_info as $row_info)
  188. $this->fields[$row_info['name']] = $row_info;
  189. }
  190. /**
  191. * Determine if this model has a requested association, and return the
  192. * association type
  193. *
  194. * @param string $field
  195. * @return string Association type of the field ('has_one', 'has_many', 'belongs_to', 'many_to_many')
  196. */
  197. protected function _hasAssociation ($field) {
  198. $relations = array('has_one', 'has_many', 'belongs_to', 'many_to_many');
  199. foreach ($relations as $r)
  200. if (isset($this->{$r}[$field]))
  201. return $r;
  202. return false;
  203. }
  204. /**
  205. * Save data into the database
  206. *
  207. * The $data array will be filtered through to only include fields that exist in this table
  208. *
  209. * @access protected
  210. * @param string $type Type of query, INSERT, UPDATE, or REPLACE
  211. * @param array $data Associative array of data
  212. * @param string $where Optional where clause to limit when updating
  213. * @param array $params_append Optional params array to use with your $where clause
  214. */
  215. protected function _save ($type, $data, $where = null, $params_append = null) {
  216. // Make sure the given $type is one we can actually do
  217. $valid_types = array('INSERT', 'UPDATE', 'REPLACE');
  218. if (!in_array($type, $valid_types)) {
  219. throw new BaseRowException("BaseRow::_save: $type is not a valid save type");
  220. }
  221. $this->_fetchfields();
  222. // Find the intersection of $data and the fields
  223. $pairs = array();
  224. $params = array();
  225. foreach ($data as $field => $value) {
  226. // Add it if, 1) It exists in the field list, 2) it isn't in the list of automatically generated fields
  227. if (isset($this->fields[$field])) {
  228. if (isset($this->wrapper_fields[$field])) {
  229. $pairs[] = "`$field` = " . sprintf($this->wrapper_fields[$field], '!');
  230. $params[] = $value;
  231. } else if (!in_array($field, $this->protected_fields) || ($type == 'REPLACE' && $field == $this->primary_key_field)) {
  232. $pairs[] = "`$field` = ?";
  233. $params[] = $value;
  234. }
  235. }
  236. }
  237. // Automatically add the timestamp of any field in the
  238. // $created_field list if it appears in our field list
  239. if ($type != 'UPDATE') {
  240. foreach ($this->created_fields as $field) {
  241. if (isset($this->fields[$field]))
  242. $pairs[] = "`$field` = NOW()";
  243. }
  244. }
  245. foreach ($this->updated_fields as $field) {
  246. if (isset($this->fields[$field]))
  247. $pairs[] = "`$field` = NOW()";
  248. }
  249. // Update nothing if we have no fields to update
  250. if (count($pairs) < 1)
  251. return null;
  252. // UPDATE statements do not need the INTO keyword
  253. $into = ($type == 'UPDATE') ? '' : 'INTO ';
  254. $query = "$type $into" . $this->_tableStatement() . " SET ";
  255. $query .= implode( ", \n", $pairs);
  256. if ($type == 'UPDATE' && !is_null($where)) {
  257. $query .= "\nWHERE $where";
  258. }
  259. if (!is_null($params_append))
  260. $params = array_merge($params, $params_append);
  261. return $this->_query($query, $params);
  262. }
  263. /**
  264. * Condenses extraneous "magic" find parameters down to the core values used by find:
  265. * - callback
  266. * - select
  267. * - where
  268. * - group_by
  269. * - having
  270. * - limit
  271. * - order_by
  272. * - params
  273. */
  274. protected function _findCondense ($params) {
  275. $condensed = array();
  276. $condensed['callback'] = (isset($params['callback'])) ? $params['callback'] : null;
  277. $condensed['select'] = (isset($params['select'])) ? $params['select'] : null;
  278. $condensed['joins'] = (isset($params['joins'])) ? $params['joins'] : null;
  279. list($condensed['where'], $condensed['params']) = $this->_findWhere($params);
  280. $condensed['group_by'] = (isset($params['group_by'])) ? $params['group_by'] : null;
  281. $condensed['having'] = (isset($params['having'])) ? $params['having'] : null;
  282. $condensed['order_by'] = $this->_findOrderBy($params);
  283. $condensed['limit'] = $this->_findLimit($params);
  284. return $condensed;
  285. }
  286. /**
  287. * Determines the default SELECT clause based on find parameters:
  288. * @access protected
  289. * @param array $params Supplied parameters. This is not inspected by default
  290. * @return string "*, ..."
  291. */
  292. protected function _findSelect ($params) {
  293. if (isset($params['select']))
  294. return $params['select'];
  295. $this->_fetchfields();
  296. $select = "*";
  297. foreach ($this->created_fields as $field)
  298. if (in_array($field, $this->fields))
  299. $select .= ", UNIX_TIMESTAMP($field) AS $field";
  300. foreach ($this->updated_fields as $field)
  301. if (in_array($field, $this->fields))
  302. $select .= ", UNIX_TIMESTAMP($field) AS $field";
  303. return $select;
  304. }
  305. /**
  306. * Determines the ORDER BY clause based on find parameters:
  307. * @access protected
  308. * @param array $params Supplied parameters, the following of which are inspected:
  309. * - where
  310. * - params
  311. * - fields
  312. * - values
  313. * - match_any
  314. * @return array (WHERE clause, prepare() params aray), e.g. array('name = ? and state = ?', array('Scott', 'LA'))
  315. */
  316. protected function _findWhere ($params) {
  317. $this->_fetchfields();
  318. if (!isset($params['params']))
  319. $params['params'] = array();
  320. // Build the WHERE clause if we don't already have one supplied
  321. if (!isset($params['where'])) {
  322. // If there was no 'where' or 'fields' parameter, we can't build anything
  323. if (!isset($params['fields']) && !isset($params['values']))
  324. return array(null, array());
  325. // Iterate through if the fields are an array
  326. if (is_array($params['fields'])) {
  327. $pieces = array();
  328. for ($i = 0; $i < count($params['fields']); $i++) {
  329. if (isset($this->fields[$params['fields'][$i]])) {
  330. $pieces[] = $this->_findWhereSegment($params['fields'][$i], $params['values'][$i]);
  331. $params['params'][] = $params['values'][$i];
  332. }
  333. }
  334. $operator = (isset($params['match_any']) ? ' OR ' : ' AND ');
  335. $params['where'] = implode($operator, $pieces);
  336. } else {
  337. if (isset($this->fields[$params['fields']])) {
  338. $params['where'] = $this->_findWhereSegment($params['fields'], $params['values']);
  339. $params['params'] = array($params['values']);
  340. } else
  341. return array(null, array());
  342. }
  343. }
  344. return array($params['where'], $params['params']);
  345. }
  346. /**
  347. * Build a params-style sub-where clause for matching a given field and value
  348. *
  349. * This method is extracted to abstract the operator for dealing with null values
  350. *
  351. * @access private
  352. * @param string field name
  353. * @param string value to match
  354. * @return string
  355. */
  356. private function _findWhereSegment ($field, $value) {
  357. return $this->_tableStatement() . ".`$field` " . (is_null($value) ? 'IS' : '=') . " ?";
  358. }
  359. /**
  360. * Determines the ORDER BY clause based on find parameters:
  361. * @access protected
  362. * @param array $params Supplied parameters, the following of which are inspected:
  363. * - sort_fields
  364. * - sort_directions
  365. * - order_by
  366. * @return string e.g. "field_1 ASC, field2_DESC", or null if a valid statement is not set
  367. */
  368. protected function _findOrderBy ($params) {
  369. // order_by parameter receives top priority, and is not sanitized
  370. if (isset($params['order_by']))
  371. return $params['order_by'];
  372. if (!isset($params['sort_fields']))
  373. return null;
  374. $pieces = array();
  375. if (is_array($params['sort_fields'])) {
  376. for ($i = 0; $i < count($params['sort_fields']); $i++)
  377. if (isset($this->fields[$params['sort_fields'][$i]]))
  378. $pieces[] = $this->_tableStatement().'.`'.$params['sort_fields'][$i] . '` ' . ((isset($params['sort_directions'][$i]) && strcasecmp($params['sort_directions'][$i], 'DESC') == 0) ? 'DESC' : 'ASC');
  379. } else if (isset($this->fields[$params['sort_fields']])) {
  380. $pieces[] = $this->_tableStatement().'.`'.$params['sort_fields'] . '` ' . ((isset($params['sort_directions']) && strcasecmp($params['sort_directions'], 'DESC') == 0) ? 'DESC' : 'ASC');
  381. }
  382. if (count($pieces) == 0)
  383. return null;
  384. else
  385. return join($pieces, ', ');
  386. }
  387. /**
  388. * Return an ORDER BY clause to be used if nothing has been supplied by scope() or find()
  389. * @return string e.g. "field_1 ASC, field2_DESC", or null if a valid statement is not set
  390. */
  391. protected function _findDefaultOrderBy () {
  392. if (isset($this->default_order_by))
  393. return $this->default_order_by;
  394. if (isset($this->default_sort_fields) && isset($this->default_sort_directions)) {
  395. return $this->_findOrderBy(array(
  396. 'sort_fields' => $this->default_sort_fields,
  397. 'sort_directions' => $this->default_sort_directions,
  398. ));
  399. } else
  400. return null;
  401. }
  402. /**
  403. * Determines the LIMIT clause based on find parameters:
  404. * @access protected
  405. * @param array $params Supplied parameters, the following of which are inspected:
  406. * - limit_start - Which record number to
  407. * - page - Alternative to 'limit_start'; Which "page number" to start on, assuming the first page is '1'.
  408. * - per_page - How many results per page do we display. If omitted, defaults to 15
  409. * - first - If set, we only want the very first. Returns '0, 1';
  410. * @return string e.g. "0, 15", or null if a start is not passed
  411. */
  412. protected function _findLimit ($params) {
  413. // This only returns the very first instance. Override the limit so we aren't wasting time
  414. if (isset($params['first']))
  415. return '0, 1';
  416. // Enforce that everything is a number
  417. if (isset($params['page']) && !preg_match('/^\d+$/', $params['page'])) $params['page'] = 1;
  418. if (isset($params['limit_start']) && !preg_match('/^\d+$/', $params['limit_start'])) $params['limit_start'] = 0;
  419. if (!isset($params['per_page']) || !preg_match('/^\d+$/', $params['per_page'])) $params['per_page'] = 15;
  420. if (!isset($params['page']) && !isset($params['limit_start']))
  421. return null;
  422. if (isset($params['page']) && !isset($params['limit_start']))
  423. $params['limit_start'] = ($params['page'] - 1) * $params['per_page'];
  424. return $params['limit_start'] . ', ' . $params['per_page'];
  425. }
  426. /**
  427. * Instantiates the row, runs callbacks, and sets it as existing
  428. * @access protected
  429. * @param array $row Associative array
  430. * @param boolean $callback Run the callback if it's implemented?
  431. * @return BaseRow
  432. */
  433. protected function _findInstantiate ($row, $callback = true) {
  434. $obj = new $this($row);
  435. $obj->saved = true;
  436. $obj->new_record = false;
  437. if ($callback && method_exists($this, 'callbackAfterFetch'))
  438. $obj->callbackAfterFetch();
  439. return $obj;
  440. }
  441. /**
  442. * Merges two pre-condensed find() param arrays together, with the right side taking dominance
  443. *
  444. * This is used to resolve a scope() chain
  445. *
  446. * @param mixed $left
  447. * @param mixed $right
  448. * @return mixed Find param()
  449. */
  450. protected function _findMergeParams($left, $right) {
  451. // These params are merged together if they both exist via sprintf. $1 = right, $2 = left
  452. $merges = array(
  453. 'select' => '%s, %s',
  454. 'joins' => "%s\n%s",
  455. 'where' => '(%2$s) AND (%1$s)',
  456. 'group_by' => '%s, %s',
  457. 'having' => '(%2$s) AND (%1$s)',
  458. 'order_by' => '%s, %s',
  459. );
  460. foreach ($merges as $key => $sprintf)
  461. if (isset($left[$key]) && isset($right[$key])) // R && L
  462. $right[$key] = sprintf($sprintf, $right[$key], $left[$key]);
  463. else if (!isset($right[$key]) && isset($left[$key])) // !R && L
  464. $right[$key] = $left[$key];
  465. // R && !L requires no work.
  466. // params are merged too, but they are arrays, so we can't use sprintf to do the merging
  467. $right['params'] = array_merge($left['params'], $right['params']);
  468. // These params will have the right parameter overwrite the left if it exists
  469. $overwrites = array(
  470. 'limit',
  471. 'callback'
  472. );
  473. foreach ($overwrites as $key)
  474. if (!isset($right[$key]) && isset($left[$key]))
  475. $right[$key] = $left[$key];
  476. return $right;
  477. }
  478. protected function _namedScopeResolve ($param) {
  479. if (is_array($param))
  480. return ($param);
  481. else if (is_string($param) && isset($this->scopes[$param]))
  482. return $this->scopes[$param];
  483. else
  484. throw new BaseRowException("Could not resolve scope rule: " . var_export($param, true));
  485. }
  486. /**
  487. * Merge find() parameters into future find() calls or association references
  488. *
  489. * @param mixed find() array
  490. * @see find
  491. * @return BaseRow
  492. */
  493. public function scope ($rules) {
  494. $new = clone $this;
  495. // Unset any cached associations. That way if we do $row->scope(..)->association, it will be forced to fetch a fresh copy with the scope enforced
  496. foreach (array_keys($this->associations) as $assoc)
  497. unset($new->$assoc);
  498. // Bottom of the stack is the front of the array. This is opposite of what array_push/pop do, but it's easier to iterate top down
  499. array_unshift($new->scope_stack, $rules);
  500. return $new;
  501. }
  502. /**
  503. * Return the scope stack
  504. *
  505. * This is primarily of utility of the BaseRowAssociation objects, and you shouldn't ever need to directly call this yourself
  506. *
  507. * @internal
  508. * @return array stack of scope() calls
  509. */
  510. public function getScopeStack () {
  511. return $this->scope_stack;
  512. }
  513. /**
  514. * Sets the scope stack from a previous getScopeStack() call
  515. * @internal
  516. * @param mixed $stack Stack from getScopeStack()
  517. */
  518. public function setScopeStack ($stack) {
  519. $this->scope_stack = $stack;
  520. }
  521. /**
  522. * Return the SELECT query for the given scope and parameters
  523. * @param array $params Analogous to find()
  524. * @return array 0 => query, 1 => scope-condensed array of find() parameters including query 'params'
  525. */
  526. public function getFindQuery ($params = array()) {
  527. $condensed = $this->_findCondense($params);
  528. foreach ($this->scope_stack as $scope)
  529. $condensed = $this->_findMergeParams($this->_findCondense($this->_namedScopeResolve($scope)), $condensed);
  530. // Populate default values if nothing was provided in $params or scope()
  531. if (is_null($condensed['select'])) $condensed['select'] = $this->_findSelect($params);
  532. if (is_null($condensed['order_by'])) $condensed['order_by'] = $this->_findDefaultOrderBy($params);
  533. $query = "SELECT " . $condensed['select'] . "\nFROM " . $this->_tableStatement();
  534. if (!is_null($condensed['joins'])) $query .= "\n" . $condensed['joins'];
  535. if (!is_null($condensed['where'])) $query .= "\nWHERE " . $condensed['where'];
  536. if (!is_null($condensed['group_by'])) $query .= "\nGROUP BY " . $condensed['group_by'];
  537. if (!is_null($condensed['having'])) $query .= "\nHAVING " . $condensed['having'];
  538. if (!is_null($condensed['order_by'])) $query .= "\nORDER BY " . $condensed['order_by'];
  539. if (!is_null($condensed['limit'])) $query .= "\nLIMIT " . $condensed['limit'];
  540. return array($query, $condensed);
  541. }
  542. /**
  543. * Locate rows based on supplied search criteria
  544. * @access public
  545. * @static (pending LSB in 5.3)
  546. * @param array Named parameters:
  547. * - string 'where' WHERE clause to return results by. Value will not be sanitized prior to querying.
  548. * - array 'params' array of prepare() parameters to use in conjunction with a 'where' clause
  549. * - mixed 'fields' field name(s) to limit results by in conjunction with passed value(s). When passed as an array, by default all values must match
  550. * - mixed 'values' value(s) to match with the above field(s)
  551. * - boolean 'match_any' When matching an array of fields/values, return ANY results to match (default: false)
  552. * - mixed 'sort_fields' field(s) to sort results by. Values which are not fields in this table will be skipped.
  553. * - mixed 'sort_directions' Direction(s) to sort results by, 'ASC' or 'DESC'. Defaults to 'ASC' if invalid or not set
  554. * - string 'order_by' Complete ORDER BY clause. Value will not be sanitized prior to querying.
  555. * - int 'per_page' Maximum number of results to return. Defaults to '15' if 'limit_start' is set
  556. * - int 'page' Automatically sets the 'limit_start' property with the assistance of 'per_page'
  557. * - int 'limit_start' Record index to start a LIMIT statement with
  558. * - boolean 'first' If set, only returns the first row of the results instead of an array
  559. * - boolean 'callback' Run callbackAfterFetch if it's present? Default: true
  560. * @return array array of BaseRows
  561. */
  562. public function find ($params = array()) {
  563. list ($q, $condensed) = $this->getFindQuery($params);
  564. $rows = $this->_query($q, $condensed['params']);
  565. // _query had an error. That's probably our fault.
  566. if (is_null($rows)) {
  567. // var_dump($query, $query_params);
  568. return null;
  569. }
  570. // Instantiate
  571. $instances = array();
  572. foreach ($rows as $row)
  573. $instances[] = $this->_findInstantiate($row, (!isset($condensed['callback']) || $condensed['callback']));
  574. if (isset($params['first']))
  575. return isset($instances[0]) ? $instances[0] : null;
  576. return $instances;
  577. }
  578. /**
  579. * Fetch a BaseRow instance that matches the passed primary key
  580. *
  581. * @access public
  582. * @static (pending LSB in 5.3)
  583. * @param PRIMARY_KEY $id Value of the row's primary key
  584. * @return BaseRow
  585. */
  586. public function get ($id) {
  587. $record = $this->find(array('fields' => $this->primary_key_field, 'values' => $id, 'first' => true));
  588. if (is_null($record))
  589. throw new BaseRowRecordNotFoundException("Could not locate " . get_class($this) . " record #" . $id);
  590. return $record;
  591. }
  592. /**
  593. * Determine if a particular row exists in the table
  594. *
  595. * @access public
  596. * @static (pending LSB in 5.3)
  597. * @param PRIMARY_KEY $id Value of the row's primary key
  598. * @return boolean
  599. */
  600. public function exists ($id) {
  601. return ($this->rowCount("`{$this->primary_key_field}` = ?", array($id)) > 0);
  602. }
  603. /**
  604. * Determine number of rows in a table for a provided where clause
  605. *
  606. * If no clause is provided, the total row count will be returned.
  607. *
  608. * This method can be scoped with the scope() function
  609. *
  610. * @access public
  611. * @static (pending LSB in 5.3)
  612. * @param string $where Optional WHERE clause to use
  613. * @param array $params Optional prepare() style params array
  614. * @return integer row count
  615. */
  616. public function rowCount ($where = null, $params = array()) {
  617. $condensed = array('where' => $where, 'params' => $params);
  618. foreach ($this->scope_stack as $scope)
  619. $condensed = $this->_findMergeParams($this->_findCondense($this->_namedScopeResolve($scope)), $condensed);
  620. $query = "SELECT COUNT(*) as `count` FROM " . $this->_tableStatement();
  621. if (!empty($condensed['where']))
  622. $query .= " WHERE " . $condensed['where'];
  623. $rows = $this->_query($query, $condensed['params']);
  624. return $rows[0]['count'];
  625. }
  626. /**
  627. * TRUNCATE the entire table
  628. *
  629. * This will remove all the rows from the table and reset the auto increment counter
  630. * @access public
  631. * @static (pending LSB in 5.3)
  632. */
  633. public function truncate () {
  634. $query = "TRUNCATE " . $this->_tableStatement();
  635. $this->_query($query);
  636. }
  637. /**
  638. * Delete a single row by provided id
  639. * @access public
  640. * @static (pending LSB in 5.3)
  641. * @param PRIMARY_KEY $id
  642. */
  643. public function delete ($id) {
  644. return $this->deleteAllBySQL('`'.$this->primary_key_field . "` = ?", array($id));
  645. }
  646. /**
  647. * Delete all rows matching a WHERE clause with provided data
  648. * @access public
  649. * @static (pending LSB in 5.3)
  650. * @param string $where Optional WHERE clause to use
  651. * @param array $params Optional prepare() style params array
  652. */
  653. public function deleteAllBySQL ($where = null, $params = null) {
  654. $query = "DELETE FROM " . $this->_tableStatement();
  655. if (!is_null($where))
  656. $query .= " WHERE $where";
  657. $this->_query($query, $params);
  658. }
  659. /**
  660. * Update a single row with provided data
  661. *
  662. * Because a partial row can be passed, no validation is run on this data
  663. *
  664. * @access public
  665. * @static (pending LSB in 5.3)
  666. * @param array $data Associative array of row data
  667. * @param PRIMARY_KEY $id ID to update
  668. */
  669. public function update ($data, $id) {
  670. return $this->updateAllBySQL($data, '`'.$this->primary_key_field . "` = ?", array($id));
  671. }
  672. /**
  673. * Update all rows matching a WHERE clause with provided data
  674. *
  675. * Because a partial row can be passed, no validation is run on this data
  676. *
  677. * @access public
  678. * @static (pending LSB in 5.3)
  679. * @param array $data Associative array of row data
  680. * @param string $where Optional WHERE clause matches which rows to update
  681. * @param array $params Optional prepare() parameters
  682. */
  683. function updateAllBySQL ($data, $where = null, $params = null) {
  684. $this->_save('UPDATE', $data, $where, $params);
  685. }
  686. /**
  687. * Run a query and return all the results as an array of rows
  688. *
  689. * This is a public wrapper for the protected _query
  690. *
  691. * @param string $query SQL query to run
  692. * @param array $params Optional prepare() parameters. If null or not passed, it will not be sent
  693. * @return array of rows
  694. */
  695. public function query ($query, $params = null) {
  696. return $this->_query($query, $params);
  697. }
  698. /*
  699. *
  700. * INSTANCE METHODS
  701. *
  702. */
  703. /**
  704. * PHP overloaded function to set the value of a field
  705. *
  706. * Example usage: $myrow->name = "stuff" will call $myrow->__set('name', 'stuff');
  707. * @access public
  708. * @param string $field Name of field to set
  709. * @param mixed $value Value to assign
  710. */
  711. public function __set ($field, $value) {
  712. $this->saved = false;
  713. $this->row[$field] = $value;
  714. }
  715. /**
  716. * PHP overloaded function to set the value of a field
  717. *
  718. * Note that this method returns by reference. This allows us to modify
  719. * properties whose values are arrays, such as with associations.
  720. *
  721. * Example usage: $myrow->name will call $myrow->__get('name');
  722. * @access public
  723. * @param string $field Name of field to fetch
  724. * @return mixed
  725. */
  726. public function &__get ($field) {
  727. // If it's an existing property, give what we have on hand
  728. if (isset($this->row[$field]))
  729. return $this->row[$field];
  730. // Check to see if the field is an association that hasn't been fetched yet
  731. if (!isset($this->associations[$field])) {
  732. // Return by reference mandates a variable to refer to.
  733. $null = null;
  734. return $null;
  735. }
  736. $this->row[$field] = $this->associations[$field]->fetch($this);
  737. return $this->row[$field];
  738. }
  739. /**
  740. * PHP overloaded function to determine if a value has been assigned
  741. *
  742. * Example usage: isset($myrow->name) will call $myrow->__isset('name');
  743. * @access public
  744. * @param string $field Name of field to check
  745. * @return boolean
  746. */
  747. public function __isset ($field) {
  748. return isset($this->row[$field]) || (isset($this->associations[$field]));
  749. }
  750. /**
  751. * PHP overloaded function to unassign a particular field
  752. *
  753. * Example usage: unset($myrow->name) will call $myrow->__unset('name');
  754. * @access public
  755. * @param string $field Name of field to unset
  756. */
  757. public function __unset ($field) {
  758. $this->saved = false;
  759. unset($this->row[$field]);
  760. }
  761. /**
  762. * Removes the database when serialize()ing
  763. * @access public
  764. */
  765. public function __sleep () {
  766. // __sleep requires an array of variable names to keep. Why can't it just be a trigger?
  767. $vars = get_class_vars(__CLASS__);
  768. unset($vars['dao']);
  769. return array_keys($vars);
  770. }
  771. /**
  772. * Return the value of the primary key of this row, if it exists
  773. * @return integer
  774. */
  775. public function id () {
  776. return $this->__get($this->primary_key_field);
  777. }
  778. /**
  779. * Merge the contents of passed array with the current data
  780. * @access public
  781. * @param array $data
  782. */
  783. public function merge ($data) {
  784. $this->saved = false;
  785. $this->row = array_merge($this->row, $data);
  786. }
  787. /**
  788. * Return the row's data as an associative array
  789. * @access public
  790. * @return array
  791. */
  792. public function toArray () {
  793. return $this->row;
  794. }
  795. /**
  796. * Is this row already existing in the database?
  797. * @access public
  798. * @return boolean
  799. */
  800. public function isNew () {
  801. return $this->new_record;
  802. }
  803. /**
  804. * Is the current state of this row, along with any changes, saved in the database?
  805. * @return boolean
  806. */
  807. public function isSaved() {
  808. return $this->saved;
  809. }
  810. /**
  811. * Validates data and constructs an associative array of error messages
  812. * @access public
  813. * @param string $type Type of validation: 'INSERT' or 'UPDATE'
  814. * @return array List of error messages
  815. */
  816. public function validate ($type = 'INSERT') {
  817. return array();
  818. }
  819. /**
  820. * Recursively call validate()
  821. * @param mixed $params
  822. * @return array List of error messages
  823. */
  824. public function validateRecursive ($params = array()) {
  825. if (!isset($params['recurse'])) $params['recurse'] = $this->default_recurse;
  826. $errors = array();
  827. if ($params['recurse'] > 0) {
  828. $params['recurse']--;
  829. $relations = array('HasOne', 'HasMany', 'BelongsTo', 'ManyToMany');
  830. foreach ($this->associations as $name => $rules)
  831. if (isset($this->row[$name]))
  832. if (is_array($this->row[$name]))
  833. for ($i = 0; $i < count($this->row[$name]); $i++)
  834. $errors += $this->_validateRecursive_prefixKeys($this->row[$name][$i]->validateRecursive($params), $name.'_'.$i.'_');
  835. else if ($this->row[$name] instanceof BaseRow) {
  836. $errors += $this->_validateRecursive_prefixKeys($this->row[$name]->validateRecursive($params), $name . '_');
  837. }
  838. }
  839. $errors += $this->validate(($this->isNew() ? 'INSERT' : 'UPDATE'));
  840. return $errors;
  841. }
  842. /**
  843. * Prefix every key in an array with a string
  844. * @param array $array Array to prefix
  845. * @param string $prefix Prefix to apply to strings
  846. * @return array
  847. */
  848. protected function _validateRecursive_prefixKeys ($array, $prefix) {
  849. $ret = array();
  850. foreach ($array as $key => $value)
  851. $ret[$prefix . $key] = $value;
  852. return $ret;
  853. }
  854. /**
  855. * Returns errors from a previous validation run
  856. * @access public
  857. * @return array of error messages
  858. */
  859. public function getErrors () {
  860. return $this->errors;
  861. }
  862. /**
  863. * Refresh the data straight from the database
  864. *
  865. * This will rerun any callbacks that occur during normal instantiation
  866. * @access public
  867. */
  868. public function reload () {
  869. $row = $this->get($this->__get($this->primary_key_field));
  870. $this->row = $row->toArray();
  871. //$this->merge($row->toArray());
  872. $this->saved = true;
  873. }
  874. /**
  875. * Saves the data into the database
  876. *
  877. * This will run an INSERT for a new row, or an UPDATE for an existing row
  878. * @param mixed $params Associative array of parameters:
  879. * - integer 'recurse' How many layers deep to save associations. If "0", no associations will be saved. Default: 1
  880. * - boolean 'validate' Validate the row before saving. If disabled, this may result in errors. Default: true
  881. * @access public
  882. */
  883. public function save ($params = array()) {
  884. // Setup $params
  885. if (!isset($params['recurse'])) $params['recurse'] = $this->default_recurse;
  886. if (!isset($params['validate'])) $params['validate'] = true;
  887. if ($params['validate']) {
  888. $this->errors = $this->validateRecursive(array('recurse' => $params['recurse']));
  889. if (count($this->errors))
  890. return false;
  891. }
  892. // Associated rows: save all instantiated associations whose ID's are needed for this row
  893. $this->_saveAssoc(array('BelongsTo'), $params);
  894. // Save this particular row if it isn't already saved
  895. if (!$this->saved) {
  896. $type = ($this->isNew() ? 'INSERT' : 'UPDATE');
  897. // Run the 'beforeSave' callback
  898. if (method_exists($this, 'callbackBeforeSave'))
  899. $this->callbackBeforeSave();
  900. // Build and run the query
  901. if ($type == 'UPDATE')
  902. $this->_save($type, $this->row, "`{$this->primary_key_field}` = ?", array($this->row[$this->primary_key_field]));
  903. else
  904. $this->_save($type, $this->row);
  905. // Set primary key
  906. if ($type == 'INSERT')
  907. $this->__set($this->primary_key_field, $this->_insertID());
  908. $this->saved = true;
  909. $this->new_record = false;
  910. }
  911. // Associated rows: save all instantiated associations that needed this row's id
  912. $this->_saveAssoc(array('HasOne', 'HasMany', 'ManyToMany'), $params);
  913. return true;
  914. }
  915. /**
  916. * Forward requests to save associated BaseRows
  917. * @param array $types Array of association types that we should attempt to save now, e.g. array('BelongsTo', 'HasMany');
  918. * @param array $params Parameters for save()
  919. */
  920. protected function _saveAssoc ($types, $params) {
  921. if ($params['recurse'] < 1)
  922. return;
  923. $params['recurse']--;
  924. foreach ($this->associations as $name => $rules) {
  925. if (isset($this->row[$name]) && in_array(get_class($rules), $types))
  926. $rules->save($this, $name, $params);
  927. }
  928. }
  929. }
  930. abstract class BaseRowAssociation {
  931. protected $rules;
  932. public function __construct ($rules) {
  933. if (!is_array($rules))
  934. throw new BaseRowException("BaseRow Association rules must be an array");
  935. $this->rules = $rules;
  936. }
  937. /**
  938. * Fetch and return the row(s) for this association rule
  939. * @param BaseRow $row Parent row
  940. * @return mixed BaseRow(s)
  941. */
  942. abstract public function fetch ($row);
  943. /**
  944. * Save the row(s) for this association rule
  945. * @param BaseRow $row Parent row
  946. * @param string $name Field Name of this association rule
  947. * @param mixed $param Parameters passed to BaseRow::save()
  948. */
  949. abstract public function save ($row, $name, $params);
  950. public function __get ($key) {
  951. if (isset($this->rules[$key]))
  952. return $this->rules[$key];
  953. else
  954. return null;
  955. }
  956. public function __set ($key, $value) {
  957. $this->rules[$key] = $value;
  958. }
  959. protected function getTable ($row) {
  960. $table = new $this->class;
  961. $table->setScopeStack($row->getScopeStack());
  962. return $table;
  963. }
  964. }
  965. class BelongsTo extends BaseRowAssociation {
  966. public function fetch ($row) {
  967. return $this->getTable($row)->get($row->{$this->key});
  968. }
  969. public function save ($row, $name, $params) {
  970. if ($row->$name instanceof BaseRow) {
  971. $row->$name->save($params);
  972. $row->{$this->key} = $row->$name->id();
  973. }
  974. }
  975. }
  976. class HasMany extends BaseRowAssociation {
  977. public function fetch ($row) {
  978. return $this->getTable($row)->find(array('fields' => $this->key, 'values' => $row->id));
  979. }
  980. public function save ($row, $name, $params) {
  981. if (is_array($row->$name)) {
  982. // Assume that this row is already saved, and update the associated row with our ID
  983. for ($i = 0; $i < count($row->$name); $i++) {
  984. if ($row->{$name}[$i] instanceof BaseRow) {
  985. $row->{$name}[$i]->{$this->key} = $row->id();
  986. $row->{$name}[$i]->save($params);
  987. }
  988. }
  989. } else if ($row->name instanceof BaseRow) {
  990. $row->$name->{$this->key} = $row->id();
  991. $row->$name->save($params);
  992. }
  993. }
  994. }
  995. class HasOne extends HasMany {
  996. public function fetch ($row) {
  997. return $this->getTable($row)->find(array('fields' => $this->key, 'values' => $row->id, 'first' => true));
  998. }
  999. }
  1000. class ManyToMany extends BaseRowAssociation {
  1001. public function fetch ($row) {
  1002. return $this->getTable($row)->find(array('where' => "id IN (SELECT {$this->remote_key} FROM {$this->table} WHERE {$this->local_key} = ?)", 'params' => array($row->id)));
  1003. }
  1004. public function save ($row, $name, $params) {
  1005. // delete all rows
  1006. $row->query("DELETE FROM `!` WHERE `!` = ?", array($this->table, $this->local_key, $row->id()));
  1007. // insert matching
  1008. for ($i = 0; $i < count($row->$name); $i++) {
  1009. if ($row->{$name}[$i] instanceof BaseRow) {
  1010. $row->{$name}[$i]->save($params);
  1011. $row->query("REPLACE INTO `!` SET `!` = ?, `!` = ?", array(
  1012. $this->table,
  1013. $this->local_key,
  1014. $row->id(),
  1015. $this->remote_key,
  1016. $row->{$name}[$i]->id()));
  1017. }
  1018. }
  1019. }
  1020. }
  1021. ?>