<?php

/**
    Cortex - a general purpose mapper for the PHP Fat-Free Framework

    The contents of this file are subject to the terms of the GNU General
    Public License Version 3.0. You may not use this file except in
    compliance with the license. Any of the license terms and conditions
    can be waived if you get permission from the copyright holder.

    crafted by   __ __     __
                |__|  |--.|  |--.-----.-----.
                |  |    < |    <|  -__|-- __|
                |__|__|__||__|__|_____|_____|

    Copyright (c) 2014 by ikkez
    Christian Knuth <ikkez0n3@gmail.com>
    https://github.com/ikkez/F3-Sugar/

        @package DB
        @version 1.3.0-dev
        @since 24.04.2012
        @date 02.04.2014
 **/

namespace DB;
use DB\SQL\Schema;

class Cortex extends Cursor {

	protected
		// config
		$db,            // DB object [ \DB\SQL, \DB\Jig, \DB\Mongo ]
		$table,         // selected table, string
		$fluid,         // fluid sql schema mode, boolean
		$fieldConf,     // field configuration, array
		$ttl,           // default mapper ttl
		$primary,       // SQL table primary key
		// behaviour
		$smartLoading,  // intelligent lazy eager loading, boolean
		$standardiseID, // return standardized '_id' field for SQL when casting
		// internals
		$dbsType,       // mapper engine type [jig, sql, mongo]
		$fieldsCache,   // relation field cache
		$saveCsd,       // mm rel save cascade
		$collectionID,  // collection set identifier
		$relFilter,     // filter for loading related models
		$hasCond,       // IDs of records the next find should have
		$mFuncs,        // magic functions cache
		$whitelist,     // restrict to these fields
		$relWhitelist,  // restrict relations to these fields
		$grp_stack,     // stack of group conditions
		$countFields,   // relational counter buffer
		$vFields;       // virtual fields buffer

	/** @var Cursor */
	protected $mapper;

	/** @var CortexQueryParser */
	protected $queryParser;

	static
		$init = false;  // just init without mapper

	const
		// special datatypes
		DT_SERIALIZED = 'SERIALIZED',
		DT_JSON = 'JSON',

		// error messages
		E_ARRAY_DATATYPE = 'Unable to save an Array in field %s. Use DT_SERIALIZED or DT_JSON.',
		E_CONNECTION = 'No valid DB Connection given.',
		E_NO_TABLE = 'No table specified.',
		E_UNKNOWN_DB_ENGINE = 'This unknown DB system is not supported: %s',
		E_FIELD_SETUP = 'No field setup defined',
		E_UNKNOWN_FIELD = 'Field %s does not exist in %s.',
		E_INVALID_RELATION_OBJECT = 'You can only save hydrated mapper objects',
		E_NULLABLE_COLLISION = 'Unable to set NULL to the NOT NULLABLE field: %s',
		E_WRONG_RELATION_CLASS = 'Relations only works with Cortex objects',
		E_MM_REL_VALUE = 'Invalid value for many field "%s". Expecting null, split-able string, hydrated mapper object, or array of mapper objects.',
		E_MM_REL_CLASS = 'Mismatching m:m relation config from class `%s` to `%s`.',
		E_MM_REL_FIELD = 'Mismatching m:m relation keys from `%s` to `%s`.',
		E_REL_CONF_INC = 'Incomplete relation config for `%s`. Linked key is missing.',
		E_MISSING_REL_CONF = 'Cannot create related model. Specify a model name or relConf array.',
		E_HAS_COND = 'Cannot use a "has"-filter on a non-bidirectional relation field';

	/**
	 * init the ORM, based on given DBS
	 * @param null|object $db
	 * @param string      $table
	 * @param null|bool   $fluid
	 * @param int         $ttl
	 */
	public function __construct($db = NULL, $table = NULL, $fluid = NULL, $ttl = 0)
	{
		if (!is_null($fluid))
			$this->fluid = $fluid;
		if (!is_object($this->db=(is_string($db=($db?:$this->db))?\Base::instance()->get($db):$db)))
			trigger_error(self::E_CONNECTION);
		if ($this->db instanceof Jig)
			$this->dbsType = 'jig';
		elseif ($this->db instanceof SQL)
			$this->dbsType = 'sql';
		elseif ($this->db instanceof Mongo)
			$this->dbsType = 'mongo';
		if ($table)
			$this->table = $table;
		if (!$this->primary || $this->dbsType != 'sql')
			$this->primary = 'id';
		if (!$this->table && !$this->fluid)
			trigger_error(self::E_NO_TABLE);
		$this->ttl = $ttl ?: 60;
		if (static::$init == TRUE) return;
		if ($this->fluid)
			static::setup($this->db,$this->getTable(),array());
		$this->initMapper();
	}

	/**
	 * create mapper instance
	 */
	public function initMapper()
	{
		switch ($this->dbsType) {
			case 'jig':
				$this->mapper = new Jig\Mapper($this->db, $this->table);
				break;
			case 'sql':
				$this->mapper = new SQL\Mapper($this->db, $this->table, $this->whitelist,
					($this->fluid)?0:$this->ttl);
				break;
			case 'mongo':
				$this->mapper = new Mongo\Mapper($this->db, $this->table);
				break;
			default:
				trigger_error(sprintf(self::E_UNKNOWN_DB_ENGINE,$this->dbsType));
		}
		$this->mFuncs = array();
		$this->queryParser = CortexQueryParser::instance();
		$this->reset();
		$this->clearFilter();
		$f3 = \Base::instance();
		$this->smartLoading = $f3->exists('CORTEX.smartLoading') ?
			$f3->get('CORTEX.smartLoading') : TRUE;
		$this->standardiseID = $f3->exists('CORTEX.standardiseID') ?
			$f3->get('CORTEX.standardiseID') : TRUE;
		if(!empty($this->fieldConf))
			foreach($this->fieldConf as &$conf)
				$conf=static::resolveRelationConf($conf);
	}

	/**
	 * get fields or set whitelist / blacklist of fields
	 * @param array $fields
	 * @param bool  $exclude
	 * @return array
	 */
	public function fields(array $fields=array(), $exclude=false)
	{
		if ($fields)
			// collect restricted fields for related mappers
			foreach($fields as $i=>$val)
				if(is_int(strpos($val,'.'))) {
					list($key, $relField) = explode('.',$val,2);
					$this->relWhitelist[$key][(int)$exclude][] = $relField;
					unset($fields[$i]);
				}
		$schema = $this->whitelist ?: $this->mapper->fields();
		if (!$schema && !$this->dbsType != 'sql' && $this->dry()) {
			$schema = $this->load()->mapper->fields();
			$this->reset();
		}
		if (!$this->whitelist && $this->fieldConf)
			$schema=array_unique(array_merge($schema,array_keys($this->fieldConf)));
		if (!$fields || empty($fields))
			return $schema;
		elseif ($exclude) {
			$this->whitelist=array_diff($schema,$fields);
		} else
			$this->whitelist=$fields;
		$id=$this->dbsType=='sql'?$this->primary:'_id';
		if(!in_array($id,$this->whitelist))
			$this->whitelist[]=$id;
		$this->initMapper();
		return $this->whitelist;
	}

	/**
	 * set model definition
	 * config example:
	 *  array('title' => array(
	 *          'type' => \DB\SQL\Schema::DT_TEXT,
	 *          'default' => 'new record title',
	 *          'nullable' => true
	 *          )
	 *        '...' => ...
	 *  )
	 * @param array $config
	 */
	function setFieldConfiguration(array $config)
	{
		$this->fieldConf = $config;
		$this->reset();
	}

	/**
	 * returns model field conf array
	 * @return array|null
	 */
	public function getFieldConfiguration()
	{
		return $this->fieldConf;
	}

	/**
	 * kick start to just fetch the config
	 * @return array
	 */
	static public function resolveConfiguration()
	{
		$refl = new \ReflectionClass(get_called_class());
		$refl->setStaticPropertyValue('init', true);
		$self = $refl->newInstance();
		$refl->setStaticPropertyValue('init', false);
		$conf = array (
			'table'=>$self->getTable(),
			'fieldConf'=>$self->getFieldConfiguration(),
			'db'=>$self->db,
			'fluid'=>$self->fluid,
			'primary'=>$self->primary,
		);
		unset($self);
		return $conf;
	}

	public function addToCollection($cID) {
		$this->collectionID = $cID;
	}

	/**
	 * returns model table name
	 * @return string
	 */
	public function getTable()
	{
		if (!$this->table && $this->fluid)
			$this->table = strtolower(get_class($this));
		return $this->table;
	}

	/**
	 * setup / update table schema
	 * @static
	 * @param $db
	 * @param $table
	 * @param $fields
	 * @return bool
	 */
	static public function setup($db=null, $table=null, $fields=null)
	{
		/** @var Cortex $self */
		$self = get_called_class();
		if (is_null($db) || is_null($table) || is_null($fields))
			$df = $self::resolveConfiguration();
		if (!is_object($db=(is_string($db=($db?:$df['db']))?\Base::instance()->get($db):$db)))
			trigger_error(self::E_CONNECTION);
		if (strlen($table=$table?:$df['table'])==0)
			trigger_error(self::E_NO_TABLE);
		if (is_null($fields))
			if (!empty($df['fieldConf']))
				$fields = $df['fieldConf'];
			elseif(!$df['fluid']) {
				trigger_error(self::E_FIELD_SETUP);
				return false;
			} else
				$fields = array();
		if ($db instanceof SQL) {
			$schema = new Schema($db);
			// prepare field configuration
			if (!empty($fields))
				foreach($fields as $key => &$field) {
					// fetch relation field types
					$field = static::resolveRelationConf($field);
					// check m:m relation
					if (array_key_exists('has-many', $field)) {
						// m:m relation conf [class,to-key,from-key]
						if (!is_array($relConf = $field['has-many'])) {
							unset($fields[$key]);
							continue;
						}
						$rel = $relConf[0]::resolveConfiguration();
						// check if foreign conf matches m:m
						if (array_key_exists($relConf[1],$rel['fieldConf'])
							&& !is_null($rel['fieldConf'][$relConf[1]])
							&& $relConf['hasRel'] == 'has-many') {
							// compute mm table name
							$mmTable = isset($relConf[2]) ? $relConf[2] :
								static::getMMTableName(
									$rel['table'], $relConf[1], $table, $key,
									$rel['fieldConf'][$relConf[1]]['has-many']);
							// create dummy to invoke table
							$mmRel = new Cortex($db,$mmTable,true);
							$rand = rand(0,1000000);
							$mmRel->{$relConf[1]} = $rand;
							$mmRel->{$key} = $rand;
							$mmRel->save();
							$mmRel->reset();
							$mmRel->erase(array($relConf[1].' = :rand AND '.$key.' = :rand',':rand'=>$rand));
						}
					}
					// skip virtual fields with no type
					if (!array_key_exists('type', $field)) {
						unset($fields[$key]);
						continue;
					}
					// transform array fields
					if (in_array($field['type'], array(self::DT_JSON, self::DT_SERIALIZED)))
						$field['type']=$schema::DT_TEXT;
					// defaults values
					if (!array_key_exists('nullable', $field)) $field['nullable'] = true;
				}
			if (!in_array($table, $schema->getTables())) {
				// create table
				$table = $schema->createTable($table);
				foreach ($fields as $field_key => $field_conf)
					$table->addColumn($field_key, $field_conf);
				if(isset($df) && $df['primary'] != 'id') {
					$table->addColumn($df['primary'])->type_int();
					$table->primary($df['primary']);
				}
				$table->build();
			} else {
				// add missing fields
				$table = $schema->alterTable($table);
				$existingCols = $table->getCols();
				foreach ($fields as $field_key => $field_conf)
					if (!in_array($field_key, $existingCols))
						$table->addColumn($field_key, $field_conf);
				// remove unused fields
				// foreach ($existingCols as $col)
				//     if (!in_array($col, array_keys($fields)) && $col!='id')
				//     $table->dropColumn($col);
				$table->build();
			}
		}
		return true;
	}

