PageRenderTime 73ms CodeModel.GetById 9ms app.highlight 55ms RepoModel.GetById 1ms app.codeStats 0ms

/include/lib/BaseRow.php

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