<?php
/**
 * Contains the FluentQuery class.
 *
 * @author    Iron Bound Designs
 * @since     2.0
 * @license   MIT
 * @copyright Iron Bound Designs, 2016.
 */

namespace IronBound\DB\Query;

use Closure;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection as DoctrineCollection;
use IronBound\DB\Collection;
use IronBound\DB\Exception\InvalidColumnException;
use IronBound\DB\Exception\ModelNotFoundException;
use IronBound\DB\Model;
use IronBound\DB\Query\Tag\From;
use IronBound\DB\Query\Tag\Generic;
use IronBound\DB\Query\Tag\Group;
use IronBound\DB\Query\Tag\Having;
use IronBound\DB\Query\Tag\Join;
use IronBound\DB\Query\Tag\Limit;
use IronBound\DB\Query\Tag\Order;
use IronBound\DB\Query\Tag\Select;
use IronBound\DB\Query\Tag\Where;
use IronBound\DB\Query\Tag\Where_Date;
use IronBound\DB\Query\Tag\Where_Raw;
use IronBound\DB\Saver\ModelSaver;
use IronBound\DB\Saver\Saver;
use IronBound\DB\Extensions\Meta\MetaTable;
use IronBound\DB\Table\Table;

/**
 * Class FluentQuery
 *
 * @package IronBound\DB\Query
 */
class FluentQuery {

	/** @var \wpdb */
	protected $wpdb;

	/** @var Table */
	protected $table;

	/** @var string */
	protected $model;

	/** @var Select */
	protected $select;

	/** @var From */
	protected $from;

	/** @var Join[] */
	protected $joins = array();

	/** @var Where */
	protected $where;

	/** @var Order */
	protected $order;

	/** @var Group */
	protected $group;

	/** @var Limit */
	protected $limit;

	/** @var Having */
	protected $having;

	/** @var string */
	protected $meta_join;

	/**@var string */
	protected $meta_type;

	/** @var MetaTable */
	protected $meta_table;

	/** @var string */
	protected $alias = 't1';

	/** @var int */
	protected $alias_count = 1;

	/** @var int */
	protected $count;

	/** @var int */
	protected $offset;

	/** @var bool */
	protected $calc_found_rows = false;

	/** @var bool */
	protected $prime_meta_cache = true;

	/** @var string */
	protected $sql;

	/** @var Collection|ArrayCollection */
	protected $results;

	/** @var int|null */
	protected $total;

	/**
	 * Map of relation attribute name to callback.
	 *
	 * @var array
	 */
	protected $relations = array();

	/** @var string[] */
	private $expressions = array();

	/** @var bool */
	private $select_single = false;

	/** @var bool */
	private $focused_select = false;

	/** @var Table[] Joined tables keyed by the used alias. */
	private $joined_tables;

	/**
	 * FluentQuery constructor.
	 *
	 * @param Table $table
	 * @param \wpdb $wpdb
	 */
	public function __construct( Table $table, \wpdb $wpdb = null ) {
		$this->table = $table;
		$this->wpdb  = $wpdb ?: $GLOBALS['wpdb'];

		$this->select = new Select( null );
		$this->from   = new From( $this->table->get_table_name( $this->wpdb ), $this->alias );
		$this->where  = new Where( '', '', '' );
	}

	/**
	 * Create a new FluentQuery object from a model.
	 *
	 * @since 2.0
	 *
	 * @param string $model
	 *
	 * @return static
	 */
	public static function from_model( $model ) {

		$query        = new static( $model::table(), $GLOBALS['wpdb'] );
		$query->model = $model;

		return $query;
	}

	/**
	 * Set the Model class.
	 *
	 * @since 2.0
	 *
	 * @param string $model
	 *
	 * @return $this
	 */
	public function set_model_class( $model ) {
		$this->model = $model;

		return $this;
	}

	/**
	 * Select certain columns.
	 *
	 * @since 2.0
	 *
	 * @param string $columns,...
	 *
	 * @return $this
	 */
	public function select( $columns ) {

		if ( $columns === Select::ALL ) {

			$this->select->all( $this->alias );

			return $this;
		}

		if ( ! is_array( $columns ) ) {
			$columns = func_get_args();
		}

		foreach ( $columns as $column ) {
			$this->select->also( $this->prepare_column( $column ) );
		}

		$this->select->also( $this->prepare_column( $this->table->get_primary_key() ) );

		$this->focused_select = true;

		return $this;
	}

	/**
	 * Select a single column.
	 *
	 * Will return a map of primary key -> column value.
	 *
	 * @since 1.36.0
	 *
	 * @param string $column
	 *
	 * @return $this
	 */
	public function select_single( $column ) {
		$this->select->also( $this->prepare_column( $column ) );
		$this->select->also( $this->prepare_column( $this->table->get_primary_key() ) );

		$this->select_single = true;

		return $this;
	}

	/**
	 * Select all results.
	 *
	 * @since 2.0
	 *
	 * @param bool $local_only Only retrieve results local to this table.
	 *
	 * @return $this
	 */
	public function select_all( $local_only = true ) {

		$this->select->all( $local_only ? $this->alias : null );

		return $this;
	}

	/**
	 * Select an expression, such as 'COUNT'.
	 *
	 * @since 2.0
	 *
	 * @param string $function
	 * @param string $column
	 * @param string $as
	 *
	 * @return $this
	 */
	public function expression( $function, $column, $as ) {
		$this->select->expression( $function, $this->prepare_column( $column ), $as );

		$this->expressions[] = $as;

		return $this;
	}

	/**
	 * Return only distinct values.
	 *
	 * @since 2.0
	 *
	 * @return $this
	 */
	public function distinct() {
		$this->select->filter_distinct();

		return $this;
	}

	/**
	 * Filter results by a condition.
	 *
	 * @since 2.0
	 *
	 * @param string|array|Where $column
	 * @param string|bool        $equality
	 * @param mixed              $value
	 * @param Closure|null       $callback Called with $this as the first parameter. Setup for nesting on the new where
	 *                                     tag.
	 * @param string             $boolean
	 *
	 * @return $this
	 *
	 * @throws \IronBound\DB\Exception\InvalidColumnException
	 * @throws \InvalidArgumentException
	 */
	public function where( $column, $equality = '', $value = '', Closure $callback = null, $boolean = null ) {

		if ( $equality ) {
			$this->assert_comparator( $equality );
		}

		if ( is_array( $column ) ) {

			$where = Where::for_clause();

			foreach ( $column as $col => $val ) {
				$where->qAnd( $this->generate_where_tag( $col, true, $val ) );
			}

		} else {
			$where = $this->generate_where_tag( $column, $equality, $value );
		}

		if ( $callback ) {
			$_where      = $this->where;
			$this->where = $where;
			$callback( $this );
			$this->where = $_where;
		}

		if ( $this->where->is_empty() && ! $where->is_for_clause() ) {
			$this->where = $where;
		} else {
			$boolean = $boolean ?: 'and';
			$boolean = 'q' . ucfirst( $boolean );
			$this->where->{$boolean}( $where );
		}

		return $this;
	}

	/**
	 * Generate a Where tag.
	 *
	 * @since 2.0.0
	 *
	 * @param string       $column
	 * @param string|bool  $equality
	 * @param string|array $value
	 *
	 * @return Where
	 *
	 * @throws \IronBound\DB\Exception\InvalidColumnException
	 * @throws \InvalidArgumentException
	 */
	protected function generate_where_tag( $column, $equality = '', $value = '' ) {

		if ( $column instanceof Where ) {
			return $column;
		}

		if ( is_array( $value ) ) {

			if ( count( $value ) === 0 ) {
				throw new \InvalidArgumentException( 'Must provide at least one value for IN query.' );
			}

			$self  = $this;
			$value = array_map( function ( $value ) use ( $column, $self ) {
				return $self->escape_value( $column, $value );
			}, $value );
		} else {
			$value = $this->escape_value( $column, $value );
		}

		$column = $this->prepare_column( $column );

		return new Where( $column, $equality, $value );
	}

	/**
	 * Add a nested where clause.
	 *
	 * @since 2.0.0
	 *
	 * @param Closure $callback
	 * @param string  $boolean
	 */
	public function add_nested_where( Closure $callback, $boolean = 'and' ) {

		$_where      = $this->where;
		$this->where = Where::for_clause();

		$callback( $this );

		$clause      = $this->where;
		$this->where = $_where;

		if ( $clause->is_empty() ) {
			return;
		}

		$boolean = $boolean ?: 'and';
		$boolean = 'q' . ucfirst( $boolean );
		$this->where->{$boolean}( $clause );
	}

	/**
	 * Add a where condition checking an already joined table to the main where clause.
	 *
	 * @param Table   $table
	 * @param Closure $callback
	 * @param string  $boolean
	 */
	public function where_joined( Table $table, Closure $callback, $boolean = 'and' ) {

		$found = false;

		foreach ( $this->joined_tables as $alias => $maybe_table ) {
			if ( $maybe_table->get_slug() === $table->get_slug() ) {
				$found = $alias;
				break;
			}
		}

		if ( ! $found ) {
			throw new \InvalidArgumentException( "Table {$table->get_slug()} has not already been joined to this table." );
		}

		$other_query                = new FluentQuery( $table, $this->wpdb );
		$other_query->alias         = $found;
		$other_query->alias_count   = $this->alias_count + 1;
		$other_query->where         = Where::for_clause();
		$other_query->joined_tables = $this->joined_tables;

		$callback( $other_query );

		$boolean = 'q' . ucfirst( $boolean );
		$this->where->{$boolean}( $other_query->where );
	}

