<?php
/**
 * Implementation of a Relational SQL-backed Model.
 *
 * SQL_Model allows you to take advantage of relational SQL database without neglecting
 * powerful functionality of your RDBMS. On top of basic load/save/delete operations, you can
 * pefrorm multi-row operations, traverse relations, or use SQL expressions
 *
 * The $table property of SQL_Model always contains the primary table. The $this->id will
 * always correspond with ID field of that table and when inserting record will always be
 * placed inside primary table first.
 *
 * Use:
 * class Model_User extends SQL_Model {
 *     public $table='user';
 *     function init(){
 *         parent::init();
 *         $this->addField('name');
 *         $this->addField('email');
 *     }
 * }
 */
class SQL_Model extends Model implements Serializable
{
    /**
    * Master DSQL record which will be cloned by other operations.
    * For low level use only. Use $this->dsql() when in doubt.
    *
    * @var DB_dsql
    */
    protected $dsql;

    /**
     * Default Field class name
     *
     * @var string
     */
    public $field_class = 'Field';

    /**
     * If you wish that alias is used for the table when selected, you can define it here.
     * This will help to keep SQL syntax shorter, but will not impact functionality.
     *
     * @var string
     */
    public $table_alias = null;

    /**
     * @deprecated 4.3.0 Use $table instead
     */
    public $entity_code = null;

    /**
     * Joins
     *
     * @var array
     */
    public $relations = array();

    /**
     * Call $model->debug(true|false) to turn on|off debug mode
     *
     * @var bool
     */
    public $debug = false;

    /**
     * Set to use different database connection
     *
     * @var DB
     */
    public $db = null;

    /**
     * Set this to true to speed up model, but sacrifice some of the consistency
     *
     * @var bool
     */
    public $fast = null;

    /**
     * False: finished iterating. True, reset not yet fetched. Object=DSQL
     *
     * @var bool|DB_dsql
     */
    protected $_iterating = false;



    // {{{ Basic Functionality, query initialization and actual field handling

    /** Initialization of ID field, which must always be defined */
    public function __construct($options = array())
    {
        // for compatibility
        if ($this->entity_code) {
            $this->table = $this->entity_code;
            unset($this->entity_code);
        }

        parent::__construct($options);
    }
    /**
     * {@inheritdoc}
     */
    public function init()
    {
        parent::init();

        if (!$this->db) {
            $this->db = $this->app->db;
        }

        if ($this->owner instanceof Field_Reference && !empty($this->owner->owner->relations)) {
            $this->relations = &$this->owner->owner->relations;
        }
    }

    /**
     * Adds field to model
     *
     * @param string $name
     * @param string $actual_field
     *
     * @return Field
     */
    public function addField($name, $actual_field = null)
    {
        if ($this->hasElement($name)) {
            if ($name == $this->id_field) {
                return $this->getElement($name);
            }
            throw $this->exception('Field with this name is already defined')
            ->addMoreInfo('field', $name);
        }
        if ($name == 'deleted' && isset($this->app->compat)) {
            /** @type Field_Deleted $f */
            $f = $this->add('Field_Deleted', $name);
            return $f->enum(array('Y', 'N'));
        }

        // $f=parent::addField($name);
        /** @type Field $f */
        $f = $this->add($this->field_class, $name);
        //

        if (!is_null($actual_field)) {
            $f->actual($actual_field);
        }

        return $f;
    }

    /** exception() will automatically add information about current model and will allow to turn on "debug" mode */
    public function exception()
    {
        return call_user_func_array(array('parent', __FUNCTION__), func_get_args())
            ->addThis($this)
            ;
    }
    /** Initializes base query for this model.
     * @link http://agiletoolkit.org/doc/modeltable/dsql */
    public function initQuery()
    {
        if (!$this->table) {
            throw $this->exception('$table property must be defined');
        }
        $this->dsql = $this->db->dsql();
        $this->dsql->debug($this->debug);
        $this->dsql->table($this->table, $this->table_alias);
        $this->dsql->default_field = $this->dsql->expr('*,'.
            $this->dsql->bt($this->table_alias ?: $this->table).'.'.
            $this->dsql->bt($this->id_field))
            ;
        $this->dsql->id_field = $this->id_field;

        return $this;
    }

    /**
     * Use this instead of accessing dsql directly.
     * This will initialize $dsql property if it does not exist yet.
     *
     * @return DB_dsql
     */
    public function _dsql()
    {
        if (!$this->dsql) {
            $this->initQuery();
        }

        return $this->dsql;
    }

    /**
     * Clone DSQL
     */
    public function __clone()
    {
        if (is_object($this->dsql)) {
            $this->dsql = clone $this->dsql;
        }
    }

