Skip to content
Snippets Groups Projects
SimpleORMap.class.php 86.5 KiB
Newer Older
<?php
/**
 * SimpleORMap.class.php
 * simple object-relational mapping
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License as
 * published by the Free Software Foundation; either version 2 of
 * the License, or (at your option) any later version.
 *
 * @author      André Noack <noack@data-quest.de>
 * @copyright   2010 Stud.IP Core-Group
 * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
 * @category    Stud.IP
*/

class SimpleORMap implements ArrayAccess, Countable, IteratorAggregate
{
    /**
     * Defines `_` as character used when joining composite primary keys.
     */
    const ID_SEPARATOR = '_';

    /**
     * table row data
     * @var array $content
     */
    protected $content = [];
    /**
     * table row data
     * @var array $content_db
     */
    protected $content_db = [];
    /**
     * new state of entry
     * @var boolean $is_new
     */
    protected $is_new = true;
    /**
     * deleted state of entry
     * @var boolean $is_deleted
     */
    protected $is_deleted = false;

     /**
     * db table metadata
     * @var ?array $schemes;
     */
    public static $schemes = null;

     * configuration data for subclasses
     * @see self::configure()
     * @var array $config;
    protected static $config = [];

     * stores instantiated related objects
     * @var array $relations
    protected $relations = [];

     * assoc array for storing values for additional fields
     *
     * @var array $additional_data
    protected $additional_data = [];
     * reserved indentifiers, fields with those names must not have an explicit getXXX() method
     * @var array $reserved_slots
    protected static $reserved_slots = ['value','newid','iterator','tablemetadata', 'relationvalue','wherequery','relationoptions','data','new','id'];
     * indicator for batch operations in findEachBySQL
     *
     * @var bool $performs_batch_operation
    protected static $performs_batch_operation = false;
    /**
     * name of db table
     * @return string
    protected static function db_table()
    {
        return static::config('db_table');
    }
     * table columns
     * @return array
    protected static function db_fields()
    {
        return static::config('db_fields');
    }

    /**
     * primary key columns
     * @return array
     */
    protected static function pk()
    {
        return static::config('pk');
    }

    /**
     * default values for columns
     * @return array
     */
    protected static function default_values()
    {
        return static::config('default_values');
    }

    /**
     * list of columns to deserialize
     * @return array key is name of column, value is name of ArrayObject class
     */
    protected static function serialized_fields()
    {
        return static::config('serialized_fields');
    }