	/**
	 * Add a OR where clause.
	 *
	 * @since 2.0
	 *
	 * @param string       $column
	 * @param string|bool  $equality
	 * @param mixed        $value
	 * @param Closure|null $callback Called with $this as the first parameter. Setup for nesting on the new where tag.
	 *
	 * @return $this
	 */
	public function or_where( $column, $equality = '', $value = '', Closure $callback = null ) {
		return $this->where( $column, $equality, $value, $callback, 'or' );
	}

	/**
	 * Add an AND where clause.
	 *
	 * @since 2.0
	 *
	 * @param string       $column
	 * @param string|bool  $equality
	 * @param mixed        $value
	 * @param Closure|null $callback Called with $this as the first parameter. Setup for nesting on the new where tag.
	 *
	 * @return $this
	 */
	public function and_where( $column, $equality = '', $value = '', Closure $callback = null ) {
		return $this->where( $column, $equality, $value, $callback, 'and' );
	}

	/**
	 * Add a XOR where caluse.
	 *
	 * @since 2.0
	 *
	 * @param string       $column
	 * @param string|bool  $equality
	 * @param mixed        $value
	 * @param Closure|null $callback Called with $this as the first parameter. Setup for nesting on the new where tag.
	 *
	 * @return $this
	 */
	public function xor_where( $column, $equality, $value, Closure $callback = null ) {
		return $this->where( $column, $equality, $value, $callback, 'xor' );
	}

	/**
	 * Perform a date based where query.
	 *
	 * @since 2.0
	 *
	 * @param array        $query
	 * @param string       $column
	 * @param Closure|null $callback
	 * @param string       $boolean
	 *
	 * @return $this
	 * @throws InvalidColumnException
	 */
	public function where_date( $query, $column, Closure $callback = null, $boolean = 'and' ) {

		$query = new \WP_Date_Query( $query, $this->prepare_column( $column ) );

		return $this->where( new Where_Date( $query ), '', '', $callback, $boolean );
	}

	/**
	 * Perform a meta query.
	 *
	 * @since 2.0
	 *
	 * @param array|\WP_Meta_Query $query
	 * @param MetaTable|null       $table     Table metadata is stored in. If not specified, will be retrieved from the
	 *                                        model.
	 * @param string               $meta_type Type of metadata. Will be determined from the model if not given.
	 *
	 * @return $this
	 *
	 * @throws \InvalidArgumentException If a MetaTable or $meta_type can't be determined.
	 */
	public function where_meta( $query, MetaTable $table = null, $meta_type = '' ) {

		if ( ! $table && $this->model && method_exists( $this->model, 'get_meta_table' ) ) {
			$table = call_user_func( array( $this->model, 'get_meta_table' ) );
		}

		if ( ! $meta_type && $this->model && method_exists( $this->model, 'get_meta_type' ) ) {
			$meta_type = call_user_func( array( $this->model, 'get_meta_type' ) );
		}

		if ( ! $table ) {
			throw new \InvalidArgumentException( "MetaTable can't be determined from the given arguments." );
		}

		if ( ! $meta_type ) {
			throw new \InvalidArgumentException( "\$meta_type can't be determined from the given arguments." );
		}

		if ( ! $query instanceof \WP_Meta_Query ) {
			$query = new \WP_Meta_Query( $query );
		}

		$fn = function ( $key, $original ) use ( $table, $meta_type ) {

			if ( $original === $meta_type . '_id' ) {
				$key = $table->get_primary_id_column();
			}

			return $key;
		};

		add_filter( 'sanitize_key', $fn, 10, 2 );

		$sql = $query->get_sql(
			$meta_type,
			$this->alias,
			$this->table->get_primary_key()
		);

		remove_filter( 'sanitize_key', $fn, 10 );

		$this->meta_join = $sql['join'];

		$where = $sql['where'];
		$where = preg_replace( '/^\sAND\s/', '', $where );

		if ( $this->where ) {
			$this->where->qAnd( new Where_Raw( $where ) );
		} else {
			$this->where = new Where_Raw( $where );
		}

		return $this;
	}

	/**
	 * Order the results by a given column.
	 *
	 * Can be called multiple times to add additional order by clauses.
	 *
	 * @since 2.0
	 *
	 * @param string $column
	 * @param string $direction
	 *
	 * @return $this
	 *
	 * @throws \IronBound\DB\Exception\InvalidColumnException
	 */
	public function order_by( $column, $direction = null ) {

		$column = $this->prepare_column( $column );

		if ( is_null( $this->order ) ) {
			$this->order = new Order( $column, $direction );
		} else {
			$this->order->then( $column, $direction );
		}

		return $this;
	}

	/**
	 * Order results by a previously calculated expression.
	 *
	 * @param string $expression_alias
	 * @param string $direction
	 *
	 * @return $this
	 * @throws InvalidColumnException
	 */
	public function order_by_expression( $expression_alias, $direction = null ) {
		if ( ! in_array( $expression_alias, $this->expressions, true ) ) {
			throw new InvalidColumnException( 'Cannot order by expression alias because the alias has not been used.' );
		}

		if ( is_null( $this->order ) ) {
			$this->order = new Order( $expression_alias, $direction );
		} else {
			$this->order->then( $expression_alias, $direction );
		}

		return $this;
	}

	/**
	 * Group the results by a given column.
	 *
	 * Can be called multiple times to add additional group by columns.
	 *
	 * @since 2.0
	 *
	 * @param string $column
	 *
	 * @return $this
	 *
	 * @throws \IronBound\DB\Exception\InvalidColumnException
	 */
	public function group_by( $column ) {

		$column = $this->prepare_column( $column );

		if ( is_null( $this->group ) ) {
			$this->group = new Group( $column );
		} else {
			$this->group->then( $column );
		}

		return $this;
	}

	/**
	 * Group the results by a given column after applying an expression to it.
	 *
	 * Can be called multiple times to add additional group by expressions.
	 *
	 * @since 2.2.0
	 *
	 * @param string $function
	 * @param string $column
	 *
	 * @return $this
	 *
	 * @throws \IronBound\DB\Exception\InvalidColumnException
	 */
	public function group_by_expression( $function, $column ) {

		$column = $this->prepare_column( $column );

		$group = "{$function}($column)";

		if ( is_null( $this->group ) ) {
			$this->group = new Group( $group );
		} else {
			$this->group->then( $group );
		}

		return $this;
	}

	/**
	 * Simple join statement.
	 *
	 * @since 2.0
	 *
	 * @param Table       $table
	 * @param string      $this_column
	 * @param string      $other_column
	 * @param bool|string $comparator
	 * @param callable    $callback Called with a FluentQuery object. Can be used to build additional where queries
	 *                              for the Join clause.
	 * @param string      $type     Join type. Defaults to 'INNER'.
	 *
	 * @return $this
	 * @throws InvalidColumnException
	 */
	public function join( Table $table, $this_column, $other_column, $comparator = '=', $callback = null, $type = 'INNER' ) {

		$this->assert_comparator( $comparator );

		$other_alias = 't' . ( ++ $this->alias_count );

		$other_query              = new FluentQuery( $table, $this->wpdb );
		$other_query->alias       = $other_alias;
		$other_query->alias_count = $this->alias_count + 1;

		$other_query->joined_tables                 = $this->joined_tables;
		$other_query->joined_tables[ $this->alias ] = $this->table;

		$from = new From( $table->get_table_name( $this->wpdb ), $other_alias );

		$where = new Where_Raw(
			"{$this->prepare_column( $this_column )} $comparator {$other_query->prepare_column( $other_column )}"
		);

		if ( $callback ) {
			$callback( $other_query );

			if ( ! $other_query->where->is_empty() ) {
				$where->qAnd( $other_query->where );
			}
		}

		$this->joins[] = new Join( $from, $where, $type );

		$this->joined_tables[ $other_alias ] = $table;

		return $this;
	}

	/**
	 * Perform a join, but instead of comparing to a simple value,
	 * use a correlated sub query to find the match.
	 *
	 * Will automatically build the SELECT and LIMIT statements.
	 *
	 * @param Table   $table        Table to join to.
	 * @param string  $this_column  Column on the current table to check equality for.
	 * @param string  $comparator   How to compare the two values, should likely be '='.
	 * @param string  $other_column The column on the joined table to check.
	 * @param Closure $callback     Callback receiving a FluentQuery to build the sub query from.
	 * @param string  $type
	 *
	 * @return $this
	 * @throws InvalidColumnException
	 */
	public function join_correlated_subquery( Table $table, $this_column, $comparator, $other_column, Closure $callback, $type = 'INNER' ) {

		$this->assert_comparator( $comparator );

		$other_alias = 't' . ( ++ $this->alias_count );

		$other_query              = new FluentQuery( $table, $this->wpdb );
		$other_query->from        = new From( $table->get_table_name( $this->wpdb ), $other_alias );
		$other_query->alias       = $other_alias;
		$other_query->alias_count = $this->alias_count + 1;

		$other_query->joined_tables                 = $this->joined_tables;
		$other_query->joined_tables[ $this->alias ] = $this->table;

		$other_query->select->also( $other_query->prepare_column( $other_column ) );
		$other_query->take( 1 );
		$callback( $other_query );

		$from = new From( $table->get_table_name( $this->wpdb ), $other_alias );

		$where = new Where_Raw(
			"{$this->prepare_column( $this_column )} $comparator ({$other_query->build_sql()})"
		);

		$this->joins[] = new Join( $from, $where, $type );

		$this->joined_tables[ $other_alias ] = $table;

		return $this;
	}