	/**
	 * erase all model data, handle with care
	 * @param null $db
	 * @param null $table
	 */
	static public function setdown($db=null, $table=null)
	{
		$self = get_called_class();
		if (is_null($db) || is_null($table))
			$df = $self::resolveConfiguration();
		if (!is_object($db=(is_string($db=($db?:$df['db']))?\Base::instance()->get($db):$db)))
			trigger_error(self::E_CONNECTION);
		if (strlen($table=strtolower($table?:$df['table']))==0)
			trigger_error(self::E_NO_TABLE);
		if (isset($df) && !empty($df['fieldConf']))
			$fields = $df['fieldConf'];
		else
			$fields = array();
		$deletable = array();
		$deletable[] = $table;
		foreach ($fields as $key => $field) {
			$field = static::resolveRelationConf($field);
			if (array_key_exists('has-many',$field)) {
				if (!is_array($relConf = $field['has-many']))
					continue;
				$rel = $relConf[0]::resolveConfiguration();
				// check if foreign conf matches m:m
				if (array_key_exists($relConf[1],$rel['fieldConf']) && !is_null($relConf[1])
					&& key($rel['fieldConf'][$relConf[1]]) == 'has-many') {
					// compute mm table name
					$deletable[] = isset($relConf[2]) ? $relConf[2] :
						static::getMMTableName(
							$rel['table'], $relConf[1], $table, $key,
							$rel['fieldConf'][$relConf[1]]['has-many']);
				}
			}
		}

		if($db instanceof Jig) {
			/** @var Jig $db */
			$dir = $db->dir();
			foreach ($deletable as $item)
				if(file_exists($dir.$item))
					unlink($dir.$item);
		} elseif($db instanceof SQL) {
			/** @var SQL $db */
			$schema = new Schema($db);
			$tables = $schema->getTables();
			foreach ($deletable as $item)
				if(in_array($item, $tables))
					$schema->dropTable($item);
		} elseif($db instanceof Mongo) {
			/** @var Mongo $db */
			foreach ($deletable as $item)
				$db->selectCollection($item)->drop();
		}
	}

	/**
	 * computes the m:m table name
	 * @param string $ftable foreign table
	 * @param string $fkey   foreign key
	 * @param string $ptable own table
	 * @param string $pkey   own key
	 * @param null|array $fConf  foreign conf [class,key]
	 * @return string
	 */
	static protected function getMMTableName($ftable, $fkey, $ptable, $pkey, $fConf=null)
	{
		if ($fConf) {
			list($fclass, $pfkey) = $fConf;
			$self = get_called_class();
			// check for a matching config
			if (!is_int(strpos($fclass, $self)))
				trigger_error(sprintf(self::E_MM_REL_CLASS, $fclass, $self));
			if ($pfkey != $pkey)
				trigger_error(sprintf(self::E_MM_REL_FIELD,
					$fclass.'.'.$pfkey, $self.'.'.$pkey));
		}
		$mmTable = array($ftable.'__'.$fkey, $ptable.'__'.$pkey);
		natcasesort($mmTable);
		$return = strtolower(str_replace('\\', '_', implode('_mm_', $mmTable)));
		return $return;
	}

	/**
	 * resolve relation field types
	 * @param $field
	 * @return mixed
	 */
	protected static function resolveRelationConf($field)
	{
		if (array_key_exists('belongs-to-one', $field)) {
			// find primary field definition
			if (!is_array($relConf = $field['belongs-to-one']))
				$relConf = array($relConf, '_id');
			// set field type
			if ($relConf[1] == '_id')
				$field['type'] = Schema::DT_INT8;
			else {
				// find foreign field type
				$fc = $relConf[0]::resolveConfiguration();
				$field['belongs-to-one']['relPK'] = $fc['primary'];
				$field['type'] = $fc['fieldConf'][$relConf[1]]['type'];
			}
			$field['nullable'] = true;
			$field['relType'] = 'belongs-to-one';
		}
		elseif (array_key_exists('belongs-to-many', $field)){
			$field['type'] = self::DT_JSON;
			$field['nullable'] = true;
			$field['relType'] = 'belongs-to-many';
		}
		elseif (array_key_exists('has-many', $field)){
			$field['relType'] = 'has-many';
			$relConf = $field['has-many'];
			if(!is_array($relConf))
				return $field;
			$rel = $relConf[0]::resolveConfiguration();
			if(array_key_exists('has-many',$rel['fieldConf'][$relConf[1]])) {
				$field['has-many']['hasRel'] = 'has-many';
				$field['has-many']['relTable'] = $rel['table'];
				$field['has-many']['relField'] = $relConf[1];
				$field['has-many']['relPK'] = $rel['primary'];
			} else {
				$field['has-many']['hasRel'] = 'belongs-to-one';
			}
		} elseif(array_key_exists('has-one', $field))
			$field['relType'] = 'has-one';
		return $field;
	}

	/**
	 * Return an array of result arrays matching criteria
	 * @param null $filter
	 * @param array $options
	 * @param int $ttl
	 * @param int $rel_depths
	 * @return array
	 */
	public function afind($filter = NULL, array $options = NULL, $ttl = 0, $rel_depths = 1)
	{
		$result = $this->find($filter, $options, $ttl);
		return $result ? $result->castAll($rel_depths): NULL;
	}

	/**
	 * Return an array of objects matching criteria
	 * @param array|null $filter
	 * @param array|null $options
	 * @param int        $ttl
	 * @return CortexCollection
	 */
	public function find($filter = NULL, array $options = NULL, $ttl = 0)
	{
		$sort=false;
		if ($this->dbsType!='sql') {
			if (!empty($this->countFields))
				// see if reordering is needed
				foreach($this->countFields as $counter) {
					if ($options && isset($options['order']) &&
						preg_match('/count_'.$counter.'\h+(asc|desc)/i',$options['order'],$match))
						$sort=true;
				}
			if ($sort) {
				// backup slice settings
				if (isset($options['limit'])) {
					$limit = $options['limit'];
					unset($options['limit']);
				}
				if (isset($options['offset'])) {
					$offset = $options['offset'];
					unset($options['offset']);
				}
			}
		}
		$result = $this->filteredFind($filter,$options,$ttl);
		if (empty($result))
			return false;
		foreach($result as &$record) {
			$record = $this->factory($record);
			unset($record);
		}
		if (!empty($this->countFields))
			// add counter for NoSQL engines
			foreach($this->countFields as $counter)
				foreach($result as &$mapper)
					$mapper->virtual('count_'.$counter,count($mapper->get($counter)));
		$cc = new \DB\CortexCollection();
		$cc->setModels($result);
		if($sort) {
			$cc->orderBy($options['order']);
			$cc->slice(isset($offset)?$offset:0,isset($limit)?$limit:NULL);
		}
		$this->clearFilter();
		return $cc;
	}

