<?php

/**
 * Model Parent Class for PICKLES
 *
 * PHP version 5
 *
 * Licensed under The MIT License
 * Redistribution of these files must retain the above copyright notice.
 *
 * @author    Joshua Sherman <pickles@joshtronic.com>
 * @copyright Copyright 2007-2013, Joshua Sherman
 * @license   http://www.opensource.org/licenses/mit-license.html
 * @package   PICKLES
 * @link      https://github.com/joshtronic/pickles
 */

/**
 * Model Class
 *
 * This is a parent class that all PICKLES data models should be extending. When
 * using the class as designed, objects will function as active record pattern
 * objects.
 */
class Model extends Object
{
	// {{{ Properties

	/**
	 * Model Name
	 *
	 * @access private
	 * @var    string
	 */
	private $model = null;

	/**
	 * Database Object
	 *
	 * @access protected
	 * @var    object
	 */
	protected $db = null;

	/**
	 * Columns
	 *
	 * Mapping of key columns for the table.
	 *
	 * @access protected
	 * @var    array
	 */
	protected $columns = null;

	/**
	 * Cache Object
	 *
	 * @access
	 * @var    object
	 */
	protected $cache = null;

	/**
	 * Whether or not to use cache
	 *
	 * @access protected
	 * @var    boolean
	 */
	protected $use_cache = false;

	/**
	 * SQL Array
	 *
	 * @access private
	 * @var    array
	 */
	private $sql = array();

	/**
	 * Input Parameters Array
	 *
	 * @access private
	 * @var    array
	 */
	private $input_parameters = array();

	/**
	 * Insert Priority
	 *
	 * Defaults to false (normal priority) but can be set to "low" or "high"
	 *
	 * @access protected
	 * @var    string
	 */
	protected $priority = false;

	/**
	 * Delayed Insert
	 *
	 * @access protected
	 * @var    boolean
	 */
	protected $delayed = false;

	/**
	 * Ignore Unique Index
	 *
	 * @access protected
	 * @var    boolean
	 */
	protected $ignore = false;

	/**
	 * Replace instead of Insert/Update?
	 *
	 * @access protected
	 * @var    boolean
	 */
	protected $replace = false;

	/**
	 * Field List
	 *
	 * SQL: SELECT
	 *
	 * @access protected
	 * @var    mixed
	 */
	protected $fields = '*';

	/**
	 * Table Name
	 *
	 * SQL: FROM
	 *
	 * @access protected
	 * @var    mixed
	 */
	protected $table = false;

	/**
	 * Joins
	 *
	 * SQL: JOIN
	 *
	 * @access protected
	 * @var    mixed
	 */
	protected $joins = false;

	/**
	 * [Index] Hints
	 *
	 * SQL: USE INDEX
	 *
	 * @access protected
	 * @var    mixed
	 */
	protected $hints = false;

	/**
	 * Conditions
	 *
	 * SQL: WHERE
	 *
	 * @access protected
	 * @var    mixed
	 */
	protected $conditions = false;

	/**
	 * Group
	 *
	 * SQL: GROUP BY
	 *
	 * @access protected
	 * @var    mixed
	 */
	protected $group = false;

	/**
	 * Having
	 *
	 * SQL: HAVING
	 *
	 * @access protected
	 * @var    mixed
	 */
	protected $having = false;

	/**
	 * Order
	 *
	 * SQL: ORDER BY
	 *
	 * @access protected
	 * @var    mixed
	 */
	protected $order = false;

	/**
	 * Limit
	 *
	 * SQL: LIMIT
	 *
	 * @access protected
	 * @var    mixed
	 */
	protected $limit = false;

	/**
	 * Offset
	 *
	 * SQL: OFFSET
	 *
	 * @access protected
	 * @var    mixed (string or array)
	 */
	protected $offset = false;

	/**
	 * Query Results
	 *
	 * @access protected
	 * @var    array
	 */
	protected $results = null;

	/**
	 * Index
	 *
	 * @var integer
	 */
	private $index = null;

	/**
	 * Record
	 *
	 * @access private
	 * @var    array
	 */
	public $record = null;

	/**
	 * Records
	 *
	 * @var array
	 */
	public $records = null;

	/**
	 * Original Record
	 *
	 * @access private
	 * @var    array
	 */
	private $original = null;

	/**
	 * Iterate
	 *
	 * Used to hold the status during a walk()
	 *
	 * @access private
	 * @var    boolean
	 */
	private $iterate = false;

	/**
	 * Snapshot
	 *
	 * Snapshot of the object properties
	 *
	 * @access private
	 * @var    array
	 */
	private $snapshot = array();

	/**
	 * MySQL?
	 *
	 * Whether or not we're using MySQL
	 *
	 * @access private
	 * @var    boolean
	 */
	private $mysql = false;

	/**
	 * PostgreSQL?
	 *
	 * Whether or not we're using PostgreSQL
	 *
	 * @access private
	 * @var    boolean
	 */
	private $postgresql = false;

