Skip to content
Snippets Groups Projects
SimpleORMap.class.php 83.4 KiB
Newer Older
        foreach(['has_many', 'belongs_to', 'has_one', 'has_and_belongs_to_many'] as $type) {
            foreach (array_keys($this->{$type}) as $one) {
                $this->relations[$one] = null;
            }
        }
        //begun the clone war has... hmpf
    }

    /**
     * try to determine all needed options for a relationship from
     * configured options
     *
     * @param string $type
     * @param string $name
     * @param array $options
     * @throws Exception if options for thru_table could not be determined
     * @return array
     */
    protected function parseRelationOptions($type, $name, $options) {
        if (!$options['class_name']) {
            throw new Exception('Option class_name not set for relation ' . $name);
        }
        if (!$options['assoc_foreign_key']) {
            if ($type === 'has_many' || $type === 'has_one') {
                $options['assoc_foreign_key'] = $this->pk[0];
            } else if ($type === 'belongs_to') {
                $options['assoc_foreign_key'] = 'id';
            }
        }
        if ($type === 'has_and_belongs_to_many') {
            $thru_table = $options['thru_table'];
            if (!$options['thru_key']) {
                $options['thru_key'] = $this->pk[0];
            }
            if (!$options['thru_assoc_key'] || !$options['assoc_foreign_key']) {
                $class = $options['class_name'];
                $record = new $class();
                $meta = $record->getTableMetadata();
                if (!$options['thru_assoc_key'] ) {
                    $options['thru_assoc_key'] = $meta['pk'][0];
                }
                if (!$options['assoc_foreign_key']) {
                    $options['assoc_foreign_key']= $meta['pk'][0];
                }
            }
            static::tableScheme($thru_table);
            if (is_array(self::$schemes[$thru_table])) {
                $thru_key_ok = isset(self::$schemes[$thru_table]['db_fields'][$options['thru_key']]);
                $thru_assoc_key_ok = isset(self::$schemes[$thru_table]['db_fields'][$options['thru_assoc_key']]);
            }
            if (!$thru_assoc_key_ok || !$thru_key_ok) {
                throw new Exception("Could not determine keys for relation " . $name . " through table " . $thru_table);
            }
            if ($options['assoc_foreign_key'] instanceof Closure) {
                throw new Exception("For relation " . $name . " assoc_foreign_key must be a name of a column");
            }
        }
        if (!$options['assoc_func']) {
            if ($type !== 'has_and_belongs_to_many') {
                $options['assoc_func'] = $options['assoc_foreign_key'] === 'id' ? 'find' : 'findBy' . $options['assoc_foreign_key'];
            } else {
                $options['assoc_func'] = 'findThru';
            }
        }
        if (!$options['foreign_key']) {
            $options['foreign_key'] = 'id';
        }
        if ($options['foreign_key'] instanceof Closure) {
            $options['assoc_func_params_func'] = function($record) use ($name, $options) { return call_user_func($options['foreign_key'], $record, $name, $options);};
        } else {
            $options['assoc_func_params_func'] = function($record) use ($name, $options) { return $options['foreign_key'] === 'id' ? $record->getId() : $record->getValue($options['foreign_key']);};
        }
        if ($options['assoc_foreign_key'] instanceof Closure) {
            if ($type === 'belongs_to') {
                $options['assoc_foreign_key_getter'] = function($record, $that) use ($name, $options) { return call_user_func($options['assoc_foreign_key'], $record, $name, $options, $that);};
            } else {
                $options['assoc_foreign_key_setter'] = function($record, $params) use ($name, $options) { return call_user_func($options['assoc_foreign_key'], $record, $params, $name, $options);};
            }
        } elseif ($options['assoc_foreign_key']) {
            if ($type === 'belongs_to') {
                $options['assoc_foreign_key_getter'] = function($record, $that) use ($name, $options) { return $record->getValue($options['assoc_foreign_key']);};
            } else {
                $options['assoc_foreign_key_setter'] = function($record, $value) use ($name, $options) { return $record->setValue($options['assoc_foreign_key'], $value);};
            }
        } else {
            throw new Exception("Could not determine assoc_foreign_key for relation " . $name);
        }
        return $options;
    }

    /**
     * returns array with option for given relation
     * available options:
     * 'type':                   relation type, on of 'has_many', 'belongs_to', 'has_one', 'has_and_belongs_to_many'
     * 'class_name':             name of class for related records
     * 'foreign_key':            name of column with foreign key
     *                           or callback to retrieve foreign key value
     * 'assoc_foreign_key':      name of foreign key column in related class
     * 'assoc_func':             name of static method to call on related class to find related records
     * 'assoc_func_params_func': callback to retrieve params for assoc_func
     * 'thru_table':             name of relation table for n:m relation
     * 'thru_key':               name of column holding foreign key in relation table
     * 'thru_assoc_key':         name of column holding foreign key from related class in relation table
     * 'on_delete':              contains simply 'delete' to indicate that related records should be deleted
     *                           or callback to invoke before record gets deleted
     * 'on_store':               contains simply 'store' to indicate that related records should be stored
     *                           or callback to invoke after record gets stored
     *
     * @param string $relation name of relation
     * @return array assoc array containing options
     */
    function getRelationOptions($relation)
    {
        $options = [];
        foreach(['has_many', 'belongs_to', 'has_one', 'has_and_belongs_to_many'] as $type) {
            if (isset($this->{$type}[$relation])) {
                $options = self::$config[get_class($this)][$type][$relation] ?: $this->{$type}[$relation];
                if (!isset($options['type'])) {
                    $options = $this->parseRelationOptions($type, $relation, $options, $this->db_table);
                    $options['type'] = $type;
                    self::$config[get_class($this)][$type][$relation] = $options;
                    $this->{$type}[$relation] = $options;
                }
                break;
            }
        }
        return $options;
    }

    /**
     * returns table and columns metadata
     *
     * @return array assoc array with columns, primary keys and name of table
     */
    function getTableMetadata()
    {
        return ['fields' => $this->db_fields,
                     'pk' => $this->pk,
                     'table' => $this->db_table,
                     'additional_fields' => $this->additional_fields,
                     'alias_fields' => $this->alias_fields,
                     'relations' => array_keys($this->relations)];
    }

    /**
     * returns true, if table has an auto_increment column
     *
     * @return boolean
     */
    function hasAutoIncrementColumn()
    {
        return $this->db_fields[$this->pk[0]]['extra'] == 'auto_increment';
    }

    /**
     * set primary key for entry, combined keys must be passed as array
     * @param string|array primary key
     * @throws InvalidArgumentException if given key is not complete
     * @return boolean
     */
    public function setId($id)
    {
        if (!is_array($id)){
            $id = [$id];
        }
        if (count($this->pk) != count($id)){
            throw new InvalidArgumentException("Invalid ID, Primary Key {$this->db_table} is " .join(",",$this->pk));
        } else {
            foreach ($this->pk as $count => $key){
                $this->content[$key] = $id[$count];
            }
            return true;
        }
        return false;
    }

    /**
     * returns primary key, multiple keys as array
     * @return string|array current primary key, null if not set
     */
    function getId()
    {
        if (count($this->pk) == 1) {
            return $this->content[$this->pk[0]];
        } else {
            $id = [];
            foreach ($this->pk as $key) {
                if ($this->content[$key] !== null) {
                    $id[] = $this->content[$key];
                }
            }
            return (count($this->pk) == count($id) ? $id : null);
        }
    }

    /**
     * create new unique pk as md5 hash
     * if pk consists of multiple columns, false is returned
     * @return boolean|string
     */
    function getNewId()
    {
        $id = false;
        if (count($this->pk) == 1) {
            do {
                $id = md5(uniqid($this->db_table,1));
                $db = DBManager::get()->query("SELECT `{$this->pk[0]}` FROM `{$this->db_table}` "
                . "WHERE `{$this->pk[0]}` = '$id'");
            } while($db->fetch());
        }
        return $id;
    }

    /**
     * returns data of table row as assoc array
     * pass array of fieldnames or ws separated string to limit
     * fields
     *
     * @param mixed $only_these_fields limit returned fields
     * @return array
     */
    function toArray($only_these_fields = null)
    {
        $ret = [];
        if (is_string($only_these_fields)) {
            $only_these_fields = words($only_these_fields);
        }
        $fields = array_diff($this->known_slots, array_keys($this->relations));
        if (is_array($only_these_fields)) {
            $only_these_fields = array_filter(array_map(function($s) {
                return is_string($s) ? strtolower($s) : null;
            }, $only_these_fields));
            $fields = array_intersect($only_these_fields, $fields);
        }
        foreach ($fields as $field) {
            $ret[$field] = $this->getValue($field);
            if ($ret[$field] instanceof StudipArrayObject) {
                $ret[$field] = $ret[$field]->getArrayCopy();
            }
        }
        return $ret;
    }

    /**
     * Returns data of table row as assoc array with raw contents like
     * they are in the database.
     * Pass array of fieldnames or ws separated string to limit
     * fields.
     *
     * @param mixed $only_these_fields
     * @return array
     */
    function toRawArray($only_these_fields = null)
    {
        $ret = [];
        if (is_string($only_these_fields)) {
            $only_these_fields = words($only_these_fields);
        }
        $fields = array_keys($this->db_fields);
        if (is_array($only_these_fields)) {
            $only_these_fields = array_filter(array_map(function ($s) {
                return is_string($s) ? strtolower($s) : null;
            }, $only_these_fields));
            $fields = array_intersect($only_these_fields, $fields);
        }
        foreach ($fields as $field) {
            if ($this->content[$field] instanceof I18NString) {
                $ret[$field] = $this->content[$field]->original();
            } elseif ($this->content[$field] === null) {
                $ret[$field] = null;
            } else {
                $ret[$field] = (string)$this->content[$field];
            }
        }
        return $ret;
    }

    /**
     * returns data of table row as assoc array
     * including related records with a 'has*' relationship
     * recurses one level without param
     *
     * $only_these_fields limits output for relationships in this way:
     * $only_these_fields = array('field_1',
     *                            'field_2',
     *                            'relation1',
     *                            'relation2' => array('rel2_f1',
     *                                                 'rel2_f2',
     *                                                 'rel2_rel11' => array(
     *                                                           rel2_rel1_f1)
     *                                                )
     *                           )
     * Here all fields of relation1 will be returned.
     *
     * @param mixed $only_these_fields limit returned fields
     * @return array
     */
    function toArrayRecursive($only_these_fields = null)
    {
        if (is_string($only_these_fields)) {
            $only_these_fields = words($only_these_fields);
        }
        if (is_null($only_these_fields)) {
            $only_these_fields = $this->known_slots;
        }
        $ret = $this->toArray($only_these_fields);
        $relations = [];
        if (is_array($only_these_fields)) {
            foreach ($only_these_fields as $key => $value) {
                if (!is_array($value) &&
                    array_key_exists(strtolower($value), $this->relations)
                ) {
                    $relations[strtolower($value)] = 0; //not null|array|string to stop recursion
                if (array_key_exists(strtolower($key), $this->relations)) {
                    $relations[strtolower($key)] = $value;
                }
            }
        }
        if (count($relations)) {
            foreach ($relations as $relation_name => $relation_only_these_fields) {
                $options = $this->getRelationOptions($relation_name);
                if ($options['type'] === 'has_one' ||
                        $options['type'] === 'belongs_to') {
                    $ret[$relation_name] =
                            $this->{$relation_name}->
                                            toArrayRecursive($relation_only_these_fields);
                }
                if ($options['type'] === 'has_many' ||
                    $options['type'] === 'has_and_belongs_to_many') {
                    $ret[$relation_name] =
                            $this->{$relation_name}->
                                            sendMessage('toArrayRecursive',
                                            [$relation_only_these_fields]);
                }
            }
        }
        return $ret;
    }

    /**
     * returns value of a column
     *
     * @throws InvalidArgumentException if column could not be found
     * @throws BadMethodCallException if getter for additional field could not be found
     * @param string $field
     * @return null|string|SimpleORMapCollection
     */
    public function getValue($field)
    {

        // No value defined, throw exception
        if (!in_array($field, $this->known_slots)) {
            throw new InvalidArgumentException(static::class . '::'.$field . ' not found.');
        }

        // Get value by getter
        if (isset($this->getter_setter_map[$field]['get'])) {
            return call_user_func([$this, $this->getter_setter_map[$field]['get']]);
        }

        // Get value from content
        if (array_key_exists($field, $this->content)) {
            return $this->content[$field];
        }

        // Get value from relation
        if (array_key_exists($field, $this->relations)) {
            $this->initRelation($field);
            return $this->relations[$field];
        }

        // Get value from additional_field
        if (isset($this->additional_fields[$field]['get'])) {
            // Getter is defined as a closure
            if ($this->additional_fields[$field]['get'] instanceof Closure) {
                return call_user_func_array($this->additional_fields[$field]['get'], [$this, $field]);
            }

            // Getter is defined as a method of this object
            return call_user_func([$this, $this->additional_fields[$field]['get']], $field);
        }

        // No value found, throw exception
        throw new RuntimeException('No value could be found for ' . static::class . '::' . $field);
    }

    /**
     * gets a value from a related object
     * only possible, if the relation has cardinality 1
     * e.g. 'has_one' or 'belongs_to'
     *
     * @param string $relation name of relation
     * @param string $field name of column
     * @throws InvalidArgumentException if no relation with given name is found
     * @return mixed the value from the related object
     */
    function getRelationValue($relation, $field)
    {
        $options = $this->getRelationOptions($relation);
        if ($options['type'] === 'has_one' || $options['type'] === 'belongs_to') {
            return $this->{$relation}->{$field};
        } else {
            throw new InvalidArgumentException('Relation ' . $relation . ' not found or not applicable.');
        }
    }

    /**
     * returns default value for column
     *
     * @param string $field name of column
     * @return mixed the default value
     */
     function getDefaultValue($field)
     {
         $default_value = null;
         if (!isset($this->default_values[$field])) {
             if (!in_array($field, $this->pk)) {
                 $meta = $this->db_fields[$field];
                 if (isset($meta['default'])) {
                     $default_value = $meta['default'];
                 } elseif ($meta['null'] == 'NO') {
                     if (strpos($meta['type'], 'text') !== false || strpos($meta['type'], 'char') !== false) {
                         $default_value = '';
                     }
                     if (strpos($meta['type'], 'int') !== false) {
                         $default_value = '0';
                     }
                 }
             }
         } else {
             $default_value = $this->default_values[$field];
         }
         return $default_value;
     }

    /**
     * sets value of a column
     *
     * @throws InvalidArgumentException if column could not be found
     * @throws BadMethodCallException if setter for additional field could not be found
     * @param string $field
     * @param string $value
     * @return string
     */
     function setValue($field, $value)
     {
         $ret = false;
         if (in_array($field, $this->known_slots)) {
             if (isset($this->getter_setter_map[$field]['set'])) {
                 return call_user_func([$this, $this->getter_setter_map[$field]['set']], $value);
             }
             if (array_key_exists($field, $this->content)) {
                 if (array_key_exists($field, $this->serialized_fields)) {
                     $ret = $this->setSerializedValue($field, $value);
                 } elseif ($this->isI18nField($field)) {
                         $ret = $this->setI18nValue($field, $value);
                 } else {
                     $ret = ($this->content[$field] = $value);
                 }
             } elseif (isset($this->additional_fields[$field]['set'])) {
                 if ($this->additional_fields[$field]['set'] instanceof Closure) {
                     return call_user_func_array($this->additional_fields[$field]['set'], [$this, $field, $value]);
                 } else {
                     return call_user_func([$this, $this->additional_fields[$field]['set']], $field, $value);
                 }
             } elseif (array_key_exists($field, $this->relations)) {
                 $options = $this->getRelationOptions($field);
                 if ($options['type'] === 'has_one' || $options['type'] === 'belongs_to') {
                     if (is_a($value, $options['class_name'])) {
                         $this->relations[$field] = $value;
                         if ($options['type'] == 'has_one') {
                             $foreign_key_value = call_user_func($options['assoc_func_params_func'], $this);
                             call_user_func($options['assoc_foreign_key_setter'], $value, $foreign_key_value);
                         } else {
                             $assoc_foreign_key_value = call_user_func($options['assoc_foreign_key_getter'], $value, $this);
                             if ($assoc_foreign_key_value === null) {
                                 throw new InvalidArgumentException(sprintf('trying to set belongs_to object of type: %s, but assoc_foreign_key: %s is null', get_class($value), $options['assoc_foreign_key']));
                             }
                             $this->setValue($options['foreign_key'], $assoc_foreign_key_value);
                         }
                     } else {
                         throw new InvalidArgumentException(sprintf('relation %s expects object of type: %s', $field, $options['class_name']));
                     }
                 }
                 if ($options['type'] == 'has_many' || $options['type'] == 'has_and_belongs_to_many') {
                     if (is_array($value) || $value instanceof Traversable) {
                         $new_ids = [];
                         $old_ids = $this->{$field}->pluck('id');
                         foreach ($value as $current) {
                             if (!is_a($current, $options['class_name'])) {
                                 throw new InvalidArgumentException(sprintf('relation %s expects object of type: %s', $field, $options['class_name']));
                             }
                             if ($options['type'] == 'has_many') {
                                 $foreign_key_value = call_user_func($options['assoc_func_params_func'], $this);
                                 call_user_func($options['assoc_foreign_key_setter'], $current, $foreign_key_value);
                             }
                             if ($current->id !== null) {
                                 $new_ids[] = $current->id;
                                 $existing = $this->{$field}->find($current->id);
                                 if ($existing) {
                                     $existing->setData($current);
                                 } else {
                                     $this->{$field}->append($current);
                                 }
                             } else {
                                 $this->{$field}->append($current);
                             }
                         }
                         foreach (array_diff($old_ids, $new_ids) as $to_delete) {
                             $this->{$field}->unsetByPK($to_delete);
                         }
                     } else {
                         throw new InvalidArgumentException(sprintf('relation %s expects collection or array of objects of type: %s', $field, $options['class_name']));
                     }
                 }
             }
         } else {
             throw new InvalidArgumentException(get_class($this) . '::'. $field . ' not found.');
         }
         return $ret;
     }

    /**
     * magic method for dynamic properties
     */
    function __get($field)
    {
        return $this->getValue($field);
    }
    /**
     * magic method for dynamic properties
     */
    function __set($field, $value)
    {
        return $this->setValue($field, $value);
    }
    /**
     * magic method for dynamic properties
     */
    function __isset($field)
    {
        if (in_array($field, $this->known_slots)) {
            $value = $this->getValue($field);
            return $value instanceOf SimpleORMapCollection ? (bool)count($value) : !is_null($value);
        } else {
            return false;
        }
    }
    /**
     * ArrayAccess: Check whether the given offset exists.
     */
    public function offsetExists($offset)
    {
        return $this->__isset($offset);
    }

    /**
     * ArrayAccess: Get the value at the given offset.
     */
    public function offsetGet($offset)
    {
        return $this->getValue($offset);
    }

    /**
     * ArrayAccess: Set the value at the given offset.
     */
    public function offsetSet($offset, $value)
    {
        $this->setValue($offset, $value);
    }
    /**
     * ArrayAccess: unset the value at the given offset (not applicable)
     */
    public function offsetUnset($offset)
    {

    }
    /**
     * IteratorAggregate
     */
    public function getIterator()
    {
        return new ArrayIterator($this->toArray());
    }
    /**
     * Countable
     */
    public function count()
    {
        return count($this->known_slots) - count($this->relations);
    }

    /**
     * check if given column exists in table
     * @param string $field
     * @return boolean
     */
    function isField($field)
    {
        return isset($this->db_fields[$field]);
    }

    /**
     * check if given column is additional
     * @param string $field
     * @return boolean
     */
    function isAdditionalField($field)
    {
        return isset($this->additional_fields[$field]);
    }

    /**
     * check if given column is an alias
     * @param string $field
     * @return boolean
     */
    function isAliasField($field)
    {
        return isset($this->alias_fields[$field]);
    }

    /**
     * check if given column is a multi-language field
     * @param string $field
     * @return boolean
     */
    function isI18nField($field)
    {
        return isset($this->i18n_fields[$field]);
    }

    /**
     * set multiple column values
     * if second param is set, existing data in object will be
     * discarded and dirty state is cleared,
     * else new data overrides old data
     *
     * @param array $data assoc array
     * @param boolean $reset existing data in object will be discarded
     * @return number of columns changed
     */
    function setData($data, $reset = false)
    {
        $count = 0;
        if ($reset) {
            if ($this->applyCallbacks('before_initialize') === false) {
                return false;
            }
            $this->initializeContent();
        }
        if (is_array($data) || $data instanceof Traversable) {
            foreach($data as $key => $value) {
                if (isset($this->db_fields[$key])
                    || isset($this->alias_fields[$key])
                    || isset($this->additional_fields[$key]['set'])
                ) {
                    $this->setValue($key, $value);
                    ++$count;
                }
            }
        }
        if ($reset) {
            $this->applyCallbacks('after_initialize');
        }
        return $count;
    }

    /**
     * check if object exists in database
     * @return boolean
     */
    function isNew()
    {
        return $this->is_new;
    }

    /**
     * check if object was deleted
     *
     * @return boolean
     */
    function isDeleted()
    {
        return $this->is_deleted;
    }

    /**
     * set object to new state
     * @param boolean $is_new
     * @return boolean
     */
    function setNew($is_new)
    {
        return $this->is_new = $is_new;
    }

    /**
     * returns sql clause with current table and pk
     * @return boolean|string
     */
    function getWhereQuery()
    {
        $where_query = null;
        $pk_not_set = [];
        foreach ($this->pk as $key) {
            $pk = $this->content_db[$key] ?: $this->content[$key];
            if (isset($pk)) {
                $where_query[] = "`{$this->db_table}`.`{$key}` = "  . DBManager::get()->quote($pk);
            } else {
                $pk_not_set[] = $key;
            }
        }
        if (!$where_query || count($pk_not_set)){
            if ($this->isNew()) {
                return false;
            } else {
                throw new UnexpectedValueException(sprintf("primary key incomplete: %s must not be null", join(',',$pk_not_set)));
            }
        }
        return $where_query;
    }

    /**
     * restore entry from database
     * @return boolean
     */
    function restore()
    {
        $where_query = $this->getWhereQuery();
        if ($where_query) {
            if ($this->applyCallbacks('before_initialize') === false) {
                return false;
            }
            $id = $this->getId();
            $this->initializeContent();
            $query = "SELECT * FROM `{$this->db_table}` WHERE "
                    . join(" AND ", $where_query);
            $st = DBManager::get()->prepare($query);
            $st->execute();
            $st->setFetchMode(PDO::FETCH_INTO , $this);
            if ($st->fetch()) {
                $this->setNew(false);
                $this->applyCallbacks('after_initialize');
                return true;
            }
        }
        $this->setData([], true);
        $this->setNew(true);
        if (isset($id)) {
            $this->setId($id);
        }
        return false;
    }

    /**
     * store entry in database
     *
     * @throws UnexpectedValueException if there are forbidden NULL values
     * @return number|boolean
     */
    function store()
    {
        if ($this->applyCallbacks('before_store') === false) {
            return false;
        }
        if (!$this->isDeleted() && ($this->isDirty() || $this->isNew())) {
            if ($this->isNew()) {
                if ($this->applyCallbacks('before_create') === false) {
                    return false;
                }
            } else {
                if ($this->applyCallbacks('before_update') === false) {
                    return false;
                }
            }
            foreach ($this->db_fields as $field => $meta) {
                $value = $this->content[$field];
                if ($field == 'chdate' && !$this->isFieldDirty($field) && $this->isDirty()) {
                    $value = time();
                }
                if ($field == 'mkdate') {
                    if ($this->isNew()) {
                        if (!$this->isFieldDirty($field)) {
                            $value = time();
                        }
                    } else {
                        continue;
                    }
                }
                if ($value === null && $meta['null'] == 'NO') {
                    throw new UnexpectedValueException($this->db_table . '.' . $field . ' must not be null.');
                }
                if (is_float($value)) {
                    $value = str_replace(',', '.', $value);
                }
                $this->content[$field] = $value;
                $query_part[] = "`$field` = " . DBManager::get()->quote($value) . " ";
            }
            if (!$this->isNew()) {
                $where_query = $this->getWhereQuery();
                $query = "UPDATE `{$this->db_table}` SET "
                    . implode(',', $query_part);
                $query .= " WHERE " . join(" AND ", $where_query);
            } else {
                $query = "INSERT INTO `{$this->db_table}` SET "
                    . implode(',', $query_part);
            }
            $ret = DBManager::get()->exec($query);
            if ($this->isNew()) {
                $this->applyCallbacks('after_create');
            } else {
                $this->applyCallbacks('after_update');
            }
        }
        $rel_ret = $this->storeRelations();
        $this->applyCallbacks('after_store');
        if ($ret || $rel_ret) {
            $this->restore();
        }
        return $ret + $rel_ret;
    }

    /**
     * sends a store message to all initialized related objects
     * if a relation has a callback for 'on_store' configured, the callback
     * is instead invoked
     *
     * @return number addition of all return values, false if none was called
     */
    protected function storeRelations($only_these = null)
    {
        $ret = false;
        if (is_string($only_these)) {
            $only_these = words($only_these);
        }
        $relations = array_keys($this->relations);
        if (is_array($only_these)) {
            $only_these = array_filter(array_map(function ($s) {
                return is_string($s) ? strtolower($s) : null;
            }, $only_these));
            $relations = array_intersect($only_these, $relations);
        }
        foreach ($relations as $relation) {
            $options = $this->getRelationOptions($relation);
            if (isset($options['on_store']) &&
            ($options['type'] === 'has_one' ||
            $options['type'] === 'has_many' ||
            $options['type'] === 'has_and_belongs_to_many')) {
                if ($options['on_store'] instanceof Closure) {
                    $ret += call_user_func($options['on_store'], $this, $relation);
                } elseif (isset($this->relations[$relation])) {
                    $foreign_key_value = call_user_func($options['assoc_func_params_func'], $this);
                    if ($options['type'] === 'has_one') {
                        call_user_func($options['assoc_foreign_key_setter'], $this->{$relation}, $foreign_key_value);
                        $ret = call_user_func([$this->{$relation}, 'store']);
                    } elseif ($options['type'] === 'has_many') {
                        foreach ($this->{$relation} as $r) {
                            call_user_func($options['assoc_foreign_key_setter'], $r, $foreign_key_value);
                        }
                        $ret += array_sum(call_user_func([$this->{$relation}, 'sendMessage'], 'store'));
                        $ret += array_sum(call_user_func([$this->{$relation}->getDeleted(), 'sendMessage'], 'delete'));
                    } else {
                        call_user_func([$this->{$relation}, 'sendMessage'], 'store');
                        $to_delete = array_filter($this->{$relation}->getDeleted()->pluck($options['assoc_foreign_key']));
                        $to_insert = array_filter($this->{$relation}->pluck($options['assoc_foreign_key']));
                        $sql = "DELETE FROM `" . $options['thru_table'] ."` WHERE `" . $options['thru_key'] ."` = ? AND `" . $options['thru_assoc_key'] . "` = ?";
                        $st = DBManager::get()->prepare($sql);
                        foreach ($to_delete as $one_value) {
                            $st->execute([$foreign_key_value, $one_value]);
                            $ret += $st->rowCount();
                        }
                        $sql = "INSERT IGNORE INTO `" . $options['thru_table'] ."` SET `" . $options['thru_key'] ."` = ?, `" . $options['thru_assoc_key'] . "` = ?";
                        $st = DBManager::get()->prepare($sql);
                        foreach ($to_insert as $one_value) {
                            $st->execute([$foreign_key_value, $one_value]);
                            $ret += $st->rowCount();
                        }
                    }
                }
            }
        }
        return $ret;
    }

    /**
     * set chdate column to current timestamp
     * @return boolean
     */
    function triggerChdate()
    {
        if ($this->db_fields['chdate']) {
            $this->content['chdate'] = time();
            if ($where_query = $this->getWhereQuery()) {
                DBManager::get()->exec("UPDATE `{$this->db_table}` SET chdate={$this->content['chdate']}
                            WHERE ". join(" AND ", $where_query));
                return true;
            }
        } else {
            return false;
        }
    }

    /**
     * delete entry from database
     * the object is cleared, but is not(!) turned to new state
     * @return int number of deleted rows
     */
    function delete()
    {
        $ret = false;
        if (!$this->isDeleted() && !$this->isNew()) {
            if ($this->applyCallbacks('before_delete') === false) {
                return false;
            }
            $ret = $this->deleteRelations();
            $where_query = $this->getWhereQuery();
            if ($where_query) {
                $query = "DELETE FROM `{$this->db_table}` WHERE "
                        . join(" AND ", $where_query);
                $ret += DBManager::get()->exec($query);
            }
            $this->is_deleted = true;
            $this->applyCallbacks('after_delete');
        }
        $this->setData([], true);
        return $ret;
    }

    /**
     * sends a delete message to all related objects
     * if a relation has a callback for 'on_delete' configured, the callback
     * is invoked instead
     *
     * @return number addition of all return values, false if none was called
     */
    protected function deleteRelations()
    {
        $ret = false;
        foreach (array_keys($this->relations) as $relation) {
            $options = $this->getRelationOptions($relation);
            if (isset($options['on_delete']) &&
                ($options['type'] === 'has_one' ||
                $options['type'] === 'has_many' ||
                $options['type'] === 'has_and_belongs_to_many')) {
                if ($options['on_delete'] instanceof Closure) {
                    $ret += call_user_func($options['on_delete'], $this, $relation);
                } else {
                    if ($options['type'] === 'has_one' || $options['type'] === 'has_many') {
                        $this->initRelation($relation);
                        if (isset($this->relations[$relation])) {
                            if ($options['type'] === 'has_one') {
                                $ret += call_user_func([$this->{$relation}, 'delete']);
                            } elseif ($options['type'] === 'has_many') {
                                $ret += array_sum(call_user_func([$this->{$relation}, 'sendMessage'], 'delete'));
                            }
                        }
                    } else {
                        $foreign_key_value = call_user_func($options['assoc_func_params_func'], $this);
                        $sql = "DELETE FROM `" . $options['thru_table'] ."` WHERE `" . $options['thru_key'] ."` = ?";
                        $st = DBManager::get()->prepare($sql);
                        $st->execute([$foreign_key_value]);
                        $ret += $st->rowCount();
                    }
                }
                $this->relations[$relation] = null;
            }
        }
        return $ret;
    }

    /**
     * init internal content arrays with nulls or defaults
     *
     * @throws UnexpectedValueException if there is an unmatched alias
     */
    protected function initializeContent()
    {
        $this->content = [];
        foreach (array_keys($this->db_fields) as $field) {
            $this->content[$field] = null;
            $this->content_db[$field] = null;
            $this->setValue($field, $this->getDefaultValue($field));
        }
        foreach ($this->alias_fields as $alias => $field) {
            if (isset($this->db_fields[$field])) {
                $this->content[$alias] =& $this->content[$field];
                $this->content_db[$alias] =& $this->content_db[$field];
            } else {