	/**
	 * wrapper for custom find queries
	 * @param array $filter
	 * @param array $options
	 * @param int $ttl
	 * @return array|false array of underlying cursor objects
	 */
	protected function filteredFind($filter = NULL, array $options = NULL, $ttl = 0)
	{
		if ($this->grp_stack) {
			if ($this->dbsType == 'mongo') {
				$group = array(
					'keys' => $this->grp_stack['keys'],
					'reduce' => 'function (obj, prev) {'.$this->grp_stack['reduce'].'}',
					'initial' => $this->grp_stack['initial'],
					'finalize' => $this->grp_stack['finalize'],
				);
				if ($options && isset($options['group'])) {
					if(is_array($options['group']))
						$options['group'] = array_merge($options['group'],$group);
					else {
						$keys = explode(',',$options['group']);
						$keys = array_combine($keys,array_fill(0,count($keys),1));
						$group['keys'] = array_merge($group['keys'],$keys);
						$options['group'] = $group;
					}
				} else
					$options = array('group'=>$group);
			}
			if($this->dbsType == 'sql') {
				if ($options && isset($options['group']))
					$options['group'].= ','.$this->grp_stack;
				else
					$options['group'] = $this->grp_stack;
			}
		}
		$hasJoin = array();
		if ($this->hasCond) {
			foreach($this->hasCond as $key => $hasCond) {
				$addToFilter = null;
				list($has_filter,$has_options) = $hasCond;
				$type = $this->fieldConf[$key]['relType'];
				$fromConf = $this->fieldConf[$key][$type];
				switch($type) {
					case 'has-one':
					case 'has-many':
						if (!is_array($fromConf))
							trigger_error(sprintf(self::E_REL_CONF_INC, $key));
						$id = $this->dbsType == 'sql' ? $this->primary : '_id';
						// many-to-many
						if ($type == 'has-many' && $fromConf['hasRel'] == 'has-many') {
							if ($this->dbsType == 'sql'
								&& !isset($has_options['limit']) && !isset($has_options['offset']))
								$hasJoin = array_merge($hasJoin,
									$this->_hasJoinMM_sql($key,$hasCond,$filter,$options));
							elseif ($result = $this->_hasRefsInMM($key,$has_filter,$has_options,$ttl))
								$addToFilter = array($id.' IN ?', $result);
						} // *-to-one
						elseif ($result = $this->_hasRefsIn($key,$has_filter,$has_options,$ttl))
							$addToFilter = array($id.' IN ?', $result);
						break;
					// one-to-*
					case 'belongs-to-one':
						if ($this->dbsType == 'sql'
							&& !isset($has_options['limit']) && !isset($has_options['offset'])) {
							if (!is_array($fromConf))
								$fromConf = array($fromConf, '_id');
							$rel = $fromConf[0]::resolveConfiguration();
							if ($this->dbsType == 'sql' && $fromConf[1] == '_id')
								$fromConf[1] = $rel['primary'];
							$hasJoin[] = $this->_hasJoin_sql($key,$rel['table'],$hasCond,$filter,$options);
						} elseif ($result = $this->_hasRefsIn($key,$has_filter,$has_options,$ttl))
								$addToFilter = array($key.' IN ?', $result);
						break;
					default:
						trigger_error(self::E_HAS_COND);
				}
				if (isset($result) && !isset($addToFilter))
					return false;
				elseif (isset($addToFilter)) {
					if (!$filter)
						$filter = array('');
					if (!empty($filter[0]))
						$filter[0] .= ' and ';
					$filter[0] .= '('.array_shift($addToFilter).')';
					$filter = array_merge($filter, $addToFilter);
				}
			}
			$this->hasCond = null;
		}
		if ($this->dbsType=='sql') {
			$qtable = $this->db->quotekey($this->table);
			// PostgreSQLism:
			if ($this->db->driver() == 'pgsql') {
				// sort NULL values to the end of a table
				if (isset($options['order']))
					$options['order'] = preg_replace('/\h+DESC/i',' DESC NULLS LAST',$options['order']);
				// all non-aggregated fields need to be present in the GROUP BY clause
				if (isset($options['group'])) {
					$groupFields = explode(',', preg_replace('/"/','',$options['group']));
					foreach (array_diff($this->mapper->fields(),array_keys($this->mapper->adhoc)) as $field)
						if (!in_array($this->table.'.'.$field,$groupFields))
							$options['group'] .= ', '.$qtable.'.'.$this->db->quotekey($field);
				}
			}
		}
		if (!empty($hasJoin)) {
			// assemble full sql query
			$filter = $this->queryParser->prepareFilter($filter,$this->dbsType);
			$adhoc='';
			if (!empty($this->mapper->adhoc))
				foreach ($this->mapper->adhoc as $key=>$val)
					$adhoc.=', '.$val['expr'].' AS '.$key;
			$sql = 'SELECT '.$qtable.'.*'.$adhoc.' FROM '.$qtable;
			foreach ($hasJoin as $q)
				$sql .= ' '.$q;
			$sql .= ' WHERE '.$filter[0];
			if (isset($options['group']))
				$sql .= ' GROUP BY '.$options['group'];
			if (isset($options['order']))
				$sql .= ' ORDER BY '.$options['order'];
			if (isset($options['limit']))
				$sql .= ' LIMIT '.(int)$options['limit'];
			if (isset($options['offset']))
				$sql .= ' OFFSET '.(int)$options['offset'];
			unset($filter[0]);
			$result = $this->db->exec($sql, $filter, $ttl);
			foreach ($result as &$record) {
				$mapper = clone($this->mapper);
				$mapper->reset();
				if (!empty($adhoc))
					$m_adhoc=$this->mapper->adhoc;
				foreach ($record as $key=>$val)
					if (isset($this->mapper->adhoc[$key]))
						$m_adhoc[$key]['value']=$val;
					else
						$mapper->set($key, $val);
				if (!empty($adhoc)) {
					$refl = new \ReflectionObject($mapper);
					$prop = $refl->getProperty('adhoc');
					$prop->setAccessible(true);
					$prop->setValue($mapper,$m_adhoc);
					$prop->setAccessible(false);
				}
				$record = $mapper;
				unset($record, $mapper);
			}
		} else {
			$filter = $this->queryParser->prepareFilter($filter,$this->dbsType,$this->fieldConf);
			$options = $this->queryParser->prepareOptions($options,$this->dbsType);
			$result =  $this->mapper->find($filter,$options,$ttl);
		}
		return $result;
	}

	/**
	 * Retrieve first object that satisfies criteria
	 * @param null  $filter
	 * @param array $options
	 * @param int   $ttl
	 * @return Cortex
	 */
	public function load($filter = NULL, array $options = NULL, $ttl = 0)
	{
		$this->reset();
		$res = $this->filteredFind($filter, $options, $ttl);
		if ($res) {
			$this->mapper->query = $res;
			$this->first();
		} else
			$this->mapper->reset();
		return $this;
	}

	/**
	 * add has-conditional filter to next find call
	 * @param string $key
	 * @param array $filter
	 * @param null $options
	 * @return $this
	 */
	public function has($key, $filter, $options = null) {
		if (!isset($this->fieldConf[$key]))
			trigger_error(sprintf(self::E_UNKNOWN_FIELD,$key,get_called_class()));
		if (!isset($this->fieldConf[$key]['relType']))
			trigger_error(self::E_HAS_COND);
		if (is_string($filter))
			$filter=array($filter);
		$this->hasCond[$key] = array($filter,$options);
		return $this;
	}

	/**
	 * return IDs of records that has a linkage to this mapper
	 * @param string $key     relation field
	 * @param array  $filter  condition for foreign records
	 * @param array  $options filter options for foreign records
	 * @param int    $ttl
	 * @return array|false
	 */
	protected function _hasRefsIn($key, $filter, $options, $ttl = 0)
	{
		$type = $this->fieldConf[$key]['relType'];
		$fieldConf = $this->fieldConf[$key][$type];
		if (!is_array($fieldConf))
			// one-to-many shortcut
			$fieldConf = array($fieldConf, '_id');
		$rel = $this->getRelInstance($fieldConf[0],null,$key);
		if($this->dbsType=='sql' && $fieldConf[1] == '_id')
			$fieldConf[1] = $rel->primary;
		$hasSet = $rel->find($filter, $options, $ttl);
		if (!$hasSet)
			return false;
		$hasSetByRelId = array_unique($hasSet->getAll($fieldConf[1], true));
		return empty($hasSetByRelId) ? false : $hasSetByRelId;
	}

	/**
	 * return IDs of own mappers that match the given relation filter on pivot tables
	 * @param string $key
	 * @param array $filter
	 * @param array $options
	 * @param int $ttl
	 * @return array|false
	 */
	protected function _hasRefsInMM($key, $filter, $options, $ttl=0)
	{
		$fieldConf = $this->fieldConf[$key]['has-many'];
		$rel = $this->getRelInstance($fieldConf[0],null,$key);
		$hasSet = $rel->find($filter,$options,$ttl);
		$result = false;
		if ($hasSet) {
			$hasIDs = $hasSet->getAll('_id',true);
			if (!array_key_exists('refTable', $fieldConf)) {
				$mmTable = isset($fieldConf[2]) ? $fieldConf[2] :
					static::getMMTableName($fieldConf['relTable'],
						$fieldConf['relField'], $this->getTable(), $key);
				$this->fieldConf[$key]['has-many']['refTable'] = $mmTable;
			} else
				$mmTable = $fieldConf['refTable'];
			$pivot = $this->getRelInstance(null,array('db'=>$this->db,'table'=>$mmTable));
			$pivotSet = $pivot->find(array($key.' IN ?',$hasIDs),null,$ttl);
			if ($pivotSet)
				$result = array_unique($pivotSet->getAll($fieldConf['relField'],true));
		}
		return $result;
	}

	/**
	 * build query for SQL pivot table join and merge conditions
	 */
	protected function _hasJoinMM_sql($key, $hasCond, &$filter, &$options)
	{
		$fieldConf = $this->fieldConf[$key]['has-many'];
		$hasJoin = array();
		if (!array_key_exists('refTable', $fieldConf)) {
			// compute mm table name
			$mmTable = isset($fieldConf[2]) ? $fieldConf[2] :
				static::getMMTableName($fieldConf['relTable'],
					$fieldConf['relField'], $this->getTable(), $key);
			$this->fieldConf[$key]['has-many']['refTable'] = $mmTable;
		} else
			$mmTable = $fieldConf['refTable'];
		$hasJoin[] = $this->_sql_left_join($this->primary,$this->table,$fieldConf['relField'],$mmTable);
		$hasJoin[] = $this->_sql_left_join($key,$mmTable,$fieldConf['relPK'],$fieldConf['relTable']);
		$this->_sql_mergeRelCondition($hasCond,$fieldConf['relTable'],$filter,$options);
		return $hasJoin;
	}

	/**
	 * build query for single SQL table join and merge conditions
	 */
	protected function _hasJoin_sql($key, $table, $cond, &$filter, &$options)
	{
		$rel = $this->getRelInstance($this->fieldConf[$key]['belongs-to-one'],null,$key);
		$fkey = is_array($this->fieldConf[$key]['belongs-to-one']) ?
			$this->fieldConf[$key]['belongs-to-one'][1] : $rel->primary;
		$query = $this->_sql_left_join($key,$this->table,$fkey,$table);
		$this->_sql_mergeRelCondition($cond,$table,$filter,$options);
		return $query;
	}

	/**
	 * assemble SQL join query string
	 */
	protected function _sql_left_join($skey,$sTable,$fkey,$fTable)
	{
		$skey = $this->db->quotekey($skey);
		$sTable = $this->db->quotekey($sTable);
		$fkey = $this->db->quotekey($fkey);
		$fTable = $this->db->quotekey($fTable);
		return 'LEFT JOIN '.$fTable.' ON '.$sTable.'.'.$skey.' = '.$fTable.'.'.$fkey;
	}