	/**
	 * Commit Type
	 *
	 * Indicates what we want to commit. Defaults to a single row commit, any
	 * calls to queue() will force the commit to process the queue.
	 *
	 * @access private
	 * @var    string
	 */
	private $commit_type = 'row';

	// }}}
	// {{{ Class Constructor

	/**
	 * Constructor
	 *
	 * Creates a new (empty) object or populates the record set.
	 *
	 * @param mixed $type_or_parameters optional type of query or parameters
	 * @param array $parameters optional data to create a query from
	 */
	public function __construct($type_or_parameters = null, $parameters = null)
	{
		// Errors if a table is not set. You're welcome, Geoff.
		if ($this->table == false)
		{
			throw new Exception('You must set the table variable');
		}

		// Runs the parent constructor so we have the config
		parent::__construct();

		// Gets an instance of the database and check which it is
		$this->db         = Database::getInstance();
		$this->use_cache  = $this->db->cache;
		$this->mysql      = ($this->db->driver == 'pdo_mysql');
		$this->postgresql = ($this->db->driver == 'pdo_pgsql');

		// Sets up the cache object and grabs the class name to use in our cache keys
		$this->cache = Cache::getInstance();
		$this->model = get_class($this);

		// Default column mapping
		$columns = array(
			'id'         => 'id',
			'created_at' => 'created_at',
			'created_id' => 'created_id',
			'updated_at' => 'updated_at',
			'updated_id' => 'updated_id',
			'deleted_at' => 'deleted_at',
			'deleted_id' => 'deleted_id',
			'is_deleted' => 'is_deleted',
		);

		// Grabs the config columns if no columns are set
		if ($this->columns === null && isset($this->db->columns))
		{
			$this->columns = $this->db->columns;
		}

		// Sets all but the `id` column to false
		if ($this->columns === false)
		{
			foreach ($columns as $column => $field)
			{
				if ($column != 'id')
				{
					$columns[$column] = false;
				}
			}
		}
		// Merges the model's columns with the defaults
		elseif (is_array($this->columns))
		{
			foreach ($this->columns as $column => $field)
			{
				$columns[$column] = $field;
			}
		}

		$this->columns = $columns;

		// Takes a snapshot of the [non-object] object properties
		foreach ($this as $variable => $value)
		{
			if (!in_array($variable, array('db', 'cache', 'config', 'snapshot')))
			{
				$this->snapshot[$variable] = $value;
			}
		}

		return $this->execute($type_or_parameters, $parameters);
	}

	// }}}
	// {{{ Database Execution Methods

