From 8474ee1063d11f1d5fd4b79674d2d734b82e18b0 Mon Sep 17 00:00:00 2001 From: Jan-Hendrik Willms Date: Mon, 6 May 2024 08:59:03 +0000 Subject: relocate sorm classes, fixes #4106 Closes #4106 Merge request studip/studip!2953 --- lib/classes/SimpleCollection.class.php | 782 +++++++++ lib/classes/SimpleORMap.class.php | 2476 +++++++++++++++++++++++++++ lib/classes/SimpleORMapCollection.class.php | 258 +++ lib/models/SimpleCollection.class.php | 782 --------- lib/models/SimpleORMap.class.php | 2476 --------------------------- lib/models/SimpleORMapCollection.class.php | 258 --- tests/unit/lib/VisualTest.php | 2 +- tests/unit/lib/classes/MigrationTest.php | 2 +- 8 files changed, 3518 insertions(+), 3518 deletions(-) create mode 100644 lib/classes/SimpleCollection.class.php create mode 100644 lib/classes/SimpleORMap.class.php create mode 100644 lib/classes/SimpleORMapCollection.class.php delete mode 100644 lib/models/SimpleCollection.class.php delete mode 100644 lib/models/SimpleORMap.class.php delete mode 100644 lib/models/SimpleORMapCollection.class.php diff --git a/lib/classes/SimpleCollection.class.php b/lib/classes/SimpleCollection.class.php new file mode 100644 index 0000000..6acc10d --- /dev/null +++ b/lib/classes/SimpleCollection.class.php @@ -0,0 +1,782 @@ + + * @copyright 2013 Stud.IP Core-Group + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + * + * @template T + */ +class SimpleCollection extends StudipArrayObject +{ + /** + * callable to initialize collection + * + * @var ?callable(): array + */ + protected $finder; + + /** + * number of records after last init + * + * @var int + */ + protected $last_count; + + /** + * collection with deleted records + * @var static + */ + protected $deleted; + + /** + * creates a collection from an array of arrays + * all arrays should contain same keys, but is not enforced + * + * @param array $data array containing assoc arrays + * @return SimpleCollection + */ + public static function createFromArray(array $data) + { + return new self($data); + } + + /** + * converts arrays or objects to ArrayObject objects + * if ArrayAccess interface is not available + * + * @param mixed $a + * @return StudipArrayObject|ArrayAccess + */ + public static function arrayToArrayObject($a) + { + if ($a instanceof StudipArrayObject) { + $a->setFlags(StudipArrayObject::ARRAY_AS_PROPS); + return $a; + } + + if ($a instanceof ArrayObject) { + return new StudipArrayObject($a->getArrayCopy(), StudipArrayObject::ARRAY_AS_PROPS); + } + + if ($a instanceof ArrayAccess) { + return $a; + } + + return new StudipArrayObject((array) $a, StudipArrayObject::ARRAY_AS_PROPS); + } + + /** + * returns closure to compare a value against given arguments + * using given operator + * + * @param string|callable(mixed, mixed|array): bool $operator + * @param mixed|array $args + * @throws InvalidArgumentException + * @return callable(mixed): bool comparison function + */ + public static function getCompFunc($operator, $args) + { + if (is_callable($operator)) { + $comp_func = function ($a) use ($args, $operator) { + return $operator($a, $args); + }; + } else { + if (!is_array($args)) { + $args = [$args]; + } + switch ($operator) { + case '==': + $comp_func = function ($a) use ($args) { + return in_array($a, $args); + }; + break; + case '===': + $comp_func = function ($a) use ($args) { + return in_array($a, $args, true); + }; + break; + case '!=': + case '<>': + $comp_func = function ($a) use ($args) { + return !in_array($a, $args); + }; + break; + case '!==': + $comp_func = function ($a) use ($args) { + return !in_array($a, $args, true); + }; + break; + case '<': + case '>': + case '<=': + case '>=': + $op_func = function ($a, $b) use ($operator) { + if ($operator === '<') { + return $a < $b; + } elseif ($operator === '<=') { + return $a <= $b; + } elseif ($operator === '>=') { + return $a >= $b; + } elseif ($operator === '>') { + return $a > $b; + } + }; + $comp_func = function ($a) use ($op_func, $args) { + return $op_func($a, $args[0]); + }; + break; + case '><': + $comp_func = function ($a) use ($args) { + return $a > $args[0] && $a < $args[1]; + }; + break; + case '>=<=': + $comp_func = function ($a) use ($args) { + return $a >= $args[0] && $a <= $args[1]; + }; + break; + case '%=': + $comp_func = function ($a) use ($args) { + $a = mb_strtolower(static::translitLatin1($a)); + $args = array_map([static::class, 'translitLatin1'], $args); + $args = array_map('mb_strtolower', $args); + return in_array($a, $args); + }; + break; + case '*=': + $comp_func = function ($a) use ($args) { + foreach ($args as $arg) { + if (mb_strpos($a, $arg) !== false) { + return true; + } + } + return false; + }; + break; + case '^=': + $comp_func = function ($a) use ($args) { + foreach ($args as $arg) { + if (mb_strpos($a, $arg) === 0) { + return true; + } + } + return false; + }; + break; + case '$=': + $comp_func = function ($a) use ($args) { + foreach ($args as $arg) { + $found = mb_strrpos($a, $arg); + if ($found !== false && ($found + mb_strlen($arg)) === mb_strlen($a)) { + return true; + } + } + return false; + }; + break; + case '~=': + $comp_func = function ($a) use ($args) { + foreach ($args as $arg) { + if (preg_match($arg, $a) === 1) { + return true; + } + } + return false; + }; + break; + default: + throw new InvalidArgumentException('unknown operator: ' . $operator); + } + } + return $comp_func; + } + + /** + * transliterates latin1 string to ascii + * + * @param string $text + * @return string + */ + public static function translitLatin1($text) + { + if (!preg_match('/[\200-\377]/', $text)) { + return $text; + } + $text = str_replace(['ä','Ä','ö','Ö','ü','Ü','ß'], ['a','A','o','O','u','U','s'], $text); + $text = str_replace(['À','Á','Â','Ã','Å','Æ'], 'A' , $text); + $text = str_replace(['à','á','â','ã','å','æ'], 'a' , $text); + $text = str_replace(['È','É','Ê','Ë'], 'E' , $text); + $text = str_replace(['è','é','ê','ë'], 'e' , $text); + $text = str_replace(['Ì','Í','Î','Ï'], 'I' , $text); + $text = str_replace(['ì','í','î','ï'], 'i' , $text); + $text = str_replace(['Ò','Ó','Õ','Ô','Ø'], 'O' , $text); + $text = str_replace(['ò','ó','ô','õ','ø'], 'o' , $text); + $text = str_replace(['Ù','Ú','Û'], 'U' , $text); + $text = str_replace(['ù','ú','û'], 'u' , $text); + $text = str_replace(['Ç','ç','Ð','Ñ','Ý','ñ','ý','ÿ'], ['C','c','D','N','Y','n','y','y'] , $text); + return $text; + } + + /** + * Constructor + * + * @param array|callable(): array $data array or closure to fill collection + */ + public function __construct($data = []) + { + parent::__construct(); + $this->finder = is_callable($data) ? $data : null; + $this->deleted = clone $this; + if (is_callable($data)) { + $this->refresh(); + } else { + $this->exchangeArray($data); + } + } + + /** + * @param array $input + * @return array + */ + public function exchangeArray($input) + { + return parent::exchangeArray(array_map( + [static::class, 'arrayToArrayObject'], + $input + )); + } + + /** + * converts the object and all elements to plain arrays + * + * @return array + */ + public function toArray() + { + $args = func_get_args(); + return $this->map(function ($a) use ($args) { + if (method_exists($a, 'toArray')) { + return call_user_func_array([$a, 'toArray'], $args); + } + if (method_exists($a, 'getArrayCopy')) { + return $a->getArrayCopy(); + } + return (array) $a; + } + ); + } + + /** + * + * @see ArrayObject::append() + */ + public function append($newval) + { + parent::append(static::arrayToArrayObject($newval)); + } + + /** + * Sets the value at the specified index + * ensures the value has ArrayAccess + * + * @param mixed $index + * @param mixed $newval + * + * @see ArrayObject::offsetSet() + */ + + public function offsetSet($index, $newval): void + { + if (is_numeric($index)) { + $index = (int) $index; + } + parent::offsetSet($index, static::arrayToArrayObject($newval)); + } + + /** + * Unsets the value at the specified index + * value is moved to internal deleted collection + * + * @see ArrayObject::offsetUnset() + * @throws InvalidArgumentException + */ + public function offsetUnset($index): void + { + if ($this->offsetExists($index)) { + $this->deleted[] = $this->offsetGet($index); + } + parent::offsetUnset($index); + } + + /** + * sets the finder function + * + * @param callable(): array $finder + * @return void + */ + public function setFinder(callable $finder) + { + $this->finder = $finder; + } + + /** + * get deleted records collection + * @return SimpleCollection + */ + public function getDeleted() + { + return $this->deleted; + } + + /** + * reloads the elements of the collection + * by calling the finder function + * + * @return ?int of records after refresh + */ + public function refresh() + { + if (is_callable($this->finder)) { + $data = call_user_func($this->finder); + $this->exchangeArray($data); + $this->deleted->exchangeArray([]); + return $this->last_count = $this->count(); + } + } + + /** + * returns a new collection containing all elements + * where given columns value matches given value(s) using passed operator + * pass array for multiple values + * + * operators: + * == equal, like php + * === identical, like php + * !=,<> not equal, like php + * !== not identical, like php + * <,>,<=,>= less,greater,less or equal,greater or equal + * >< between without borders, needs two arguments + * >=<= between including borders, needs two arguments + * %= like string, transliterate to ascii,case insensitive + * *= contains string + * ^= begins with string + * $= ends with string + * ~= regex + * + * @param string $key the column name + * @param mixed $values value to search for + * @param string|callable $op operator to find + * @return SimpleCollection with found records + */ + public function findBy($key, $values, $op = '==') + { + $comp_func = self::getCompFunc($op, $values); + return $this->filter(function ($record) use ($comp_func, $key) { + return $comp_func($record[$key]); + }); + } + + /** + * returns the first element + * where given column has given value(s) + * pass array for multiple values + * + * @param string $key the column name + * @param mixed $values value to search for, + * @param string|callable $op operator to find + * @return ?T found record + */ + public function findOneBy($key, $values, $op = '==') + { + $comp_func = self::getCompFunc($op, $values); + return $this->filter(function ($record) use ($comp_func, $key) { + return $comp_func($record[$key]); + }, 1)->first(); + } + + /** + * apply given callback to all elements of + * collection + * + * @param callable(T): int $func the function to call + * @return int|false addition of return values + */ + public function each(callable $func) + { + $result = false; + foreach ($this->storage as $record) { + $result += call_user_func($func, $record); + } + return $result; + } + + /** + * apply given callback to all elements of + * collection and give back array of return values + * + * @param callable(T, mixed): mixed $func the function to call + * @return array + */ + public function map(callable $func) + { + $results = []; + foreach ($this->storage as $key => $value) { + $results[$key] = call_user_func($func, $value, $key); + } + return $results; + } + + /** + * filter elements + * if given callback returns true + * + * @param ?callable(T, mixed): bool $func the function to call + * @param ?integer $limit limit number of found records + * @return SimpleCollection containing filtered elements + */ + public function filter(callable $func = null, $limit = null) + { + $results = []; + $found = 0; + foreach ($this->storage as $key => $value) { + if (call_user_func($func, $value, $key)) { + $results[$key] = $value; + if ($limit && (++$found == $limit)) { + break; + } + } + } + return self::createFromArray($results); + } + + /** + * Returns whether any element of the collection returns true for the + * given callback. + * + * @param callable(T, mixed): bool $func the function to call + * @return bool + */ + public function any(callable $func) + { + foreach ($this->storage as $key => $value) { + if (call_user_func($func, $value, $key)) { + return true; + } + } + return false; + } + + /** + * Returns whether every element of the collection returns true for the + * given callback. + * + * @param callable(T, mixed): bool $func the function to call + * @return bool + */ + public function every(callable $func) + { + foreach ($this->storage as $key => $value) { + if (!call_user_func($func, $value, $key)) { + return false; + } + } + return true; + } + + /** + * extract array of columns values + * pass array or space-delimited string for multiple columns + * + * @param string|array $columns the column(s) to extract + * @return array of extracted values + */ + public function pluck($columns) + { + if (!is_array($columns)) { + $columns = words($columns); + } + $func = function ($r) use ($columns) { + $result = []; + foreach ($columns as $c) { + $result[] = $r[$c]; + } + return $result; + }; + $result = $this->map($func); + return count($columns) === 1 ? array_map('current', $result) : $result; + } + + /** + * returns the collection as grouped array + * first param is the column to group by, it becomes the key in + * the resulting array, default is pk. Limit returned fields with second param + * The grouped entries can optoionally go through the given + * callback. If no callback is provided, only the first grouped + * entry is returned, suitable for grouping by unique column + * + * @param string $group_by the column to group by, pk if ommitted + * @param string|array|null $only_these_fields limit returned fields + * @param ?callable $group_func closure to aggregate grouped entries + * @return array assoc array + */ + public function toGroupedArray($group_by = 'id', $only_these_fields = null, callable $group_func = null) + { + $result = []; + if (is_string($only_these_fields)) { + $only_these_fields = words($only_these_fields); + } + foreach ($this->toArray() as $record) { + $key = $record[$group_by]; + $ret = []; + if (is_array($only_these_fields)) { + $result[$key][] = array_intersect_key($record, array_flip($only_these_fields)); + } else { + $result[$key][] = $record; + } + } + if ($group_func === null) { + $group_func = 'current'; + } + return array_map($group_func, $result); + } + + /** + * get the first element + * + * @return ?T first element or null + */ + public function first() + { + $keys = array_keys($this->storage); + $first_offset = reset($keys); + return $this->offsetGet($first_offset ?: 0); + } + + /** + * get the last element + * + * @return ?T last element or null + */ + public function last() + { + $keys = array_keys($this->storage); + $last_offset = end($keys); + return $this->offsetGet($last_offset ?: 0); + } + + /** + * get the the value from given key from first element + * + * @param string $key + * @return mixed + */ + public function val($key) + { + $first = $this->first(); + return $first[$key] ?? null; + } + + /** + * mark element(s) for deletion + * where given column has given value(s) + * element(s) are moved to + * internal deleted collection + * pass array for multiple values + * + * operators: + * == equal, like php + * === identical, like php + * !=,<> not equal, like php + * !== not identical, like php + * <,>,<=,>= less,greater,less or equal,greater or equal + * >< between without borders, needs two arguments + * >=<= between including borders, needs two arguments + * %= like string, transliterate to ascii,case insensitive + * *= contains string + * ^= begins with string + * $= ends with string + * ~= regex + * + * @param string $key + * @param mixed $values + * @param string|callable(mixed, mixed|array): bool $op operator to find elements + * @return int|false number of unsetted elements + */ + public function unsetBy($key, $values, $op = '==') + { + $ret = false; + $comp_func = self::getCompFunc($op, $values); + foreach ($this->storage as $k => $record) { + if ($comp_func($record[$key])) { + $this->offsetunset($k); + $ret += 1; + } + } + return $ret; + } + + /** + * sorts the collection by columns of contained elements and returns it + * + * works like sql order by: + * first param is a string containing combinations of column names + * and sort direction, separated by comma e.g. + * 'name asc, nummer desc ' + * sorts first by name ascending and then by nummer descending + * second param denotes the sort type (using PHP sort constants): + * SORT_LOCALE_STRING: + * compare items as strings, transliterate latin1 to ascii, case insensitiv, natural order for numbers + * SORT_NUMERIC: + * compare items as integers + * SORT_STRING: + * compare items as strings + * SORT_NATURAL: + * compare items as strings using "natural ordering" + * SORT_FLAG_CASE: + * can be combined (bitwise OR) with SORT_STRING or SORT_NATURAL to sort strings case-insensitively + * + * @param string $order columns to order by + * @param integer $sort_flags + * @return $this the sorted collection + */ + public function orderBy($order, $sort_flags = SORT_LOCALE_STRING) + { + //('name asc, nummer desc ') + $sort_locale = false; + switch ($sort_flags) { + case SORT_NATURAL: + $sort_func = 'strnatcmp'; + break; + case SORT_NATURAL | SORT_FLAG_CASE: + $sort_func = 'strnatcasecmp'; + break; + case SORT_STRING | SORT_FLAG_CASE: + $sort_func = 'strcasecmp'; + break; + case SORT_STRING: + $sort_func = 'strcmp'; + break; + case SORT_NUMERIC: + $sort_func = function ($a, $b) { + return (int) $a - (int) $b; + }; + break; + case SORT_LOCALE_STRING: + default: + $sort_func = 'strnatcasecmp'; + $sort_locale = true; + } + + $sorter = []; + foreach (explode(',', $order) as $one) { + $sorter[] = array_values(array_filter(array_map('trim', explode(' ', $one)))); + } + + $func = function ($d1, $d2) use ($sorter, $sort_func, $sort_locale) { + do { + $current_sorter = current($sorter); + $field = $current_sorter[0]; + $dir = $current_sorter[1] ?? ''; + if (!$sort_locale) { + $value1 = $d1[$field]; + $value2 = $d2[$field]; + } else { + $value1 = static::translitLatin1(mb_substr($d1[$field], 0, 100)); + $value2 = static::translitLatin1(mb_substr($d2[$field], 0, 100)); + } + $ret = $sort_func($value1, $value2); + if (strtolower($dir) == 'desc') $ret = $ret * -1; + } while ($ret === 0 && next($sorter)); + + return $ret; + }; + if (count($sorter)) { + $this->uasort($func); + } + return $this; + } + + /** + * returns a new collection contaning a sequence of original collection + * mimics the sql limit constrain: + * used with one parameter, the first x elements are extracted + * used with two parameters, the first parameter denotes the offset, the second the + * number of elements + * + * @param integer $arg1 + * @param ?integer $arg2 + * @return SimpleCollection + */ + public function limit($arg1, $arg2 = null) + { + if (is_null($arg2)) { + if ($arg1 > 0) { + $row_count = $arg1; + $offset = 0; + } else { + $row_count = abs($arg1); + $offset = $arg1; + } + } else { + $offset = $arg1; + $row_count = $arg2; + } + return self::createFromArray(array_slice($this->storage, $offset, $row_count, true)); + } + + /** + * calls the given method on all elements + * of the collection + * @param literal-string $method methodname to call + * @param array $params parameters for methodcall + * @return array of all return values + */ + public function sendMessage($method, $params = []) { + $results = []; + foreach ($this->storage as $record) { + $results[] = call_user_func_array([$record, $method], $params); + } + return $results; + } + + /** + * magic version of sendMessage + * calls undefineds methods on all elements of the collection + * But beware of the dark side... + * + * @param literal-string $method methodname to call + * @param array $params parameters for methodcall + * @return array of all return values + */ + public function __call($method, $params) + { + return $this->sendMessage($method, $params); + } + + /** + * merge in another collection, elements are appended + * + * @param SimpleCollection $a_collection + * @return void + */ + public function merge(SimpleCollection $a_collection) + { + $this->storage = array_merge($this->storage, $a_collection->getArrayCopy()); + } +} diff --git a/lib/classes/SimpleORMap.class.php b/lib/classes/SimpleORMap.class.php new file mode 100644 index 0000000..f86ec6f --- /dev/null +++ b/lib/classes/SimpleORMap.class.php @@ -0,0 +1,2476 @@ + + * @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 + * @return array + */ + protected static function alias_fields() + { + return static::config('alias_fields'); + } + + /** + * multi-language fields + * name => boolean + * @return array + */ + protected static function i18n_fields() + { + return static::config('i18n_fields'); + } + + /** + * additional computed fields + * name => callable + * @return array + */ + protected static function additional_fields() + { + return static::config('additional_fields'); + } + + /** + * 1:n relation + * @return array + */ + protected static function has_many() + { + return static::config('has_many'); + } + + /** + * 1:1 relation + * @return array + */ + protected static function has_one() + { + return static::config('has_one'); + } + + /** + * n:1 relations + * @return array + */ + protected static function belongs_to() + { + return static::config('belongs_to'); + } + + /** + * n:m relations + * @return array + */ + protected static function has_and_belongs_to_many() + { + return static::config('has_and_belongs_to_many'); + } + + /** + * callbacks + * @return array> + */ + 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) + * @return array + */ + 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' + * + * @return array + */ + protected static function notification_map() + { + return static::config('notification_map'); + } + + /** + * assoc array for mapping get/set Methods + * + * @return array + */ + protected static function getter_setter_map() + { + return static::config('getter_setter_map'); + } + + ////////////////////////////////////////////////// + + /** + * set configuration data from subclass + * + * @param ?array $config configuration data + * @return void + */ + protected static function configure($config = []) + { + $class = static::class; + + 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) { + if (is_array($a_config) && !(isset($a_config['get']) || isset($a_config['set']))) { + $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) { + 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']), + 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 + * @return mixed value of config key (null if not set) + */ + protected static function config($key) + { + if (!array_key_exists(static::class, self::$config)) { + static::configure(); + } + + 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'){ + $pk[] = strtolower($rs['Field']); + } + } + 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 + * @return void + */ + 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); + /** @var static $record */ + $record = $ref->newInstanceArgs(func_get_args()); + if (!$record->isNew()) { + return $record; + } else { + return null; + } + } + + /** + * Returns true if given key exists in the database. + * + * @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 + * @return int + */ + 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 = 'WHERE ' . $sql; + } + $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 + * @return ?static + */ + public static function create($data) + { + $record = new static(); + $record->setData($data, false); + if ($record->store()) { + return $record; + } else { + return null; + } + } + + /** + * build object with given data + * + * @param array $data assoc array of record + * @param ?bool $is_new set object to new state + * @return static + */ + public static function build($data, $is_new = true) + { + $record = new static(); + $record->setData($data, !$is_new); + $record->setNew($is_new); + return $record; + } + + /** + * build object with given data and mark it as existing + * + * @param array $data assoc array of record + * @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) { + $temp = static::alias_fields()[$key] ?? $key; + if (isset(static::db_fields()[$temp])) { + $record_data[$key] = $value; + } 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); + } + 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); + } + } 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(); + + $has_join = stripos($sql, 'JOIN '); + if ($has_join === false || $has_join > 10) { + $sql = 'WHERE ' . $sql; + } + $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` + WHERE `$thru_table`.`$thru_key` = ? " . ($options['order_by'] ?? ''); + $st = DBManager::get()->prepare($sql); + $st->execute([$foreign_key_value]); + + $record = static::build([], false); + + $ret = []; + do { + $clone = clone $record; + $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 + * @param string $sql where clause of sql + * @param ?array $params sql statement parameters + * @return integer number of found records + */ + public static function findEachBySQL($callable, $sql, $params = []) + { + $has_join = stripos($sql, 'JOIN '); + if ($has_join === false || $has_join > 10) { + $sql = "WHERE {$sql}"; + } + + $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; + + $record = static::build([], false); + + $ret = 0; + do { + $clone = clone $record; + $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)) { + $pk = static::pk(); + $key_values = []; + 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; + } + } 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(); + $name = strtolower($name); + $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': + $order = $arguments[1] ?? ''; + $param_arr[0] =& $where; + $param_arr[1] = [$where_param]; + $method = 'findonebysql'; + break; + case 'find': + case 'findmany': + $order = $arguments[1] ?? ''; + $param_arr[0] =& $where; + $param_arr[1] = [$where_param]; + $method = 'findbysql'; + break; + case 'findeach': + case 'findeachmany': + $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"; + break; + default: + 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 _) + * @param mixed $field unused parameter + * @return ?string + */ + 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 + * @return bool + */ + protected function _setId($field, $value) + { + return $this->setId(explode(self::ID_SEPARATOR, $value)); + } + + /** + * retrieves an additional field value from relation + * + * @param string $field + * @return mixed + */ + 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)); + } + return $this->additional_data[$field]; + } + + /** + * sets additional value in field imported from relation + * + * @param string $field + * @param mixed $value + * @return mixed + */ + protected function _setAdditionalValueFromRelation($field, $value) + { + list($relation, $relation_field) = [$this->additional_fields()[$field]['relation'], + $this->additional_fields()[$field]['relation_field']]; + $this->$relation->$field = $value; + unset($this->additional_data[$field]); + return $this->_getAdditionalValueFromRelation($field); + } + + /** + * @param string $field + * @return mixed + */ + protected function _getAdditionalValue($field) + { + return $this->additional_data[$field]; + } + + /** + * @param string $field + * @param mixed $value + * @return mixed + */ + protected function _setAdditionalValue($field, $value) + { + return $this->additional_data[$field] = $value; + } + + /** + * clean up references after cloning + * @return void + */ + function __clone() + { + $this->setNew(true); + //all references link still to old object => reset all aliases + foreach ($this->alias_fields() as $alias => $field) { + if (isset($this->db_fields()[$field])) { + $content_value = $this->content[$field]; + $content_db_value = $this->content_db[$field]; + unset($this->content[$alias]); + unset($this->content_db[$alias]); + unset($this->content[$field]); + unset($this->content_db[$field]); + if (is_object($content_value)) { + $this->content[$field] = clone $content_value; + } else { + $this->content[$field] = $content_value; + } + if (is_object($content_db_value)) { + $this->content_db[$field] = clone $content_db_value; + } else { + $this->content_db[$field] = $content_db_value; + } + } + } + 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]; + } + } + //unset all relations for now + //TODO: maybe a deep copy of all belonging objects is more appropriate + 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 (empty($options['class_name'])) { + throw new Exception('Option class_name not set for relation ' . $name); + } + if (empty($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 (empty($options['thru_key'])) { + $options['thru_key'] = $this->pk()[0]; + } + if (empty($options['thru_assoc_key']) || empty($options['assoc_foreign_key'])) { + $class = $options['class_name']; + $record = new $class(); + $meta = $record->getTableMetadata(); + if (empty($options['thru_assoc_key'])) { + $options['thru_assoc_key'] = $meta['pk'][0]; + } + if (empty($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 (empty($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 (empty($options['foreign_key'])) { + $options['foreign_key'] = 'id'; + } + if (isset($options['foreign_key']) && $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 (isset($options['assoc_foreign_key']) && $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 (!empty($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]; + if (!isset($options['type'])) { + $options = $this->parseRelationOptions($type, $relation, $options, $this->db_table()); + $options['type'] = $type; + self::$config[get_class($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 int|string|array $id 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; + } + } + + /** + * returns primary key, multiple keys as array + * @return null|string|array current primary key, null if not set + */ + function getId() + { + if (count($this->pk()) == 1) { + return $this->content[$this->pk()[0]] ?? null; + } else { + $id = []; + foreach ($this->pk() as $key) { + if (array_key_exists($key, $this->content)) { + $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 null|array|string $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 null|array|string $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 null|array|string $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) + { + $field = strtolower($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) + { + $field = strtolower($field); + $options = $this->getRelationOptions($relation); + if ($options['type'] === 'has_one' || $options['type'] === 'belongs_to') { + return $this->{$relation}->{$field} ?? null; + } 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 mixed $value + * @return string + */ + function setValue($field, $value) + { + $field = strtolower($field); + $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 + * + * @throws InvalidArgumentException if column could not be found + * @throws BadMethodCallException if getter for additional field could not be found + * @param string $field the column or additional field + * @return null|string|SimpleORMapCollection + */ + function __get($field) + { + return $this->getValue($field); + } + /** + * magic method for dynamic properties + * + * @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 __set($field, $value) + { + return $this->setValue($field, $value); + } + /** + * magic method for dynamic properties + * + * @param string $field + * @return bool + */ + function __isset($field) + { + $field = strtolower($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. + * + * @param string $offset + */ + public function offsetExists($offset): bool + { + return $this->__isset($offset); + } + + /** + * ArrayAccess: Get the value at the given offset. + * + * @throws InvalidArgumentException if column could not be found + * @throws BadMethodCallException if getter for additional field could not be found + * @param string $offset the column or additional field + * @return null|string|SimpleORMapCollection + */ + public function offsetGet($offset): mixed + { + return $this->getValue($offset); + } + + /** + * ArrayAccess: Set the value at the given offset. + * + * @throws InvalidArgumentException if column could not be found + * @throws BadMethodCallException if setter for additional field could not be found + * @param string $offset + * @param mixed $value + */ + public function offsetSet($offset, $value): void + { + $this->setValue($offset, $value); + } + + /** + * ArrayAccess: unset the value at the given offset (not applicable) + * + * @param string $offset + */ + public function offsetUnset($offset): void + { + + } + + /** + * IteratorAggregate + */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->toArray()); + } + + /** + * Countable + */ + public function count(): int + { + return count($this->known_slots()) - count($this->relations); + } + + /** + * check if given column exists in table + * @param string $field + * @return boolean + */ + function isField($field) + { + $field = strtolower($field); + return isset($this->db_fields()[$field]); + } + + /** + * check if given relation exists in this class + * @param string $field + * @return boolean + */ + function isRelation($field) + { + $field = strtolower($field); + return array_key_exists($field, $this->relations); + } + + /** + * check if given column is additional + * @param string $field + * @return boolean + */ + function isAdditionalField($field) + { + $field = strtolower($field); + return isset($this->additional_fields()[$field]); + } + + /** + * check if given column is an alias + * @param string $field + * @return boolean + */ + function isAliasField($field) + { + $field = strtolower($field); + return isset($this->alias_fields()[$field]); + } + + /** + * check if given column is a multi-language field + * @param string $field + * @return boolean + */ + function isI18nField($field) + { + $field = strtolower($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 ?iterable $data assoc array + * @param ?boolean $reset existing data in object will be discarded + * @return int|bool 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_iterable($data)) { + foreach($data as $key => $value) { + $key = strtolower($key); + 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 + * @throws UnexpectedValueException if the primary key is incomplete + * @return boolean|array + */ + function getWhereQuery() + { + $where_query = null; + $pk_not_set = []; + foreach ($this->pk() as $key) { + $pk = $this->content_db[$key] ?? $this->content[$key] ?? null; + 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(); + $id = $this->getId(); + if ($where_query) { + if ($this->applyCallbacks('before_initialize') === false) { + return false; + } + $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() + { + // Set id or prepare setting of id + if ($this->isNew() && $this->getId() === null) { + // Explicitly set id to 0 if auto increment pk is null + if ($this->hasAutoIncrementColumn()) { + $this->setId(0); + } else { + $this->setId($this->getNewId()); + } + } + + if ($this->applyCallbacks('before_store') === false) { + return false; + } + + $ret = 0; + + if (!$this->isDeleted() && ($this->isDirty() || $this->isNew())) { + $callback = $this->isNew() ? 'before_create' : 'before_update'; + if ($this->applyCallbacks($callback) === false) { + return false; + } + + // Collect i18n contents + $i18ncontent = []; + foreach (array_keys($this->i18n_fields()) as $field) { + if ($this->content[$field] instanceof I18NString) { + $i18ncontent[$field] = $this->content[$field]; + $this->content[$field] = $this->content[$field]->original(); + $this->content_db[$field] = $this->content_db[$field]->original(); + } + } + + // Create sql data assignment chunks + 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) . " "; + } + + // Create store query + 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); + + // Retrieve generated id from database if pk is an auto increment + // column + if ($this->isNew()) { + if ($this->hasAutoIncrementColumn() && !$this->getId()) { + $this->setId(DBManager::get()->lastInsertId()); + } + } + + // Store i18n contents + foreach ($i18ncontent as $field => $one) { + $meta = [ + 'object_id' => $this->getId(), + 'table' => $this->db_table(), + 'field' => $field + ]; + $one->setMetadata($meta); + $one->storeTranslations(); + if (!$this->content[$field] instanceof I18NString) { + $this->content[$field] = $one; + $this->content_db[$field] = clone $one; + } + } + + // Apply callbacks + $this->applyCallbacks($this->isNew() ? 'after_create' : '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 + * + * @param null|array|string $only_these + * @return int|false 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; + } + } + + return false; + } + + /** + * delete entry from database + * the object is cleared, but is not(!) turned to new state + * @return bool|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'); + + // Remove i18n translations + if (I18N::isEnabled()) { + foreach (array_keys($this->i18n_fields()) as $field) { + if ($this->content[$field] instanceof I18NString) { + $this->content[$field]->removeTranslations(); + } + } + } + } + $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 bool|int 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 + * @return void + */ + 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 { + throw new UnexpectedValueException(sprintf('Column %s not found for alias %s', $field, $alias)); + } + } + foreach (array_keys($this->relations) as $one) { + $this->relations[$one] = null; + } + $this->additional_data = []; + } + + /** + * checks if at least one field was modified since last restore + * + * @return boolean + */ + public function isDirty() + { + foreach (array_keys($this->db_fields()) as $field) { + if ($this->isFieldDirty($field)) { + return true; + } + } + return false; + } + + /** + * checks if given field was modified since last restore + * + * @param string $field + * @return boolean + */ + public function isFieldDirty($field) + { + $field = strtolower($field); + if ($this->content[$field] === null || $this->content_db[$field] === null) { + return $this->content[$field] !== $this->content_db[$field]; + } else if ($this->content[$field] instanceof I18NString || $this->content_db[$field] instanceof I18NString) { + return $this->content[$field] != $this->content_db[$field]; + } else { + return (string)$this->content[$field] !== (string)$this->content_db[$field]; + } + } + + /** + * reverts value of given field to last restored value + * + * @param string $field + * @return mixed the restored value + */ + public function revertValue($field) + { + $field = strtolower($field); + return ($this->content[$field] = $this->content_db[$field]); + } + + /** + * returns unmodified value of given field + * + * @param string $field + * @throws InvalidArgumentException + * @return mixed + */ + public function getPristineValue($field) + { + $field = strtolower($field); + if (array_key_exists($field, $this->content_db)) { + return $this->content_db[$field]; + } else { + throw new InvalidArgumentException(get_class($this) . '::'. $field . ' not found.'); + } + } + + /** + * intitalize a relationship and get related record(s) + * + * @param string $relation name of relation + * @throws InvalidArgumentException if the relation does not exists + * @return void + */ + public function initRelation($relation) + { + if (!array_key_exists($relation, $this->relations)) { + throw new InvalidArgumentException('Unknown relation: ' . $relation); + } + if ($this->relations[$relation] === null) { + $options = $this->getRelationOptions($relation); + $to_call = [$options['class_name'], $options['assoc_func']]; + if (!is_callable($to_call)) { + throw new RuntimeException('assoc_func: ' . join('::', $to_call) . ' is not callable.' ); + } + $params = $options['assoc_func_params_func']; + if ($options['type'] === 'has_many') { + $records = function($record) use ($to_call, $params, $options) { + $p = (array)$params($record); + return call_user_func_array($to_call, array_merge(count($p) ? $p : [null], [$options['order_by'] ?? null])); + }; + $this->relations[$relation] = new SimpleORMapCollection($records, $options, $this); + } elseif ($options['type'] === 'has_and_belongs_to_many') { + $records = function($record) use ($to_call, $params, $options) {$p = (array)$params($record); return call_user_func_array($to_call, array_merge(count($p) ? $p : [null], [$options]));}; + $this->relations[$relation] = new SimpleORMapCollection($records, $options, $this); + } else { + $p = (array)$params($this); + $records = call_user_func_array($to_call, count($p) ? $p : [null]); + $result = is_array($records) ? ($records[0] ?? null) : $records; + $this->relations[$relation] = $result; + } + } + } + + /** + * clear data for a relationship + * + * @param string $relation name of relation + * @throws InvalidArgumentException if teh relation does not exists + * @return void + */ + public function resetRelation($relation) + { + if (!array_key_exists($relation, $this->relations)) { + throw new InvalidArgumentException('Unknown relation: ' . $relation); + } + $this->relations[$relation] = null; + } + + /** + * invoke registered callbacks for given type + * if one callback returns false the following will not + * be invoked + * + * @param string $type type of callback + * @return bool return value from last callback + */ + protected function applyCallbacks($type) + { + $ok = true; + foreach ($this->registered_callbacks()[$type] as $cb) { + if ($cb instanceof Closure) { + $function = $cb; + $params = [$this, $type, $cb]; + } else { + $function = [$this, $cb]; + $params = [$type]; + }; + $ok = call_user_func_array($function, $params); + if ($ok === false) { + break; + } + } + return $ok; + } + + /** + * register given callback for one or many possible callback types + * callback param could be a closure or method name of current class + * + * @param string|array $types types to register callback for + * @param callable $cb callback + * @throws InvalidArgumentException if the callback type is not known + * @return number of registered callbacks + */ + protected static function registerCallback($types, $cb) + { + trigger_error(__METHOD__ . ' is deprecated. Please use the configuration in configure().', E_USER_DEPRECATED); + + $types = is_array($types) ? $types : words($types); + $reg = 0; + foreach ($types as $type) { + if (isset(static::registered_callbacks()[$type])) { + $found = array_search($cb, self::$config[static::class]['registered_callbacks'][$type], true); + if ($found === false) { + self::$config[static::class]['registered_callbacks'][$type][] = $cb; + $reg++; + } + } else { + throw new InvalidArgumentException('Unknown callback type: ' . $type); + } + } + return $reg; + } + + /** + * unregister given callback for one or many possible callback types + * + * @param string|array $types types to unregister callback for + * @param mixed $cb + * @throws InvalidArgumentException if the callback type is not known + * @return number of unregistered callbacks + */ + protected static function unregisterCallback($types, $cb) + { + trigger_error(__METHOD__ . ' is deprecated. Please use the configuration in configure().', E_USER_DEPRECATED); + + $types = is_array($types) ? $types : words($types); + foreach ($types as $type) { + if (isset(static::registered_callbacks()[$type])) { + $found = array_search($cb, self::$config[static::class]['registered_callbacks'][$type], true); + if ($found !== false) { + $unreg++; + unset(self::$config[static::class]['registered_callbacks'][$type][$found]); + } + } else { + throw new InvalidArgumentException('Unknown callback type: ' . $type); + } + } + return $unreg; + } + + /** + * default callback used to map specific callbacks to NotificationCenter + * + * @param string $cb_type callback type + * @return void|boolean + */ + protected function cbNotificationMapper($cb_type) + { + if (isset($this->notification_map()[$cb_type])) { + try { + foreach(words($this->notification_map()[$cb_type]) as $notification) { + NotificationCenter::postNotification($notification, $this); + } + } catch (NotificationVetoException $e) { + return false; + } + } + } + + /** + * default callback used to map specific callbacks to NotificationCenter + * + * @param string $cb_type callback type + * @return void|boolean + */ + protected function cbAfterInitialize($cb_type) + { + foreach (array_keys($this->db_fields()) as $field) { + if (is_object($this->content[$field])) { + $this->content_db[$field] = clone $this->content[$field]; + } else { + $this->content_db[$field] = $this->content[$field]; + } + } + } + + /** + * default setter used to proxy serialized fields with + * ArrayObjects + * + * @param string $field column name + * @param mixed $value value + * @return mixed + */ + protected function setSerializedValue($field, $value) + { + $object_type = $this->serialized_fields()[$field]; + if (is_null($value) || $value instanceof $object_type) { + $this->content[$field] = $value; + } else { + $this->content[$field] = new $object_type($value); + } + return $this->content[$field]; + } + + /** + * default setter used to proxy I18N fields with + * I18NString + * + * @param string $field column name + * @param mixed $value value + * @return mixed + */ + protected function setI18nValue($field, $value) + { + $meta = ['object_id' => $this->getId(), + 'table' => $this->db_table(), + 'field' => $field]; + if ($value instanceof I18NString) { + $value->setMetadata($meta); + $this->content[$field] = $value; + } else { + $this->content[$field] = new I18NString($value, null, $meta); + } + return $this->content[$field]; + } + + /** + * Cleans up this object. This essentially reset all relations of + * this object and marks them as unused so that the garbage collector may + * remove the objects. + * + * Use this function when you ran into memory problems and need to free + * some memory; + * + * @return void + */ + public function cleanup() + { + foreach ($this->relations as $relation => $object) { + if ($object instanceof SimpleORMap || $object instanceof SimpleORMapCollection) { + $this->relations[$relation]->cleanup(); + } + $this->resetRelation($relation); + } + } +} diff --git a/lib/classes/SimpleORMapCollection.class.php b/lib/classes/SimpleORMapCollection.class.php new file mode 100644 index 0000000..7debb80 --- /dev/null +++ b/lib/classes/SimpleORMapCollection.class.php @@ -0,0 +1,258 @@ + + * @copyright 2012 Stud.IP Core-Group + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + * + * @extends SimpleCollection + * + * @template T of SimpleORMap + */ +class SimpleORMapCollection extends SimpleCollection +{ + /** + * @var int Exception error code denoting a wrong type of objects. + */ + const WRONG_OBJECT_TYPE = 1; + + /** + * @var int Exception error code denoting that an object of this `id` already exists. + */ + const OBJECT_EXISTS = 2; + + /** + * the record object this collection belongs to + * + * @var ?SimpleORMap + */ + protected $related_record; + + /** + * relation options + * @var array + */ + protected $relation_options = []; + + /** + * creates a collection from an array of objects + * all objects should be of the same type + * + * @throws InvalidArgumentException if first entry is not SimpleOrMap + * @param T[] $data array with SimpleORMap objects + * @param bool $strict check every element for correct type and unique pk + * @return SimpleORMapCollection + */ + public static function createFromArray(array $data, $strict = true) + { + $ret = new SimpleORMapCollection(); + if (count($data)) { + $first = current($data); + if ($first instanceof SimpleORMap) { + $ret->setClassName(get_class($first)); + if ($strict) { + foreach ($data as $one) { + $ret[] = $one; + } + } else { + $ret->exchangeArray($data); + } + } else { + throw new InvalidArgumentException('This collection only accepts objects derived from SimpleORMap', self::WRONG_OBJECT_TYPE); + } + } + return $ret; + } + + /** + * Constructor + * + * @param ?Closure $finder callable to fill collection + * @param ?array $options relationship options + * @param SimpleORMap|null $record related record + */ + public function __construct(Closure $finder = null, array $options = null, SimpleORMap $record = null) + { + $this->relation_options = $options; + $this->related_record = $record; + parent::__construct($finder === null ? [] : $finder); + } + + /** + * Sets the value at the specified index + * checks if the value is an object of specified class + * + * @see ArrayObject::offsetSet() + * @throws InvalidArgumentException if the given model does not fit (wrong type or id) + */ + public function offsetSet($index, $newval): void + { + if (!is_null($index)) { + $index = (int)$index; + } + if (!is_a($newval, $this->getClassName())) { + throw new InvalidArgumentException('This collection only accepts objects of type: ' . $this->getClassName(), self::WRONG_OBJECT_TYPE); + } + if ($this->related_record && $this->relation_options['type'] === 'has_many') { + $foreign_key_value = call_user_func($this->relation_options['assoc_func_params_func'], $this->related_record); + call_user_func($this->relation_options['assoc_foreign_key_setter'], $newval, $foreign_key_value); + } + if ($newval->id !== null) { + $exists = $this->find($newval->id); + if ($exists) { + throw new InvalidArgumentException('Element could not be appended, element with id: ' . $exists->id . ' is in the way', self::OBJECT_EXISTS); + } + } + parent::offsetSet($index, $newval); + } + + /** + * sets the allowed class name + * @param class-string $class_name + * @return void + */ + public function setClassName($class_name) + { + $this->relation_options['class_name'] = strtolower($class_name); + $this->deleted->relation_options['class_name'] = strtolower($class_name); + } + + /** + * sets the related record + * + * @param SimpleORMap $record + * @return void + */ + public function setRelatedRecord(SimpleORMap $record) + { + $this->related_record = $record; + } + + /** + * gets the allowed classname + * + * @return string + */ + public function getClassName() + { + return strtolower($this->relation_options['class_name']); + } + + /** + * reloads the elements of the collection + * by calling the finder function + * + * @throws InvalidArgumentException + * @return ?int number of records after refresh + */ + public function refresh() + { + if (is_callable($this->finder)) { + $data = call_user_func($this->finder, $this->related_record); + foreach ($data as $one) { + if (!is_a($one, $this->getClassName())) { + throw new InvalidArgumentException('This collection only accepts objects of type: ' . $this->getClassName(), self::WRONG_OBJECT_TYPE); + } + } + $this->exchangeArray($data); + $this->deleted->exchangeArray([]); + return $this->last_count = $this->count(); + } + + return null; + } + + /** + * returns element with given primary key value + * + * @param string $value primary key value to search for + * @return ?T + */ + public function find($value) + { + return $this->findOneBy('id', $value); + } + + /** + * returns the collection as grouped array + * first param is the column to group by, it becomes the key in + * the resulting array, default is pk. Limit returned fields with second param + * The grouped entries can optoionally go through the given + * callback. If no callback is provided, only the first grouped + * entry is returned, suitable for grouping by unique column + * + * @param string $group_by the column to group by, pk if ommitted + * @param mixed $only_these_fields limit returned fields + * @param ?callable $group_func closure to aggregate grouped entries + * @return array assoc array + */ + public function toGroupedArray($group_by = 'id', $only_these_fields = null, callable $group_func = null) + { + $result = []; + foreach ($this as $record) { + $key = $record->getValue($group_by); + if (is_array($key)) { + $key = join('_', $key); + } + $result[$key][] = $record->toArray($only_these_fields); + } + if ($group_func === null) { + $group_func = 'current'; + } + return array_map($group_func, $result); + } + + /** + * mark element(s) for deletion + * element(s) with given primary key are moved to + * internal deleted collection + * + * @param string $id primary key of element + * @return int number of unsetted elements + */ + public function unsetByPk($id) + { + return $this->unsetBy('id', $id); + } + + /** + * merge in another collection, elements must be of + * the same type, if an element already exists it is + * replaced or ignored depending on second param + * + * @param SimpleORMapCollection $a_collection + * @param string $mode 'replace' or 'ignore' + * @return void + */ + public function merge(SimpleCollection $a_collection, string $mode = 'ignore') + { + $mode = func_get_arg(1); + foreach ($a_collection as $element) { + try { + /** + * @throws InvalidArgumentException + * @see SimpleORMapCollection::offsetSet() + */ + $this[] = $element; + } catch (InvalidArgumentException $e) { + if ($e->getCode() === self::OBJECT_EXISTS) { + if ($mode === 'replace') { + $this->unsetByPk($element->id); + $this[] = $element; + } // else $mode means 'ignore' + } else { + throw $e; + } + } + } + $this->storage = array_values($this->storage); + } +} diff --git a/lib/models/SimpleCollection.class.php b/lib/models/SimpleCollection.class.php deleted file mode 100644 index 6acc10d..0000000 --- a/lib/models/SimpleCollection.class.php +++ /dev/null @@ -1,782 +0,0 @@ - - * @copyright 2013 Stud.IP Core-Group - * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 - * @category Stud.IP - * - * @template T - */ -class SimpleCollection extends StudipArrayObject -{ - /** - * callable to initialize collection - * - * @var ?callable(): array - */ - protected $finder; - - /** - * number of records after last init - * - * @var int - */ - protected $last_count; - - /** - * collection with deleted records - * @var static - */ - protected $deleted; - - /** - * creates a collection from an array of arrays - * all arrays should contain same keys, but is not enforced - * - * @param array $data array containing assoc arrays - * @return SimpleCollection - */ - public static function createFromArray(array $data) - { - return new self($data); - } - - /** - * converts arrays or objects to ArrayObject objects - * if ArrayAccess interface is not available - * - * @param mixed $a - * @return StudipArrayObject|ArrayAccess - */ - public static function arrayToArrayObject($a) - { - if ($a instanceof StudipArrayObject) { - $a->setFlags(StudipArrayObject::ARRAY_AS_PROPS); - return $a; - } - - if ($a instanceof ArrayObject) { - return new StudipArrayObject($a->getArrayCopy(), StudipArrayObject::ARRAY_AS_PROPS); - } - - if ($a instanceof ArrayAccess) { - return $a; - } - - return new StudipArrayObject((array) $a, StudipArrayObject::ARRAY_AS_PROPS); - } - - /** - * returns closure to compare a value against given arguments - * using given operator - * - * @param string|callable(mixed, mixed|array): bool $operator - * @param mixed|array $args - * @throws InvalidArgumentException - * @return callable(mixed): bool comparison function - */ - public static function getCompFunc($operator, $args) - { - if (is_callable($operator)) { - $comp_func = function ($a) use ($args, $operator) { - return $operator($a, $args); - }; - } else { - if (!is_array($args)) { - $args = [$args]; - } - switch ($operator) { - case '==': - $comp_func = function ($a) use ($args) { - return in_array($a, $args); - }; - break; - case '===': - $comp_func = function ($a) use ($args) { - return in_array($a, $args, true); - }; - break; - case '!=': - case '<>': - $comp_func = function ($a) use ($args) { - return !in_array($a, $args); - }; - break; - case '!==': - $comp_func = function ($a) use ($args) { - return !in_array($a, $args, true); - }; - break; - case '<': - case '>': - case '<=': - case '>=': - $op_func = function ($a, $b) use ($operator) { - if ($operator === '<') { - return $a < $b; - } elseif ($operator === '<=') { - return $a <= $b; - } elseif ($operator === '>=') { - return $a >= $b; - } elseif ($operator === '>') { - return $a > $b; - } - }; - $comp_func = function ($a) use ($op_func, $args) { - return $op_func($a, $args[0]); - }; - break; - case '><': - $comp_func = function ($a) use ($args) { - return $a > $args[0] && $a < $args[1]; - }; - break; - case '>=<=': - $comp_func = function ($a) use ($args) { - return $a >= $args[0] && $a <= $args[1]; - }; - break; - case '%=': - $comp_func = function ($a) use ($args) { - $a = mb_strtolower(static::translitLatin1($a)); - $args = array_map([static::class, 'translitLatin1'], $args); - $args = array_map('mb_strtolower', $args); - return in_array($a, $args); - }; - break; - case '*=': - $comp_func = function ($a) use ($args) { - foreach ($args as $arg) { - if (mb_strpos($a, $arg) !== false) { - return true; - } - } - return false; - }; - break; - case '^=': - $comp_func = function ($a) use ($args) { - foreach ($args as $arg) { - if (mb_strpos($a, $arg) === 0) { - return true; - } - } - return false; - }; - break; - case '$=': - $comp_func = function ($a) use ($args) { - foreach ($args as $arg) { - $found = mb_strrpos($a, $arg); - if ($found !== false && ($found + mb_strlen($arg)) === mb_strlen($a)) { - return true; - } - } - return false; - }; - break; - case '~=': - $comp_func = function ($a) use ($args) { - foreach ($args as $arg) { - if (preg_match($arg, $a) === 1) { - return true; - } - } - return false; - }; - break; - default: - throw new InvalidArgumentException('unknown operator: ' . $operator); - } - } - return $comp_func; - } - - /** - * transliterates latin1 string to ascii - * - * @param string $text - * @return string - */ - public static function translitLatin1($text) - { - if (!preg_match('/[\200-\377]/', $text)) { - return $text; - } - $text = str_replace(['ä','Ä','ö','Ö','ü','Ü','ß'], ['a','A','o','O','u','U','s'], $text); - $text = str_replace(['À','Á','Â','Ã','Å','Æ'], 'A' , $text); - $text = str_replace(['à','á','â','ã','å','æ'], 'a' , $text); - $text = str_replace(['È','É','Ê','Ë'], 'E' , $text); - $text = str_replace(['è','é','ê','ë'], 'e' , $text); - $text = str_replace(['Ì','Í','Î','Ï'], 'I' , $text); - $text = str_replace(['ì','í','î','ï'], 'i' , $text); - $text = str_replace(['Ò','Ó','Õ','Ô','Ø'], 'O' , $text); - $text = str_replace(['ò','ó','ô','õ','ø'], 'o' , $text); - $text = str_replace(['Ù','Ú','Û'], 'U' , $text); - $text = str_replace(['ù','ú','û'], 'u' , $text); - $text = str_replace(['Ç','ç','Ð','Ñ','Ý','ñ','ý','ÿ'], ['C','c','D','N','Y','n','y','y'] , $text); - return $text; - } - - /** - * Constructor - * - * @param array|callable(): array $data array or closure to fill collection - */ - public function __construct($data = []) - { - parent::__construct(); - $this->finder = is_callable($data) ? $data : null; - $this->deleted = clone $this; - if (is_callable($data)) { - $this->refresh(); - } else { - $this->exchangeArray($data); - } - } - - /** - * @param array $input - * @return array - */ - public function exchangeArray($input) - { - return parent::exchangeArray(array_map( - [static::class, 'arrayToArrayObject'], - $input - )); - } - - /** - * converts the object and all elements to plain arrays - * - * @return array - */ - public function toArray() - { - $args = func_get_args(); - return $this->map(function ($a) use ($args) { - if (method_exists($a, 'toArray')) { - return call_user_func_array([$a, 'toArray'], $args); - } - if (method_exists($a, 'getArrayCopy')) { - return $a->getArrayCopy(); - } - return (array) $a; - } - ); - } - - /** - * - * @see ArrayObject::append() - */ - public function append($newval) - { - parent::append(static::arrayToArrayObject($newval)); - } - - /** - * Sets the value at the specified index - * ensures the value has ArrayAccess - * - * @param mixed $index - * @param mixed $newval - * - * @see ArrayObject::offsetSet() - */ - - public function offsetSet($index, $newval): void - { - if (is_numeric($index)) { - $index = (int) $index; - } - parent::offsetSet($index, static::arrayToArrayObject($newval)); - } - - /** - * Unsets the value at the specified index - * value is moved to internal deleted collection - * - * @see ArrayObject::offsetUnset() - * @throws InvalidArgumentException - */ - public function offsetUnset($index): void - { - if ($this->offsetExists($index)) { - $this->deleted[] = $this->offsetGet($index); - } - parent::offsetUnset($index); - } - - /** - * sets the finder function - * - * @param callable(): array $finder - * @return void - */ - public function setFinder(callable $finder) - { - $this->finder = $finder; - } - - /** - * get deleted records collection - * @return SimpleCollection - */ - public function getDeleted() - { - return $this->deleted; - } - - /** - * reloads the elements of the collection - * by calling the finder function - * - * @return ?int of records after refresh - */ - public function refresh() - { - if (is_callable($this->finder)) { - $data = call_user_func($this->finder); - $this->exchangeArray($data); - $this->deleted->exchangeArray([]); - return $this->last_count = $this->count(); - } - } - - /** - * returns a new collection containing all elements - * where given columns value matches given value(s) using passed operator - * pass array for multiple values - * - * operators: - * == equal, like php - * === identical, like php - * !=,<> not equal, like php - * !== not identical, like php - * <,>,<=,>= less,greater,less or equal,greater or equal - * >< between without borders, needs two arguments - * >=<= between including borders, needs two arguments - * %= like string, transliterate to ascii,case insensitive - * *= contains string - * ^= begins with string - * $= ends with string - * ~= regex - * - * @param string $key the column name - * @param mixed $values value to search for - * @param string|callable $op operator to find - * @return SimpleCollection with found records - */ - public function findBy($key, $values, $op = '==') - { - $comp_func = self::getCompFunc($op, $values); - return $this->filter(function ($record) use ($comp_func, $key) { - return $comp_func($record[$key]); - }); - } - - /** - * returns the first element - * where given column has given value(s) - * pass array for multiple values - * - * @param string $key the column name - * @param mixed $values value to search for, - * @param string|callable $op operator to find - * @return ?T found record - */ - public function findOneBy($key, $values, $op = '==') - { - $comp_func = self::getCompFunc($op, $values); - return $this->filter(function ($record) use ($comp_func, $key) { - return $comp_func($record[$key]); - }, 1)->first(); - } - - /** - * apply given callback to all elements of - * collection - * - * @param callable(T): int $func the function to call - * @return int|false addition of return values - */ - public function each(callable $func) - { - $result = false; - foreach ($this->storage as $record) { - $result += call_user_func($func, $record); - } - return $result; - } - - /** - * apply given callback to all elements of - * collection and give back array of return values - * - * @param callable(T, mixed): mixed $func the function to call - * @return array - */ - public function map(callable $func) - { - $results = []; - foreach ($this->storage as $key => $value) { - $results[$key] = call_user_func($func, $value, $key); - } - return $results; - } - - /** - * filter elements - * if given callback returns true - * - * @param ?callable(T, mixed): bool $func the function to call - * @param ?integer $limit limit number of found records - * @return SimpleCollection containing filtered elements - */ - public function filter(callable $func = null, $limit = null) - { - $results = []; - $found = 0; - foreach ($this->storage as $key => $value) { - if (call_user_func($func, $value, $key)) { - $results[$key] = $value; - if ($limit && (++$found == $limit)) { - break; - } - } - } - return self::createFromArray($results); - } - - /** - * Returns whether any element of the collection returns true for the - * given callback. - * - * @param callable(T, mixed): bool $func the function to call - * @return bool - */ - public function any(callable $func) - { - foreach ($this->storage as $key => $value) { - if (call_user_func($func, $value, $key)) { - return true; - } - } - return false; - } - - /** - * Returns whether every element of the collection returns true for the - * given callback. - * - * @param callable(T, mixed): bool $func the function to call - * @return bool - */ - public function every(callable $func) - { - foreach ($this->storage as $key => $value) { - if (!call_user_func($func, $value, $key)) { - return false; - } - } - return true; - } - - /** - * extract array of columns values - * pass array or space-delimited string for multiple columns - * - * @param string|array $columns the column(s) to extract - * @return array of extracted values - */ - public function pluck($columns) - { - if (!is_array($columns)) { - $columns = words($columns); - } - $func = function ($r) use ($columns) { - $result = []; - foreach ($columns as $c) { - $result[] = $r[$c]; - } - return $result; - }; - $result = $this->map($func); - return count($columns) === 1 ? array_map('current', $result) : $result; - } - - /** - * returns the collection as grouped array - * first param is the column to group by, it becomes the key in - * the resulting array, default is pk. Limit returned fields with second param - * The grouped entries can optoionally go through the given - * callback. If no callback is provided, only the first grouped - * entry is returned, suitable for grouping by unique column - * - * @param string $group_by the column to group by, pk if ommitted - * @param string|array|null $only_these_fields limit returned fields - * @param ?callable $group_func closure to aggregate grouped entries - * @return array assoc array - */ - public function toGroupedArray($group_by = 'id', $only_these_fields = null, callable $group_func = null) - { - $result = []; - if (is_string($only_these_fields)) { - $only_these_fields = words($only_these_fields); - } - foreach ($this->toArray() as $record) { - $key = $record[$group_by]; - $ret = []; - if (is_array($only_these_fields)) { - $result[$key][] = array_intersect_key($record, array_flip($only_these_fields)); - } else { - $result[$key][] = $record; - } - } - if ($group_func === null) { - $group_func = 'current'; - } - return array_map($group_func, $result); - } - - /** - * get the first element - * - * @return ?T first element or null - */ - public function first() - { - $keys = array_keys($this->storage); - $first_offset = reset($keys); - return $this->offsetGet($first_offset ?: 0); - } - - /** - * get the last element - * - * @return ?T last element or null - */ - public function last() - { - $keys = array_keys($this->storage); - $last_offset = end($keys); - return $this->offsetGet($last_offset ?: 0); - } - - /** - * get the the value from given key from first element - * - * @param string $key - * @return mixed - */ - public function val($key) - { - $first = $this->first(); - return $first[$key] ?? null; - } - - /** - * mark element(s) for deletion - * where given column has given value(s) - * element(s) are moved to - * internal deleted collection - * pass array for multiple values - * - * operators: - * == equal, like php - * === identical, like php - * !=,<> not equal, like php - * !== not identical, like php - * <,>,<=,>= less,greater,less or equal,greater or equal - * >< between without borders, needs two arguments - * >=<= between including borders, needs two arguments - * %= like string, transliterate to ascii,case insensitive - * *= contains string - * ^= begins with string - * $= ends with string - * ~= regex - * - * @param string $key - * @param mixed $values - * @param string|callable(mixed, mixed|array): bool $op operator to find elements - * @return int|false number of unsetted elements - */ - public function unsetBy($key, $values, $op = '==') - { - $ret = false; - $comp_func = self::getCompFunc($op, $values); - foreach ($this->storage as $k => $record) { - if ($comp_func($record[$key])) { - $this->offsetunset($k); - $ret += 1; - } - } - return $ret; - } - - /** - * sorts the collection by columns of contained elements and returns it - * - * works like sql order by: - * first param is a string containing combinations of column names - * and sort direction, separated by comma e.g. - * 'name asc, nummer desc ' - * sorts first by name ascending and then by nummer descending - * second param denotes the sort type (using PHP sort constants): - * SORT_LOCALE_STRING: - * compare items as strings, transliterate latin1 to ascii, case insensitiv, natural order for numbers - * SORT_NUMERIC: - * compare items as integers - * SORT_STRING: - * compare items as strings - * SORT_NATURAL: - * compare items as strings using "natural ordering" - * SORT_FLAG_CASE: - * can be combined (bitwise OR) with SORT_STRING or SORT_NATURAL to sort strings case-insensitively - * - * @param string $order columns to order by - * @param integer $sort_flags - * @return $this the sorted collection - */ - public function orderBy($order, $sort_flags = SORT_LOCALE_STRING) - { - //('name asc, nummer desc ') - $sort_locale = false; - switch ($sort_flags) { - case SORT_NATURAL: - $sort_func = 'strnatcmp'; - break; - case SORT_NATURAL | SORT_FLAG_CASE: - $sort_func = 'strnatcasecmp'; - break; - case SORT_STRING | SORT_FLAG_CASE: - $sort_func = 'strcasecmp'; - break; - case SORT_STRING: - $sort_func = 'strcmp'; - break; - case SORT_NUMERIC: - $sort_func = function ($a, $b) { - return (int) $a - (int) $b; - }; - break; - case SORT_LOCALE_STRING: - default: - $sort_func = 'strnatcasecmp'; - $sort_locale = true; - } - - $sorter = []; - foreach (explode(',', $order) as $one) { - $sorter[] = array_values(array_filter(array_map('trim', explode(' ', $one)))); - } - - $func = function ($d1, $d2) use ($sorter, $sort_func, $sort_locale) { - do { - $current_sorter = current($sorter); - $field = $current_sorter[0]; - $dir = $current_sorter[1] ?? ''; - if (!$sort_locale) { - $value1 = $d1[$field]; - $value2 = $d2[$field]; - } else { - $value1 = static::translitLatin1(mb_substr($d1[$field], 0, 100)); - $value2 = static::translitLatin1(mb_substr($d2[$field], 0, 100)); - } - $ret = $sort_func($value1, $value2); - if (strtolower($dir) == 'desc') $ret = $ret * -1; - } while ($ret === 0 && next($sorter)); - - return $ret; - }; - if (count($sorter)) { - $this->uasort($func); - } - return $this; - } - - /** - * returns a new collection contaning a sequence of original collection - * mimics the sql limit constrain: - * used with one parameter, the first x elements are extracted - * used with two parameters, the first parameter denotes the offset, the second the - * number of elements - * - * @param integer $arg1 - * @param ?integer $arg2 - * @return SimpleCollection - */ - public function limit($arg1, $arg2 = null) - { - if (is_null($arg2)) { - if ($arg1 > 0) { - $row_count = $arg1; - $offset = 0; - } else { - $row_count = abs($arg1); - $offset = $arg1; - } - } else { - $offset = $arg1; - $row_count = $arg2; - } - return self::createFromArray(array_slice($this->storage, $offset, $row_count, true)); - } - - /** - * calls the given method on all elements - * of the collection - * @param literal-string $method methodname to call - * @param array $params parameters for methodcall - * @return array of all return values - */ - public function sendMessage($method, $params = []) { - $results = []; - foreach ($this->storage as $record) { - $results[] = call_user_func_array([$record, $method], $params); - } - return $results; - } - - /** - * magic version of sendMessage - * calls undefineds methods on all elements of the collection - * But beware of the dark side... - * - * @param literal-string $method methodname to call - * @param array $params parameters for methodcall - * @return array of all return values - */ - public function __call($method, $params) - { - return $this->sendMessage($method, $params); - } - - /** - * merge in another collection, elements are appended - * - * @param SimpleCollection $a_collection - * @return void - */ - public function merge(SimpleCollection $a_collection) - { - $this->storage = array_merge($this->storage, $a_collection->getArrayCopy()); - } -} diff --git a/lib/models/SimpleORMap.class.php b/lib/models/SimpleORMap.class.php deleted file mode 100644 index f86ec6f..0000000 --- a/lib/models/SimpleORMap.class.php +++ /dev/null @@ -1,2476 +0,0 @@ - - * @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 - * @return array - */ - protected static function alias_fields() - { - return static::config('alias_fields'); - } - - /** - * multi-language fields - * name => boolean - * @return array - */ - protected static function i18n_fields() - { - return static::config('i18n_fields'); - } - - /** - * additional computed fields - * name => callable - * @return array - */ - protected static function additional_fields() - { - return static::config('additional_fields'); - } - - /** - * 1:n relation - * @return array - */ - protected static function has_many() - { - return static::config('has_many'); - } - - /** - * 1:1 relation - * @return array - */ - protected static function has_one() - { - return static::config('has_one'); - } - - /** - * n:1 relations - * @return array - */ - protected static function belongs_to() - { - return static::config('belongs_to'); - } - - /** - * n:m relations - * @return array - */ - protected static function has_and_belongs_to_many() - { - return static::config('has_and_belongs_to_many'); - } - - /** - * callbacks - * @return array> - */ - 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) - * @return array - */ - 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' - * - * @return array - */ - protected static function notification_map() - { - return static::config('notification_map'); - } - - /** - * assoc array for mapping get/set Methods - * - * @return array - */ - protected static function getter_setter_map() - { - return static::config('getter_setter_map'); - } - - ////////////////////////////////////////////////// - - /** - * set configuration data from subclass - * - * @param ?array $config configuration data - * @return void - */ - protected static function configure($config = []) - { - $class = static::class; - - 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) { - if (is_array($a_config) && !(isset($a_config['get']) || isset($a_config['set']))) { - $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) { - 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']), - 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 - * @return mixed value of config key (null if not set) - */ - protected static function config($key) - { - if (!array_key_exists(static::class, self::$config)) { - static::configure(); - } - - 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'){ - $pk[] = strtolower($rs['Field']); - } - } - 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 - * @return void - */ - 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); - /** @var static $record */ - $record = $ref->newInstanceArgs(func_get_args()); - if (!$record->isNew()) { - return $record; - } else { - return null; - } - } - - /** - * Returns true if given key exists in the database. - * - * @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 - * @return int - */ - 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 = 'WHERE ' . $sql; - } - $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 - * @return ?static - */ - public static function create($data) - { - $record = new static(); - $record->setData($data, false); - if ($record->store()) { - return $record; - } else { - return null; - } - } - - /** - * build object with given data - * - * @param array $data assoc array of record - * @param ?bool $is_new set object to new state - * @return static - */ - public static function build($data, $is_new = true) - { - $record = new static(); - $record->setData($data, !$is_new); - $record->setNew($is_new); - return $record; - } - - /** - * build object with given data and mark it as existing - * - * @param array $data assoc array of record - * @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) { - $temp = static::alias_fields()[$key] ?? $key; - if (isset(static::db_fields()[$temp])) { - $record_data[$key] = $value; - } 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); - } - 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); - } - } 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(); - - $has_join = stripos($sql, 'JOIN '); - if ($has_join === false || $has_join > 10) { - $sql = 'WHERE ' . $sql; - } - $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` - WHERE `$thru_table`.`$thru_key` = ? " . ($options['order_by'] ?? ''); - $st = DBManager::get()->prepare($sql); - $st->execute([$foreign_key_value]); - - $record = static::build([], false); - - $ret = []; - do { - $clone = clone $record; - $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 - * @param string $sql where clause of sql - * @param ?array $params sql statement parameters - * @return integer number of found records - */ - public static function findEachBySQL($callable, $sql, $params = []) - { - $has_join = stripos($sql, 'JOIN '); - if ($has_join === false || $has_join > 10) { - $sql = "WHERE {$sql}"; - } - - $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; - - $record = static::build([], false); - - $ret = 0; - do { - $clone = clone $record; - $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)) { - $pk = static::pk(); - $key_values = []; - 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; - } - } 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(); - $name = strtolower($name); - $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': - $order = $arguments[1] ?? ''; - $param_arr[0] =& $where; - $param_arr[1] = [$where_param]; - $method = 'findonebysql'; - break; - case 'find': - case 'findmany': - $order = $arguments[1] ?? ''; - $param_arr[0] =& $where; - $param_arr[1] = [$where_param]; - $method = 'findbysql'; - break; - case 'findeach': - case 'findeachmany': - $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"; - break; - default: - 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 _) - * @param mixed $field unused parameter - * @return ?string - */ - 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 - * @return bool - */ - protected function _setId($field, $value) - { - return $this->setId(explode(self::ID_SEPARATOR, $value)); - } - - /** - * retrieves an additional field value from relation - * - * @param string $field - * @return mixed - */ - 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)); - } - return $this->additional_data[$field]; - } - - /** - * sets additional value in field imported from relation - * - * @param string $field - * @param mixed $value - * @return mixed - */ - protected function _setAdditionalValueFromRelation($field, $value) - { - list($relation, $relation_field) = [$this->additional_fields()[$field]['relation'], - $this->additional_fields()[$field]['relation_field']]; - $this->$relation->$field = $value; - unset($this->additional_data[$field]); - return $this->_getAdditionalValueFromRelation($field); - } - - /** - * @param string $field - * @return mixed - */ - protected function _getAdditionalValue($field) - { - return $this->additional_data[$field]; - } - - /** - * @param string $field - * @param mixed $value - * @return mixed - */ - protected function _setAdditionalValue($field, $value) - { - return $this->additional_data[$field] = $value; - } - - /** - * clean up references after cloning - * @return void - */ - function __clone() - { - $this->setNew(true); - //all references link still to old object => reset all aliases - foreach ($this->alias_fields() as $alias => $field) { - if (isset($this->db_fields()[$field])) { - $content_value = $this->content[$field]; - $content_db_value = $this->content_db[$field]; - unset($this->content[$alias]); - unset($this->content_db[$alias]); - unset($this->content[$field]); - unset($this->content_db[$field]); - if (is_object($content_value)) { - $this->content[$field] = clone $content_value; - } else { - $this->content[$field] = $content_value; - } - if (is_object($content_db_value)) { - $this->content_db[$field] = clone $content_db_value; - } else { - $this->content_db[$field] = $content_db_value; - } - } - } - 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]; - } - } - //unset all relations for now - //TODO: maybe a deep copy of all belonging objects is more appropriate - 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 (empty($options['class_name'])) { - throw new Exception('Option class_name not set for relation ' . $name); - } - if (empty($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 (empty($options['thru_key'])) { - $options['thru_key'] = $this->pk()[0]; - } - if (empty($options['thru_assoc_key']) || empty($options['assoc_foreign_key'])) { - $class = $options['class_name']; - $record = new $class(); - $meta = $record->getTableMetadata(); - if (empty($options['thru_assoc_key'])) { - $options['thru_assoc_key'] = $meta['pk'][0]; - } - if (empty($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 (empty($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 (empty($options['foreign_key'])) { - $options['foreign_key'] = 'id'; - } - if (isset($options['foreign_key']) && $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 (isset($options['assoc_foreign_key']) && $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 (!empty($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]; - if (!isset($options['type'])) { - $options = $this->parseRelationOptions($type, $relation, $options, $this->db_table()); - $options['type'] = $type; - self::$config[get_class($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 int|string|array $id 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; - } - } - - /** - * returns primary key, multiple keys as array - * @return null|string|array current primary key, null if not set - */ - function getId() - { - if (count($this->pk()) == 1) { - return $this->content[$this->pk()[0]] ?? null; - } else { - $id = []; - foreach ($this->pk() as $key) { - if (array_key_exists($key, $this->content)) { - $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 null|array|string $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 null|array|string $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 null|array|string $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) - { - $field = strtolower($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) - { - $field = strtolower($field); - $options = $this->getRelationOptions($relation); - if ($options['type'] === 'has_one' || $options['type'] === 'belongs_to') { - return $this->{$relation}->{$field} ?? null; - } 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 mixed $value - * @return string - */ - function setValue($field, $value) - { - $field = strtolower($field); - $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 - * - * @throws InvalidArgumentException if column could not be found - * @throws BadMethodCallException if getter for additional field could not be found - * @param string $field the column or additional field - * @return null|string|SimpleORMapCollection - */ - function __get($field) - { - return $this->getValue($field); - } - /** - * magic method for dynamic properties - * - * @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 __set($field, $value) - { - return $this->setValue($field, $value); - } - /** - * magic method for dynamic properties - * - * @param string $field - * @return bool - */ - function __isset($field) - { - $field = strtolower($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. - * - * @param string $offset - */ - public function offsetExists($offset): bool - { - return $this->__isset($offset); - } - - /** - * ArrayAccess: Get the value at the given offset. - * - * @throws InvalidArgumentException if column could not be found - * @throws BadMethodCallException if getter for additional field could not be found - * @param string $offset the column or additional field - * @return null|string|SimpleORMapCollection - */ - public function offsetGet($offset): mixed - { - return $this->getValue($offset); - } - - /** - * ArrayAccess: Set the value at the given offset. - * - * @throws InvalidArgumentException if column could not be found - * @throws BadMethodCallException if setter for additional field could not be found - * @param string $offset - * @param mixed $value - */ - public function offsetSet($offset, $value): void - { - $this->setValue($offset, $value); - } - - /** - * ArrayAccess: unset the value at the given offset (not applicable) - * - * @param string $offset - */ - public function offsetUnset($offset): void - { - - } - - /** - * IteratorAggregate - */ - public function getIterator(): ArrayIterator - { - return new ArrayIterator($this->toArray()); - } - - /** - * Countable - */ - public function count(): int - { - return count($this->known_slots()) - count($this->relations); - } - - /** - * check if given column exists in table - * @param string $field - * @return boolean - */ - function isField($field) - { - $field = strtolower($field); - return isset($this->db_fields()[$field]); - } - - /** - * check if given relation exists in this class - * @param string $field - * @return boolean - */ - function isRelation($field) - { - $field = strtolower($field); - return array_key_exists($field, $this->relations); - } - - /** - * check if given column is additional - * @param string $field - * @return boolean - */ - function isAdditionalField($field) - { - $field = strtolower($field); - return isset($this->additional_fields()[$field]); - } - - /** - * check if given column is an alias - * @param string $field - * @return boolean - */ - function isAliasField($field) - { - $field = strtolower($field); - return isset($this->alias_fields()[$field]); - } - - /** - * check if given column is a multi-language field - * @param string $field - * @return boolean - */ - function isI18nField($field) - { - $field = strtolower($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 ?iterable $data assoc array - * @param ?boolean $reset existing data in object will be discarded - * @return int|bool 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_iterable($data)) { - foreach($data as $key => $value) { - $key = strtolower($key); - 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 - * @throws UnexpectedValueException if the primary key is incomplete - * @return boolean|array - */ - function getWhereQuery() - { - $where_query = null; - $pk_not_set = []; - foreach ($this->pk() as $key) { - $pk = $this->content_db[$key] ?? $this->content[$key] ?? null; - 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(); - $id = $this->getId(); - if ($where_query) { - if ($this->applyCallbacks('before_initialize') === false) { - return false; - } - $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() - { - // Set id or prepare setting of id - if ($this->isNew() && $this->getId() === null) { - // Explicitly set id to 0 if auto increment pk is null - if ($this->hasAutoIncrementColumn()) { - $this->setId(0); - } else { - $this->setId($this->getNewId()); - } - } - - if ($this->applyCallbacks('before_store') === false) { - return false; - } - - $ret = 0; - - if (!$this->isDeleted() && ($this->isDirty() || $this->isNew())) { - $callback = $this->isNew() ? 'before_create' : 'before_update'; - if ($this->applyCallbacks($callback) === false) { - return false; - } - - // Collect i18n contents - $i18ncontent = []; - foreach (array_keys($this->i18n_fields()) as $field) { - if ($this->content[$field] instanceof I18NString) { - $i18ncontent[$field] = $this->content[$field]; - $this->content[$field] = $this->content[$field]->original(); - $this->content_db[$field] = $this->content_db[$field]->original(); - } - } - - // Create sql data assignment chunks - 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) . " "; - } - - // Create store query - 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); - - // Retrieve generated id from database if pk is an auto increment - // column - if ($this->isNew()) { - if ($this->hasAutoIncrementColumn() && !$this->getId()) { - $this->setId(DBManager::get()->lastInsertId()); - } - } - - // Store i18n contents - foreach ($i18ncontent as $field => $one) { - $meta = [ - 'object_id' => $this->getId(), - 'table' => $this->db_table(), - 'field' => $field - ]; - $one->setMetadata($meta); - $one->storeTranslations(); - if (!$this->content[$field] instanceof I18NString) { - $this->content[$field] = $one; - $this->content_db[$field] = clone $one; - } - } - - // Apply callbacks - $this->applyCallbacks($this->isNew() ? 'after_create' : '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 - * - * @param null|array|string $only_these - * @return int|false 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; - } - } - - return false; - } - - /** - * delete entry from database - * the object is cleared, but is not(!) turned to new state - * @return bool|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'); - - // Remove i18n translations - if (I18N::isEnabled()) { - foreach (array_keys($this->i18n_fields()) as $field) { - if ($this->content[$field] instanceof I18NString) { - $this->content[$field]->removeTranslations(); - } - } - } - } - $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 bool|int 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 - * @return void - */ - 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 { - throw new UnexpectedValueException(sprintf('Column %s not found for alias %s', $field, $alias)); - } - } - foreach (array_keys($this->relations) as $one) { - $this->relations[$one] = null; - } - $this->additional_data = []; - } - - /** - * checks if at least one field was modified since last restore - * - * @return boolean - */ - public function isDirty() - { - foreach (array_keys($this->db_fields()) as $field) { - if ($this->isFieldDirty($field)) { - return true; - } - } - return false; - } - - /** - * checks if given field was modified since last restore - * - * @param string $field - * @return boolean - */ - public function isFieldDirty($field) - { - $field = strtolower($field); - if ($this->content[$field] === null || $this->content_db[$field] === null) { - return $this->content[$field] !== $this->content_db[$field]; - } else if ($this->content[$field] instanceof I18NString || $this->content_db[$field] instanceof I18NString) { - return $this->content[$field] != $this->content_db[$field]; - } else { - return (string)$this->content[$field] !== (string)$this->content_db[$field]; - } - } - - /** - * reverts value of given field to last restored value - * - * @param string $field - * @return mixed the restored value - */ - public function revertValue($field) - { - $field = strtolower($field); - return ($this->content[$field] = $this->content_db[$field]); - } - - /** - * returns unmodified value of given field - * - * @param string $field - * @throws InvalidArgumentException - * @return mixed - */ - public function getPristineValue($field) - { - $field = strtolower($field); - if (array_key_exists($field, $this->content_db)) { - return $this->content_db[$field]; - } else { - throw new InvalidArgumentException(get_class($this) . '::'. $field . ' not found.'); - } - } - - /** - * intitalize a relationship and get related record(s) - * - * @param string $relation name of relation - * @throws InvalidArgumentException if the relation does not exists - * @return void - */ - public function initRelation($relation) - { - if (!array_key_exists($relation, $this->relations)) { - throw new InvalidArgumentException('Unknown relation: ' . $relation); - } - if ($this->relations[$relation] === null) { - $options = $this->getRelationOptions($relation); - $to_call = [$options['class_name'], $options['assoc_func']]; - if (!is_callable($to_call)) { - throw new RuntimeException('assoc_func: ' . join('::', $to_call) . ' is not callable.' ); - } - $params = $options['assoc_func_params_func']; - if ($options['type'] === 'has_many') { - $records = function($record) use ($to_call, $params, $options) { - $p = (array)$params($record); - return call_user_func_array($to_call, array_merge(count($p) ? $p : [null], [$options['order_by'] ?? null])); - }; - $this->relations[$relation] = new SimpleORMapCollection($records, $options, $this); - } elseif ($options['type'] === 'has_and_belongs_to_many') { - $records = function($record) use ($to_call, $params, $options) {$p = (array)$params($record); return call_user_func_array($to_call, array_merge(count($p) ? $p : [null], [$options]));}; - $this->relations[$relation] = new SimpleORMapCollection($records, $options, $this); - } else { - $p = (array)$params($this); - $records = call_user_func_array($to_call, count($p) ? $p : [null]); - $result = is_array($records) ? ($records[0] ?? null) : $records; - $this->relations[$relation] = $result; - } - } - } - - /** - * clear data for a relationship - * - * @param string $relation name of relation - * @throws InvalidArgumentException if teh relation does not exists - * @return void - */ - public function resetRelation($relation) - { - if (!array_key_exists($relation, $this->relations)) { - throw new InvalidArgumentException('Unknown relation: ' . $relation); - } - $this->relations[$relation] = null; - } - - /** - * invoke registered callbacks for given type - * if one callback returns false the following will not - * be invoked - * - * @param string $type type of callback - * @return bool return value from last callback - */ - protected function applyCallbacks($type) - { - $ok = true; - foreach ($this->registered_callbacks()[$type] as $cb) { - if ($cb instanceof Closure) { - $function = $cb; - $params = [$this, $type, $cb]; - } else { - $function = [$this, $cb]; - $params = [$type]; - }; - $ok = call_user_func_array($function, $params); - if ($ok === false) { - break; - } - } - return $ok; - } - - /** - * register given callback for one or many possible callback types - * callback param could be a closure or method name of current class - * - * @param string|array $types types to register callback for - * @param callable $cb callback - * @throws InvalidArgumentException if the callback type is not known - * @return number of registered callbacks - */ - protected static function registerCallback($types, $cb) - { - trigger_error(__METHOD__ . ' is deprecated. Please use the configuration in configure().', E_USER_DEPRECATED); - - $types = is_array($types) ? $types : words($types); - $reg = 0; - foreach ($types as $type) { - if (isset(static::registered_callbacks()[$type])) { - $found = array_search($cb, self::$config[static::class]['registered_callbacks'][$type], true); - if ($found === false) { - self::$config[static::class]['registered_callbacks'][$type][] = $cb; - $reg++; - } - } else { - throw new InvalidArgumentException('Unknown callback type: ' . $type); - } - } - return $reg; - } - - /** - * unregister given callback for one or many possible callback types - * - * @param string|array $types types to unregister callback for - * @param mixed $cb - * @throws InvalidArgumentException if the callback type is not known - * @return number of unregistered callbacks - */ - protected static function unregisterCallback($types, $cb) - { - trigger_error(__METHOD__ . ' is deprecated. Please use the configuration in configure().', E_USER_DEPRECATED); - - $types = is_array($types) ? $types : words($types); - foreach ($types as $type) { - if (isset(static::registered_callbacks()[$type])) { - $found = array_search($cb, self::$config[static::class]['registered_callbacks'][$type], true); - if ($found !== false) { - $unreg++; - unset(self::$config[static::class]['registered_callbacks'][$type][$found]); - } - } else { - throw new InvalidArgumentException('Unknown callback type: ' . $type); - } - } - return $unreg; - } - - /** - * default callback used to map specific callbacks to NotificationCenter - * - * @param string $cb_type callback type - * @return void|boolean - */ - protected function cbNotificationMapper($cb_type) - { - if (isset($this->notification_map()[$cb_type])) { - try { - foreach(words($this->notification_map()[$cb_type]) as $notification) { - NotificationCenter::postNotification($notification, $this); - } - } catch (NotificationVetoException $e) { - return false; - } - } - } - - /** - * default callback used to map specific callbacks to NotificationCenter - * - * @param string $cb_type callback type - * @return void|boolean - */ - protected function cbAfterInitialize($cb_type) - { - foreach (array_keys($this->db_fields()) as $field) { - if (is_object($this->content[$field])) { - $this->content_db[$field] = clone $this->content[$field]; - } else { - $this->content_db[$field] = $this->content[$field]; - } - } - } - - /** - * default setter used to proxy serialized fields with - * ArrayObjects - * - * @param string $field column name - * @param mixed $value value - * @return mixed - */ - protected function setSerializedValue($field, $value) - { - $object_type = $this->serialized_fields()[$field]; - if (is_null($value) || $value instanceof $object_type) { - $this->content[$field] = $value; - } else { - $this->content[$field] = new $object_type($value); - } - return $this->content[$field]; - } - - /** - * default setter used to proxy I18N fields with - * I18NString - * - * @param string $field column name - * @param mixed $value value - * @return mixed - */ - protected function setI18nValue($field, $value) - { - $meta = ['object_id' => $this->getId(), - 'table' => $this->db_table(), - 'field' => $field]; - if ($value instanceof I18NString) { - $value->setMetadata($meta); - $this->content[$field] = $value; - } else { - $this->content[$field] = new I18NString($value, null, $meta); - } - return $this->content[$field]; - } - - /** - * Cleans up this object. This essentially reset all relations of - * this object and marks them as unused so that the garbage collector may - * remove the objects. - * - * Use this function when you ran into memory problems and need to free - * some memory; - * - * @return void - */ - public function cleanup() - { - foreach ($this->relations as $relation => $object) { - if ($object instanceof SimpleORMap || $object instanceof SimpleORMapCollection) { - $this->relations[$relation]->cleanup(); - } - $this->resetRelation($relation); - } - } -} diff --git a/lib/models/SimpleORMapCollection.class.php b/lib/models/SimpleORMapCollection.class.php deleted file mode 100644 index 7debb80..0000000 --- a/lib/models/SimpleORMapCollection.class.php +++ /dev/null @@ -1,258 +0,0 @@ - - * @copyright 2012 Stud.IP Core-Group - * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 - * @category Stud.IP - * - * @extends SimpleCollection - * - * @template T of SimpleORMap - */ -class SimpleORMapCollection extends SimpleCollection -{ - /** - * @var int Exception error code denoting a wrong type of objects. - */ - const WRONG_OBJECT_TYPE = 1; - - /** - * @var int Exception error code denoting that an object of this `id` already exists. - */ - const OBJECT_EXISTS = 2; - - /** - * the record object this collection belongs to - * - * @var ?SimpleORMap - */ - protected $related_record; - - /** - * relation options - * @var array - */ - protected $relation_options = []; - - /** - * creates a collection from an array of objects - * all objects should be of the same type - * - * @throws InvalidArgumentException if first entry is not SimpleOrMap - * @param T[] $data array with SimpleORMap objects - * @param bool $strict check every element for correct type and unique pk - * @return SimpleORMapCollection - */ - public static function createFromArray(array $data, $strict = true) - { - $ret = new SimpleORMapCollection(); - if (count($data)) { - $first = current($data); - if ($first instanceof SimpleORMap) { - $ret->setClassName(get_class($first)); - if ($strict) { - foreach ($data as $one) { - $ret[] = $one; - } - } else { - $ret->exchangeArray($data); - } - } else { - throw new InvalidArgumentException('This collection only accepts objects derived from SimpleORMap', self::WRONG_OBJECT_TYPE); - } - } - return $ret; - } - - /** - * Constructor - * - * @param ?Closure $finder callable to fill collection - * @param ?array $options relationship options - * @param SimpleORMap|null $record related record - */ - public function __construct(Closure $finder = null, array $options = null, SimpleORMap $record = null) - { - $this->relation_options = $options; - $this->related_record = $record; - parent::__construct($finder === null ? [] : $finder); - } - - /** - * Sets the value at the specified index - * checks if the value is an object of specified class - * - * @see ArrayObject::offsetSet() - * @throws InvalidArgumentException if the given model does not fit (wrong type or id) - */ - public function offsetSet($index, $newval): void - { - if (!is_null($index)) { - $index = (int)$index; - } - if (!is_a($newval, $this->getClassName())) { - throw new InvalidArgumentException('This collection only accepts objects of type: ' . $this->getClassName(), self::WRONG_OBJECT_TYPE); - } - if ($this->related_record && $this->relation_options['type'] === 'has_many') { - $foreign_key_value = call_user_func($this->relation_options['assoc_func_params_func'], $this->related_record); - call_user_func($this->relation_options['assoc_foreign_key_setter'], $newval, $foreign_key_value); - } - if ($newval->id !== null) { - $exists = $this->find($newval->id); - if ($exists) { - throw new InvalidArgumentException('Element could not be appended, element with id: ' . $exists->id . ' is in the way', self::OBJECT_EXISTS); - } - } - parent::offsetSet($index, $newval); - } - - /** - * sets the allowed class name - * @param class-string $class_name - * @return void - */ - public function setClassName($class_name) - { - $this->relation_options['class_name'] = strtolower($class_name); - $this->deleted->relation_options['class_name'] = strtolower($class_name); - } - - /** - * sets the related record - * - * @param SimpleORMap $record - * @return void - */ - public function setRelatedRecord(SimpleORMap $record) - { - $this->related_record = $record; - } - - /** - * gets the allowed classname - * - * @return string - */ - public function getClassName() - { - return strtolower($this->relation_options['class_name']); - } - - /** - * reloads the elements of the collection - * by calling the finder function - * - * @throws InvalidArgumentException - * @return ?int number of records after refresh - */ - public function refresh() - { - if (is_callable($this->finder)) { - $data = call_user_func($this->finder, $this->related_record); - foreach ($data as $one) { - if (!is_a($one, $this->getClassName())) { - throw new InvalidArgumentException('This collection only accepts objects of type: ' . $this->getClassName(), self::WRONG_OBJECT_TYPE); - } - } - $this->exchangeArray($data); - $this->deleted->exchangeArray([]); - return $this->last_count = $this->count(); - } - - return null; - } - - /** - * returns element with given primary key value - * - * @param string $value primary key value to search for - * @return ?T - */ - public function find($value) - { - return $this->findOneBy('id', $value); - } - - /** - * returns the collection as grouped array - * first param is the column to group by, it becomes the key in - * the resulting array, default is pk. Limit returned fields with second param - * The grouped entries can optoionally go through the given - * callback. If no callback is provided, only the first grouped - * entry is returned, suitable for grouping by unique column - * - * @param string $group_by the column to group by, pk if ommitted - * @param mixed $only_these_fields limit returned fields - * @param ?callable $group_func closure to aggregate grouped entries - * @return array assoc array - */ - public function toGroupedArray($group_by = 'id', $only_these_fields = null, callable $group_func = null) - { - $result = []; - foreach ($this as $record) { - $key = $record->getValue($group_by); - if (is_array($key)) { - $key = join('_', $key); - } - $result[$key][] = $record->toArray($only_these_fields); - } - if ($group_func === null) { - $group_func = 'current'; - } - return array_map($group_func, $result); - } - - /** - * mark element(s) for deletion - * element(s) with given primary key are moved to - * internal deleted collection - * - * @param string $id primary key of element - * @return int number of unsetted elements - */ - public function unsetByPk($id) - { - return $this->unsetBy('id', $id); - } - - /** - * merge in another collection, elements must be of - * the same type, if an element already exists it is - * replaced or ignored depending on second param - * - * @param SimpleORMapCollection $a_collection - * @param string $mode 'replace' or 'ignore' - * @return void - */ - public function merge(SimpleCollection $a_collection, string $mode = 'ignore') - { - $mode = func_get_arg(1); - foreach ($a_collection as $element) { - try { - /** - * @throws InvalidArgumentException - * @see SimpleORMapCollection::offsetSet() - */ - $this[] = $element; - } catch (InvalidArgumentException $e) { - if ($e->getCode() === self::OBJECT_EXISTS) { - if ($mode === 'replace') { - $this->unsetByPk($element->id); - $this[] = $element; - } // else $mode means 'ignore' - } else { - throw $e; - } - } - } - $this->storage = array_values($this->storage); - } -} diff --git a/tests/unit/lib/VisualTest.php b/tests/unit/lib/VisualTest.php index dfa2945..c62cd05 100644 --- a/tests/unit/lib/VisualTest.php +++ b/tests/unit/lib/VisualTest.php @@ -9,7 +9,7 @@ * the License, or (at your option) any later version. */ -require_once 'lib/models/SimpleORMap.class.php'; +require_once 'lib/classes/SimpleORMap.class.php'; require_once 'lib/models/OpenGraphURL.class.php'; require_once 'lib/visual.inc.php'; require_once 'lib/classes/Config.class.php'; diff --git a/tests/unit/lib/classes/MigrationTest.php b/tests/unit/lib/classes/MigrationTest.php index 444ebf2..4a45e71 100644 --- a/tests/unit/lib/classes/MigrationTest.php +++ b/tests/unit/lib/classes/MigrationTest.php @@ -16,10 +16,10 @@ class MigrationTest extends \Codeception\Test\Unit $this->before = $GLOBALS['CACHING_ENABLE'] ?? null; $GLOBALS['CACHING_ENABLE'] = false; + require_once 'lib/classes/SimpleORMap.class.php'; require_once 'lib/classes/StudipCache.class.php'; require_once 'lib/classes/StudipMemoryCache.class.php'; require_once 'lib/classes/StudipCacheFactory.class.php'; - require_once 'lib/models/SimpleORMap.class.php'; require_once 'lib/migrations/Migration.php'; require_once 'lib/migrations/Migrator.php'; -- cgit v1.0