	/**
	 * merge condition of relation with current condition
	 * @param array $cond		condition of related model
	 * @param string $table		table of related model
	 * @param array $filter		current filter to merge with
	 * @param array $options	current options to merge with
	 */
	protected function _sql_mergeRelCondition($cond, $table, &$filter, &$options)
	{
		$table = $this->db->quotekey($table);
		if (!empty($cond[0])) {
			if (!$filter)
				$filter = array('');
			if (!empty($filter[0]))
				$filter[0] .= ' and ';
			$whereClause = '('.array_shift($cond[0]).')';
			$db = $this->db;
			$whereClause = preg_replace_callback('/\w+/i',function($match) use($table,$db) {
				if (preg_match('/\b(AND|OR|IN|LIKE|NOT)\b/i',$match[0]))
					return $match[0];
				return $table.'.'.$db->quotekey($match[0]);
			}, $whereClause);
			$filter[0] .= $whereClause;
			$filter = array_merge($filter, $cond[0]);
		}
		if (isset($options['group']))
			$options['group'] .= ',';
		else
			$options['group'] = '';
		$options['group'] .= $this->db->quotekey($this->getTable().'.'.$this->primary);
		if ($cond[1] && isset($cond[1]['group'])) {
			$hasGroup = preg_replace('/(\w+)/i', $table.'.$1', $cond[1]['group']);
			$options['group'] .= ','.$hasGroup;
		}
	}

	/**
	 * add filter for loading related models
	 * @param string $key
	 * @param array $filter
	 * @param array $option
	 * @return $this
	 */
	public function filter($key,$filter=null,$option=null)
	{
		$this->relFilter[$key] = array($filter,$option);
		return $this;
	}

	/**
	 * removes one or all relation filter
	 * @param null|string $key
	 */
	public function clearFilter($key = null)
	{
		if (!$key)
			$this->relFilter = array();
		elseif(isset($this->relFilter[$key]))
			unset($this->relFilter[$key]);
	}

	/**
	 * merge the relation filter to the query criteria if it exists
	 * @param string $key
	 * @param array $crit
	 * @return array
	 */
	protected function mergeWithRelFilter($key,$crit)
	{
		if (array_key_exists($key, $this->relFilter) &&
			!empty($this->relFilter[$key][0]))
		{
			$filter = $this->relFilter[$key][0];
			$crit[0] .= ' and '.array_shift($filter);
			$crit = array_merge($crit, $filter);
		}
		return $crit;
	}

	/**
	 * returns the option condition for a relation filter, if defined
	 * @param string $key
	 * @return array null
	 */
	protected function getRelFilterOption($key)
	{
		return (array_key_exists($key, $this->relFilter) &&
			!empty($this->relFilter[$key][1]))
			? $this->relFilter[$key][1] : null;
	}

	/**
	 * Delete object/s and reset ORM
	 * @param $filter
	 * @return void
	 */
	public function erase($filter = null)
	{
		$filter = $this->queryParser->prepareFilter($filter, $this->dbsType);
		$this->mapper->erase($filter);
	}

	/**
	 * Save mapped record
	 * @return mixed
	 **/
	function save()
	{
		$result = $this->dry() ? $this->insert() : $this->update();
		// m:m save cascade
		if (!empty($this->saveCsd)) {
			$fields = $this->fieldConf;
			foreach($this->saveCsd as $key => $val) {
				if($fields[$key]['relType'] == 'has-many') {
					$relConf = $fields[$key]['has-many'];
					if (!isset($relConf['refTable'])) {
						// compute mm table name
						$mmTable = isset($relConf[2]) ? $relConf[2] :
							static::getMMTableName($relConf['relTable'],
								$relConf['relField'], $this->getTable(), $key);
						$this->fieldConf[$key]['has-many']['refTable'] = $mmTable;
					} else
						$mmTable = $relConf['refTable'];
					$rel = $this->getRelInstance(null, array('db'=>$this->db, 'table'=>$mmTable));
					// delete all refs
					if (is_null($val))
						$rel->erase(array($relConf['relField'].' = ?', $this->get('_id',true)));
					// update refs
					elseif (is_array($val)) {
						$id = $this->get('_id',true);
						$rel->erase(array($relConf['relField'].' = ?', $id));
						foreach($val as $v) {
							$rel->set($key,$v);
							$rel->set($relConf['relField'],$id);
							$rel->save();
							$rel->reset();
						}
					}
					unset($rel);
				} elseif($fields[$key]['relType'] == 'has-one') {
					$val->save();
				}
			}
		}
		return $result;
	}

	/**
	 * Count records that match criteria
	 * @param null $filter
	 * @param int $ttl
	 * @return mixed
	 */
	public function count($filter = NULL, $ttl = 0)
	{
		$filter = $this->queryParser->prepareFilter($filter, $this->dbsType);
		return $this->mapper->count($filter, $ttl);
	}

	/**
	 * Count records that are currently loaded
	 * @return int
	 */
	public function loaded() {
		return count($this->mapper->query);
	}

	/**
	 * add a virtual field that counts occurring relations
	 * @param $key
	 */
	public function countRel($key) {
		if (isset($this->fieldConf[$key])){
			// one-to-one, one-to-many
			if ($this->fieldConf[$key]['relType'] == 'belongs-to-one') {
				if ($this->dbsType == 'sql') {
					$this->set('count_'.$key,'count('.$key.')');
					$this->grp_stack=(!$this->grp_stack)?$key:$this->grp_stack.','.$key;
				} elseif ($this->dbsType == 'mongo')
					$this->_mongo_addGroup(array(
						'keys'=>array($key=>1),
						'reduce' => 'prev.count_'.$key.'++;',
						"initial" => array("count_".$key => 0)
					));
				else
					trigger_error('Cannot add direct relational counter.');
			} elseif($this->fieldConf[$key]['relType'] == 'has-many') {
				$relConf=$this->fieldConf[$key]['has-many'];
				if ($this->fieldConf[$key]['has-many']['hasRel']=='has-many') {
					// many-to-many
					if ($this->dbsType == 'sql') {
						if (!isset($relConf['refTable'])) {
							// compute mm table name
							$mmTable = isset($relConf[2]) ? $relConf[2] :
								static::getMMTableName($relConf['relTable'],
									$relConf['relField'], $this->getTable(), $key);
							$this->fieldConf[$key]['has-many']['refTable'] = $mmTable;
						} else
							$mmTable = $relConf['refTable'];
						$this->set('count_'.$key,'(select count('.$relConf['relField'].') from '.$mmTable.' where '.
							$mmTable.'.'.$relConf['relField'].' = '.$this->getTable().'.'.$this->primary.
							' group by '.$mmTable.'.'.$relConf['relField'].')');
					} else {
						// count rel
						$this->countFields[]=$key;
					}
				} elseif($this->fieldConf[$key]['has-many']['hasRel']=='belongs-to-one') {
					// many-to-one
					if ($this->dbsType == 'sql') {
						$fConf=$relConf[0]::resolveConfiguration();
						$fTable=$this->db->quotekey($fConf['table']);
						$fKey=$this->db->quotekey($fConf['primary']);
						$rKey=$this->db->quotekey($relConf[1]);
						$pKey=$this->db->quotekey($this->primary);
						$table=$this->db->quotekey($this->getTable());
						$this->set('count_'.$key,'(select count('.$fTable.'.'.$fKey.') from '.$fTable.' where '.
							$fTable.'.'.$rKey.' = '.$table.'.'.$pKey.' group by '.$fTable.'.'.$rKey.')');
					} else {
						// count rel
						$this->countFields[]=$key;
					}
				}
			}
		}
	}

	protected function _mongo_addGroup($opt){
		if (!$this->grp_stack)
			$this->grp_stack = array('keys'=>array(),'initial'=>array(),'reduce'=>'','finalize'=>'');
		if (isset($opt['keys']))
			$this->grp_stack['keys']+=$opt['keys'];
		if (isset($opt['reduce']))
			$this->grp_stack['reduce'].=$opt['reduce'];
		if (isset($opt['initial']))
			$this->grp_stack['initial']+=$opt['initial'];
		if (isset($opt['finalize']))
			$this->grp_stack['finalize'].=$opt['finalize'];
	}