	/**
	 * Execute
	 *
	 * Potentially populates the record set from the passed arguments.
	 *
	 * @param mixed $type_or_parameters optional type of query or parameters
	 * @param mixed $parameter_or_key optional data to create query or cache key
	 * @param string $passed_key optional key to use for caching
	 */
	public function execute($type_or_parameters = null, $parameters_or_key = null, $passed_key = null)
	{
		// Resets internal properties
		foreach ($this->snapshot as $variable => $value)
		{
			$this->$variable = $value;
		}

		// Builds out the query
		if ($type_or_parameters != null)
		{
			// Loads the parameters into the object
			if (is_array($type_or_parameters))
			{
				if (is_array($parameters_or_key))
				{
					throw new Exception('You cannot pass in 2 query parameter arrays');
				}

				$this->prepareParameters($type_or_parameters);

				if ($this->use_cache
					&& isset($type_or_parameters['conditions'][$this->columns['id']])
					&& count($type_or_parameters) == 1
					&& count($type_or_parameters['conditions']) == 1)
				{
					$cache_keys     = array();
					$sorted_records = array();

					if (!is_array($type_or_parameters['conditions'][$this->columns['id']]))
					{
						$type_or_parameters['conditions'][$this->columns['id']] = array($type_or_parameters['conditions'][$this->columns['id']]);
					}

					foreach ($type_or_parameters['conditions'][$this->columns['id']] as $id)
					{
						$cache_keys[]        = strtoupper($this->model) . '-' . $id;
						$sorted_records[$id] = true;
					}

					$cached        = $this->cache->get($cache_keys);
					$partial_cache = array();

					if ($cached !== false)
					{
						foreach ($cached as $record)
						{
							$partial_cache[$record['id']] = $record;
						}
					}

					unset($cached);

					foreach ($type_or_parameters['conditions'][$this->columns['id']] as $key => $id)
					{
						if (isset($partial_cache[$id]))
						{
							unset($type_or_parameters['conditions'][$this->columns['id']][$key]);
						}
					}

					if (count($type_or_parameters['conditions'][$this->columns['id']]) == 0)
					{
						$cache_key = true;
						$cached    = array_values($partial_cache);
					}
				}

				if ($this->columns['is_deleted'])
				{
					$type_or_parameters['conditions'][$this->columns['is_deleted']] = '0';
				}

				$this->loadParameters($type_or_parameters);
			}
			elseif (is_array($parameters_or_key))
			{
				$this->prepareParameters($parameters_or_key);

				if ($this->use_cache
					&& isset($parameters_or_key['conditions'][$this->columns['id']])
					&& count($parameters_or_key) == 1
					&& count($parameters_or_key['conditions']) == 1)
				{
					$cache_keys     = array();
					$sorted_records = array();

					foreach ($parameters_or_key['conditions'][$this->columns['id']] as $id)
					{
						$cache_keys[]        = strtoupper($this->model) . '-' . $id;
						$sorted_records[$id] = true;
					}

					$cached        = $this->cache->get($cache_keys);
					$partial_cache = array();

					if ($cached !== false)
					{
						foreach ($cached as $record)
						{
							$partial_cache[$record['id']] = $record;
						}
					}

					unset($cached);

					foreach ($parameters_or_key['conditions'][$this->columns['id']] as $key => $id)
					{
						if (isset($partial_cache[$id]))
						{
							unset($parameters_or_key['conditions'][$this->columns['id']][$key]);
						}
					}

					if (count($parameters_or_key['conditions'][$this->columns['id']]) == 0)
					{
						$cache_key = true;
						$cached    = array_values($partial_cache);
					}
				}

				if ($this->columns['is_deleted'])
				{
					$parameters_or_key['conditions'][$this->columns['is_deleted']] = '0';
				}

				$this->loadParameters($parameters_or_key);
			}
			elseif (ctype_digit((string)$type_or_parameters))
			{
				$cache_key         = strtoupper($this->model) . '-' . $type_or_parameters;
				$parameters_or_key = array($this->columns['id'] => $type_or_parameters);

				if ($this->columns['is_deleted'])
				{
					$parameters_or_key[$this->columns['is_deleted']] = '0';
				}

				$this->loadParameters($parameters_or_key);
			}
			elseif (ctype_digit((string)$parameters_or_key))
			{
				$cache_key         = strtoupper($this->model) . '-' . $parameters_or_key;
				$parameters_or_key = array($this->columns['id'] => $parameters_or_key);

				if ($this->columns['is_deleted'])
				{
					$parameters_or_key[$this->columns['is_deleted']] = '0';
				}

				$this->loadParameters($parameters_or_key);
			}
			elseif ($this->columns['is_deleted'])
			{
				$this->loadParameters(array($this->columns['is_deleted'] => '0'));
			}

			if (is_string($parameters_or_key))
			{
				$passed_key = $parameters_or_key;
			}

			if (is_string($passed_key))
			{
				$cache_key = $passed_key;
			}

			// Starts with a basic SELECT ... FROM
			$this->sql = array(
				'SELECT ' . (is_array($this->fields) ? implode(', ', $this->fields) : $this->fields),
				'FROM '   . $this->table,
			);

			switch ($type_or_parameters)
			{
				// Updates query to use COUNT syntax
				case 'count':
					$this->sql[0] = 'SELECT COUNT(*) AS count';
					$this->generateQuery();
					break;

				// Adds the rest of the query
				case 'all':
				case 'list':
				case 'indexed':
				default:
					if (!isset($cache_key) || $cache_key !== true)
					{
						$this->generateQuery();
					}
					break;
			}

			if (isset($cache_key) && $this->use_cache && !isset($cached))
			{
				$cached = $this->cache->get($cache_key);
			}

			if (isset($cached) && $cached !== false)
			{
				$this->records = $cached;
			}
			else
			{
				$this->records = $this->db->fetch(
					implode(' ', $this->sql),
					(count($this->input_parameters) == 0 ? null : $this->input_parameters)
				);

				if (isset($partial_cache))
				{
					$records = array_merge($partial_cache, $this->records);

					if (isset($sorted_records))
					{
						foreach ($records as $record)
						{
							$sorted_records[$record['id']] = $record;
						}

						$records = $sorted_records;
					}

					$this->records = $records;
				}

				if ($this->use_cache)
				{
					if (isset($cache_key))
					{
						if ($passed_key)
						{
							$cache_value = $this->records;
						}
						elseif (isset($this->records[0]))
						{
							$cache_value = $this->records[0];
						}

						// Only set the value for non-empty records. Caching
						// values that are empty could be caused by querying
						// records that don't exist at the moment, but could
						// exist in the future. INSERTs do not do any sort of
						// cache invalidation at this time.
						if (isset($cache_value))
						{
							$this->cache->set($cache_key, $cache_value);
						}
					}
					elseif (isset($cache_keys))
					{
						// @todo Move to Memcached extension and switch to use setMulti()
						foreach ($this->records as $record)
						{
							$this->cache->set(strtoupper($this->model) . '-' . $record['id'], $record);
						}
					}
				}
			}

			$index_records = in_array($type_or_parameters, array('list', 'indexed'));

			// Flattens the data into a list
			if ($index_records == true)
			{
				$list = array();

				foreach ($this->records as $record)
				{
					// Uses the first value as the key and the second as the value
					if ($type_or_parameters == 'list')
					{
						$list[array_shift($record)] = array_shift($record);
					}
					// Uses the first value as the key
					else
					{
						$list[current($record)] = $record;
					}
				}

				$this->records = $list;
			}

			// Sets up the current record
			if (isset($this->records[0]))
			{
				$this->record = $this->records[0];
			}
			else
			{
				if ($index_records == true)
				{
					$this->record[key($this->records)] = current($this->records);
				}
				else
				{
					$this->record = $this->records;
				}
			}

			$this->index    = 0;
			$this->original = $this->records;
		}

		return true;
	}