    /**
     * Produces a clone of Dynamic SQL object configured with table, conditions and joins of this model.
     * Use for statements you are going to execute manually.
     *
     * @return DB_dsql
     */
    public function dsql()
    {
        return clone $this->_dsql();
    }

    /**
     * Turns debugging mode on|off for this model. All database operations will be outputed.
     *
     * @param bool $enabled
     *
     * @return $this
     */
    public function debug($enabled = true)
    {
        if ($enabled === true) {
            $this->debug = $enabled;
            if ($this->dsql) {
                $this->dsql->debug($enabled);
            }
        } else {
            parent::debug($enabled);
        }

        return $this;
    }

    /**
     * Completes initialization of dsql() by adding fields and expressions.
     *
     * @param array $fields
     *
     * @return DB_dsql
     */
    public function selectQuery($fields = null)
    {
        /**/$this->app->pr->start('selectQuery/getActualF');

        $actual_fields = $fields ?: $this->getActualFields();

        if ($this->fast && $this->_selectQuery) {
            return $this->_selectQuery();
        }

        $this->_selectQuery = $select = $this->_dsql()->del('fields');

        /**/$this->app->pr->next('selectQuery/addSystemFields');
        // add system fields into select
        foreach ($this->elements as $el) {
            if ($el instanceof Field) {
                if ($el->system() && !in_array($el->short_name, $actual_fields)) {
                    $actual_fields[] = $el->short_name;
                }
            }
        }
        /**/$this->app->pr->next('selectQuery/updateQuery');

        // add actual fields
        foreach ($actual_fields as $field) {
            /** @type Field $field */
            $field = $this->hasElement($field);
            if (!$field) {
                continue;
            }

            $field->updateSelectQuery($select);
        }
        /**/$this->app->pr->stop();

        return $select;
    }
    /** Return query for a specific field. All other fields are ommitted. */
    public function fieldQuery($field)
    {
        $query = $this->dsql()->del('fields');
        if (is_string($field)) {
            $field = $this->getElement($field);
        }
        $field->updateSelectQuery($query);

        return $query;
    }
    /** Returns query which selects title field */
    public function titleQuery()
    {
        $query = $this->dsql()->del('fields');
        /** @type Field $el */
        $el = $this->hasElement($this->title_field);
        if ($this->title_field && $el) {
            $el->updateSelectQuery($query);

            return $query;
        }

        return $query->field($query->concat('Record #', $this->getElement($this->id_field)));
    }
    // }}}

    // {{{ SQL_Model supports more than just fields. Expressions, References and Joins can be added

    /**
     * Adds and returns SQL-calculated expression as a read-only field.
     *
     * See Field_Expression class.
     *
     * @param string $name
     * @param mixed $expression
     *
     * @return DB_dsql
     */
    public function addExpression($name, $expression = null)
    {
        /** @type Field_Expression $f */
        $f = $this->add('Field_Expression', $name);

        return $f->set($expression);
    }

    /**
     * Constructs model from multiple tables.
     * Queries will join tables, inserts, updates and deletes will be applied on both tables
     */
    public function join(
        $foreign_table,
        $master_field = null,
        $join_kind = null,
        $_foreign_alias = null,
        $relation = null
    ) {
        if (!$_foreign_alias) {
            $_foreign_alias = '_'.$foreign_table[0];
        }
        $_foreign_alias = $this->_unique($this->relations, $_foreign_alias);

        /** @type SQL_Relation $rel */
        $rel = $this->add('SQL_Relation', $_foreign_alias);

        return $this->relations[$_foreign_alias] = $rel
            ->set($foreign_table, $master_field, $join_kind, $relation);
    }