	/**
	 * Bind value to key
	 * @return mixed
	 * @param $key string
	 * @param $val mixed
	 */
	function set($key, $val)
	{
		$fields = $this->fieldConf;
		unset($this->fieldsCache[$key]);
		// pre-process if field config available
		if (!empty($fields) && isset($fields[$key]) && is_array($fields[$key])) {
			// handle relations
			if (isset($fields[$key]['belongs-to-one'])) {
				// one-to-many, one-to-one
				if (is_null($val))
					$val = NULL;
				elseif (is_object($val) &&
					!($this->dbsType=='mongo' && $val instanceof \MongoId)) {
					// fetch fkey from mapper
					if (!$val instanceof Cortex || $val->dry())
						trigger_error(self::E_INVALID_RELATION_OBJECT);
					else {
						$relConf = $fields[$key]['belongs-to-one'];
						$rel_field = (is_array($relConf) ? $relConf[1] : '_id');
						$val = $val->get($rel_field,true);
					}
				} elseif ($this->dbsType == 'mongo' && !$val instanceof \MongoId)
					$val = new \MongoId($val);
			} elseif (isset($fields[$key]['has-one'])){
				$relConf = $fields[$key]['has-one'];
				if (is_null($val)) {
					$val = $this->get($key);
					$val->set($relConf[1],NULL);
				} else {
					if (!$val instanceof Cortex) {
						$rel = $this->getRelInstance($relConf[0],null,$key);
						$rel->load(array('_id = ?', $val));
						$val = $rel;
					}
					$val->set($relConf[1], $this->_id);
				}
				$this->saveCsd[$key] = $val;
				return $val;
			} elseif (isset($fields[$key]['belongs-to-many'])) {
				// many-to-many, unidirectional
				$fields[$key]['type'] = self::DT_JSON;
				$relConf = $fields[$key]['belongs-to-many'];
				$rel_field = (is_array($relConf) ? $relConf[1] : '_id');
				$val = $this->getForeignKeysArray($val, $rel_field, $key);
			}
			elseif (isset($fields[$key]['has-many'])) {
				// many-to-many, bidirectional
				$relConf = $fields[$key]['has-many'];
				if ($relConf['hasRel'] == 'has-many') {
					// custom setter
					$val = $this->emit('set', $key, $val);
					$val = $this->getForeignKeysArray($val,'_id',$key);
					$this->saveCsd[$key] = $val; // array of keys
					return $val;
				} elseif ($relConf['hasRel'] == 'belongs-to-one') {
					// TODO: many-to-one, bidirectional, inverse way
					trigger_error("not implemented");
				}
			}
			// convert array content
			if (is_array($val) && $this->dbsType == 'sql' && !empty($fields))
				if ($fields[$key]['type'] == self::DT_SERIALIZED)
					$val = serialize($val);
				elseif ($fields[$key]['type'] == self::DT_JSON)
					$val = json_encode($val);
				else
					trigger_error(sprintf(self::E_ARRAY_DATATYPE, $key));
			// add nullable polyfill
			if ($val === NULL && ($this->dbsType == 'jig' || $this->dbsType == 'mongo')
				&& !empty($fields) && array_key_exists('nullable', $fields[$key])
				&& $fields[$key]['nullable'] === false)
				trigger_error(sprintf(self::E_NULLABLE_COLLISION,$key));
			// MongoId shorthand
			if ($this->dbsType == 'mongo' && $key == '_id' && !$val instanceof \MongoId)
				$val = new \MongoId($val);
		}
		// fluid SQL
		if ($this->fluid && $this->dbsType == 'sql') {
			$schema = new Schema($this->db);
			$table = $schema->alterTable($this->table);
			// add missing field
			if(!in_array($key,$table->getCols())) {
				// determine data type
				if (is_int($val)) $type = $schema::DT_INT;
				elseif (is_double($val)) $type = $schema::DT_DOUBLE;
				elseif (is_float($val)) $type = $schema::DT_FLOAT;
				elseif (is_bool($val)) $type = $schema::DT_BOOLEAN;
				elseif (date('Y-m-d H:i:s', strtotime($val)) == $val) $type = $schema::DT_DATETIME;
				elseif (date('Y-m-d', strtotime($val)) == $val) $type = $schema::DT_DATE;
				elseif (\UTF::instance()->strlen($val)<=255) $type = $schema::DT_VARCHAR256;
				else $type = $schema::DT_TEXT;
				$table->addColumn($key)->type($type);
				$table->build();
				// update mapper fields
				$newField = $table->getCols(true);
				$newField = $newField[$key];
				$refl = new \ReflectionObject($this->mapper);
				$prop = $refl->getProperty('fields');
				$prop->setAccessible(true);
				$fields = $prop->getValue($this->mapper);
				$fields[$key] = $newField + array('value'=>NULL,'changed'=>NULL);
				$prop->setValue($this->mapper,$fields);
			}
		}
		// custom setter
		$val = $this->emit('set', $key, $val);
		return $this->mapper->set($key, $val);
	}

	/**
	 * call custom field handlers
	 * @param $type
	 * @param $key
	 * @param $val
	 * @return mixed
	 */
	protected function emit($type, $key, $val)
	{
		if ($type == 'set' || $type == 'get') {
			$event = $type.'_'.$key;
			if (in_array($event, $this->mFuncs))
				$val = call_user_func(array($this,$event),$val);
			elseif (method_exists($this,$event)) {
				$this->mFuncs[] = $event;
				$val = call_user_func(array($this,$event),$val);
			}
		}
		return $val;
	}

	/**
	 * virtual mapper field setter
	 * @param string $key
	 * @param mixed|callback $val
	 * @return mixed|null
	 */
	public function virtual($key,$val) {
		$this->vFields[$key]=$val;
	}

	/**
	 * Retrieve contents of key
	 * @return mixed
	 * @param string $key
	 * @param bool $raw
	 */
	function get($key,$raw = false)
	{
		// handle virtual fields
		if (isset($this->vFields[$key]))
			return (is_callable($this->vFields[$key]))
				? call_user_func($this->vFields[$key],$this) : $this->vFields[$key];
		$fields = $this->fieldConf;
		$id = ($this->dbsType == 'sql') ? $this->primary : '_id';
		if ($key == '_id' && $this->dbsType == 'sql')
			$key = $id;
		if ($this->whitelist && !in_array($key,$this->whitelist))
			return null;
		if ($raw)
			return $this->exists($key) ? $this->mapper->{$key} : NULL;
		if (!empty($fields) && isset($fields[$key]) && is_array($fields[$key])
			&& !array_key_exists($key,$this->fieldsCache)) {
			// load relations
			if (isset($fields[$key]['belongs-to-one'])) {
				// one-to-X, bidirectional, direct way
				if (!$this->exists($key) || is_null($this->mapper->{$key}))
					$this->fieldsCache[$key] = null;
				else {
					// get config for this field
					$relConf = $fields[$key]['belongs-to-one'];
					if (!is_array($relConf))
						$relConf = array($relConf, '_id');
					// fetch related model
					$rel = $this->getRelInstance($relConf[0],null,$key);
					if ($this->dbsType == 'sql' && $relConf[1] == '_id')
						$relConf[1] = $rel->primary;
					// am i part of a result collection?
					if ($this->collectionID && $this->smartLoading) {
						$cx = CortexCollection::instance($this->collectionID);
						// does the collection has cached results for this key?
						if (!$cx->hasRelSet($key)) {
							// build the cache, find all values of current key
							$relKeys = array_unique($cx->getAll($key,true));
							// find related models
							$crit = array($relConf[1].' IN ?', $relKeys);
							$relSet = $rel->find($this->mergeWithRelFilter($key, $crit),
								$this->getRelFilterOption($key));
							// cache relSet, sorted by ID
							$cx->setRelSet($key, $relSet ? $relSet->getBy($relConf[1]) : NULL);
						}
						// get a subset of the preloaded set
						$result = $cx->getSubset($key,(string) $this->get($key,true));
						$this->fieldsCache[$key] = $result ? $result[0] : NULL;
					} else {
						$crit = array($relConf[1].' = ?', $this->get($key, true));
						$crit = $this->mergeWithRelFilter($key, $crit);
						$this->fieldsCache[$key] = $rel->findone($crit,$this->getRelFilterOption($key));
					}
				}
			}
			elseif (($type = isset($fields[$key]['has-one']))
				|| isset($fields[$key]['has-many'])) {
				$type = $type ? 'has-one' : 'has-many';
				$fromConf = $fields[$key][$type];
				if (!is_array($fromConf))
					trigger_error(sprintf(self::E_REL_CONF_INC, $key));
				$rel = $this->getRelInstance($fromConf[0],null,$key);
				$relFieldConf = $rel->getFieldConfiguration();
				$relType = key($relFieldConf[$fromConf[1]]);
				// one-to-*, bidirectional, inverse way
				if ($relType == 'belongs-to-one') {
					$toConf = $relFieldConf[$fromConf[1]]['belongs-to-one'];
					if(!is_array($toConf))
						$toConf = array($toConf, $id);
					if ($toConf[1] != $id && (!$this->exists($toConf[1])
							|| is_null($this->mapper->get($toConf[1]))))
						$this->fieldsCache[$key] = null;
					elseif($this->collectionID && $this->smartLoading) {
						// part of a result set
						$cx = CortexCollection::instance($this->collectionID);
						if(!$cx->hasRelSet($key)) {
							// emit eager loading
							$relKeys = $cx->getAll($toConf[1],true);
							$crit = array($fromConf[1].' IN ?', $relKeys);
							$relSet = $rel->find($this->mergeWithRelFilter($key,$crit),
								$this->getRelFilterOption($key));
							$cx->setRelSet($key, $relSet ? $relSet->getBy($fromConf[1],true) : NULL);
						}
						$result = $cx->getSubset($key, array($this->get($toConf[1])));
						$this->fieldsCache[$key] = $result ? (($type == 'has-one')
							? $result[0][0] : $result[0]) : NULL;
					} else {
						$crit = array($fromConf[1].' = ?', $this->get($toConf[1],true));
						$crit = $this->mergeWithRelFilter($key, $crit);
						$opt = $this->getRelFilterOption($key);
						$this->fieldsCache[$key] = (($type == 'has-one') ? $rel->findone($crit,$opt)
							: $rel->find($crit,$opt)) ?: NULL;
					}
				}
				// many-to-many, bidirectional
				elseif ($relType == 'has-many') {
					if (!array_key_exists('refTable', $fromConf)) {
						// compute mm table name
						$toConf = $relFieldConf[$fromConf[1]]['has-many'];
						$mmTable = isset($fromConf[2]) ? $fromConf[2] :
							static::getMMTableName($fromConf['relTable'],
							$fromConf['relField'], $this->getTable(), $key, $toConf);
						$this->fieldConf[$key]['has-many']['refTable'] = $mmTable;
					} else
						$mmTable = $fromConf['refTable'];
					// create mm table mapper
					$rel = $this->getRelInstance(null,array('db'=>$this->db,'table'=>$mmTable));
					if ($this->collectionID && $this->smartLoading) {
						$cx = CortexCollection::instance($this->collectionID);
						if (!$cx->hasRelSet($key)) {
							// get IDs of all results
							$relKeys = $cx->getAll($id,true);
							// get all pivot IDs
							$mmRes = $rel->find(array($fromConf['relField'].' IN ?', $relKeys));
							if (!$mmRes)
								$cx->setRelSet($key, NULL);
							else {
								foreach($mmRes as $model) {
									$val = $model->get($key,true);
									$pivotRel[ (string) $model->get($fromConf['relField'])][] = $val;
									$pivotKeys[] = $val;
								}
								// cache pivot keys
								$cx->setRelSet($key.'_pivot', $pivotRel);
								// preload all rels
								$pivotKeys = array_unique($pivotKeys);
								$fRel = $this->getRelInstance($fromConf[0],null,$key);
								$crit = array(($fRel->primary!='id' ? $fRel->primary : '_id').' IN ?', $pivotKeys);
								$relSet = $fRel->find($this->mergeWithRelFilter($key, $crit),
									$this->getRelFilterOption($key));
								$cx->setRelSet($key, $relSet ? $relSet->getBy($id) : NULL);
								unset($fRel);
							}
						}
						// fetch subset from preloaded rels using cached pivot keys
						$fkeys = $cx->getSubset($key.'_pivot', array($this->get($id)));
						$this->fieldsCache[$key] = $fkeys ? $cx->getSubset($key, $fkeys[0]) : NULL;
					} // no collection
					else {
						// find foreign keys
						$results = $rel->find(
							array($fromConf['relField'].' = ?', $this->get($id,true)));
						if(!$results)
							$this->fieldsCache[$key] = NULL;
						else {
							$fkeys = $results->getAll($key,true);
							// create foreign table mapper
							unset($rel);
							$rel = $this->getRelInstance($fromConf[0],null,$key);
							// load foreign models
							$filter = array(($rel->primary!='id' ? $rel->primary : '_id').' IN ?', $fkeys);
							$filter = $this->mergeWithRelFilter($key, $filter);
							$this->fieldsCache[$key] = $rel->find($filter,$this->getRelFilterOption($key));
						}
					}
				}
			}
			elseif (isset($fields[$key]['belongs-to-many'])) {
				// many-to-many, unidirectional
				$fields[$key]['type'] = self::DT_JSON;
				$result = !$this->exists($key) ? null :$this->mapper->get($key);
				if ($this->dbsType == 'sql')
					$result = json_decode($result, true);
				if (!is_array($result))
					$this->fieldsCache[$key] = $result;
				else {
					// create foreign table mapper
					$relConf = $fields[$key]['belongs-to-many'];
					if (!is_array($relConf))
						$relConf = array($relConf, '_id');
					$rel = $this->getRelInstance($relConf[0],null,$key);
					if ($this->dbsType == 'sql' && $relConf[1] == '_id')
						$relConf[1] = $rel->primary;
					$fkeys = array();
					foreach ($result as $el)
						$fkeys[] = (string) $el;
					// if part of a result set
					if ($this->collectionID && $this->smartLoading) {
						$cx = CortexCollection::instance($this->collectionID);
						if (!$cx->hasRelSet($key)) {
							// find all keys
							$relKeys = ($cx->getAll($key,true));
							if ($this->dbsType == 'sql'){
								foreach ($relKeys as &$val) {
									$val = substr($val, 1, -1);
									unset($val);
								}
								$relKeys = json_decode('['.implode(',',$relKeys).']');
							} else
								$relKeys = call_user_func_array('array_merge', $relKeys);
							// get related models
							$crit = array($relConf[1].' IN ?', array_unique($relKeys));
							$relSet = $rel->find($this->mergeWithRelFilter($key, $crit),
								$this->getRelFilterOption($key));
							// cache relSet, sorted by ID
							$cx->setRelSet($key, $relSet ? $relSet->getBy($relConf[1]) : NULL);
						}
						// get a subset of the preloaded set
						$this->fieldsCache[$key] = $cx->getSubset($key, $fkeys);
					} else {
						// load foreign models
						$filter = array($relConf[1].' IN ?', $fkeys);
						$filter = $this->mergeWithRelFilter($key, $filter);
						$this->fieldsCache[$key] = $rel->find($filter,$this->getRelFilterOption($key));
					}
				}
			}
			// resolve array fields
			elseif ($this->dbsType == 'sql' && isset($fields[$key]['type'])) {
				if ($fields[$key]['type'] == self::DT_SERIALIZED)
					$this->fieldsCache[$key] = unserialize($this->mapper->{$key});
				elseif ($fields[$key]['type'] == self::DT_JSON)
					$this->fieldsCache[$key] = json_decode($this->mapper->{$key},true);
			}
		}
		// fetch cached value, if existing
		$val = array_key_exists($key,$this->fieldsCache) ? $this->fieldsCache[$key]
			: (($this->exists($key) || $key == $id) ? $this->mapper->{$key} : null);
		if ($this->dbsType == 'mongo' && $val instanceof \MongoId) {
			// conversion to string makes further processing in template, etc. much easier
			$val = (string) $val;
		}
		// custom getter
		return $this->emit('get', $key, $val);
	}