	// }}}
	// {{{ SQL Generation Methods

	/**
	 * Generate Query
	 *
	 * Goes through all of the object variables that correspond with parts of
	 * the query and adds them to the master SQL array.
	 *
	 * @return boolean true
	 */
	private function generateQuery()
	{
		// Adds the JOIN syntax
		if ($this->joins != false)
		{
			if (is_array($this->joins))
			{
				foreach ($this->joins as $join => $tables)
				{
					$join_pieces = array((stripos('JOIN ', $join) === false ? 'JOIN' : strtoupper($join)));

					if (is_array($tables))
					{
						foreach ($tables as $table => $conditions)
						{
							$join_pieces[] = $table;

							if (is_array($conditions))
							{
								$type       = strtoupper(key($conditions));
								$conditions = current($conditions);

								$join_pieces[] = $type;
								$join_pieces[] = $this->generateConditions($conditions, true);
							}
							else
							{
								$join_pieces = $conditions;
							}
						}
					}
					else
					{
						$join_pieces[] = $tables;
					}
				}

				$this->sql[] = implode(' ', $join_pieces);

				unset($join_pieces);
			}
			else
			{
				$this->sql[] = (stripos('JOIN ', $join) === false ? 'JOIN ' : '') . $this->joins;
			}
		}

		// Adds the index hints
		if ($this->hints != false)
		{
			if (is_array($this->hints))
			{
				foreach ($this->hints as $hint => $columns)
				{
					if (is_array($columns))
					{
						$this->sql[] = $hint . ' (' . implode(', ', $columns) . ')';
					}
					else
					{
						$format = (stripos($columns, 'USE ') === false);

						$this->sql[] = ($format ? 'USE INDEX (' : '') . $columns . ($format ? ')' : '');
					}
				}
			}
			else
			{
				$format = (stripos($this->hints, 'USE ') === false);

				$this->sql[] = ($format ? 'USE INDEX (' : '') . $this->hints . ($format ? ')' : '');
			}
		}

		// Adds the WHERE conditionals
		if ($this->conditions != false)
		{
			$use_id = true;

			foreach ($this->conditions as $column => $value)
			{
				if (!is_int($column))
				{
					$use_id = false;
				}
			}

			if ($use_id)
			{
				$this->conditions = array($this->columns['id'] => $this->conditions);
			}

			$this->sql[] = 'WHERE ' . (is_array($this->conditions) ? $this->generateConditions($this->conditions) : $this->conditions);
		}

		// Adds the GROUP BY syntax
		if ($this->group != false)
		{
			$this->sql[] = 'GROUP BY ' . (is_array($this->group) ? implode(', ', $this->group) : $this->group);
		}

		// Adds the HAVING conditions
		if ($this->having != false)
		{
			$this->sql[] = 'HAVING ' . (is_array($this->having) ? $this->generateConditions($this->having) : $this->having);
		}

		// Adds the ORDER BY syntax
		if ($this->order != false)
		{
			$this->sql[] = 'ORDER BY ' . (is_array($this->order) ? implode(', ', $this->order) : $this->order);
		}

		// Adds the LIMIT syntax
		if ($this->limit != false)
		{
			$this->sql[] = 'LIMIT ' . (is_array($this->limit) ? implode(', ', $this->limit) : $this->limit);
		}

		// Adds the OFFSET syntax
		if ($this->offset != false)
		{
			$this->sql[] = 'OFFSET ' . $this->offset;
		}

		return true;
	}