    /**
     * Creates weak join between tables.
     * The foreign table may be absent and will not be automatically deleted.
     */
    public function leftJoin(
        $foreign_table,
        $master_field = null,
        $join_kind = null,
        $_foreign_alias = null,
        $relation = null
    ) {
        if (!$join_kind) {
            $join_kind = 'left';
        }
        $res = $this->join($foreign_table, $master_field, $join_kind, $_foreign_alias, $relation);
        $res->delete_behaviour = 'ignore';

        return $res;
    }
    /** Defines one to many association */
    public function hasOne($model, $our_field = null, $display_field = null, $as_field = null)
    {

        // register reference, but don't create any fields there
        // parent::hasOne($model,null);
        // model, our_field
        $this->_references[null] = $model;

        if (!$our_field) {
            if (!is_object($model)) {
                $tmp = $this->app->normalizeClassName($model, 'Model');
                $tmp = new $tmp(); // avoid recursion
            } else {
                $tmp = $model;
            }
            $our_field = ($tmp->table).'_id';
        }

        /** @type Field_Reference $r */
        $r = $this->add('Field_Reference', array('name' => $our_field, 'dereferenced_field' => $as_field));
        $r->setModel($model, $display_field);
        $r->system(true)->editable(true);

        return $r;
    }
    /** Defines many to one association */
    public function hasMany($model, $their_field = null, $our_field = null, $as_field = null)
    {
        if (!$our_field) {
            $our_field = $this->id_field;
        }
        if (!$their_field) {
            $their_field = ($this->table).'_id';
        }
        /** @type SQL_Many $rel */
        $rel = $this->add('SQL_Many', $as_field ?: $model);
        $rel->set($model, $their_field, $our_field);

        return $rel;
    }
    /** Defines contained model for field */
    public function containsOne($field, $model)
    {
        if (is_array($field) && $field[0]) {
            $field['name'] = $field[0];
            unset($field[0]);
        }
        if ($e = $this->hasElement(is_string($field) ? $field : $field['name'])) {
            $e->destroy();
        }
        $this->add('Relation_ContainsOne', $field)
            ->setModel($model);
    }
    /** Defines multiple contained models for field */
    public function containsMany($field, $model)
    {
        if (is_array($field) && $field[0]) {
            $field['name'] = $field[0];
            unset($field[0]);
        }
        if ($e = $this->hasElement(is_string($field) ? $field : $field['name'])) {
            $e->destroy();
        }
        $this->add('Relation_ContainsMany', $field)
            ->setModel($model);
    }
    /** Traverses references. Use field name for hasOne() relations. Use model name for hasMany() */
    public function ref($name, $load = null)
    {
        if (!$name) {
            return $this;
        }

        /** @type Field $field */
        $field = $this->getElement($name);

        return $field->ref($load);
    }
    /** Returns Model with SQL join usable for subqueries. */
    public function refSQL($name, $load = null)
    {
        /** @type Field_Reference $ref */
        $ref = $this->getElement($name);

        return $ref->refSQL($load);
    }
    /** @obsolete - return model referenced by a field. Use model name for one-to-many relations */
    public function getRef($name, $load = null)
    {
        return $this->ref($name, $load);
    }
    /**
     * Adds "WHERE" condition / conditions in underlying DSQL.
     *
     * It tries to be smart about where and how the field is defined.
     *
     * $field can be passed as:
     *      - string (field name in this model)
     *      - Field object
     *      - DSQL expression
     *      - array (see note below)
     *
     * $cond can be passed as:
     *      - string ('=', '>', '<=', etc.)
     *      - value can be passed here, then it's used as $value with condition '='
     *
     * $value can be passed as:
     *      - string, integer, boolean or any other simple data type
     *      - Field object
     *      - DSQL expreession
     *
     * NOTE: $field can be passed as array of conditions. Then all conditions
     *      will be joined with `OR` using DSQLs orExpr method.
     *      For example,
     *          $model->addCondition(array(
     *              array('profit', '=', null),
     *              array('profit', '<', 1000),
     *          ));
     *      will generate "WHERE profit is null OR profit < 1000"
     *
     * EXAMPLES:
     * you can pass [dsql, dsql, dsql ...] and this will be treated
     * as (dsql OR dsql OR dsql) ...
     *
     * you can pass [[field,cond,value], [field,cond,value], ...] and this will
     * be treated as (field=value OR field=value OR ...)
     *
     * BTW, you can mix these too :)
     * [[field,cond,value], dsql, [field,cond,value], ...]
     * will become (field=value OR dsql OR field=value)
     *
     * Value also can be DSQL expression, so following will work nicely:
     * [dsql,'>',dsql] will become (dsql > dsql)
     * [dsql, dsql] will become (dsql = dsql)
     * [field, cond, dsql] will become (field = dsql)
     *
     * @todo Romans: refactor using parent::conditions (through array)
     *
     * @param mixed   $field Field for comparing or array of conditions
     * @param mixed   $cond  Condition
     * @param mixed   $value Value for comparing
     * @param DB_dsql $dsql  DSQL object to which conditions will be added
     *
     * @return $this
     */
    public function addCondition($field, $cond = UNDEFINED, $value = UNDEFINED, $dsql = null)
    {
        // by default add condition to models DSQL
        if (!$dsql) {
            $dsql = $this->_dsql();
        }

        // if array passed, then create multiple conditions joined with OR
        if (is_array($field)) {
            $or = $this->dsql()->orExpr();

            foreach ($field as $row) {
                if (!is_array($row)) {
                    $row = array($row);
                }
                // add each condition to OR expression (not models DSQL)
                $f = $row[0];
                $c = array_key_exists(1, $row) ? $row[1] : UNDEFINED;
                $v = array_key_exists(2, $row) ? $row[2] : UNDEFINED;

                // recursively calls addCondition method, but adds conditions
                // to OR expression not models DSQL object
                $this->addCondition($f, $c, $v, $or);
            }

            // pass generated DSQL expression as "field"
            $field = $or;
            $cond = $value = UNDEFINED;
        }

        // You may pass DSQL expression as a first argument
        if ($field instanceof DB_dsql) {
            $dsql->where($field, $cond, $value);

            return $this;
        }

        // value should be specified
        if ($cond === UNDEFINED && $value === UNDEFINED) {
            throw $this->exception('Incorrect condition. Please specify value');
        }

        // get model field object
        if (!$field instanceof Field) {
            $field = $this->getElement($field);
        }

        /** @type Field $field */

        if ($cond !== UNDEFINED && $value === UNDEFINED) {
            $value = $cond;
            $cond = '=';
        }
        if ($field->type() == 'boolean') {
            $value = $field->getBooleanValue($value);
        }

        if ($cond === '=' && !is_array($value)) {
            $field->defaultValue($value)->system(true)->editable(false);
        }

        $f = $field->actual_field ?: $field->short_name;

        if ($field instanceof Field_Expression) {
            // TODO: should we use expression in where?

            $dsql->where($field->getExpr(), $cond, $value);
            //$dsql->having($f, $cond, $value);
            //$field->updateSelectQuery($this->dsql);
        } elseif ($field->relation) {
            $dsql->where($field->relation->short_name.'.'.$f, $cond, $value);
        } elseif (!empty($this->relations)) {
            $dsql->where(($this->table_alias ?: $this->table).'.'.$f, $cond, $value);
        } else {
            $dsql->where(($this->table_alias ?: $this->table).'.'.$f, $cond, $value);
        }

        return $this;
    }
    /** Sets limit on query */
    public function setLimit($count, $offset = null)
    {
        $this->_dsql()->limit($count, $offset);

        return $this;
    }
    /** Sets an order on the field. Field must be properly defined */
    public function setOrder($field, $desc = null)
    {
        if (!$field instanceof Field) {
            if (is_object($field)) {
                $this->_dsql()->order($field, $desc);

                return $this;
            }

            if (is_string($field) && strpos($field, ',') !== false) {
                $field = explode(',', $field);
            }
            if (is_array($field)) {
                if (!is_null($desc)) {
                    throw $this->exception('If first argument is array, second argument must not be used');
                }

                foreach (array_reverse($field) as $o) {
                    $this->setOrder($o);
                }

                return $this;
            }

            if (is_null($desc) && is_string($field) && strpos($field, ' ') !== false) {
                list($field, $desc) = array_map('trim', explode(' ', trim($field), 2));
            }

            /** @type Field $field */
            $field = $this->getElement($field);
        }

        $this->_dsql()->order($field, $desc);

        return $this;
    }
    /** @deprecated use two-argument addCondition. Always keep $field equals to $value for queries and new data */
    public function setMasterField($field, $value)
    {
        return $this->addCondition($field, $value);
    }
    // }}}