	/**
	 * find the ID values of given relation collection
	 * @param $val string|array|object|bool
	 * @param $rel_field string
	 * @param $key string
	 * @return array|Cortex|null|object
	 */
	protected function getForeignKeysArray($val, $rel_field, $key)
	{
		if (is_null($val))
			return NULL;
		if (is_object($val) && $val instanceof CortexCollection)
			$val = $val->expose();
		elseif (is_string($val))
			// split-able string of collection IDs
			$val = \Base::instance()->split($val);
		elseif (!is_array($val) && !(is_object($val)
				&& ($val instanceof Cortex && !$val->dry())))
			trigger_error(sprintf(self::E_MM_REL_VALUE, $key));
		// hydrated mapper as collection
		if (is_object($val)) {
			$nval = array();
			while (!$val->dry()) {
				$nval[] = $val->get($rel_field,true);
				$val->next();
			}
			$val = $nval;
		}
		elseif (is_array($val)) {
			// array of single hydrated mappers, raw ID value or mixed
			$isMongo = ($this->dbsType == 'mongo');
			foreach ($val as &$item) {
				if (is_object($item) &&
					!($isMongo && $item instanceof \MongoId)) {
					if (!$item instanceof Cortex || $item->dry())
						trigger_error(self::E_INVALID_RELATION_OBJECT);
					else $item = $item->get($rel_field,true);
				}
				if ($isMongo && $rel_field == '_id' && is_string($item))
					$item = new \MongoId($item);
				if (is_numeric($item))
					$item = (int) $item;
				unset($item);
			}
		}
		return $val;
	}

	/**
	 * creates and caches related mapper objects
	 * @param string $model
	 * @param array $relConf
	 * @param string $key
	 * @return Cortex
	 */
	protected function getRelInstance($model=null,$relConf=null,$key='')
	{
		if (!$model && !$relConf)
			trigger_error(self::E_MISSING_REL_CONF);
		$relConf = $model ? $model::resolveConfiguration() : $relConf;
		$relName = ($model?:'Cortex').'\\'.$relConf['db']->uuid().'\\'.$relConf['table'];
		if (\Registry::exists($relName)) {
			$rel = \Registry::get($relName);
			$rel->reset();
		} else {
			$rel = $model ? new $model : new Cortex($relConf['db'], $relConf['table']);
			if (!$rel instanceof Cortex)
				trigger_error(self::E_WRONG_RELATION_CLASS);
			\Registry::set($relName, $rel);
		}
		// restrict fields of related mapper
		if(!empty($key) && isset($this->relWhitelist[$key])) {
			if (isset($this->relWhitelist[$key][0]))
				$rel->fields($this->relWhitelist[$key][0],false);
			if (isset($this->relWhitelist[$key][1]))
				$rel->fields($this->relWhitelist[$key][1],true);
		}
		return $rel;
	}

	/**
	 * Return fields of mapper object as an associative array
	 * @return array
	 * @param bool|Cortex $obj
	 * @param bool|int $rel_depths depths to resolve relations
	 */
	public function cast($obj = NULL, $rel_depths = 1)
	{
		$fields = $this->mapper->cast( ($obj) ? $obj->mapper : null );
		if (!empty($this->vFields))
			foreach(array_keys($this->vFields) as $key)
				$fields[$key]=$this->get($key);
		if (is_int($rel_depths))
			$rel_depths--;
		if (!empty($this->fieldConf)) {
			$fields += array_fill_keys(array_keys($this->fieldConf),NULL);
			if($this->whitelist)
				$fields = array_intersect_key($fields, array_flip($this->whitelist));
			$mp = $obj ? : $this;
			foreach ($fields as $key => &$val) {
				//reset relType
				unset($relType);
				// post process configured fields
				if (isset($this->fieldConf[$key]) && is_array($this->fieldConf[$key])) {
					// handle relations
					if (($rel_depths === TRUE || (is_int($rel_depths) && $rel_depths >= 0))) {
						$relTypes = array('belongs-to-one','has-many','belongs-to-many','has-one');
						foreach ($relTypes as $type)
							if (isset($this->fieldConf[$key][$type])) {
								$relType = $type;
								break;
							}
						if (isset($relType)) {
							// cast relations
							$val = (($relType == 'belongs-to-one' || $relType == 'belongs-to-many')
								&& !$mp->exists($key)) ? NULL : $mp->get($key);
							if (is_array($val) || is_object($val)) {
								if ($relType == 'belongs-to-one' || $relType == 'has-one')
									// single object
									$val = $val->cast(null, $rel_depths);
								elseif ($relType == 'belongs-to-many' || $relType == 'has-many')
									// multiple objects
									foreach ($val as $k => $item)
										$val[$k] = !is_null($item) ? $item->cast(null, $rel_depths) : null;
							}
						}
						if ($val instanceof CortexCollection)
							$val = $val->expose();
					}
					// decode array fields
					elseif ($this->dbsType == 'sql' && isset($this->fieldConf[$key]['type']))
						if ($this->fieldConf[$key]['type'] == self::DT_SERIALIZED)
							$val=unserialize($this->mapper->{$key});
						elseif ($this->fieldConf[$key]['type'] == self::DT_JSON)
							$val=json_decode($this->mapper->{$key}, true);
				}
				if ($this->dbsType == 'mongo' && $key == '_id')
					$val = (string) $val;
				if ($this->dbsType == 'sql' && $key == 'id' && $this->standardiseID) {
					$fields['_id'] = $val;
					unset($fields[$key]);
				}
				unset($val);
			}
		}
		// custom getter
		foreach ($fields as $key => &$val) {
			$val = $this->emit('get', $key, $val);
			unset($val);
		}
		return $fields;
	}

	/**
	 * cast a related collection of mappers
	 * @param string|array $key  array of mapper objects, or field name
	 * @param int $rel_depths  depths to resolve relations
	 * @return array    array of associative arrays
	 */
	function castField($key, $rel_depths=0)
	{
		if (!$key)
			return NULL;
		$mapper_arr = $this->get($key);
		if(!$mapper_arr)
			return NULL;
		$out = array();
		foreach ($mapper_arr as $mp)
			$out[] = $mp->cast(null,$rel_depths);
		return $out;
	}

	/**
	 * wrap result mapper
	 * @param Cursor|array $mapper
	 * @return Cortex
	 */
	protected function factory($mapper)
	{
		if (is_array($mapper)) {
			$mp = clone($this->mapper);
			$mp->reset();
			$cx = $this->factory($mp);
			$cx->copyfrom($mapper);
		} else {
			$cx = clone($this);
			$cx->reset(false);
			$cx->mapper = $mapper;
		}
		return $cx;
	}

	public function dry() {
		return $this->mapper->dry();
	}