	/**
	 * Generate Conditions
	 *
	 * Generates the conditional blocks of SQL from the passed array of
	 * conditions. Supports as much as I could remember to implement. This
	 * method is utilized by both the WHERE and HAVING clauses.
	 *
	 * @param  array $conditions array of potentially nested conditions
	 * @param  boolean $inject_values whether or not to use input parameters
	 * @param  string $conditional syntax to use between conditions
	 * @return string $sql generated SQL for the conditions
	 */
	private function generateConditions($conditions, $inject_values = false, $conditional = 'AND')
	{
		$sql = '';

		foreach ($conditions as $key => $value)
		{
			$key = trim($key);

			if (strtoupper($key) == 'NOT')
			{
				$key = 'AND NOT';
			}

			// Checks if conditional to start recursion
			if (preg_match('/^(AND|&&|OR|\|\||XOR)( NOT)?$/i', $key))
			{
				if (is_array($value))
				{
					// Determines if we need to include ( )
					$nested = (count($value) > 1);

					$conditional = $key;

					$sql .= ' ' . ($sql == '' ? '' : $key) . ' ' . ($nested ? '(' : '');
					$sql .= $this->generateConditions($value, $inject_values, $conditional);
					$sql .= ($nested ? ')' : '');
				}
				else
				{
					$sql .= ' ' . ($sql == '' ? '' : $key) . ' ' . $value;
				}
			}
			else
			{
				if ($sql != '')
				{
					if (preg_match('/^(AND|&&|OR|\|\||XOR)( NOT)?/i', $key))
					{
						$sql .= ' ';
					}
					else
					{
						$sql .= ' ' . $conditional . ' ';
					}
				}

				// Checks for our keywords to control the flow
				$operator  = preg_match('/(<|<=|=|>=|>|!=|!|<>| LIKE)$/i', $key);
				$between   = preg_match('/ BETWEEN$/i', $key);
				$is_is_not = preg_match('/( IS| IS NOT)$/i', $key);

				// Checks for boolean and null
				$is_true  = ($value === true);
				$is_false = ($value === false);
				$is_null  = ($value === null);

				// Generates an IN statement
				if (is_array($value) && $between == false)
				{
					$sql .= $key . ' IN (';

					if ($inject_values == true)
					{
						$sql .= implode(', ', $value);
					}
					else
					{
						$sql .= implode(', ', array_fill(1, count($value), '?'));
						$this->input_parameters = array_merge($this->input_parameters, $value);
					}

					$sql .= ')';
				}
				else
				{
					// If the key is numeric it wasn't set, so don't use it
					if (is_numeric($key))
					{
						$sql .= $value;
					}
					else
					{
						// Omits the operator as the operator is there
						if ($operator == true || $is_is_not == true)
						{
							if ($is_true || $is_false || $is_null)
							{
								// Scrubs the operator if someone doesn't use IS / IS NOT
								if ($operator == true)
								{
									$key = preg_replace('/ ?(!=|!|<>)$/i',         ' IS NOT', $key);
									$key = preg_replace('/ ?(<|<=|=|>=| LIKE)$/i', ' IS',     $key);
								}

								$sql .= $key . ' ';

								if ($is_true)
								{
									$sql .= 'TRUE';
								}
								elseif ($is_false)
								{
									$sql .= 'FALSE';
								}
								else
								{
									$sql .= 'NULL';
								}
							}
							else
							{
								$sql .= $key . ' ';

								if ($inject_values == true)
								{
									$sql .= $value;
								}
								else
								{
									$sql .= '?';
									$this->input_parameters[] = $value;
								}
							}
						}
						// Generates a BETWEEN statement
						elseif ($between == true)
						{
							if (is_array($value))
							{
								// Checks the number of values, BETWEEN expects 2
								if (count($value) != 2)
								{
									throw new Exception('Between expects 2 values');
								}
								else
								{
									$sql .= $key . ' ';

									if ($inject_values == true)
									{
										$sql .= $value[0] . ' AND ' . $value[1];
									}
									else
									{
										$sql .= '? AND ?';
										$this->input_parameters = array_merge($this->input_parameters, $value);
									}
								}
							}
							else
							{
								throw new Exception('Between usage expects values to be in an array');
							}
						}
						else
						{
							$sql .= $key . ' ';

							// Checks if we're working with NULL values
							if ($is_true)
							{
								$sql .= 'IS TRUE';
							}
							elseif ($is_false)
							{
								$sql .= 'IS FALSE';
							}
							elseif ($is_null)
							{
								$sql .= 'IS NULL';
							}
							else
							{
								if ($inject_values == true)
								{
									$sql .= '= ' . $value;
								}
								else
								{
									$sql .= '= ?';
									$this->input_parameters[] = $value;
								}
							}
						}
					}
				}
			}
		}

		return $sql;
	}

	// }}}
	// {{{ Record Interaction Methods

	/**
	 * Count Records
	 *
	 * Counts the records
	 */
	public function count()
	{
		return count($this->records);
	}

	/**
	 * Sort Records
	 *
	 * Sorts the records by the specified index in the specified order.
	 *
	 * @param  string $index the index to be sorted on
	 * @param  string $order the direction to order
	 * @return boolean true
	 * @todo   Implement this method
	 */
	public function sort($index, $order = 'ASC')
	{
		return true;
	}

	/**
	 * Shuffle Records
	 *
	 * Sorts the records in a pseudo-random order.
	 *
	 * @return boolean true
	 * @todo   Implement this method
	 */
	public function shuffle()
	{
		return true;
	}

	/**
	 * Next Record
	 *
	 * Increment the record array to the next member of the record set.
	 *
	 * @return boolean whether or not there was next element
	 */
	public function next()
	{
		$return = (boolean)($this->record = next($this->records));

		if ($return == true)
		{
			$this->index++;
		}

		return $return;
	}

	/**
	 * Previous Record
	 *
	 * Decrement the record array to the next member of the record set.
	 *
	 * @return boolean whether or not there was previous element
	 */
	public function prev()
	{
		$return = (boolean)($this->record = prev($this->records));

		if ($return == true)
		{
			$this->index--;
		}

		return $return;
	}