	/**
	 * Only retrieve a certain number of results.
	 *
	 * @since 2.0
	 *
	 * @param int $number
	 *
	 * @return $this
	 */
	public function take( $number ) {
		$this->count = $number;

		return $this;
	}

	/**
	 * Offset the results retrieved by a certain amount.
	 *
	 * @since 2.0
	 *
	 * @param int $amount
	 *
	 * @return $this
	 */
	public function offset( $amount ) {
		$this->offset = $amount;

		return $this;
	}

	/**
	 * Paginate results.
	 *
	 * @since 2.0
	 *
	 * @param int $page
	 * @param int $per_page
	 *
	 * @return $this
	 */
	public function paginate( $page, $per_page ) {
		$this->count           = $per_page;
		$this->offset          = $per_page * ( $page - 1 );
		$this->calc_found_rows = true;

		return $this;
	}

	/**
	 * Calculate the total rows found.
	 *
	 * @since 2.0.0
	 *
	 * @return $this
	 */
	public function calc_found_rows() {
		$this->calc_found_rows = true;

		return $this;
	}

	/**
	 * Execute a callback over every row, chunking by a certain number.
	 *
	 * @since 2.0
	 *
	 * @param int      $number
	 * @param callable $callback
	 *
	 * @return bool
	 */
	public function each( $number, $callback ) {

		$this->offset = 0;
		$this->count  = $number;

		$query = clone $this;

		do {

			$_query  = clone $query;
			$results = $query->results();

			foreach ( $results as $result ) {
				if ( $callback( $result ) === false ) {
					return false;
				}
			}

			$_query->offset += $number;
			$query          = $_query;

		} while ( $results->count() === $number );

		return true;
	}

	/**
	 * Perform this query while loading a set of relations.
	 *
	 * @param string|array $relations,... An array of relations to Closure callbacks
	 * @param Closure      $callback      The callback for the relation. Called with a FluentQuery object to allow for
	 *                                    customizing which models are eager loaded.
	 *
	 * @return $this
	 */
	public function with( $relations, $callback = null ) {

		$default = null;

		if ( is_string( $relations ) ) {
			$relations = func_get_args();

			if ( func_num_args() === 2 && $relations[1] instanceof Closure ) {
				$relations = array(
					$relations[0] => $relations[1],
				);
			}
		}

		$parsed = array();

		foreach ( $relations as $relation => $callback ) {

			if ( ! $callback instanceof Closure ) {
				$relation = $callback;
				$callback = $default;
			}

			if ( strpos( $relation, '.' ) !== false ) {
				$parsed = $this->parse_nested_with( $relation, $parsed );
			} else {
				$parsed[ $relation ] = $callback;
			}
		}

		$this->relations = $parsed;

		return $this;
	}

	/**
	 * Get this table's main alias string.
	 *
	 * For example, 't1'.
	 *
	 * @since 2.0.0
	 *
	 * @return string
	 */
	public function get_alias() {
		return $this->alias;
	}

	/**
	 * Get the number of aliases in use.
	 *
	 * @since 2.0.0
	 *
	 * @return int
	 */
	public function get_alias_count() {
		return $this->alias_count;
	}

	/**
	 * Parse the nested relations.
	 *
	 * @since 2.0
	 *
	 * @param string $name
	 * @param array  $results
	 *
	 * @return array
	 */
	protected function parse_nested_with( $name, $results ) {

		$parts = explode( '.', $name );
		$first = $parts[0];

		$results[ $first ] = false;

		$this->assign_array_by_path( $results, $name, true );

		return $results;
	}

	/**
	 * Fill an array by array dot notation.
	 *
	 * @since 2.0
	 *
	 * @param array  $arr
	 * @param string $path
	 * @param mixed  $value
	 */
	protected function assign_array_by_path( &$arr, $path, $value ) {

		$keys = explode( '.', $path );

		foreach ( $keys as $key ) {
			$arr = &$arr[ $key ];
		}

		$arr = $value;
	}

	/**
	 * Prime the meta cache.
	 *
	 * @since 2.0
	 *
	 * @param bool $prime
	 *
	 * @return $this
	 */
	public function prime_meta_cache( $prime = true ) {
		$this->prime_meta_cache = $prime;

		return $this;
	}

	/**
	 * Make the limit tag for this query.
	 *
	 * @since 2.0
	 *
	 * @return $this
	 */
	protected function make_limit_tag() {

		if ( ! $this->count ) {
			return $this;
		}

		$this->limit = new Limit( $this->count, $this->offset );

		return $this;
	}

	/**
	 * Build the SQL statement.
	 *
	 * @since 2.0
	 *
	 * @return string
	 */
	protected function build_sql() {

		$this->make_limit_tag();

		$builder = new Builder();

		if ( ! $this->select->is_all() && ! $this->select->get_columns() ) {
			$this->select->all( $this->alias );
		}

		$this->select->calc_found_rows( $this->calc_found_rows );

		$builder->append( $this->select );
		$builder->append( $this->from );

		foreach ( $this->joins as $join ) {
			$builder->append( $join );
		}

		if ( $this->meta_join ) {
			$builder->append( new Generic( '', $this->meta_join ) );
		}

		if ( $this->where ) {
			$builder->append( $this->where );
		}

		if ( $this->group ) {
			$builder->append( $this->group );
		}

		if ( $this->having ) {
			$builder->append( $this->having );
		}

		if ( $this->order ) {
			$builder->append( $this->order );
		}

		if ( $this->limit ) {
			$builder->append( $this->limit );
		}

		return $builder->build();
	}

	/**
	 * Retrieve results.
	 *
	 * @since 2.0
	 *
	 * @param Saver $saver
	 *
	 * @return Collection|DoctrineCollection
	 */
	public function results( Saver $saver = null ) {

		if ( $this->results ) {
			return $this->results;
		}

		if ( $saver instanceof ModelSaver && $this->model ) {
			$saver->set_model_class( $this->model );
		} elseif ( ! $saver && $this->model ) {
			$saver = new ModelSaver( $this->model );
		}

		$this->sql = $sql = trim( $this->build_sql() );

		$results = $this->wpdb->get_results( $sql, ARRAY_A );

		if ( $this->calc_found_rows ) {

			$count_results = $this->wpdb->get_results( "SELECT FOUND_ROWS() AS COUNT" );

			if ( empty( $count_results ) || empty( $count_results[0] ) ) {
				$this->total = 0;
			} else {
				$this->total = $count_results[0]->COUNT;
			}
		}

		if ( ! $saver || $this->expressions || ! $this->select->is_all() ) {

			if ( ! $this->expressions && ! $this->select->is_all() ) {

				$columns    = $this->select->get_columns();
				$collection = array();

				foreach ( $results as $result ) {

					if ( $this->select_single ) {
						$column = key( $columns );
						$column = $this->get_short_column_from_qualified( $column );
						$value  = $result[ $column ];
					} else {
						$value = $result;
					}

					$collection[ $result[ $this->table->get_primary_key() ] ] = $value;
				}

				$collection = new ArrayCollection( $collection );
			} elseif ( count( $this->expressions ) === 1 && ! $this->focused_select ) {
				$collection = new ArrayCollection( reset( $results ) );
			} else {
				$collection = new ArrayCollection( $results );
			}

			$this->results = $collection;

			return $collection;
		}

		$models = array();

		foreach ( $results as $result ) {

			$model = $saver->make_model( $result );

			if ( $model ) {
				$models[ $saver->get_pk( $model ) ] = $model;
			}
		}

		if ( ! empty( $this->relations ) && ! empty( $models ) ) {
			$this->handle_eager_loading( $models );
		}

		$collection = new Collection( $models, false, $saver );

		$this->results = $collection;

		if ( $this->prime_meta_cache && ( $this->meta_table || ( $this->model && method_exists( $this->model, 'get_meta_table' ) ) ) ) {
			$this->update_meta_cache();
		}

		return $collection;
	}

	/**
	 * Retrieve the total number of records matching the result set using SQL_CALC_FOUND_ROWS().
	 *
	 * @since 2.0.0
	 *
	 * @return int
	 *
	 * @throws \UnexpectedValueException Thrown if found rows calculation was not set.
	 */
	public function total() {
		if ( $this->total === null ) {
			throw new \UnexpectedValueException( 'FluentQuery not performed with SQL_CALC_FOUND_ROWS() flag enabled.' );
		} else {
			return $this->total;
		}
	}

	/**
	 * Update the meta cache.
	 *
	 * @since 2.0
	 */
	protected function update_meta_cache() {

		$ids       = $this->results->getKeys();
		$table     = $this->meta_table ?: call_user_func( array( $this->model, 'get_meta_table' ) );
		$meta_type = $this->meta_type ?: call_user_func( array( $this->model, 'get_meta_type' ) );

		$fn = function ( $key, $original ) use ( $table, $meta_type ) {

			if ( $original === $meta_type . '_id' ) {
				$key = $table->get_primary_id_column();
			}

			return $key;
		};

		add_filter( 'sanitize_key', $fn, 10, 2 );

		update_meta_cache( $meta_type, $ids );

		remove_filter( 'sanitize_key', $fn );
	}

	/**
	 * Get the first result from the query.
	 *
	 * @since 2.0
	 *
	 * @return Model
	 */
	public function first() {
		return $this->results()->first();
	}

	/**
	 * Find a model by its primary key.
	 *
	 * @since 2.0
	 *
	 * @param string|int|array $primary_key
	 *
	 * @return Model|Collection
	 */
	public function find( $primary_key ) {

		if ( is_array( $primary_key ) ) {
			return $this->find_many( $primary_key );
		}

		return $this->where( $this->table->get_primary_key(), true, $primary_key )->first();
	}