    // {{{ Iterator support
    public function rewind()
    {
        $this->_iterating = true;
    }
    public function _preexec()
    {
        $this->_iterating = $this->selectQuery();
        $this->hook('beforeLoad', array($this->_iterating));

        return $this->_iterating;
    }
    public function next()
    {
        if ($this->_iterating === true) {
            $this->_iterating = $this->selectQuery();
            $this->_iterating->stmt = null;
            $this->_iterating->rewind();
            $this->hook('beforeLoad', array($this->_iterating));
        }
        $this->_iterating->next();
        $this->data = $this->_iterating->current();

        if ($this->data === false) {
            $this->unload();
            $this->_iterating = false;

            return;
        }

        $this->id = @$this->data[$this->id_field];
        $this->dirty = array();

        $this->hook('afterLoad');
    }
    public function current()
    {
        return $this;
    }
    public function key()
    {
        return $this->id;
    }
    public function valid()
    {
        /*
        if(!$this->_iterating){
            $this->next();
            $this->_iterating=$this->selectQuery();
        }
        */
        if ($this->_iterating === true) {
            $this->next();
        }

        return $this->loaded();
    }

    // }}}

    // {{{ Multiple ways to load data by a model

    /** Loads all matching data into array of hashes */
    public function getRows($fields = null)
    {
        /**/$this->app->pr->start('getRows/selecting');
        $a = $this->selectQuery($fields);
        /**/$this->app->pr->next('getRows/fetching');
        $a = $a->get();
        $this->app->pr->stop();

        return $a;
    }
    /**
     * Returns dynamic query selecting number of entries in the database.
     *
     * @param string $alias Optional alias of count expression
     *
     * @return DB_dsql
     */
    public function count($alias = null)
    {
        // prepare new query
        $q = $this->dsql()->del('fields')->del('order');

        // add expression field to query
        return $q->field($q->count(), $alias);
    }
    /**
     * Returns dynamic query selecting sum of particular field or fields.
     *
     * @param string|array|Field $field
     *
     * @return DB_dsql
     */
    public function sum($field)
    {
        // prepare new query
        $q = $this->dsql()->del('fields')->del('order');

        // put field in array if it's not already
        if (!is_array($field)) {
            $field = array($field);
        }

        // add all fields to query
        foreach ($field as $f) {
            if (!is_object($f)) {
                $f = $this->getElement($f);
            }
            $q->field($q->sum($f), $f->short_name);
        }

        // return query
        return $q;
    }
    /** @obsolete same as loaded() - returns if any record is currently loaded. */
    public function isInstanceLoaded()
    {
        return $this->loaded();
    }
    /** Loads the first matching record from the model */
    public function loadAny()
    {
        try {
            return $this->_load(null);
        } catch (Exception_NoRecord $e) {
            throw $this->exception('No matching records found', null, 404);
        }
    }
    /** Try to load a matching record for the model. Will not raise exception if no records are found */
    public function tryLoadAny()
    {
        return $this->_load(null, true);
    }
    /** Loads random entry into model */
    public function tryLoadRandom()
    {
        // get ID first
        $id = $this->dsql()->order('rand()')->limit(1)->field($this->getElement($this->id_field))->getOne();
        if ($id) {
            $this->load($id);
        }

        return $this;
    }
    public function loadRandom()
    {
        $this->tryLoadRandom();
        if (!$this->loaded()) {
            throw $this->exception('Unable to load random entry');
        }

        return $this;
    }
    /** Try to load a record by specified ID. Will not raise exception if record is not found */
    public function tryLoad($id)
    {
        if (is_null($id)) {
            throw $this->exception('Record ID must be specified, otherwise use loadAny()');
        }

        return $this->_load($id, true);
    }
    /** Loads record specified by ID. */
    public function load($id)
    {
        if (is_null($id)) {
            throw $this->exception('Record ID must be specified, otherwise use loadAny()');
        }

        return $this->_load($id);
    }
    /**
     * Similar to loadAny() but will apply condition before loading.
     * Condition is temporary. Fails if record is not loaded.
     */
    public function loadBy($field, $cond = UNDEFINED, $value = UNDEFINED)
    {
        $q = $this->dsql;
        $this->dsql = $this->dsql();
        $this->addCondition($field, $cond, $value);
        $this->loadAny();
        $this->dsql = $q;

        return $this;
    }
    /** Attempt to load using a specified condition, but will not fail if such record is not found */
    public function tryLoadBy($field, $cond = UNDEFINED, $value = UNDEFINED)
    {
        $q = $this->dsql;
        $this->dsql = $this->dsql();
        $this->addCondition($field, $cond, $value);
        $this->tryLoadAny();
        $this->dsql = $q;

        return $this;
    }
    /** Loads data record and return array of that data. Will not affect currently loaded record. */
    public function getBy($field, $cond = UNDEFINED, $value = UNDEFINED)
    {
        $data = $this->data;
        $id = $this->id;

        $this->tryLoadBy($field, $cond, $value);
        $row = $this->data;

        $this->data = $data;
        $this->id = $id;

        return $row;
    }
    /** Internal loading funciton. Do not use. OK to override. */
    protected function _load($id, $ignore_missing = false)
    {
        /**/$this->app->pr->start('load/selectQuery');
        $this->unload();
        $load = $this->selectQuery();
        /**/$this->app->pr->next('load/clone');
        $p = '';
        if (!empty($this->relations)) {
            $p = ($this->table_alias ?: $this->table).'.';
        }
        /**/$this->app->pr->next('load/where');
        if (!is_null($id)) {
            $load->where($p.$this->id_field, $id);
        }

        /**/$this->app->pr->next('load/beforeLoad');
        $this->hook('beforeLoad', array($load, $id));

        if (!$this->loaded()) {
            /**/$this->app->pr->next('load/get');
            $s = $load->stmt;
            $l = $load->args['limit'];
            $load->stmt = null;
            $data = $load->limit(1)->getHash();
            $load->stmt = $s;
            $load->args['limit'] = $l;

            if (!is_null($id)) {
                array_pop($load->args['where']);
            }    // remove where condition
            /**/$this->app->pr->next('load/ending');
            $this->reset();

            if (@!$data) {
                if ($ignore_missing) {
                    return $this;
                } else {
                    throw $this->exception('Record could not be loaded', 'Exception_NoRecord')
                    ->addMoreInfo('model', $this)
                    ->addMoreInfo('id', $id);
                }
            }

            $this->data = $data;  // avoid using set() for speed and to avoid field checks
            $this->id = $this->data[$this->id_field];
        }

        $this->hook('afterLoad');
        /**/$this->app->pr->stop();

        return $this;
    }
    /**
     * @deprecated 4.3.3 Backward-compatible. Will attempt to load but will not fail
     */
    public function loadData($id = null)
    {
        if ($id) {
            $this->tryLoad($id);
        }

        return $this;
    }
    // }}}