    /**
     * aliases for columns
     * alias => column
    protected static function alias_fields()
    {
        return static::config('alias_fields');
    }

    /**
     * multi-language fields
     * name => boolean
    protected static function i18n_fields()
    {
        return static::config('i18n_fields');
    }

    /**
     * additional computed fields
     * name => callable
    protected static function additional_fields()
    {
        return static::config('additional_fields');
    }
    protected static function has_many()
    {
        return static::config('has_many');
    }
    protected static function has_one()
    {
        return static::config('has_one');
    }
    protected static function belongs_to()
    {
        return static::config('belongs_to');
    }
    protected static function has_and_belongs_to_many()
    {
        return static::config('has_and_belongs_to_many');
    }
     * @return array<string, array<string|Closure>>
    protected static function registered_callbacks()
    {
        return static::config('registered_callbacks');
    }

    /**
     * contains an array of all used identifiers for fields
     * (db columns + aliased columns + additional columns + relations)
    protected static function known_slots()
    {
        return static::config('known_slots');
    }

    /**
     * assoc array used to map SORM callback to NotificationCenter
     * keys are SORM callbacks, values notifications
     * eg. 'after_create' => 'FooDidCreate'
     *
    protected static function notification_map()
    {
        return static::config('notification_map');
    }

    /**
     * assoc array for mapping get/set Methods
     *
    protected static function getter_setter_map()
    {
        return static::config('getter_setter_map');
    }
    //////////////////////////////////////////////////

    /**
     * set configuration data from subclass
     *
     * @return void
     */
    protected static function configure($config = [])
    {

        if (empty($config['db_table'])) {
            $config['db_table'] = strtolower($class);
        }

        if (!isset($config['db_fields'])) {
            if (static::tableScheme($config['db_table'])) {
                $config['db_fields'] = self::$schemes[$config['db_table']]['db_fields'];
                $config['pk'] = self::$schemes[$config['db_table']]['pk'];
            }
        }

        if (isset($config['pk'])
            && !isset($config['db_fields']['id'])
            && !isset($config['alias_fields']['id'])
            && !isset($config['additional_fields']['id'])
        ) {
            if (count($config['pk']) === 1) {
                $config['alias_fields']['id'] = $config['pk'][0];
            } else {
                $config['additional_fields']['id'] = ['get' => '_getId',
                                                           'set' => '_setId'];
            }
        }
        if (isset($config['additional_fields'])) {
            foreach ($config['additional_fields'] as $a_field => $a_config) {
André Noack's avatar
André Noack committed
                if (is_array($a_config) && !(isset($a_config['get']) || isset($a_config['set']))) {
Moritz Strohm's avatar
Moritz Strohm committed
                    $relation = $a_config[0] ?? '';
                    $relation_field = $a_config[1] ?? '';
                    if (!$relation) {
                        list($relation, $relation_field) = explode('_', $a_field);
                    }
                    if (!$relation_field || !$relation) {
                        throw new UnexpectedValueException('no relation found for autoget/set additional field: ' . $a_field);
                    }
                    $config['additional_fields'][$a_field] = ['get'            => '_getAdditionalValueFromRelation',
                                                                   'set'            => '_setAdditionalValue',
                                                                   'relation'       => $relation,
                                                                   'relation_field' => $relation_field];
                }
            }
        }
        if (isset($config['serialized_fields'])) {
            foreach ($config['serialized_fields'] as $a_field => $object_type) {
                if (!(is_subclass_of($object_type, 'StudipArrayObject'))) {
                    throw new UnexpectedValueException(sprintf('serialized field %s must use subclass of StudipArrayObject', $a_field));
                }
            }
        }

        foreach (['default_values', 'serialized_fields', 'alias_fields', 'i18n_fields', 'additional_fields'] as $fields) {
            if (!isset($config[$fields])) {
                $config[$fields] = [];
            }
        }

        foreach (['has_many', 'belongs_to', 'has_one', 'has_and_belongs_to_many'] as $type) {
Moritz Strohm's avatar
Moritz Strohm committed
            if (isset($config[$type]) && is_array($config[$type])) {
                foreach (array_keys($config[$type]) as $one) {
                    $config['relations'][$one] = null;
                }
            } else {
                $config[$type] = [];
            }
        }

        $callbacks = ['before_create',
                      'before_update',
                      'before_store',
                      'before_delete',
                      'before_initialize',
                      'after_create',
                      'after_update',
                      'after_store',
                      'after_delete',
                      'after_initialize'];

        foreach ($callbacks as $callback) {
            if (!isset($config['registered_callbacks'][$callback])) {
                $config['registered_callbacks'][$callback] = [];
            }
        }

        $auto_notification_map['after_create'] = $class . 'DidCreate';
        $auto_notification_map['after_store'] = $class . 'DidStore';
        $auto_notification_map['after_delete'] = $class . 'DidDelete';
        $auto_notification_map['after_update'] = $class . 'DidUpdate';
        $auto_notification_map['before_create'] = $class . 'WillCreate';
        $auto_notification_map['before_store'] = $class . 'WillStore';
        $auto_notification_map['before_delete'] = $class . 'WillDelete';
        $auto_notification_map['before_update'] = $class . 'WillUpdate';

        foreach ($auto_notification_map as $cb => $notification) {
            if (isset($config['notification_map'][$cb])) {
                if (strpos($config['notification_map'][$cb], $notification) !== false) {
                    $config['notification_map'][$cb] .= ' ' . $notification;
                }
            } else {
                $config['notification_map'][$cb] = $notification;
            }
        }

        if (is_array($config['notification_map'])) {
            foreach (array_keys($config['notification_map']) as $cb) {
                $config['registered_callbacks'][$cb][] = 'cbNotificationMapper';
            }
        }

        if (!I18N::isEnabled() || empty($config['i18n_fields'])) {
            $config['i18n_fields'] = [];
        } elseif (is_string($config['i18n_fields'])) {
            $i18n_fields = words($config['i18n_fields']);
            $config['i18n_fields'] = array_combine(
                $i18n_fields,
                array_fill(0, count($i18n_fields), true)
            );
        } elseif (array_is_list($config['i18n_fields'])) {
            $config['i18n_fields'] = array_combine(
                $config['i18n_fields'],
                array_fill(0, count($config['i18n_fields']), true)
            );
        }

        array_unshift($config['registered_callbacks']['after_initialize'], 'cbAfterInitialize');

        $config['known_slots'] = array_merge(
            array_keys($config['db_fields']),
Moritz Strohm's avatar
Moritz Strohm committed
            array_keys($config['alias_fields'] ?? []),
            array_keys($config['additional_fields'] ?? []),
            array_keys($config['relations'] ?? [])
        foreach (array_map('strtolower', get_class_methods($class)) as $method) {
            if (in_array(substr($method, 0, 3), ['get', 'set'])) {
                $verb = substr($method, 0, 3);
                $name = substr($method, 3);
                if (in_array($name, $config['known_slots']) && !in_array($name, static::$reserved_slots) && !isset($config['additional_fields'][$name][$verb])) {
                    $config['getter_setter_map'][$name][$verb] = $method;
                }
            }
        }
        self::$config[$class] = $config;
    }

    /**
     * fetch config data for the called class
     *
     * @param string $key config key
     */
    protected static function config($key)
    {
        if (!array_key_exists(static::class, self::$config)) {
            static::configure();
        }

Moritz Strohm's avatar
Moritz Strohm committed
        return self::$config[static::class][$key] ?? null;
    }

    /**
     * fetch table metadata from db or from local cache
     *
     * @param string $db_table
     * @return bool true if metadata could be fetched
     */
    public static function tableScheme($db_table)
    {
        if (self::$schemes === null) {
            $cache = StudipCacheFactory::getCache();
            self::$schemes = unserialize($cache->read('DB_TABLE_SCHEMES'));
        }
        if (!isset(self::$schemes[$db_table])) {
            $db = DBManager::get()->query("SHOW COLUMNS FROM $db_table");
            while($rs = $db->fetch(PDO::FETCH_ASSOC)){
                $db_fields[strtolower($rs['Field'])] = [
                    'name' => $rs['Field'],
                    'null' => $rs['Null'],
                    'default' => $rs['Default'],
                    'type' => $rs['Type'],
                    'extra' => $rs['Extra']
                ];
                if ($rs['Key'] == 'PRI'){
                }
            }
            self::$schemes[$db_table]['db_fields'] = $db_fields;
            self::$schemes[$db_table]['pk'] = $pk;
            $cache = StudipCacheFactory::getCache();
            $cache->write('DB_TABLE_SCHEMES', serialize(self::$schemes));
        }
        return isset(self::$schemes[$db_table]);
    }

    /**
     * force reload of cached table metadata
     */
    public static function expireTableScheme()
    {
        StudipCacheFactory::getCache()->expire('DB_TABLE_SCHEMES');
        self::$schemes = null;
        self::$config = [];
    }

    /**
     * Returns new instance for given key when found in the database, else null.
     *
     * @param string $id primary key
     * @return static|null
     */
    public static function find($id)
    {
        $ref = new ReflectionClass(static::class);
        $record = $ref->newInstanceArgs(func_get_args());
        if (!$record->isNew()) {
            return $record;
        } else {
            return null;
        }
    }

    /**
     * @param string|array $id primary key
     * @return boolean
     */
    public static function exists($id)
    {
        $ret = false;
        $db_table = static::db_table();
        $record = new static();
        $record->setId(...func_get_args());
        $where_query = $record->getWhereQuery();
        if ($where_query) {
            $query = "SELECT 1 FROM `$db_table` WHERE "
                    . join(" AND ", $where_query);
            $ret = (bool)DBManager::get()->query($query)->fetchColumn();
        }
        return $ret;
    }

    /**
     * returns number of records
     *
     * @param ?string $sql sql clause to use on the right side of WHERE
     * @param ?array $params params for query
    public static function countBySql($sql = '1', $params = [])
        $db_table = static::db_table();
        $db = DBManager::get();
        $has_join = stripos($sql, 'JOIN ');
        if ($has_join === false || $has_join > 10) {
        $sql = "SELECT count(*) FROM `" .  $db_table . "` " . $sql;
        $st = $db->prepare($sql);
        $st->execute($params);
        return (int)$st->fetchColumn();
    }

    /**
     * creates new record with given data in db
     * returns the new object or null
     * @param array $data assoc array of record
     */
    public static function create($data)
    {
        $record->setData($data, false);
        if ($record->store()) {
            return $record;
        } else {
            return null;
        }
    }

    /**
     * build object with given data
     *
     * @param array $data assoc array of record
     * @return static
     */
    public static function build($data, $is_new = true)
    {
        $record->setData($data, !$is_new);
        $record->setNew($is_new);
        return $record;
    }

    /**
     * build object with given data and mark it as existing
     *
     * @return static
     */
    public static function buildExisting($data)
    {
        return static::build($data, false);
    }

    /**
     * generate SimpleORMap object structure from assoc array
     * if given array contains data of related objects in sub-arrays
     * they are also generated. Existing records are updated, new records are created
     * (but changes are not yet stored)
     *
     * @param array $data
     * @return static
     */
    public static function import($data)
    {
        $record_data = [];
        $relation_data = [];
        foreach ($data as $key => $value) {
Jan-Hendrik Willms's avatar
Jan-Hendrik Willms committed
            $temp = static::alias_fields()[$key] ?? $key;
            if (isset(static::db_fields()[$temp])) {
                $record_data[$key] = $value;
Jan-Hendrik Willms's avatar
Jan-Hendrik Willms committed
            } else {
                $relation_data[$key] = $value;
            }
        }
        $record = static::toObject($record_data);
        if (!$record instanceof static) {
            $record = new static();
            $record->setData($record_data, true);
        } else {
            $record->setData($record_data);
        }
Jan-Hendrik Willms's avatar
Jan-Hendrik Willms committed
        foreach ($relation_data as $relation => $data) {
            if (!$record->isRelation($relation)) {
                continue;
            }

            $options = $record->getRelationOptions($relation);
            if ($options['type'] == 'has_one') {
                $record->{$relation} = call_user_func([$options['class_name'], 'import'], $data);
            }
            if ($options['type'] == 'has_many' || $options['type'] == 'has_and_belongs_to_many') {
                foreach ($data as $one) {
                    $current = call_user_func([$options['class_name'], 'import'], $one);
                    if ($options['type'] == 'has_many') {
                        $foreign_key_value = call_user_func($options['assoc_func_params_func'], $record);
                        call_user_func($options['assoc_foreign_key_setter'], $current, $foreign_key_value);
                    }
                    if ($current->id !== null) {
                        $existing = $record->{$relation}->find($current->id);
                        if ($existing) {
                            $existing->setData($current);
                        } else {
                            $record->{$relation}->append($current);
                        }
Jan-Hendrik Willms's avatar
Jan-Hendrik Willms committed
                    } else {
                        $record->{$relation}->append($current);
                    }
                }
            }
        }
        return $record;
    }

    /**
     * returns array of instances of given class filtered by given sql
     * @param string $sql sql clause to use on the right side of WHERE
     * @param ?array $params parameters for query
     * @return static[] array of "self" objects
    public static function findBySQL($sql, $params = [])
        $db_table = static::db_table();
        if ($has_join === false || $has_join > 10) {
        $sql = "SELECT `" . $db_table . "`.* FROM `" . $db_table . "` " . $sql;
        $stmt = DBManager::get()->prepare($sql);
        $stmt->execute($params);

        $record = static::build([], false);

        $ret = [];
        do  {
            $clone = clone $record;
            $clone->setNew(false);
            $stmt->setFetchMode(PDO::FETCH_INTO, $clone);

            if ($clone = $stmt->fetch()) {
                $clone->applyCallbacks('after_initialize');
                $ret[] = $clone;
            }
        } while ($clone);
        return $ret;
    }

    /**
     * returns one instance of given class filtered by given sql
     * only first row of query is used
     * @param string $where sql clause to use on the right side of WHERE
     * @param ?array $params parameters for query
     * @return ?static
     */
    public static function findOneBySQL($where, $params = [])
    {
        if (stripos($where, 'LIMIT') === false) {
            $where .= " LIMIT 1";
        }
        $found = static::findBySQL($where, $params);
        return isset($found[0]) ? $found[0] : null;
    }

    /**
     * find related records for a n:m relation (has_many_and_belongs_to_many)
     * using a combination table holding the keys
     *
     * @param string $foreign_key_value value of foreign key to find related records
     * @param array $options relation options from other side of relation
     * @return static[] array of "self" objects
     */
    public static function findThru($foreign_key_value, $options)
    {
        $thru_table = $options['thru_table'];
        $thru_key = $options['thru_key'];
        $thru_assoc_key = $options['thru_assoc_key'];
        $assoc_foreign_key = $options['assoc_foreign_key'];

        $db_table = static::db_table();
        $sql = "SELECT `$db_table`.* FROM `$thru_table`
        INNER JOIN `$db_table` ON `$thru_table`.`$thru_assoc_key` = `$db_table`.`$assoc_foreign_key`
Moritz Strohm's avatar
Moritz Strohm committed
        WHERE `$thru_table`.`$thru_key` = ? " . ($options['order_by'] ?? '');
        $st->execute([$foreign_key_value]);
            $clone->setNew(false);
            $st->setFetchMode(PDO::FETCH_INTO, $clone);
            if ($clone = $st->fetch()) {
                $clone->applyCallbacks('after_initialize');
                $ret[] = $clone;
            }
        } while ($clone);
        return $ret;
    }