	/**
	 * hydrate the mapper from hive key or given array
	 * @param string|array $key
	 * @param callback|array|string $fields
	 * @return NULL
	 */
	public function copyfrom($key, $fields = null)
	{
		$f3 = \Base::instance();
		$srcfields = is_array($key) ? $key : $f3->get($key);
		if ($fields)
			if (is_callable($fields))
				$srcfields = $fields($srcfields);
			else {
				if (is_string($fields))
					$fields = $f3->split($fields);
				$srcfields = array_intersect_key($srcfields, array_flip($fields));
			}
		foreach ($srcfields as $key => $val) {
			if (isset($this->fieldConf[$key]) && isset($this->fieldConf[$key]['type'])) {
				if ($this->fieldConf[$key]['type'] == self::DT_JSON && is_string($val))
					$val = json_decode($val);
				elseif ($this->fieldConf[$key]['type'] == self::DT_SERIALIZED && is_string($val))
					$val = unserialize($val);
			}
			$this->set($key, $val);
		}
	}

	/**
	 * copy mapper values into hive key
	 * @param string $key the hive key to copy into
	 * @param int $relDepth the depth of relations to resolve
	 * @return NULL|void
	 */
	public function copyto($key, $relDepth=0) {
		\Base::instance()->set($key, $this->cast(null,$relDepth));
	}

	public function skip($ofs = 1)
	{
		$this->reset(false);
		if($this->mapper->skip($ofs))
			return $this;
		else
			$this->reset(false);
	}

	public function first()
	{
		$this->reset(false);
		$this->mapper->first();
		return $this;
	}

	public function last()
	{
		$this->reset(false);
		$this->mapper->last();
		return $this;
	}

	/**
	 * reset and re-initialize the mapper
	 * @param bool $mapper
	 * @return NULL|void
	 */
	public function reset($mapper = true)
	{
		if ($mapper)
			$this->mapper->reset();
		$this->fieldsCache=array();
		$this->saveCsd=array();
		$this->countFields=array();
		$this->grp_stack=null;
		// set default values
		if (($this->dbsType == 'jig' || $this->dbsType == 'mongo')
			&& !empty($this->fieldConf))
			foreach($this->fieldConf as $field_key => $field_conf)
				if (array_key_exists('default',$field_conf)) {
					$val = ($field_conf['default'] === \DB\SQL\Schema::DF_CURRENT_TIMESTAMP)
						? date('Y-m-d H:i:s') : $field_conf['default'];
					$this->set($field_key, $val);
				}
	}

	/**
	 * check if a certain field exists in the mapper or
	 * or is a virtual relation field
	 * @param string $key
	 * @param bool $relField
	 * @return bool
	 */
	function exists($key, $relField = false) {
		if ($key == '_id') return true;
		return $this->mapper->exists($key) || ($relField && isset($this->fieldConf[$key]['relType']));
	}

	/**
	 * clear any mapper field or relation
	 * @param string $key
	 * @return NULL|void
	 */
	function clear($key) {
		unset($this->fieldsCache[$key]);
		if (isset($this->fieldConf[$key]['relType']))
			$this->set($key,null);
		$this->mapper->clear($key);
	}

	function insert() {
		$res = $this->mapper->insert();
		if (is_array($res))
			$res = $this->mapper;
		if (is_object($res))
			$res = $this->factory($res);
		return is_int($res) ? $this : $res;
	}

	function update() {
		$res = $this->mapper->update();
		if (is_array($res))
			$res = $this->mapper;
		if (is_object($res))
			$res = $this->factory($res);
		return is_int($res) ? $this : $res;
	}

	function dbtype() {
		return $this->mapper->dbtype();
	}

	/**
	 * cleanup on destruct
	 */
	public function __destruct() {
		unset($this->mapper);
	}

	public function __clone() {
		$this->mapper = clone($this->mapper);
	}
}


class CortexQueryParser extends \Prefab {

	const
		E_BRACKETS = 'Invalid query: unbalanced brackets found',
		E_INBINDVALUE = 'Bind value for IN operator must be a populated array',
		E_ENGINEERROR = 'Engine not supported',
		E_MISSINGBINDKEY = 'Named bind parameter `%s` does not exist in filter arguments';

	protected
		$queryCache = array();

	/**
	 * converts the given filter array to fit the used DBS
	 *
	 * example filter:
	 *   array('text = ? AND num = ?','bar',5)
	 *   array('num > ? AND num2 <= ?',5,10)
	 *   array('num1 > num2')
	 *   array('text like ?','%foo%')
	 *   array('(text like ? OR text like ?) AND num != ?','foo%','%bar',23)
	 *
	 * @param array $cond
	 * @param string $engine
	 * @param null $fieldConf
	 * @return array|bool|null
	 */
	public function prepareFilter($cond, $engine,$fieldConf=null)
	{
		if (is_null($cond)) return $cond;
		if (is_string($cond))
			$cond = array($cond);
		$f3 = \Base::instance();
		$cacheHash = $f3->hash($f3->stringify($cond)).'.'.$engine;
		if (isset($this->queryCache[$cacheHash]))
			// load from memory
			return $this->queryCache[$cacheHash];
		elseif ($f3->exists('CORTEX.queryParserCache')
			&& ($ttl = (int) $f3->get('CORTEX.queryParserCache'))) {
			$cache = \Cache::instance();
			// load from cache
			if ($f3->get('CACHE') && $ttl && ($cached = $cache->exists($cacheHash, $ncond))
				&& $cached[0] + $ttl > microtime(TRUE)) {
				$this->queryCache[$cacheHash] = $ncond;
				return $ncond;
			}
		}
		$where = array_shift($cond);
		$args = $cond;
		$where = str_replace(array('&&', '||'), array('AND', 'OR'), $where);
		// prepare IN condition
		$where = preg_replace('/\bIN\b\s*\(\s*(\?|:\w+)?\s*\)/i', 'IN $1', $where);
		switch ($engine) {
			case 'jig':
				$ncond = $this->_jig_parse_filter($where, $args);
				break;
			case 'mongo':
				$parts = $this->splitLogical($where);
				if (is_int(strpos($where, ':')))
					list($parts, $args) = $this->convertNamedParams($parts, $args);
				foreach ($parts as &$part) {
					$part = $this->_mongo_parse_relational_op($part, $args, $fieldConf);
					unset($part);
				}
				$ncond = $this->_mongo_parse_logical_op($parts);
				break;
			case 'sql':
				// preserve identifier
				$where = preg_replace('/(?!\B)_id/', 'id', $where);
				$parts = $this->splitLogical($where);
				// ensure positional bind params
				if (is_int(strpos($where, ':')))
					list($parts, $args) = $this->convertNamedParams($parts, $args);
				$ncond = array();
				foreach ($parts as &$part) {
					// enhanced IN handling
					if (is_int(strpos($part, '?'))) {
						$val = array_shift($args);
						if (is_int($pos = strpos($part, 'IN ?'))) {
							if (!is_array($val) || empty($val))
								trigger_error(self::E_INBINDVALUE);
							$bindMarks = str_repeat('?,', count($val) - 1).'?';
							$part = substr($part, 0, $pos).'IN ('.$bindMarks.')';
							$ncond = array_merge($ncond, $val);
						} else
							$ncond[] = $val;
					}
				}
				array_unshift($ncond, implode(' ', $parts));
				break;
			default:
				trigger_error(self::E_ENGINEERROR);
		}
		$this->queryCache[$cacheHash] = $ncond;
		if(isset($ttl) && $f3->get('CACHE')) {
			// save to cache
			$cache = \Cache::instance();
			$cache->set($cacheHash,$ncond,$ttl);
		}
		return $ncond;
	}