    // {{{ Saving Data
    /** Save model into database and don't try to load it back */
    public function saveAndUnload()
    {
        $this->_save_as = false;
        $this->save();
        $this->_save_as = null;

        return $this;
    }
    /**
     * Save model into database and try to load it back as a new model of specified class.
     * Instance of new class is returned.
     */
    public function saveAs($model)
    {
        if (is_string($model)) {
            $model = $this->app->normalizeClassName($model, 'Model');
            $model = $this->add($model);
        }
        $this->_save_as = $model;
        $res = $this->save();
        $this->_save_as = null;

        return $res;
    }
    /**
     * Save model into database and load it back.
     * If for some reason it won't load, whole operation is undone.
     */
    public function save()
    {
        $this->_dsql()->owner->beginTransaction();
        $this->hook('beforeSave');

        // decide, insert or modify
        if ($this->loaded()) {
            $res = $this->modify();
        } else {
            $res = $this->insert();
        }

        $res->hook('afterSave');
        $this->_dsql()->owner->commit();

        return $res;
    }
    /**
     * Internal function which performs insert of data. Use save() instead. OK to override.
     *  Will return new object if saveAs() is used.
     */
    private function insert()
    {
        $insert = $this->dsql();

        // Performs the actual database changes. Throw exception if problem occurs
        foreach ($this->elements as $name => $f) {
            if ($f instanceof Field) {
                if (!$f->editable() && !$f->system()) {
                    continue;
                }
                if (!isset($this->dirty[$name]) && $f->defaultValue() === null) {
                    continue;
                }

                $f->updateInsertQuery($insert);
            }
        }
        $this->hook('beforeInsert', array(&$insert));
        //delayed is not supported by INNODB, but what's worse - it shows error.
        //if($this->_save_as===false)$insert->option_insert('delayed');
        $id = $insert->insert();
        if ($id == 0) {
            // no auto-increment column present
            $id = $this->get($this->id_field);

            if ($id === null && $this->_save_as !== false) {
                throw $this->exception('Please add auto-increment ID column to your table or specify ID manually');
            }
        }
        $res = $this->hook('afterInsert', array($id));
        if ($res === false) {
            return $this;
        }

        if ($this->_save_as === false) {
            return $this->unload();
        }
        if ($this->_save_as) {
            $this->unload();
        }
        $o = $this->_save_as ?: $this;

        if ($this->fast && !$this->_save_as) {
            $this[$this->id_field] = $this->id = $id;

            return $this;
        }
        $res = $o->tryLoad($id);
        if (!$res->loaded()) {
            throw $this->exception('Saved model did not match conditions. Save aborted.');
        }

        return $res;
    }
    /**
     * Internal function which performs modification of existing data. Use save() instead. OK to override.
     * Will return new object if saveAs() is used.
     */
    private function modify()
    {
        $modify = $this->dsql()->del('where');
        $modify->where($this->getElement($this->id_field), $this->id);

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

        foreach ($this->dirty as $name => $junk) {
            if ($el = $this->hasElement($name)) {
                if ($el instanceof Field) {
                    $el->updateModifyQuery($modify);
                }
            }
        }

        // Performs the actual database changes. Throw exceptions if problem occurs
        $this->hook('beforeModify', array($modify));
        if ($modify->args['set']) {
            $modify->update();
        }

        if ($this->dirty[$this->id_field]) {
            $this->id = $this->get($this->id_field);
        }

        $this->hook('afterModify');

        if ($this->_save_as === false) {
            return $this->unload();
        }
        $id = $this->id;
        if ($this->_save_as) {
            $this->unload();
        }
        $o = $this->_save_as ?: $this;

        return $o->load($id);
    }
    /**
     * @deprecated 4.3.1 Use set() then save().
     */
    public function update($data = array())
    {
        if (!empty($data)) {
            $this->set($data);
        }

        return $this->save();
    }