	/**
	 * Reset Record
	 *
	 * Set the pointer to the first element of the record set.
	 *
	 * @return boolean whether or not records is an array (and could be reset)
	 */
	public function reset()
	{
		$return = (boolean)($this->record = reset($this->records));

		if ($return == true)
		{
			$this->index = 0;
		}

		return $return;
	}

	/**
	 * First Record
	 *
	 * Alias of reset(). "first" is more intuitive to me, but reset stays in
	 * line with the built in PHP functions. Not sure why I'd want to add some
	 * consistency to one of the most inconsistent languages.
	 *
	 * @return boolean whether or not records is an array (and could be reset)
	 */
	public function first()
	{
		return $this->reset();
	}

	/**
	 * End Record
	 *
	 * Set the pointer to the last element of the record set.
	 *
	 * @return boolean whether or not records is an array (and end() worked)
	 */
	public function end()
	{
		$return = (boolean)($this->record = end($this->records));

		if ($return == true)
		{
			$this->index = $this->count() - 1;
		}

		return $return;
	}

	/**
	 * Last Record
	 *
	 * Alias of end(). "last" is more intuitive to me, but end stays in line
	 * with the built in PHP functions.
	 *
	 * @return boolean whether or not records is an array (and end() worked)
	 */
	public function last()
	{
		return $this->end();
	}

	/**
	 * Walk Records
	 *
	 * Returns the current record and advances to the next. Built to allow for
	 * simplified code when looping through a record set.
	 *
	 * @return mixed either an array of the current record or false
	 * @todo   Does not currently support "indexed" or "list" return types
	 */
	public function walk()
	{
		// Checks if we should start iterating, solves off by one issues with next()
		if ($this->iterate == false)
		{
			$this->iterate = true;

			// Resets the records, saves calling reset() when walking multiple times
			$this->reset();
		}
		else
		{
			$this->next();
		}

		return $this->record;
	}

	/**
	 * Queue Record
	 *
	 * Stashes the current record and creates an empty record ready to be
	 * manipulated. Eliminates looping through records and INSERTing each one
	 * separately and/or the need for helper methods in the models.
	 */
	public function queue()
	{
		$this->commit_type = 'queue';
		$this->records[]   = $this->record;
		$this->record      = null;
	}

	// }}}
	// {{{ Record Manipulation Methods