	/**
	 * split where criteria string into logical chunks
	 * @param $cond
	 * @return array
	 */
	protected function splitLogical($cond)
	{
		return preg_split('/\s*((?<!\()\)|\((?!\))|\bAND\b|\bOR\b)\s*/i', $cond, -1,
			PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
	}

	/**
	 * converts named parameter filter to positional
	 * @param $parts
	 * @param $args
	 * @return array
	 */
	protected function convertNamedParams($parts, $args)
	{
		if (empty($args)) return array($parts, $args);
		$params = array();
		$pos = 0;
		foreach ($parts as &$part) {
			if (preg_match('/:\w+/i', $part, $match)) {
				if (!isset($args[$match[0]]))
					trigger_error(sprintf(self::E_MISSINGBINDKEY,
						$match[0]));
				$part = str_replace($match[0], '?', $part);
				$params[] = $args[$match[0]];
			} elseif (is_int(strpos($part, '?')))
				$params[] = $args[$pos++];
		}
		return array($parts, $params);
	}

	/**
	 * convert filter array to jig syntax
	 * @param $where
	 * @param $args
	 * @return array
	 */
	protected function _jig_parse_filter($where, $args)
	{
		$parts = $this->splitLogical($where);
		if (is_int(strpos($where, ':')))
			list($parts, $args) = $this->convertNamedParams($parts, $args);
		$ncond = array();
		foreach ($parts as &$part) {
			if (in_array(strtoupper($part), array('AND', 'OR')))
				continue;
			// prefix field names
			$part = preg_replace('/([a-z_-]+)/i', '@$1', $part, -1, $count);
			// value comparison
			if (is_int(strpos($part, '?'))) {
				$val = array_shift($args);
				preg_match('/(@\w+)/i', $part, $match);
				// find like operator
				if (is_int(strpos($upart = strtoupper($part), ' @LIKE '))) {
					if ($not = is_int($npos = strpos($upart, '@NOT')))
						$pos = $npos;
					$val = $this->_likeValueToRegEx($val);
					$part = ($not ? '!' : '').'preg_match(?,'.$match[0].')';
				} // find IN operator
				else if (is_int($pos = strpos($upart, ' @IN '))) {
					if ($not = is_int($npos = strpos($upart, '@NOT')))
						$pos = $npos;
					$part = ($not ? '!' : '').'in_array('.substr($part, 0, $pos).
						',array(\''.implode('\',\'', $val).'\'))';
					unset($val);
				}
				// add existence check
				$part = '(isset('.$match[0].') && '.$part.')';
				if (isset($val))
					$ncond[] = $val;
			} elseif ($count >= 1) {
				// field comparison
				preg_match_all('/(@\w+)/i', $part, $matches);
				$chks = array();
				foreach ($matches[0] as $field)
					$chks[] = 'isset('.$field.')';
				$part = '('.implode(' && ',$chks).' && ('.$part.'))';
			}
		}
		array_unshift($ncond, implode(' ', $parts));
		return $ncond;
	}

	/**
	 * find and wrap logical operators AND, OR, (, )
	 * @param $parts
	 * @return array
	 */
	protected function _mongo_parse_logical_op($parts)
	{
		$b_offset = 0;
		$ncond = array();
		$child = array();
		for ($i = 0, $max = count($parts); $i < $max; $i++) {
			$part = $parts[$i];
			if ($part == '(') {
				// add sub-bracket to parse array
				if ($b_offset > 0)
					$child[] = $part;
				$b_offset++;
			} elseif ($part == ')') {
				$b_offset--;
				// found closing bracket
				if ($b_offset == 0) {
					$ncond[] = ($this->_mongo_parse_logical_op($child));
					$child = array();
				} elseif ($b_offset < 0)
					trigger_error(self::E_BRACKETS);
				else
					// add sub-bracket to parse array
					$child[] = $part;
			} // add to parse array
			elseif ($b_offset > 0)
				$child[] = $part;
			// condition type
			elseif (!is_array($part)) {
				if (strtoupper($part) == 'AND')
					$add = true;
				elseif (strtoupper($part) == 'OR')
					$or = true;
			} else // skip
				$ncond[] = $part;
		}
		if ($b_offset > 0)
			trigger_error(self::E_BRACKETS);
		if (isset($add))
			return array('$and' => $ncond);
		elseif (isset($or))
			return array('$or' => $ncond); else
			return $ncond[0];
	}

	/**
	 * find and convert relational operators
	 * @param $part
	 * @param $args
	 * @param null $fieldConf
	 * @return array|null
	 */
	protected function _mongo_parse_relational_op($part, &$args, $fieldConf=null)
	{
		if (is_null($part))
			return $part;
		if (preg_match('/\<\=|\>\=|\<\>|\<|\>|\!\=|\=\=|\=|like|not like|in|not in/i', $part, $match)) {
			$var = is_int(strpos($part, '?')) ? array_shift($args) : null;
			$exp = explode($match[0], $part);
			$key = trim($exp[0]);
			// unbound value
			if (is_numeric($exp[1]))
				$var = $exp[1];
			// field comparison
			elseif (!is_int(strpos($exp[1], '?')))
				return array('$where' => 'this.'.$key.' '.$match[0].' this.'.trim($exp[1]));
			$upart = strtoupper($match[0]);
			// MongoID shorthand
			if ($key == '_id' || (isset($fieldConf[$key]) && isset($fieldConf[$key]['relType']))) {
				if (is_array($var))
					foreach ($var as &$id) {
						if (!$id instanceof \MongoId)
							$id = new \MongoId($id);
						unset($id);
					}
				elseif(!$var instanceof \MongoId)
					$var = new \MongoId($var);
			}
			// find LIKE operator
			if (in_array($upart, array('LIKE','NOT LIKE'))) {
				$rgx = $this->_likeValueToRegEx($var);
				$var = new \MongoRegex($rgx);
				if ($upart == 'NOT LIKE')
					$var = array('$not' => $var);
			} // find IN operator
			elseif (in_array($upart, array('IN','NOT IN'))) {
				$var = array(($upart=='NOT IN')?'$nin':'$in' => $var);
			} // translate operators
			elseif (!in_array($match[0], array('==', '='))) {
				$opr = str_replace(array('<>', '<', '>', '!', '='),
					array('$ne', '$lt', '$gt', '$n', 'e'), $match[0]);
				$var = array($opr => (strtolower($var) == 'null') ? null :
						(is_object($var) ? $var : (is_numeric($var) ? $var + 0 : $var)));
			}
			return array($key => $var);
		}
		return $part;
	}

	/**
	 * @param string $var
	 * @return string
	 */
	protected function _likeValueToRegEx($var)
	{
		$lC = substr($var, -1, 1);
		// %var% -> /var/
		if ($var[0] == '%' && $lC == '%')
			$var = '/'.substr($var, 1, -1).'/';
		// var%  -> /^var/
		elseif ($lC == '%')
			$var = '/^'.substr($var, 0, -1).'/';
		// %var  -> /var$/
		elseif ($var[0] == '%')
			$var = '/'.substr($var, 1).'$/';
		return $var;
	}

	/**
	 * convert options array syntax to given engine type
	 *
	 * example:
	 *   array('order'=>'location') // default direction is ASC
	 *   array('order'=>'num1 desc, num2 asc')
	 *
	 * @param array $options
	 * @param string $engine
	 * @return array|null
	 */
	public function prepareOptions($options, $engine)
	{
		if (empty($options) || !is_array($options))
			return null;
		switch ($engine) {
			case 'jig':
				if (array_key_exists('order', $options))
					$options['order'] = str_replace(array('asc', 'desc'),
						array('SORT_ASC', 'SORT_DESC'), strtolower($options['order']));
				break;
			case 'mongo':
				if (array_key_exists('order', $options)) {
					$sorts = explode(',', $options['order']);
					$sorting = array();
					foreach ($sorts as $sort) {
						$sp = explode(' ', trim($sort));
						$sorting[$sp[0]] = (array_key_exists(1, $sp) &&
							strtoupper($sp[1]) == 'DESC') ? -1 : 1;
					}
					$options['order'] = $sorting;
				}
				if (array_key_exists('group', $options) && is_string($options['group'])) {
					$keys = explode(',',$options['group']);
					$options['group']=array('keys'=>array(),'initial'=>array(),'reduce'=>'function (obj, prev) {}','finalize'=>'');
					$keys = array_combine($keys,array_fill(0,count($keys),1));
					$options['group']['keys']=$keys;
					$options['group']['initial']=$keys;
				}
				break;
		}
		return $options;
	}
}

class CortexCollection extends \ArrayIterator {

	protected
		$relSets = array(),
		$pointer = 0,
		$cid;

	const
		E_UnknownCID = 'This Collection does not exist: %s',
		E_SubsetKeysValue = '$keys must be an array or split-able string, but %s was given.';

	public function __construct() {
		$this->cid = uniqid('cortex_collection_');
		\Registry::set($this->cid,$this);
		parent::__construct();
	}

	public function __destruct() {
		// free embedded relation cache from memory
		\Registry::clear($this->cid);
	}

	//! Prohibit cloning to ensure an existing relation cache
	private function __clone() { }

	/**
	 * set a collection of models
	 * @param $models
	 */
	function setModels($models) {
		array_map(array($this,'add'),$models);
	}

	/**
	 * add single model to collection
	 * @param $model
	 */
	function add(Cortex $model)
	{
		$model->addToCollection($this->cid);
		$this->append($model);
	}

	/**
	 * get a related collection
	 * @param $key
	 * @return null
	 */
	public function getRelSet($key) {
		return (isset($this->relSets[$key])) ? $this->relSets[$key] : null;
	}

	/**
	 * set a related collection for caching it for the lifetime of this collection
	 * @param $key
	 * @param $set
	 */
	public function setRelSet($key,$set) {
		$this->relSets[$key] = $set;
	}

	/**
	 * check if a related collection exists in runtime cache
	 * @param $key
	 * @return bool
	 */
	public function hasRelSet($key) {
		return array_key_exists($key,$this->relSets);
	}

	public function expose() {
		return $this->getArrayCopy();
	}

	/**
	 * get an intersection from a cached relation-set, based on given keys
	 * @param string $prop
	 * @param array|string $keys
	 * @return array
	 */
	public function getSubset($prop,$keys) {
		if (is_string($keys))
			$keys = \Base::instance()->split($keys);
		if (!is_array($keys))
			trigger_error(sprintf(self::E_SubsetKeysValue,gettype($keys)));
		if (!$this->hasRelSet($prop) || !($relSet = $this->getRelSet($prop)))
			return null;
		foreach ($keys as &$key)
			if ($key instanceof \MongoId)
				$key = (string) $key;
		return array_values(array_intersect_key($relSet, array_flip($keys)));
	}

	/**
	 * returns all values of a specified property from all models
	 * @param string $prop
	 * @param bool $raw
	 * @return array
	 */
	public function getAll($prop, $raw = false)
	{
		$out = array();
		foreach ($this->getArrayCopy() as $model)
			if ($model->exists($prop,true)) {
				$val = $model->get($prop, $raw);
				if (!empty($val))
					$out[] = $val;
			}
		return $out;
	}

	/**
	 * cast all contained mappers to a nested array
	 * @param int $rel_depths depths to resolve relations
	 * @return array
	 */
	public function castAll($rel_depths=1) {
		$out = array();
		foreach ($this->getArrayCopy() as $model)
			$out[] = $model->cast(null,$rel_depths);
		return $out;
	}

	/**
	 * return all models keyed by a specified index key
	 * @param string $index
	 * @param bool $nested
	 * @return array
	 */
	public function getBy($index, $nested = false)
	{
		$out = array();
		foreach ($this->getArrayCopy() as $model)
			if ($model->exists($index)) {
				$val = $model->get($index, true);
				if (!empty($val))
					if($nested) $out[(string) $val][] = $model;
					else        $out[(string) $val] = $model;
			}
		return $out;
	}

	/**
	 * re-assort the current collection using a sql-like syntax
	 * @param $cond
	 */
	public function orderBy($cond){
		$cols=\Base::instance()->split($cond);
		$this->uasort(function($val1,$val2) use($cols) {
			foreach ($cols as $col) {
				$parts=explode(' ',$col,2);
				$order=empty($parts[1])?'ASC':$parts[1];
				$col=$parts[0];
				list($v1,$v2)=array($val1[$col],$val2[$col]);
				if ($out=strnatcmp($v1,$v2)*
					((strtoupper($order)=='ASC')*2-1))
					return $out;
			}
			return 0;
		});
	}

	/**
	 * slice the collection
	 * @param $offset
	 * @param null $limit
	 */
	public function slice($offset,$limit=null) {
		$this->rewind();
		$i=0;
		$del=array();
		while ($this->valid()) {
			if ($i < $offset)
				$del[]=$this->key();
			elseif ($i >= $offset && $limit && $i >= ($offset+$limit))
				$del[]=$this->key();
			$i++;
			$this->next();
		}
		foreach ($del as $ii)
			unset($this[$ii]);
	}

	/**
	 * @param $cid
	 * @return CortexCollection
	 */
	static public function instance($cid) {
		if (!\Registry::exists($cid))
			trigger_error(sprintf(self::E_UnknownCID, $cid));
		return \Registry::get($cid);
	}

}