	/**
	 * Find many models by their primary keys.
	 *
	 * @since 2.0
	 *
	 * @param array $primary_keys
	 *
	 * @return Collection
	 */
	public function find_many( array $primary_keys ) {
		return $this->where( $this->table->get_primary_key(), true, $primary_keys )->results();
	}

	/**
	 * Find a model by its primary key or throw an exception.
	 *
	 * @since 2.0
	 *
	 * @param string|int|array $primary_key
	 *
	 * @return Model|Collection
	 *
	 * @throws ModelNotFoundException
	 */
	public function find_or_fail( $primary_key ) {

		$result = $this->find( $primary_key );

		if ( is_array( $primary_key ) ) {
			if ( count( $result ) == count( array_unique( $primary_key ) ) ) {
				return $result;
			}
		} elseif ( $result ) {
			return $result;
		}

		throw new ModelNotFoundException( "No model found for '$primary_key'." );
	}

	/**
	 * Find a model by its primary key or create a new model.
	 *
	 * @since 2.0
	 *
	 * @param string|int $primary_key
	 *
	 * @return Model
	 */
	public function find_or_new( $primary_key ) {

		$model = $this->find( $primary_key );

		if ( is_null( $model ) ) {
			$model = new $this->model;
		}

		return $model;
	}

	/**
	 * Find the first model matching a given a set of attributes, or construct a new one.
	 *
	 * The newly constructed model follows the fillable rules for the model.
	 *
	 * @since 2.0
	 *
	 * @param array $attributes
	 *
	 * @return Model
	 */
	public function first_or_new( array $attributes ) {

		$model = $this->where( $attributes )->first();

		if ( $model ) {
			return $model;
		}

		return new $this->model( (object) $attributes );
	}

	/**
	 * Find the first model matching a given set of attributes, or construct and save a new one.
	 *
	 * The newly constructed model follows the fillable rules for the model.
	 *
	 * @since 2.0
	 *
	 * @param array $attributes
	 *
	 * @return Model
	 */
	public function first_or_create( array $attributes ) {

		$model = $this->where( $attributes )->first();

		if ( $model ) {
			return $model;
		}

		$model = new $this->model( (object) $attributes );
		$model->save();

		return $model;
	}

	/**
	 * Find a model matching a given set of attributes. If it does not exist,
	 * a new model is constructed with the given set of attributes.
	 *
	 * The model is then updated with the given set of values.
	 *
	 * @since 2.0
	 *
	 * @param array $attributes
	 * @param array $values
	 *
	 * @return Model
	 */
	public function update_or_create( array $attributes, array $values ) {

		$model = $this->first_or_new( $attributes );
		$model->fill( $values )->save();

		return $model;
	}

	/**
	 * Handle eager loading of relations.
	 *
	 * @since 2.0
	 *
	 * @param Model[] $models
	 */
	protected function handle_eager_loading( $models ) {

		/** @var Model $model */
		$model = new $this->model;

		foreach ( $this->relations as $relation => $customize_callback ) {
			if ( is_array( $customize_callback ) ) {
				$loaded = $model->get_relation( $relation )->eager_load( $models );
				$this->do_nested_eager_load( $loaded, $relation, $customize_callback );
			} else {
				$model->get_relation( $relation )->eager_load( $models, $customize_callback );
			}
		}
	}

	/**
	 * Handle a nested eager loaded.
	 *
	 * @since 2.0
	 *
	 * @param Collection $loaded
	 * @param string     $relation
	 * @param array      $nested
	 */
	protected function do_nested_eager_load( Collection $loaded, $relation, $nested ) {

		$model = $loaded->first();

		foreach ( $nested as $value => $ignore ) {
			if ( is_string( $value ) ) {
				if ( $model instanceof Model ) {
					$model->get_relation( $value )->eager_load( $loaded->toArray() );
				}

				return;
			} else {
				if ( ! $model instanceof Model ) {
					return;
				}

				$loaded = $model->get_relation( $relation )->eager_load( $loaded->toArray() );

				$this->do_nested_eager_load( $loaded, $nested, $value );
			}
		}
	}

	/**
	 * Get the SQL statement executed.
	 *
	 * @since 2.0
	 *
	 * @return string
	 */
	public function _get_sql() {
		return $this->sql;
	}

	/**
	 * Assert the operator is valid.
	 *
	 * @since 2.0
	 *
	 * @param string|bool $operator
	 *
	 * @throws \InvalidArgumentException
	 */
	protected function assert_comparator( $operator ) {
		if ( is_bool( $operator ) ) {
			return;
		}

		if ( in_array( $operator, array(
			'=',
			'!=',
			'>',
			'<',
			'>=',
			'<=',
			'<=>',
			'<>',
			'LIKE',
			'BETWEEN',
			'COALESCE',
			'GREATEST',
			'IN',
			'INTERVAL',
			'IS',
			'IS NOT',
			'IS NOT NULL',
			'IS NULL',
			'ISNULL',
			'LEAST',
			'NOT BETWEEN',
			'NOT IN',
			'NOT LIKE',
		), true ) ) {
			return;
		}

		throw new \InvalidArgumentException( sprintf( 'Invalid SQL operator % s . ', $operator ) );
	}

	/**
	 * Prepare a column.
	 *
	 * @since 2.0
	 *
	 * @param $column
	 *
	 * @return string
	 * @throws InvalidColumnException
	 */
	public function prepare_column( $column ) {

		$columns = $this->table->get_columns();

		if ( ! isset( $columns[ $column ] ) ) {
			throw new InvalidColumnException( "Invalid database column '$column'." );
		}

		return "{$this->alias}.`{$column}`";
	}

	/**
	 * Escape a value.
	 *
	 * @since 2.0
	 *
	 * @param string $column
	 * @param mixed  $value
	 *
	 * @return mixed
	 *
	 * @throws InvalidColumnException
	 */
	public function escape_value( $column, $value ) {

		$columns = $this->table->get_columns();

		if ( ! isset( $columns[ $column ] ) ) {
			throw new InvalidColumnException( "Invalid database column '$column'." );
		}

		if ( is_null( $value ) ) {
			return null;
		}

		if ( empty( $value ) ) {
			return '';
		}

		return esc_sql( $columns[ $column ]->prepare_for_storage( $value ) );
	}

	/**
	 * Get the short column name from a qualified column name.
	 *
	 * @since 2.0.0
	 *
	 * @param string $qualified
	 *
	 * @return string
	 */
	protected function get_short_column_from_qualified( $qualified ) {
		return str_replace( array( "{$this->alias}.", '`' ), '', $qualified );
	}
}


<?php
/**
 * This file is part of PicORM.
 *
 * PicORM is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * PicORM is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with PicORM.  If not, see <http://www.gnu.org/licenses/>.
 *
 * PHP version 5.4
 *
 * @category Core
 * @package  PicORM
 * @author   iNem0o <contact@inem0o.fr>
 * @license  LGPL http://opensource.org/licenses/lgpl-license.php
 * @link     https://github.com/iNem0o/PicORM
 */
namespace PicORM;

/**
 * Class PicORM
 * Static class to configure libraries from user option
 *
 * @category Core
 * @package  PicORM
 * @author   iNem0o <contact@inem0o.fr>
 * @license  LGPL http://opensource.org/licenses/lgpl-license.php
 * @link     https://github.com/iNem0o/PicORM
 */
class PicORM
{
    /**
     * Datasource instance
     *
     * @var \PDO
     */
    protected static $_dataSource;

    /**
     * Configuration array
     *
     * @var array
     */
    protected static $_configuration;

    /**
     * Default PicORM configuration
     *
     * @var array
     */
    protected static $_defaultConfiguration = array(
        'cache'      => false, // !!TODO!!
        'datasource' => null
    );

    /**
     * Set PicORM global configuration
     *
     * @param array $configuration
     *
     * @throws Exception
     */
    final public static function configure(array $configuration)
    {
        // override with default configuration if not present
        $configuration += static::$_defaultConfiguration;

        // test if datasource is a PDO instance
        if ($configuration['datasource'] === null || !$configuration['datasource'] instanceof \PDO) {
            throw new Exception("PDO Datasource is required!");
        }

        // set global datasource for all model
        static::$_dataSource = $configuration['datasource'];
        Model::setDataSource(static::$_dataSource);

        // store PicORM configuration
        static::$_configuration = $configuration;
    }

    /**
     * Return main datasource extracted from configuration
     * @return \PDO
     */
    public static function getDataSource()
    {
        return static::$_dataSource;
    }
}


<?php
/**
 * Builder for DB models.
 *
 * @author      Iron Bound Designs
 * @since       1.0
 * @copyright   2015 (c) Iron Bound Designs.
 * @license     GPLv2
 */

namespace IronBound\DB;

use IronBound\DB\Table\Table;

/**
 * Class Builder
 *
 * @package IronBound\DB
 */
abstract class Builder {

	/**
	 * Data to be inserted.
	 *
	 * @var array
	 */
	private $data = array();

	/**
	 * Constructor.
	 *
	 * @since 1.0
	 */
	public function __construct() {

		foreach ( $this->get_table()->get_column_defaults() as $col => $default ) {
			$this->set_col_value( $col, $default );
		}
	}

	/**
	 * Build the object.
	 *
	 * @since 1.0
	 *
	 * @return Model
	 * @throws Exception
	 */
	public abstract function build();

	/**
	 * Set a column value.
	 *
	 * @since 1.0
	 *
	 * @param string $col
	 * @param mixed  $val
	 */
	protected final function set_col_value( $col, $val ) {
		$this->data[ $col ] = $val;
	}