    // }}}

    // {{{ Unloading and Deleting data

    /** forget currently loaded record and it's ID. Will not affect database */
    public function unload()
    {
        if ($this->_save_later) {
            $this->_save_later = false;
            $this->saveAndUnload();
        }
        $this->hook('beforeUnload');
        $this->id = null;
        // parent::unload();

        if ($this->_save_later) {
            $this->_save_later = false;
            $this->saveAndUnload();
        }
        if ($this->loaded()) {
            $this->hook('beforeUnload');
        }
        $this->data = $this->dirty = array();
        $this->id = null;
        $this->hook('afterUnload');
        //

        $this->hook('afterUnload');

        return $this;
    }
    /** Tries to delete record, but does nothing if not found */
    public function tryDelete($id = null)
    {
        if (!is_null($id)) {
            $this->tryLoad($id);
        }
        if ($this->loaded()) {
            $this->delete();
        }

        return $this;
    }
    /** Deletes record matching the ID */
    public function delete($id = null)
    {
        if (!is_null($id)) {
            $this->load($id);
        }
        if (!$this->loaded()) {
            throw $this->exception('Unable to determine which record to delete');
        }

        $tmp = $this->dsql;

        $this->initQuery();
        $delete = $this->dsql->where($this->id_field, $this->id);

        $delete->owner->beginTransaction();
        $this->hook('beforeDelete', array($delete));
        $delete->delete();
        $this->hook('afterDelete');
        $delete->owner->commit();

        $this->dsql = $tmp;
        $this->unload();

        return $this;
    }
    /** Deletes all records matching this model. Use with caution. */
    public function deleteAll()
    {
        $delete = $this->dsql();
        $delete->owner->beginTransaction();
        $this->hook('beforeDeleteAll', array($delete));
        $delete->delete();
        $this->hook('afterDeleteAll');
        $delete->owner->commit();
        $this->reset();

        return $this;
    }