	/**
	 * Commit
	 *
	 * Inserts or updates a record in the database.
	 *
	 * @return boolean results of the query
	 */
	public function commit()
	{
		// Multiple row query / queries
		if ($this->commit_type == 'queue')
		{
			$update     = false;
			$cache_keys = array();

			/**
			 * @todo I outta loop through twice to determine if it's an INSERT
			 * or an UPDATE. As it stands, you could run into a scenario where
			 * you could have a mixed lot that would attempt to build out a
			 * query with both INSERT and UPDATE syntax and would probably cause
			 * a doomsday scenario for our universe.
			 */
			foreach ($this->records as $record)
			{
				// Performs an UPDATE with multiple queries
				if (array_key_exists($this->columns['id'], $record))
				{
					$update = true;

					if (!isset($sql))
					{
						$sql              = '';
						$input_parameters = array();
					}

					$update_fields = array();

					foreach ($record as $field => $value)
					{
						if ($field != $this->columns['id'])
						{
							$update_fields[]    = $field . ' = ?';
							$input_parameters[] = (is_array($value) ? (JSON_AVAILABLE ? json_encode($value) : serialize($value)) : $value);
						}
						else
						{
							$cache_keys[] = strtoupper($this->model) . '-' . $value;
						}
					}

					// @todo Check if the column was passed in
					if ($this->columns['updated_at'] != false)
					{
						$update_fields[]    = $this->columns['updated_at'] . ' = ?';
						$input_parameters[] = Time::timestamp();
					}

					// @todo Check if the column was passed in
					if ($this->columns['updated_id'] != false && isset($_SESSION['__pickles']['security']['user_id']))
					{
						$update_fields[]    = $this->columns['updated_id'] . ' = ?';
						$input_parameters[] = $_SESSION['__pickles']['security']['user_id'];
					}

					if ($sql != '')
					{
						$sql .= '; ';
					}

					$sql .= 'UPDATE ' . $this->table . ' SET ' . implode(', ', $update_fields) . ' WHERE ';

					if (isset($record[$this->columns['id']]))
					{
						$sql                .= $this->columns['id'] . ' = ?';
						$input_parameters[]  = $record[$this->columns['id']];
					}
					else
					{
						throw new Exception('Missing UID field');
					}
				}
				// Performs a multiple row INSERT
				else
				{
					if (!isset($sql))
					{
						$field_count   = count($record);
						$insert_fields = array_keys($record);

						if ($this->columns['created_at'] != false)
						{
							$insert_fields[] = $this->columns['created_at'];
							$field_count++;
						}

						if ($this->columns['created_id'] != false && isset($_SESSION['__pickles']['security']['user_id']))
						{
							$insert_fields[] = $this->columns['created_id'];
							$field_count++;
						}

						$values           = '(' . implode(', ', array_fill(0, $field_count, '?')) . ')';
						$input_parameters = array();

						// INSERT INTO ...
						$sql = 'INSERT INTO ' . $this->table . ' (' . implode(', ', $insert_fields) . ') VALUES ' . $values;
					}
					else
					{
						$sql .= ', ' . $values;
					}

					$record_field_count = count($record);

					foreach ($record as $variable => $value)
					{
						$input_parameters[] = (is_array($value) ? (JSON_AVAILABLE ? json_encode($value) : serialize($value)) : $value);
					}

					// @todo Check if the column was passed in
					if ($this->columns['created_at'] != false)
					{
						$input_parameters[] = Time::timestamp();
						$record_field_count++;
					}

					// @todo Check if the column was passed in
					if ($this->columns['created_id'] != false && isset($_SESSION['__pickles']['security']['user_id']))
					{
						$input_parameters[] = $_SESSION['__pickles']['security']['user_id'];
						$record_field_count++;
					}

					if ($record_field_count != $field_count)
					{
						throw new Exception('Record does not match the excepted field count');
					}
				}
			}

			$results = $this->db->execute($sql . ';', $input_parameters);

			// Clears the cache
			if ($update && $this->use_cache)
			{
				$this->cache->delete($cache_keys);
			}

			return $results;
		}
		// Single row INSERT or UPDATE
		elseif (count($this->record) > 0)
		{
			// Determines if it's an UPDATE or INSERT
			$update = (isset($this->record[$this->columns['id']]) && trim($this->record[$this->columns['id']]) != '');

			// Starts to build the query, optionally sets PRIORITY, DELAYED and IGNORE syntax
			if ($this->replace === true && $this->mysql)
			{
				$sql = 'REPLACE';

				if (strtoupper($this->priority) == 'LOW')
				{
					$sql .= ' LOW_PRIORITY';
				}
				elseif ($this->delayed == true)
				{
					$sql .= ' DELAYED';
				}

				$sql .= ' INTO ' . $this->table;
			}
			else
			{
				if ($update == true)
				{
					$sql = 'UPDATE';
				}
				else
				{
					$sql = 'INSERT';

					// PRIORITY syntax takes priority over DELAYED
					if ($this->mysql)
					{
						if ($this->priority !== false && in_array(strtoupper($this->priority), array('LOW', 'HIGH')))
						{
							$sql .= ' ' . strtoupper($this->priority) . '_PRIORITY';
						}
						elseif ($this->delayed == true)
						{
							$sql .= ' DELAYED';
						}

						if ($this->ignore == true)
						{
							$sql .= ' IGNORE';
						}
					}

					$sql .= ' INTO';
				}

				$sql .= ' ' . $this->table . ($update ? ' SET ' : ' ');
			}

			$input_parameters = null;

			// Limits the columns being updated
			$record = ($update ? array_diff_assoc($this->record, isset($this->original[$this->index]) ? $this->original[$this->index] : array()) : $this->record);

			// Makes sure there's something to INSERT or UPDATE
			if (count($record) > 0)
			{
				$insert_fields = array();

				// Loops through all the columns and assembles the query
				foreach ($record as $column => $value)
				{
					if ($column != $this->columns['id'])
					{
						if ($update == true)
						{
							if ($input_parameters != null)
							{
								$sql .= ', ';
							}

							$sql .= $column . ' = ';

							if (in_array($value, array('++', '--')))
							{
								$sql  .= $column . ' ' . substr($value, 0, 1) . ' ?';
								$value = 1;
							}
							else
							{
								$sql .= '?';
							}
						}
						else
						{
							$insert_fields[] = $column;
						}

						$input_parameters[] = (is_array($value) ? (JSON_AVAILABLE ? json_encode($value) : serialize($value)) : $value);
					}
				}

				// If it's an UPDATE tack on the ID
				if ($update == true)
				{
					if ($this->columns['updated_at'] != false)
					{
						if ($input_parameters != null)
						{
							$sql .= ', ';
						}

						$sql                .= $this->columns['updated_at'] . ' = ?';
						$input_parameters[]  = Time::timestamp();
					}

					if ($this->columns['updated_id'] != false && isset($_SESSION['__pickles']['security']['user_id']))
					{
						if ($input_parameters != null)
						{
							$sql .= ', ';
						}

						$sql                .= $this->columns['updated_id'] . ' = ?';
						$input_parameters[] = $_SESSION['__pickles']['security']['user_id'];
					}

					$sql                .= ' WHERE ' . $this->columns['id'] . ' = ?' . ($this->mysql ? ' LIMIT 1' : '') . ';';
					$input_parameters[]  = $this->record[$this->columns['id']];
				}
				else
				{
					if ($this->columns['created_at'] != false)
					{
						$insert_fields[]    = $this->columns['created_at'];
						$input_parameters[] = Time::timestamp();
					}

					if ($this->columns['created_id'] != false && isset($_SESSION['__pickles']['security']['user_id']))
					{
						$insert_fields[]    = $this->columns['created_id'];
						$input_parameters[] = $_SESSION['__pickles']['security']['user_id'];
					}

					$sql .= '(' . implode(', ', $insert_fields) . ') VALUES (' . implode(', ', array_fill(0, count($input_parameters), '?')) . ')';

					// PDO::lastInsertId() doesn't work so we return the ID with the query
					if ($this->postgresql)
					{
						$sql .= ' RETURNING ' . $this->columns['id'];
					}

					$sql .= ';';
				}

				// Executes the query
				if ($this->postgresql && $update == false)
				{
					$results = $this->db->fetch($sql, $input_parameters);

					return $results[0][$this->columns['id']];
				}
				else
				{
					$results = $this->db->execute($sql, $input_parameters);

					// Clears the cache
					if ($update && $this->use_cache)
					{
						$this->cache->delete(strtoupper($this->model) . '-' . $this->record[$this->columns['id']]);
					}

					return $results;
				}
			}
		}

		return false;
	}