	/**
	 * Get the value of a column.
	 *
	 * @since 1.0
	 *
	 * @param string $col
	 *
	 * @return mixed
	 */
	protected final function get_col_value( $col ) {
		if ( ! isset( $this->data[ $col ] ) ) {
			throw new \InvalidArgumentException( "Column does not exist." );
		}

		return $this->data[ $col ];
	}

	/**
	 * Create the object.
	 *
	 * This will call validate functions on columns if they exist. These should
	 * be used to validate data that is dependent on other data.
	 *
	 * @since 1.0
	 *
	 * @return int|string PK of new record.
	 * @throws Exception
	 */
	protected function create() {

		foreach ( $this->data as $col => $val ) {

			if ( method_exists( $this, "validate_$col" ) ) {
				$this->data[ $col ] = $this->{"validate_$col"}( $val );
			}
		}

		return Manager::make_simple_query_object( $this->get_table()->get_slug() )
		              ->insert( $this->data );
	}

	/**
	 * Get the table for this model.
	 *
	 * @since 1.0
	 *
	 * @return Table
	 */
	protected abstract function get_table();
}


<?php
/**
 * Order By Tag
 *
 * @author      Iron Bound Designs
 * @since       1.0
 * @copyright   2015 (c) Iron Bound Designs.
 * @license     GPLv2
 */

namespace IronBound\DB\Query\Tag;

/**
 * Class Order
 * @package IronBound\DB\Query\Tag
 */
class Order extends Generic {

	/**
	 * Sort in ascending order.
	 */
	const ASC = 'ASC';

	/**
	 * Sort in descending order.
	 */
	const DESC = 'DESC';

	/**
	 * Order by random.
	 */
	const RAND = 'RAND()';

	/**
	 * @var bool
	 */
	private $is_rand = false;

	/**
	 * @var array
	 */
	private $orders = array();

	/**
	 * Constructor.
	 *
	 * @param string      $col
	 * @param string|null $direction Either ASC, DESC, or RAND. If null, mysql default.
	 */
	public function __construct( $col, $direction = null ) {

		if ( $col === self::RAND ) {
			$this->is_rand = true;
		} else {
			$this->add_order( $col, $direction );
		}

		parent::__construct( "ORDER BY" );
	}

	/**
	 * Add an additional order by clause.
	 *
	 * @since 1.0
	 *
	 * @param string      $col
	 * @param string|null $direction
	 *
	 * @return Order
	 */
	public function then( $col, $direction = null ) {
		if ( $this->is_rand ) {
			throw new \LogicException( "This ORDER BY statement is already RAND()." );
		}

		$this->orders[ $col ] = $direction;

		return $this;
	}

	/**
	 * Sanitize inputs and save state.
	 *
	 * @since 1.0
	 *
	 * @param string      $col
	 * @param string|null $direction
	 */
	protected function add_order( $col, $direction ) {
		if ( $direction !== null && ! in_array( $direction, array( self::ASC, self::DESC ) ) ) {
			throw new \InvalidArgumentException( "Invalid ORDER BY direction." );
		}

		$this->orders[ $col ] = $direction;
	}

	/**
	 * Override the get value method.
	 *
	 * @since 1.0
	 *
	 * @return string
	 */
	protected function get_value() {
		if ( $this->is_rand ) {
			return self::RAND;
		}

		$query = '';

		foreach ( $this->orders as $column => $direction ) {
			$query .= "$column";

			if ( $direction !== null ) {
				$query .= " $direction";
			}

			$query .= ', ';
		}

		$query = substr( $query, 0, - 2 );

		return $query;
	}

}


<?php
/**
 * Select tag.
 *
 * @author      Iron Bound Designs
 * @since       1.0
 * @copyright   2015 (c) Iron Bound Designs.
 * @license     GPLv2
 */

namespace IronBound\DB\Query\Tag;

/**
 * Class Select
 *
 * @package IronBound\DB\Query\Tag
 */
class Select extends Generic {

	/**
	 * Select ALL values.
	 */
	const ALL = '*';

	/**
	 * @var array
	 */
	private $columns = array();

	/**
	 * @var bool
	 */
	private $all_columns;

	/**
	 * @var string
	 */
	private $all_as;

	/**
	 * @var bool
	 */
	private $calc_found_rows = false;

	/**
	 * @var bool
	 */
	private $distinct = false;

	/**
	 * Constructor.
	 *
	 * @param string|null $column
	 * @param string|null $as
	 */
	public function __construct( $column = '*', $as = null ) {

		if ( $column == self::ALL ) {
			$this->all_columns = true;
			$this->all_as      = $as;
		} elseif ( $column !== null ) {
			$this->columns[ $column ] = $as;
		}

		parent::__construct( "select" );
	}

	/**
	 * Select another column.
	 *
	 * @since 1.0
	 *
	 * @param string      $column
	 * @param string|null $as
	 *
	 * @return $this
	 */
	public function also( $column, $as = null ) {
		$this->columns[ $column ] = $as;

		return $this;
	}

	/**
	 * Select an expression.
	 *
	 * Ex. AVG(total)
	 *
	 * @since 1.0
	 *
	 * @param string      $function
	 * @param string      $column
	 * @param string|null $as
	 *
	 * @return $this
	 */
	public function expression( $function, $column, $as = null ) {
		$this->columns["{$function}($column)"] = $as;

		return $this;
	}

	/**
	 * Whether to set the calculate total found rows flag.
	 *
	 * @since 1.0
	 *
	 * @param bool $calculate
	 *
	 * @return $this
	 */
	public function calc_found_rows( $calculate = true ) {
		$this->calc_found_rows = (bool) $calculate;

		return $this;
	}

	/**
	 * Whether to only show distinct results.
	 *
	 * @since 1.0
	 *
	 * @param bool $distinct
	 *
	 * @return $this
	 */
	public function filter_distinct( $distinct = true ) {
		$this->distinct = $distinct;

		return $this;
	}

	/**
	 * Convert this select statement to retrieve all columns.
	 *
	 * @since 2.0
	 *
	 * @param string $as
	 *
	 * @return $this
	 */
	public function all( $as = null ) {
		$this->all_columns = true;

		if ( $as ) {
			$this->all_as = $as;
		}

		return $this;
	}

	/**
	 * Determine if this is an 'All' query.
	 *
	 * @since 2.0
	 *
	 * @return bool
	 */
	public function is_all() {
		return $this->all_columns;
	}

	/**
	 * Get all columns being selected.
	 *
	 * @since 2.0
	 *
	 * @return array
	 */
	public function get_columns() {
		return $this->columns;
	}

	/**
	 * Override the get value method.
	 *
	 * @since 1.0
	 *
	 * @return string
	 */
	protected function get_value() {
		$query = '';

		if ( $this->distinct ) {
			$query .= "DISTINCT ";
		}

		if ( $this->calc_found_rows ) {
			$query .= "SQL_CALC_FOUND_ROWS ";
		}

		if ( $this->all_columns ) {
			if ( $this->all_as ) {
				$query .= "{$this->all_as}.*";
			} else {
				$query .= '*';
			}
		} else {

			foreach ( $this->columns as $column => $as ) {
				$query .= $column;

				if ( $as !== null ) {
					$query .= " AS $as";
				}

				$query .= ", ";
			}

			$query = substr( $query, 0, - 2 );
		}

		return $query;
	}
}


<?php
/**
 * HasOneOrMany relation base class.
 *
 * @author    Iron Bound Designs
 * @since     2.0
 * @license   MIT
 * @copyright Iron Bound Designs, 2016.
 */

namespace IronBound\DB\Relations;

use IronBound\DB\Collection;
use IronBound\DB\Model;
use IronBound\DB\Query\FluentQuery;

/**
 * Class HasOneOrMany
 *
 * @package IronBound\DB\Relations
 */
abstract class HasOneOrMany extends Relation {

	/**
	 * @var string
	 */
	protected $foreign_key;

	/**
	 * Relation constructor.
	 *
	 * @param string $foreign_key   Foreign key that references the $parent model.
	 * @param string $related_model Class name of the related model.
	 * @param Model  $parent
	 * @param string $attribute
	 */
	public function __construct( $foreign_key, $related_model, Model $parent, $attribute ) {
		parent::__construct( $related_model, $parent, $attribute );

		$this->foreign_key = $foreign_key;
	}

	/**
	 * @inheritDoc
	 */
	protected function fetch_results() {

		$related = $this->related_model;

		$query = $related::query_with_no_global_scopes()->where( $this->foreign_key, true, $this->parent->get_pk() );

		$this->apply_scopes_for_fetch( $query );

		return $query->results();
	}

	/**
	 * Fetch results for eager loading.
	 *
	 * @since 2.0
	 *
	 * @param Model[]  $models
	 * @param callable $callback
	 *
	 * @return Collection
	 */
	protected function fetch_results_for_eager_load( array $models, $callback = null ) {

		$cached = array();

		if ( $callback === null ) {
			foreach ( $models as $pk => $model ) {
				$result = $this->load_from_cache( $model );

				if ( $result ) {
					$cached[ $pk ] = $result instanceof Collection ? $result->toArray() : array( $result );
				}
			}

			if ( count( $cached ) === count( $models ) ) {
				return new Collection( $this->flatten( $cached ), false, $this->saver );
			}
		}

		/** @var FluentQuery $query */
		$query = call_user_func( array( $this->related_model, 'query_with_no_global_scopes' ) );

		$pks = array_keys( $models );
		$pks = array_diff( $pks, array_keys( $cached ) );
		$pks = array_filter( $pks );

		if ( count( $pks ) === 0 ) {
			return new Collection( array(), false, $this->saver );
		}

		$query->where( $this->foreign_key, true, $pks );

		$this->apply_scopes_for_eager_load( $query );

		if ( $callback ) {
			$callback( $query );
		}

		$results = $query->results()->toArray();
		$cached  = $this->flatten( $cached );

		return new Collection( array_merge( $results, $cached ), false, $this->saver );
	}