    // }}}

    // Override all methods to keep back-compatible
    public function set($name, $value = UNDEFINED)
    {
        if (is_array($name)) {
            foreach ($name as $key => $val) {
                $this->set($key, $val);
            }

            return $this;
        }
        if ($name === false || $name === null) {
            return $this->reset();
        }

        // Verify if such a field exists
        if ($this->strict_fields && !$this->hasElement($name)) {
            throw $this->exception('No such field', 'Logic')
            ->addMoreInfo('name', $name);
        }

        if ($value !== UNDEFINED
            && (
                is_object($value)
                || is_object($this->data[$name])
                || is_array($value)
                || is_array($this->data[$name])
                || (string) $value != (string) $this->data[$name] // this is not nice..
                || $value !== $this->data[$name] // considers case where value = false and data[$name] = null
                || !isset($this->data[$name]) // considers case where data[$name] is not initialized at all
                                              //    (for example in model using array controller)
            )
        ) {
            $this->data[$name] = $value;
            $this->setDirty($name);
        }

        return $this;
    }

    public function get($name = null)
    {
        if ($name === null) {
            return $this->data;
        }

        /** @type Field $f */
        $f = $this->hasElement($name);

        if ($this->strict_fields && !$f) {
            throw $this->exception('No such field', 'Logic')->addMoreInfo('field', $name);
        }

        // See if we have data for the field
        if (!$this->loaded() && !isset($this->data[$name])) { // && !$this->hasElement($name))

            if ($f && $f->has_default_value) {
                return $f->defaultValue();
            }

            if ($this->strict_fields) {
                throw $this->exception('Model field was not loaded')
                ->addMoreInfo('id', $this->id)
                ->addMoreinfo('field', $name);
            }

            return;
        }

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

    public function getActualFields($group = UNDEFINED)
    {
        if ($group === UNDEFINED && !empty($this->actual_fields)) {
            return $this->actual_fields;
        }

        $fields = array();

        if (strpos($group, ',') !== false) {
            $groups = explode(',', $group);

            foreach ($groups as $group) {
                if ($group[0] == '-') {
                    $el = $this->getActualFields(substr($group, 1));
                    $fields = array_diff($fields, $el);
                } else {
                    $el = $this->getActualFields($group);
                    $fields = array_merge($fields, $el);
                }
            }
        }

        foreach ($this->elements as $el) {
            if ($el instanceof Field && !$el->hidden()) {
                if ($group === UNDEFINED ||
                    $el->group() == $group ||
                    (strtolower($group == 'visible') && $el->visible()) ||
                    (strtolower($group == 'editable') && $el->editable())
                ) {
                    $fields[] = $el->short_name;
                }
            }
        }

        return $fields;
    }

    public function setActualFields(array $fields)
    {
        $this->actual_fields = $fields;

        return $this;
    }

    public function setDirty($name)
    {
        $this->dirty[$name] = true;

        return $this;
    }
    public function isDirty($name)
    {
        /** @type Field $f */
        $f = $this->getElement($name);

        return $this->dirty[$name] ||
            (!$this->loaded() && $f->has_default_value);
    }
    public function reset()
    {
        return $this->unload();
    }

    public function offsetExists($name)
    {
        return (bool) $this->hasElement($name);
    }
    public function offsetGet($name)
    {
        return $this->get($name);
    }
    public function offsetSet($name, $val)
    {
        $this->set($name, $val);
    }
    public function offsetUnset($name)
    {
        unset($this->dirty[$name]);
    }

    public function setSource($controller, $table = null, $id = null)
    {
        if (is_string($controller)) {
            $controller = $this->app->normalizeClassName($controller, 'Data');
        } elseif (!$controller instanceof Controller_Data) {
            throw $this->exception('Inappropriate Controller. Must extend Controller_Data');
        }

        $this->controller = $this->setController($controller);
        /** @type Controller @this->controller */

        $this->controller->setSource($this, $table);

        if ($id) {
            $this->load($id);
        }

        return $this;
    }
    /**
     * @todo This is something wierd. Method addHooks is not defined anywhere in ATK source
     */
    public function addCache($controller, $table = null, $priority = 5)
    {
        $controller = $this->app->normalizeClassName($controller, 'Data');

        return $this->setController($controller)
            ->addHooks($this, $priority)
            ->setSource($this, $table);
    }

    public function each($callable)
    {
        if (!($this instanceof Iterator)) {
            throw $this->exception('Calling each() on non-iterative model');
        }

        if (is_string($callable)) {
            foreach ($this as $value) {
                $this->$callable();
            }

            return $this;
        }

        foreach ($this as $value) {
            if (call_user_func($callable, $this) === false) {
                break;
            }
        }

        return $this;
    }

    public function newField($name)
    {
        return $this->addField($name);
    }
    public function hasField($name)
    {
        return $this->hasElement($name);
    }
    public function getField($f)
    {
        return $this->getElement($f);
    }
    public function _ref($ref, $class, $field, $val)
    {
        /** @type Model $m */
        $m = $this->add($this->app->normalizeClassName($class, 'Model'));
        $m = $m->ref($ref);

        // For one to many relation, create condition, otherwise do nothing,
        // as load will follow
        if ($field) {
            $m->addCondition($field, $val);
        }

        return $m;
    }

    /**
     * Strange method. Uses undefined $field variable, undefined refBind() method etc.
     * https://github.com/atk4/atk4/issues/711
     */
    public function _refBind($field_in, $expression, $field_out = null)
    {
        if ($this->controller) {
            return $this->controller->refBind($this, $field, $expression);
        }

        list($myref, $rest) = explode('/', $ref, 2);

        if (!$this->_references[$myref]) {
            throw $this->exception('No such relation')
            ->addMoreInfo('ref', $myref)
            ->addMoreInfo('rest', $rest);
        }
        // Determine and populate related model

        if (is_array($this->_references[$myref])) {
            $m = $this->_references[$myref][0];
        } else {
            $m = $this->_references[$myref];
        }
        $m = $this->add($m);
        if ($rest) {
            $m = $m->_ref($rest);
        }
        $this->_refGlue();

        if (!isset($this->_references[$ref])) {
            throw $this->exception('Unable to traverse, no reference defined by this name')
            ->addMoreInfo('name', $ref);
        }

        $r = $this->_references[$ref];

        if (is_array($r)) {
            list($m, $our_field, $their_field) = $r;

            if (is_string($m)) {
                $m = $this->add($m);
            } else {
                $m = $m->newInstance();
            }

            return $m->addCondition($their_field, $this[$our_field]);
        }

        if (is_string($m)) {
            $m = $this->add($m);
        } else {
            $m = $m->newInstance();
        }

        return $m->load($this[$our_field]);
    }

    public function serialize()
    {
        return serialize(array(
            'id' => $this->id,
            'data' => $this->data,
        ));
    }

    public function unserialize($data)
    {
        $data = unserialize($data);
        $this->id = $data['id'];
        $this->data = $data['data'];
    }
}