    /**
     * passes objects for given sql through given callback
     *
     * @param callable $callable callback which gets the current record as param
     * @return integer number of found records
     */
    public static function findEachBySQL($callable, $sql, $params = [])
        if ($has_join === false || $has_join > 10) {
        $db_table = static::db_table();
        $st = DBManager::get()->prepare("SELECT `{$db_table}`.* FROM `{$db_table}` {$sql}");
        $st->execute($params);

        // Indicate that we are performing a batch operation
        static::$performs_batch_operation = true;

            $clone->setNew(false);
            $st->setFetchMode(PDO::FETCH_INTO, $clone);
            if ($clone = $st->fetch()) {
                $clone->applyCallbacks('after_initialize');
                $callable($clone, $ret++);
            }
        } while ($clone);

        // Reset batch operation indicator
        static::$performs_batch_operation = false;

        return $ret;
    }

    /**
     * returns array of instances of given class for by given pks
     * @param ?array $pks array of primary keys
     * @param ?string $order order by clause
     * @param ?array $order_params
     * @return static[]
     */
    public static function findMany($pks = [], $order = '', $order_params = [])
    {
        $db_table = static::db_table();
        $pk = static::pk();
        $db = DBManager::get();
        if (count($pk) > 1) {
            throw new Exception('not implemented yet');
        }
        $where = "`$db_table`.`{$pk[0]}` IN ("  . $db->quote($pks) . ") ";
        return static::findBySQL($where . $order, $order_params);
    }

    /**
     * passes objects for by given pks through given callback
     *
     * @param callable $callable callback which gets the current record as param
     * @param ?array $pks array of primary keys of called class
     * @param ?string $order order by sql
     * @param ?array $order_params
     * @return integer number of found records
     */
    public static function findEachMany($callable, $pks = [], $order = '', $order_params = [])
    {
        $db_table = static::db_table();
        $pk = static::pk();
        $db = DBManager::get();
        if (count($pk) > 1) {
            throw new Exception('not implemented yet');
        }
        $where = "`$db_table`.`{$pk[0]}` IN ("  . $db->quote($pks) . ") ";
        return static::findEachBySQL($callable, $where . $order, $order_params);
    }

    /**
     * passes objects for given sql through given callback
     * and returns an array of callback return values
     *
     * @param callable $callable callback which gets the current record as param
     * @param string $where where clause of sql
     * @param array $params sql statement parameters
     * @return array return values of callback
     */
    public static function findAndMapBySQL($callable, $where, $params = [])
    {
        $ret = [];
        $calleach = function($m) use (&$ret, $callable) {
            $ret[] = $callable($m);
        };
        static::findEachBySQL($calleach, $where, $params);
        return $ret;
    }

    /**
     * passes objects for by given pks through given callback
     * and returns an array of callback return values
     *
     * @param callable $callable callback which gets the current record as param
     * @param ?array $pks array of primary keys of called class
     * @param ?string $order order by sql
     * @param ?array $order_params
     * @return array return values of callback
     */
    public static function findAndMapMany($callable, $pks = [], $order = '', $order_params = [])
    {
        $ret = [];
        $calleach = function($m) use (&$ret, $callable) {
            $ret[] = $callable($m);
        };
        $db_table = static::db_table();
        $pk = static::pk();
        $db = DBManager::get();
        if (count($pk) > 1) {
            throw new Exception('not implemented yet');
        }
        $where = "`$db_table`.`{$pk[0]}` IN ("  . $db->quote($pks) . ") ";
        static::findEachBySQL($calleach, $where . $order, $order_params);
        return $ret;
    }

    /**
     * deletes objects specified by sql clause
     * @param string $where sql clause to use on the right side of WHERE
     * @param ?array $params parameters for query
     * @return integer number of deleted records
     */
    public static function deleteBySQL($where, $params = [])
    {
        $killeach = function($record) {$record->delete();};
        return static::findEachBySQL($killeach, $where, $params);
    }

    /**
     * returns object of given class for given id or null
     * the param could be a string, an assoc array containing primary key field
     * or an already matching object. In all these cases an object is returned
     *
     * @param string|static|array $id_or_object id as string, object or assoc array
     * @return static
     */
    public static function toObject($id_or_object)
    {
        if ($id_or_object instanceof static) {
            return $id_or_object;
        }
        if (is_array($id_or_object)) {
            $key_values = [];
Jan-Hendrik Willms's avatar
Jan-Hendrik Willms committed
            foreach ($pk as $key) {
                if (array_key_exists($key, $id_or_object)) {
                    $key_values[] = $id_or_object[$key];
                }
            }
            if (count($pk) === count($key_values)) {
                if (count($pk) === 1) {
                    $id = $key_values[0];
                } else {
                    $id = $key_values;
                }
Jan-Hendrik Willms's avatar
Jan-Hendrik Willms committed
            } else {
                $id = null;
            }
        } else {
            $id = $id_or_object;
        }
        return static::find($id);
    }

    /**
     * interceptor for static findByColumn / findEachByColumn / countByColumn /
     * deleteByColumn magic
     * @param string $name
     * @param array $arguments
     * @throws BadMethodCallException
     * @return int|static|static[]
    public static function __callStatic(string $name, array $arguments)
        $db_table = static::db_table();
        $alias_fields = static::alias_fields();
        $db_fields = static::db_fields();
Moritz Strohm's avatar
Moritz Strohm committed
        $order = '';
        $param_arr = [];
        $where = '';
        $where_param = is_array($arguments[0]) ? $arguments[0] : [$arguments[0]];
        $action = strstr($name, 'by', true);
        $field = substr($name, strlen($action) + 2);
        switch ($action) {
            case 'findone':
Moritz Strohm's avatar
Moritz Strohm committed
                $order = $arguments[1] ?? '';
                $param_arr[0] =& $where;
                $param_arr[1] = [$where_param];
                $method = 'findonebysql';
                break;
            case 'find':
            case 'findmany':
Moritz Strohm's avatar
Moritz Strohm committed
                $order = $arguments[1] ?? '';
                $param_arr[0] =& $where;
                $param_arr[1] = [$where_param];
                $method = 'findbysql';
                break;
            case 'findeach':
            case 'findeachmany':
Moritz Strohm's avatar
Moritz Strohm committed
                $order = $arguments[2] ?? '';
                $param_arr[0] = $arguments[0];
                $param_arr[1] =& $where;
                $param_arr[2] = [$arguments[1]];
                $method = 'findeachbysql';
                break;
            case 'count':
            case 'delete':
                $param_arr[0] =& $where;
                $param_arr[1] = [$where_param];
                $method = "{$action}bysql";
                throw new BadMethodCallException("Method " . static::class . "::$name not found");
        }
        if (isset($alias_fields[$field])) {
            $field = $alias_fields[$field];
        }
        if (isset($db_fields[$field])) {
            $where = "`$db_table`.`$field` IN(?) " . $order;
            return call_user_func_array([static::class, $method], $param_arr);
        throw new BadMethodCallException("Method " . static::class . "::$name not found");
    }

    /**
     * constructor, give primary key of record as param to fetch
     * corresponding record from db if available, if not preset primary key
     * with given value. Give null to create new record
     *
     * @param null|int|string|array $id primary key of table
     */
    function __construct($id = null)
    {
        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;
            }
        }

        if ($id) {
            $this->setId($id);
        }
        $this->restore();
    }

    /**
     * returns internal used id value (multiple keys concatenated with _)
     */
    protected function _getId($field)
    {
        return is_null($this->getId())
             ? null
             : implode(self::ID_SEPARATOR, $this->getId());
    }

    /**
     * sets internal used id value (multiple keys concatenated with _)
     * @param string $field Field to set (unused since it's always the id)
     * @param string $value Value for id field
     */
    protected function _setId($field, $value)
    {
        return $this->setId(explode(self::ID_SEPARATOR, $value));
    }

    /**
     * retrieves an additional field value from relation
     *
     * @param string $field
     */
    protected function _getAdditionalValueFromRelation($field)
    {
        list($relation, $relation_field) = [$this->additional_fields()[$field]['relation'],
                                                $this->additional_fields()[$field]['relation_field']];
        if (!array_key_exists($field, $this->additional_data)) {
            $this->_setAdditionalValue($field, $this->getRelationValue($relation, $relation_field));
        }