	/**
	 * Recursively flatten an array.
	 *
	 * @since 2.0
	 *
	 * @param array $array
	 *
	 * @return array
	 */
	private function flatten( $array ) {
		if ( ! is_array( $array ) ) {
			// nothing to do if it's not an array
			return array( $array );
		}

		$result = array();
		foreach ( $array as $value ) {
			// explode the sub-array, and add the parts
			$result = array_merge( $result, $this->flatten( $value ) );
		}

		return $result;
	}

	/**
	 * Apply scopes to the fetch results query.
	 *
	 * @since 2.0
	 *
	 * @param FluentQuery $query
	 */
	protected function apply_scopes_for_fetch( FluentQuery $query ) {

	}

	/**
	 * Apply scopes to the eager load query.
	 *
	 * @since 2.0
	 *
	 * @param FluentQuery $query
	 */
	protected function apply_scopes_for_eager_load( FluentQuery $query ) {

	}

	/**
	 * Build an eager load map.
	 *
	 * @since 2.0
	 *
	 * @param Collection $models
	 *
	 * @return array
	 */
	protected function build_eager_load_map( $models ) {

		$map = array();

		$foreign = $this->foreign_key;

		foreach ( $models as $model ) {
			$pk = $model->get_raw_attribute( $foreign );

			if ( $pk instanceof Model ) {
				$pk = $pk->get_pk();
			}

			$map[ $pk ][ $model->get_pk() ] = $model;
		}

		return $map;
	}

	/**
	 * @inheritDoc
	 */
	public function eager_load( array $models, $callback = null ) {

		$results = $this->fetch_results_for_eager_load( $models, $callback );

		$map = $this->build_eager_load_map( $results );

		/** @var Model $model */
		foreach ( $models as $model ) {

			if ( isset( $map[ $model->get_pk() ] ) ) {
				$related = $map[ $model->get_pk() ];

				$model->set_relation_value( $this->attribute, $this->wrap_eager_loaded_results( $related ) );
			} else {
				$model->set_relation_value( $this->attribute, $this->wrap_eager_loaded_results( null ) );
			}
		}

		return $results;
	}

	/**
	 * Wrap the eager loaded results before setting them on the Model.
	 *
	 * @since 2.0
	 *
	 * @param mixed $results
	 *
	 * @return mixed
	 */
	protected abstract function wrap_eager_loaded_results( $results );
}


<?php
/**
 * Contains the CommentSaver class definition.
 *
 * @author    Iron Bound Designs
 * @since     2.0
 * @license   MIT
 * @copyright Iron Bound Designs, 2016.
 */

namespace IronBound\DB\Saver;

/**
 * Class CommentSaver
 * @package IronBound\DB\Value
 */
class CommentSaver extends Saver {

	/**
	 * @inheritDoc
	 */
	public function get_pk( $value ) {
		return $value->comment_ID;
	}

	/**
	 * @inheritDoc
	 */
	public function get_model( $pk ) {
		return get_comment( $pk );
	}

	/**
	 * @inheritDoc
	 */
	public function make_model( $attributes ) {
		return new \WP_Comment( (object) $attributes );
	}

	/**
	 * @inheritDoc
	 */
	public function save( $value, array $options = array() ) {

		if ( ! $value instanceof \WP_Comment && ! property_exists( $value, 'comment_ID' ) ) {
			throw new \InvalidArgumentException( 'ForeignComment can only save WP_Comment objects.' );
		}

		if ( ! $value->comment_ID ) {
			return $this->do_save( $value );
		}

		$current = get_comment( $value->comment_ID );

		if ( ! $current ) {
			return $this->do_save( $value );
		}

		$old = $current->to_array();
		$new = $value->to_array();

		if ( $this->has_changes( $old, $new ) ) {
			return $this->do_save( $value );
		}

		return $value;
	}

	/**
	 * Do the saving for a comment.
	 *
	 * @since 2.0
	 *
	 * @param \WP_Comment $comment
	 *
	 * @return \WP_Comment
	 */
	protected function do_save( $comment ) {

		if ( ! $comment->comment_ID ) {
			$id = wp_insert_comment( wp_slash( $comment->to_array() ) );
		} else {
			if ( wp_update_comment( wp_slash( $comment->to_array() ) ) ) {
				$id = $comment->comment_ID;
			}
		}

		if ( empty( $id ) ) {
			throw new \InvalidArgumentException( 'Error encountered while saving WP_Comment.' );
		}

		return get_comment( $id );
	}
}


<?php
/**
 * API for making complex queries.
 *
 * @author      Iron Bound Designs
 * @since       1.0
 * @copyright   2015 (c) Iron Bound Designs.
 * @license     GPLv2
 */

namespace IronBound\DB\Query;

use IronBound\Cache\Cache;
use IronBound\DB\Manager;
use IronBound\DB\Model;
use IronBound\DB\Query\Tag\Limit;
use IronBound\DB\Query\Tag\Order;
use IronBound\DB\Query\Tag\Select;
use IronBound\DB\Query\Tag\Where;
use IronBound\DB\Table\Table as Table;

/**
 * Class Base
 *
 * @package IronBound\DB\Query
 */
abstract class Complex_Query {

	/**
	 * @var array
	 */
	protected $args = array();

	/**
	 * @var \IronBound\DB\Query\Simple_Query|null
	 */
	protected $db_query;

	/**
	 * @var Table
	 */
	protected $table;

	/**
	 * @var \wpdb
	 */
	protected $wpdb;

	/**
	 * @var string
	 */
	protected $sql;

	/**
	 * @var int|null
	 */
	protected $total_items = null;

	/**
	 * @var array
	 */
	protected $results = array();

	/**
	 * Constructor.
	 *
	 * @param Table $table
	 * @param array $args
	 * @param \wpdb $wpdb
	 */
	public function __construct( Table $table, array $args = array(), \wpdb $wpdb = null ) {

		$this->table    = $table;
		$this->wpdb     = $wpdb ?: $GLOBALS['wpdb'];
		$this->db_query = new Simple_Query( $this->wpdb, $table );

		$this->args = wp_parse_args( $args, $this->get_default_args() );

		if ( $this->args['items_per_page'] != '-1' && $this->args['sql_calc_found_rows'] === null ) {
			$this->args['sql_calc_found_rows'] = true;
		}

		$this->sql = $this->build_sql();

		$this->query();
	}

	/**
	 * Get the total items found ignoring pagination.
	 *
	 * If sql_calc_found_rows is set to false, the return value will be null.
	 *
	 * @since 1.0
	 *
	 * @return int|null
	 */
	public function get_total_items() {
		return $this->total_items;
	}

	/**
	 * Retrieve the queried results.
	 *
	 * @since 1.0
	 *
	 * @return Model[]|\stdClass[]|mixed[]
	 */
	public function get_results() {
		return $this->results;
	}

	/**
	 * Debug function.
	 *
	 * @since 1.0
	 *
	 * @return string
	 */
	public function _get_sql() {
		return $this->sql;
	}

	/**
	 * Get the default args.
	 *
	 * @since 1.0
	 *
	 * @return array
	 */
	protected function get_default_args() {
		return array(
			'order'               => array(),
			'items_per_page'      => - 1,
			'page'                => 1,
			'sql_calc_found_rows' => null,
			'return_value'        => 'object',
			'distinct'            => false
		);
	}

	/**
	 * Get a default arg.
	 *
	 * @since 1.0
	 *
	 * @param string $arg
	 *
	 * @return mixed
	 */
	protected function get_default_arg( $arg ) {

		$args = $this->get_default_args();

		if ( isset( $args[ $arg ] ) ) {
			return $args[ $arg ];
		} else {
			throw new \InvalidArgumentException();
		}
	}

	/**
	 * Query the database and store the results.
	 *
	 * @since 1.0
	 */
	protected function query() {
		$results = $this->wpdb->get_results( $this->sql );

		// we query for found rows first to prevent instantiation of record objects from interfering with the count
		if ( $this->args['sql_calc_found_rows'] ) {

			$count_results     = $this->wpdb->get_results( "SELECT FOUND_ROWS() AS COUNT" );

			if ( empty( $count_results ) || empty( $count_results[0] ) ) {
				$this->total_items = 0;
			} else {
				$this->total_items = $count_results[0]->COUNT;
			}

		} elseif ( $this->args['return_value'] == 'count' ) {

			if ( empty( $results ) || empty( $results[0] ) ) {
				$this->total_items = 0;
			} else {
				$this->total_items = $results[0]->COUNT;
			}
		}

		$this->results = $this->parse_results( $results );
	}

	/**
	 * Parse the results returned from the DB.
	 *
	 * @since 1.0
	 *
	 * @param array $results
	 *
	 * @return array
	 */
	protected function parse_results( $results ) {

		if ( is_array( $this->args['return_value'] ) ) {
			return $results;
		} elseif ( $this->args['return_value'] == 'count' ) {

			if ( empty( $results ) || empty( $results[0] ) ) {
				return 0;
			}

			return $results[0]->COUNT;
		} elseif ( $this->args['return_value'] != 'object' ) {
			$values = array();
			$field  = $this->args['return_value'];

			foreach ( $results as $result ) {
				$values[] = $result->$field;
			}

			return $values;
		} else {
			$records = array();

			foreach ( $results as $result ) {

				$object = $this->make_object( $result );
				Cache::update( $object );

				$records[ $result->{$this->table->get_primary_key()} ] = $object;
			}

			return $records;
		}
	}