	/**
	 * Delete Record
	 *
	 * Deletes the current record from the database.
	 *
	 * @return boolean status of the query
	 */
	public function delete()
	{
		if (isset($this->record[$this->columns['id']]))
		{
			// Logical deletion
			if ($this->columns['is_deleted'])
			{
				$sql              = 'UPDATE ' . $this->table . ' SET ' . $this->columns['is_deleted'] . ' = ?';
				$input_parameters = array('1');

				if ($this->columns['deleted_at'])
				{
					$sql                .= ', ' . $this->columns['deleted_at'] . ' = ?';
					$input_parameters[]  = Time::timestamp();
				}

				if ($this->columns['deleted_id'] && isset($_SESSION['__pickles']['security']['user_id']))
				{
					$sql                .= ', ' . $this->columns['deleted_id'] . ' = ?';
					$input_parameters[]  = $_SESSION['__pickles']['security']['user_id'];
				}

				$sql .= ' WHERE ' . $this->columns['id'] . ' = ?';
			}
			// For reals deletion
			else
			{
				$sql = 'DELETE FROM ' . $this->table . ' WHERE ' . $this->columns['id'] . ' = ?' . ($this->mysql ? ' LIMIT 1' : '') . ';';
			}

			$input_parameters[] = $this->record[$this->columns['id']];
			$results            = $this->db->execute($sql, $input_parameters);

			// Clears the cache
			if ($this->use_cache)
			{
				$this->cache->delete(strtoupper($this->model) . '-' . $this->record[$this->columns['id']]);
			}

			return $results;
		}
		else
		{
			return false;
		}
	}

	// }}}
	// {{{ Utility Methods

	/**
	 * Prepare Parameters
	 *
	 * Checks if the parameters array is only integers and reconstructs the
	 * array with the proper conditions format.
	 *
	 * @access private
	 * @param  array $array parameters array, passed by reference
	 */
	private function prepareParameters(&$parameters)
	{
		$all_integers = true;

		foreach ($parameters as $key => $value)
		{
			if (!ctype_digit((string)$key) || !ctype_digit((string)$value))
			{
				$all_integers = false;
			}
		}

		if ($all_integers)
		{
			$parameters = array('conditions' => array($this->columns['id'] => $parameters));
		}
	}

	/**
	 * Load Parameters
	 *
	 * Loads the passed parameters back into the object.
	 *
	 * @access private
	 * @param  array $parameters key / value list
	 * @param  boolean whether or not the parameters were loaded
	 */
	private function loadParameters($parameters)
	{
		if (is_array($parameters))
		{
			$conditions = true;

			// Adds the parameters to the object
			foreach ($parameters as $key => $value)
			{
				// Clean up the variable just in case
				$key = trim(strtolower($key));

				// Assigns valid keys to the appropriate class property
				if (in_array($key, array('fields', 'table', 'joins', 'hints', 'conditions', 'group', 'having', 'order', 'limit', 'offset')))
				{
					$this->$key = $value;
					$conditions = false;
				}
			}

			// If no valid properties were found, assume it's the conditionals
			if ($conditions == true)
			{
				$this->conditions = $parameters;
			}

			return true;
		}

		return false;
	}

	/**
	 * Unescape String
	 *
	 * Assuming magic quotes is turned on, strips slashes from the string.
	 *
	 * @access protected
	 * @param  string $value string to be unescaped
	 * @return string unescaped string
	 */
	protected function unescape($value)
	{
		if (get_magic_quotes_gpc())
		{
			$value = stripslashes($value);
		}

		return $value;
	}

	/**
	 * Field Values
	 *
	 * Pulls the value from a single field and returns an array without any
	 * duplicates. Perfect for extracting foreign keys to use in later queries.
	 *
	 * @param  string $field field we want the values for
	 * @return array values for the passed field
	 */
	public function fieldValues($field)
	{
		$values = array();

		foreach ($this->records as $record)
		{
			$values[] = $record[$field];
		}

		return array_unique($values);
	}

	// }}}
}

?>