	/**
	 * Build the sql query.
	 *
	 * @since 1.0
	 *
	 * @return string
	 */
	protected abstract function build_sql();

	/**
	 * Convert data to its object.
	 *
	 * @since 1.0
	 *
	 * @param \stdClass $data
	 *
	 * @return Model
	 */
	protected abstract function make_object( \stdClass $data );

	/**
	 * Build the select query.
	 *
	 * @since 1.0
	 *
	 * @param string $alias
	 *
	 * @return Select
	 */
	protected function parse_select( $alias = 'q' ) {

		if ( is_array( $this->args['return_value'] ) ) {
			$select = new Select( null );

			foreach ( $this->args['return_value'] as $column ) {
				$select->also( "$alias.$column" );
			}
		} elseif ( $this->args['return_value'] == 'count' ) {
			$select = new Select( 'COUNT(1)', 'COUNT' );
		} elseif ( $this->args['return_value'] != 'object' ) {
			$select = new Select( "$alias." . $this->args['return_value'] );
		} else {
			$select = new Select( "$alias.*" );
		}

		if ( $this->args['sql_calc_found_rows'] ) {
			$select->calc_found_rows();
		}

		$select->filter_distinct( $this->args['distinct'] );

		return $select;
	}

	/**
	 * Generic Where builder for queries that follow the in/not_in pattern.
	 *
	 * @since 1.0
	 *
	 * @param string $column
	 * @param array  $in
	 * @param array  $not_in
	 *
	 * @return Where|null
	 */
	protected function parse_in_or_not_in_query( $column, array $in, array $not_in ) {

		if ( ! empty( $in ) ) {

			foreach ( $in as $key => $value ) {
				$in[ $key ] = $this->db_query->escape_value( $column, $value );
			}

			$in_where = new Where( "q.`$column`", true, $in );
		}

		if ( ! empty( $not_in ) ) {

			foreach ( $not_in as $key => $value ) {
				$not_in[ $key ] = $this->db_query->escape_value( $column, $value );
			}

			$not_where = new Where( "q.`$column`", false, $not_in );

			if ( isset( $in_where ) ) {
				$in_where->qAnd( $not_where );

				return $in_where;
			} else {
				return $not_where;
			}
		}

		if ( isset( $in_where ) ) {
			return $in_where;
		} else {
			return null;
		}
	}

	/**
	 * Parse the orderby query. There is always a default or.
	 *
	 * @since 1.0
	 *
	 * @param string $alias
	 *
	 * @return Order
	 */
	protected function parse_order( $alias = 'q' ) {

		if ( ! is_array( $this->args['order'] ) && $this->args['order'] === 'rand' ) {
			return new Order( Order::RAND );
		} elseif ( ! is_array( $this->args['order'] ) ) {
			throw new \InvalidArgumentException( "Order must either be 'rand' or an array of columns to directions." );
		}

		$white_list = $this->table->get_columns();

		foreach ( $this->args['order'] as $column => $direction ) {

			$direction = strtoupper( $direction );

			if ( ! in_array( $direction, array( Order::ASC, Order::DESC ) ) ) {
				throw new \InvalidArgumentException( "Invalid order direction $direction for column $column." );
			}

			$column = $this->translate_order_by_to_column_name( $column );

			if ( ! isset( $white_list[ $column ] ) ) {
				throw new \InvalidArgumentException( "Invalid order column $column." );
			}

			$column = "{$alias}.$column";

			if ( ! isset( $order ) ) {
				$order = new Order( $column, $direction );
			} else {
				$order->then( $column, $direction );
			}
		}

		if ( isset( $order ) ) {
			return $order;
		} else {
			return new Order( "{$alias}.{$this->table->get_primary_key()}", Order::ASC );
		}
	}

	/**
	 * Translate a human given order by, to its corresponding column name.
	 *
	 * @since 1.0
	 *
	 * @param string $order_by
	 *
	 * @return string
	 */
	protected function translate_order_by_to_column_name( $order_by ) {

		return $order_by;
	}

	/**
	 * Parse the pagination query.
	 *
	 * @since 1.0
	 *
	 * @return Limit|null
	 */
	protected function parse_pagination() {

		if ( $this->args['items_per_page'] == - 1 ) {
			return null;
		}

		if ( $this->args['page'] < 1 ) {
			throw new \InvalidArgumentException( "page parameter must be at least 1." );
		}

		$per_page = absint( $this->args['items_per_page'] );
		$page     = absint( $this->args['page'] );

		$count  = $per_page;
		$offset = $per_page * ( $page - 1 );

		return new Limit( $count, $offset );
	}
}


<?php
/**
 * Contains the abstract SavableValue class.
 *
 * @author    Iron Bound Designs
 * @since     2.0
 * @license   MIT
 * @copyright Iron Bound Designs, 2016.
 */

namespace IronBound\DB\Saver;

/**
 * Class SavableValue
 * @package IronBound\DB\Saver
 */
abstract class Saver {

	/**
	 * Save a value.
	 *
	 * Data is expected to be un-slashed.
	 *
	 * It should not save if unnecessary.
	 *
	 * @since 2.0
	 *
	 * @param mixed $value   The value object.
	 * @param array $options Additional options to use when saving.
	 *
	 * @return mixed Updated value object. If created, the primary key should be set.
	 */
	public abstract function save( $value, array $options = array() );

	/**
	 * Get the primary key from a value.
	 *
	 * @since 2.0
	 *
	 * @param mixed $value
	 *
	 * @return string|int
	 */
	public abstract function get_pk( $value );

	/**
	 * Get a model by its primary key.
	 *
	 * @since 2.0
	 *
	 * @param string|int $pk
	 *
	 * @return object
	 */
	public abstract function get_model( $pk );

	/**
	 * Make a model from its given attributes
	 *
	 * @since 2.0
	 *
	 * @param array $attributes
	 *
	 * @return object
	 */
	public abstract function make_model( $attributes );

	/**
	 * Check if two things are numerically equivalent.
	 *
	 * @since 2.0
	 *
	 * @param mixed $a
	 * @param mixed $b
	 *
	 * @return bool
	 */
	protected function numerically_equivalent( $a, $b ) {
		return is_numeric( $a ) && is_numeric( $b ) && strcmp( (string) $a, (string) $b ) === 0;
	}

	/**
	 * Check if there are changes.
	 *
	 * @since 2.0
	 *
	 * @param array $old
	 * @param array $new
	 *
	 * @return bool
	 */
	protected function has_changes( $old, $new ) {

		foreach ( $new as $key => $value ) {
			if ( $value !== $old[ $key ] && ! $this->numerically_equivalent( $value, $old[ $key ] ) ) {
				return true;
			}
		}

		return false;
	}
}


<?php
/**
 * This file is part of PicORM.
 *
 * PicORM is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * PicORM is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with PicORM.  If not, see <http://www.gnu.org/licenses/>.
 *
 * PHP version 5.4
 *
 * @category Collection
 * @package  PicORM
 * @author   iNem0o <contact@inem0o.fr>
 * @license  LGPL http://opensource.org/licenses/lgpl-license.php
 * @link     https://github.com/iNem0o/PicORM
 */

namespace PicORM;

/**
 * Class Collection
 *
 * Store a model array fetched from an InternalQueryHelper
 * and allow to fetch / modify / delete them
 *
 * @category Collection
 * @package  PicORM
 * @author   iNem0o <contact@inem0o.fr>
 * @license  LGPL http://opensource.org/licenses/lgpl-license.php
 * @link     https://github.com/iNem0o/PicORM
 */
class Collection implements \Iterator, \Countable, \ArrayAccess
{
    /**
     * Active or not pagination
     *
     * @var bool
     */
    protected $_usePagination = false;

    /**
     * Number of model by page
     *
     * @var int
     */
    protected $_paginationNbModelByPage = 0;

    /**
     * Total models rows founded during pagination
     *
     * @var int
     */
    protected $_paginationFoundModels = 0;

    /**
     * Iterator pointer position
     *
     * @var int
     */
    protected $position = 0;

    /**
     * Models
     *
     * @var array
     */
    protected $models = array();

    /**
     * Boolean to test if collection have been already fetched
     *
     * @var bool
     */
    protected $isFetched = false;

    /**
     * Datasource to execute query
     *
     * @var \PDO
     */
    protected $_dataSource;

    /**
     * Model class name
     *
     * @var
     */
    private $_className;

    /**
     * QueryBuilder to fetch collection
     *
     * @var InternalQueryHelper
     */
    protected $_queryHelper;

    /**
     * Constructor
     *
     * @param \PDO                $dataSource  - Pdo instance
     * @param InternalQueryHelper $queryHelper - QueryBuilder to fetch collection
     * @param string              $className   - class name of the model
     */
    public function __construct(\PDO $dataSource, InternalQueryHelper $queryHelper, $className)
    {
        $this->_dataSource  = $dataSource;
        $this->_className   = $className;
        $this->_queryHelper = $queryHelper;
    }

    /**
     * Return collection query helper
     *
     * @return InternalQueryHelper
     */
    public function getQueryHelper()
    {
        return clone($this->_queryHelper);
    }

    /**
     * Define collection query helper
     *
     * @param QueryBuilder $queryHelper - Query builder to set inside collection
     *
     * @return void
     */
    public function setQueryHelper(QueryBuilder $queryHelper)
    {
        $this->_queryHelper = $queryHelper;
    }

    /**
     * Execute query and fetch models from database
     *
     * @return $this
     * @throws Exception
     */
    public function fetch()
    {
        $modelName = $this->_className;

        // execute fetch query
        $query = $this->_dataSource->prepare($this->_queryHelper->buildQuery());
        $query->execute($this->_queryHelper->getWhereParamsValues());

        // check for mysql error
        $errorcode = $query->errorInfo();
        if ($errorcode[0] != "00000") {
            throw new Exception($errorcode[2]);
        }

        // fetch query and hydrate models
        $fetch = $query->fetchAll(\PDO::FETCH_ASSOC);
        foreach ($fetch as &$unRes) {
            /** @var $object \PicORM\Model */
            $object = new $modelName();
            $object->hydrate($unRes, false);
            $unRes = $object;
        }

        // configure collection after fetch
        $this->isFetched = true;
        $this->models    = $fetch;

        // if pagination used grab the total found model
        if ($this->_usePagination) {
            $this->_paginationFoundModels = $this->queryFoundModels();
        }

        return $this;
    }

    /**
     * Delete model in collection
     *
     * @throws Exception
     * @return bool - true if deleted
     */
    public function delete()
    {
        $modelClass = $this->_className;

        // cloning fetch query to get where,order by and limit values
        $deleteQuery = clone($this->_queryHelper);

        // transform query to delete
        $deleteQuery->cleanQueryBeforeSwitching()
                    ->delete($modelClass::formatTableNameMySQL());

        // execute query
        $query = $this->_dataSource->prepare($deleteQuery->buildQuery());
        $query->execute($deleteQuery->getWhereParamsValues());

        // check for mysql error
        $errorcode = $query->errorInfo();
        if ($errorcode[0] != "00000") {
            throw new Exception($errorcode[2]);
        }

        return true;
    }

    /**
     * Update models in collection with specified values
     *
     * @param array $setValues - Associative array with model field name as key, and model field value as value
     *
     * @throws Exception
     *
     * @return void
     */
    public function update(array $setValues)
    {
        $modelClass = $this->_className;

        // cloning fetch query to get where,order by and limit values
        $updateQuery = clone($this->_queryHelper);

        // transform query to update
        $updateQuery->cleanQueryBeforeSwitching()
                    ->update($modelClass::formatTableNameMySQL());

        // build set values
        $params = array();
        foreach ($setValues as $fieldName => $value) {
            $updateQuery->set($fieldName, '?');
            $params[] = $value;
        }

        // merge set values with where values
        $params = array_merge($params, $updateQuery->getWhereParamsValues());

        // execute query
        $query = $this->_dataSource->prepare($updateQuery->buildQuery());
        $query->execute($params);

        // check for mysql error
        $errorcode = $query->errorInfo();
        if ($errorcode[0] != "00000") {
            throw new Exception($errorcode[2]);
        }
    }

    /**
     * Return an element from collection by index
     *
     * @param int $index - needed array index
     *
     * @return Model
     */
    public function get($index)
    {
        if (!$this->isFetched) {
            $this->fetch();
        }

        return $this->models[$index];

    }

    /**
    * Return all models inside the collection
    *
    * @return array
    */
    public function getModels()
    {
        return $this->models;
    }

    /**
     * Return total page available
     *
     * @return int
     */
    public function getTotalPages()
    {
        if ($this->_usePagination === false) {
            return 0;
        }
        if (!$this->isFetched) {
            $this->fetch();
        }

        return (int)ceil($this->_paginationFoundModels / $this->_paginationNbModelByPage);
    }

    /**
     * Paginate collection to match a num page
     *
     * @param int $neededNumPage - Needed page number
     *
     * @return Collection
     */
    public function paginate($neededNumPage)
    {
        if ($this->_usePagination === false) {
            return $this;
        }

        // build the limit start
        $limitStart = max(0, $neededNumPage - 1) * $this->_paginationNbModelByPage;

        // limit fetch query for page $neededNumPage
        $this->_queryHelper->limit($limitStart, $this->_paginationNbModelByPage);

        return $this;
    }

    /**
     * Enable pagination in collection
     *
     * @param int $nbModelByPage - Number of model by page
     *
     * @return Collection
     */
    public function activePagination($nbModelByPage)
    {
        $this->_usePagination           = true;
        $this->_paginationNbModelByPage = $nbModelByPage;

        return $this;
    }

    /**
     * Execute query to count number of model in database without limit
     *
     * @return mixed
     */
    protected function queryFoundModels()
    {
        $countQueryHelper = clone($this->_queryHelper);
        $countQueryHelper->resetSelect("count(*)");
        $countQueryHelper->resetOrderBy();
        $countQueryHelper->resetLimit();
        $query = $this->_dataSource->prepare($countQueryHelper->buildQuery());
        $query->execute($countQueryHelper->getWhereParamsValues());

        return (int)$query->fetch(\PDO::FETCH_COLUMN);
    }

    /**
     * Remove limit from fetch query and count the number
     * of models in database
     *
     * @return mixed
     */
    public function countModelsWithoutLimit()
    {
        if (!$this->isFetched) {
            $this->fetch();
        }

        // no pagination, we have to manually count number of model
        if (!$this->_usePagination) {
            $this->_paginationFoundModels = $this->queryFoundModels();
        }

        return $this->_paginationFoundModels;
    }

    /**
     * Test if collection has element at this $index
     *
     * @param int $index - needed index
     *
     * @return bool
     */
    public function has($index)
    {
        if (!$this->isFetched) {
            $this->fetch();
        }

        return isset($this->models[$index]);
    }

    /**
     * Set collection element with $model at $index
     *
     * @param int   $index - needed index
     * @param mixed $model - model to store
     *
     * @return void
     */
    public function set($index, $model)
    {
        $this->models[$index] = $model;
    }

    /**
     * Rewind the Iterator to the first element
     * Rewind method allow to lazy fetch collection
     * when iteration begins
     *
     * @return void
     */
    public function rewind()
    {
        if (!$this->isFetched) {
            $this->fetch();
        }
        $this->position = 0;
    }

    /**
     * Return the current model
     *
     * @return Model
     */
    public function current()
    {
        return $this->models[$this->position];
    }

    /**
     * Return the key of the current model
     *
     * @return int|mixed
     */
    public function key()
    {
        return $this->position;
    }

    /**
     * Move forward to next model
     *
     * @return void
     */
    public function next()
    {
        ++$this->position;
    }

    /**
     * Checks if current position is valid
     *
     * @return bool - Returns true on success or false on failure
     */
    public function valid()
    {
        return isset($this->models[$this->position]);
    }

    /**
     * Count elements of an object
     *
     * @return int The custom count as an integer.
     */
    public function count()
    {
        if (!$this->isFetched) {
            $this->fetch();
        }

        return count($this->models);
    }

    /**
     * Whether a offset exists
     *
     * @param mixed $offset - Offset name to test
     *
     * @return bool
     */
    public function offsetExists($offset)
    {
        if (!$this->isFetched) {
            $this->fetch();
        }

        return isset($this->models[$offset]);
    }

    /**
     * Offset to set
     *
     * @param mixed $offset - Offset name to set
     * @param mixed $value  - Value to set
     *
     * @return void
     */
    public function offsetSet($offset, $value)
    {
        if (!$this->isFetched) {
            $this->fetch();
        }

        if (is_null($offset)) {
            $this->models[] = $value;
        } else {
            $this->models[$offset] = $value;
        }
    }

    /**
     * Offset to unset
     *
     * @param mixed $offset - Offset name to unset
     *
     * @return void
     */
    public function offsetUnset($offset)
    {
        if (!$this->isFetched) {
            $this->fetch();
        }

        unset($this->models[$offset]);
    }

    /**
     * Offset to retrieve
     *
     * @param mixed $offset - Offset name to get
     *
     * @return mixed|null
     */
    public function offsetGet($offset)
    {
        if (!$this->isFetched) {
            $this->fetch();
        }

        return isset($this->models[$offset]) ? $this->models[$offset] : null;
    }
}

?>


<?php
/**
 * A simple query builder.
 *
 * @author Iron Bound Designs
 * @since  1.0
 */

namespace IronBound\DB\Query;
use IronBound\DB\Query\Tag\Generic;

/**
 * Class Builder
 * @package IronBound\DB\Query
 */
final class Builder {

	/**
	 * @var Generic|Builder
	 */
	private $parts = array();

	/**
	 * Append new sql to the existing query string.
	 *
	 * It is advisable to use the convenience methods.
	 *
	 * @since 1.0
	 *
	 * @param Generic $sql
	 *
	 * @return Builder
	 */
	public function append( Generic $sql ) {
		$this->parts[] = $sql;

		return $this;
	}

	/**
	 * Perform a subquery.
	 *
	 * @since 1.0
	 *
	 * @param Builder $builder
	 *
	 * @return $this
	 */
	public function subquery( Builder $builder ) {
		$this->parts[] = $builder;

		return $this;
	}

	/**
	 * toString method.
	 *
	 * @since 1.0
	 *
	 * @return string
	 */
	public function __toString() {
		return $this->build();
	}

	/**
	 * Build a sql statement.
	 *
	 * @since 1.0
	 *
	 * @return string
	 */
	public function build() {

		if ( empty( $this->parts ) ) {
			return '';
		}

		$query = '';

		foreach ( $this->parts as $part) {

			if ($part instanceof Builder) {
				$query .= "($part)";
			} elseif ( $sql = (string) $part ) {
				$query .= "$part ";
			}
		}

		return $query;
	}
}