From 44ea04b2c6c17d269e72c81a90c61b3b0fc51e8e Mon Sep 17 00:00:00 2001 From: Dennis Eichhorn Date: Sun, 28 Nov 2021 20:02:35 +0100 Subject: [PATCH] impl. draft for new datamapper --- .../Database/Mapper/DataMapperAbstract.php | 190 +++++ .../Database/Mapper/DataMapperFactory.php | 451 ++++++++++ DataStorage/Database/Mapper/DeleteMapper.php | 256 ++++++ DataStorage/Database/Mapper/MapperType.php | 52 ++ DataStorage/Database/Mapper/ReadMapper.php | 793 ++++++++++++++++++ DataStorage/Database/Mapper/UpdateMapper.php | 306 +++++++ DataStorage/Database/Mapper/WriteMapper.php | 343 ++++++++ 7 files changed, 2391 insertions(+) create mode 100644 DataStorage/Database/Mapper/DataMapperAbstract.php create mode 100644 DataStorage/Database/Mapper/DataMapperFactory.php create mode 100644 DataStorage/Database/Mapper/DeleteMapper.php create mode 100644 DataStorage/Database/Mapper/MapperType.php create mode 100644 DataStorage/Database/Mapper/ReadMapper.php create mode 100644 DataStorage/Database/Mapper/UpdateMapper.php create mode 100644 DataStorage/Database/Mapper/WriteMapper.php diff --git a/DataStorage/Database/Mapper/DataMapperAbstract.php b/DataStorage/Database/Mapper/DataMapperAbstract.php new file mode 100644 index 000000000..85bebf3d8 --- /dev/null +++ b/DataStorage/Database/Mapper/DataMapperAbstract.php @@ -0,0 +1,190 @@ +mapper = $mapper; + $this->db = $db; + } + + // Only for relations, no impact on anything else + public function with(string $member) : self + { + $split = \explode('/', $member); + $memberSplit = \array_shift($split); + + $this->with[$memberSplit ?? ''][] = [ + 'child' => \implode('/', $split), + ]; + + return $this; + } + + public function sort(string $member, string $order = OrderType::DESC) : self + { + $split = \explode('/', $member); + $memberSplit = \array_shift($split); + + $this->sort[$memberSplit ?? ''][] = [ + 'child' => \implode('/', $split), + 'order' => $order, + ]; + + return $this; + } + + public function limit(int $limit = 0, string $member = '') : self + { + $split = \explode('/', $member); + $memberSplit = \array_shift($split); + + $this->limit[$memberSplit ?? ''][] = [ + 'child' => \implode('/', $split), + 'limit' => $limit, + ]; + + return $this; + } + + public function where(string $member, mixed $value, string $logic = '=', string $comparison = 'AND') : self + { + $split = \explode('/', $member); + $memberSplit = \array_shift($split); + + $this->where[$memberSplit ?? ''][] = [ + 'child' => \implode('/', $split), + 'value' => $value, + 'logic' => $logic, + 'comparison' => $comparison, + ]; + + return $this; + } + + public function createRelationMapper(self $mapper, string $member) : self + { + $relMapper = $mapper; + + if (isset($this->with[$member])) { + foreach ($this->with[$member] as $with) { + if ($with['child'] === '') { + continue; + } + + $relMapper->with($with['child']); + } + } + + if (isset($this->sort[$member])) { + foreach ($this->sort[$member] as $sort) { + if ($sort['child'] === '') { + continue; + } + + $relMapper->sort($sort['child'], $sort['order']); + } + } + + if (isset($this->limit[$member])) { + foreach ($this->limit[$member] as $limit) { + if ($limit['child'] === '') { + continue; + } + + $relMapper->limit($limit['limit'], $limit['child']); + } + } + + if (isset($this->where[$member])) { + foreach ($this->where[$member] as $where) { + if ($where['child'] === '') { + continue; + } + + $relMapper->where($where['child'], $where['value'], $where['logic'], $where['comparison']); + } + } + + return $relMapper; + } + + /** + * Parse value + * + * @param string $type Value type + * @param mixed $value Value to parse + * + * @return mixed + * + * @since 1.0.0 + */ + public function parseValue(string $type, mixed $value = null) : mixed + { + if ($value === null) { + return null; + } elseif ($type === 'int') { + return (int) $value; + } elseif ($type === 'string') { + return (string) $value; + } elseif ($type === 'float') { + return (float) $value; + } elseif ($type === 'bool') { + return (bool) $value; + } elseif ($type === 'DateTime' || $type === 'DateTimeImmutable') { + return $value === null ? null : $value->format($this->mapper::$datetimeFormat); + } elseif ($type === 'Json' || $value instanceof \JsonSerializable) { + return (string) \json_encode($value); + } elseif ($type === 'Serializable') { + return $value->serialize(); + } elseif (\is_object($value) && \method_exists($value, 'getId')) { + return $value->getId(); + } + + return $value; + } + + abstract function execute(...$options); +} diff --git a/DataStorage/Database/Mapper/DataMapperFactory.php b/DataStorage/Database/Mapper/DataMapperFactory.php new file mode 100644 index 000000000..79b2ee6af --- /dev/null +++ b/DataStorage/Database/Mapper/DataMapperFactory.php @@ -0,0 +1,451 @@ + + * @since 1.0.0 + */ + public const COLUMNS = []; + + /** + * Has many relation. + * + * @var array + * @since 1.0.0 + */ + public const HAS_MANY = []; + + /** + * Relations. + * + * Relation is defined in current mapper + * + * @var array + * @since 1.0.0 + */ + public const OWNS_ONE = []; + + /** + * Belongs to. + * + * @var array + * @since 1.0.0 + */ + public const BELONGS_TO = []; + + /** + * Table. + * + * @var string + * @since 1.0.0 + */ + public const TABLE = ''; + + /** + * Parent column. + * + * @var string + * @since 1.0.0 + */ + public const PARENT = ''; + + /** + * Model to use by the mapper. + * + * @var string + * @since 1.0.0 + */ + public const MODEL = ''; + + /** + * Database connection. + * + * @var ConnectionAbstract + * @since 1.0.0 + */ + public static ConnectionAbstract $db; + + /** + * Initialized objects for cross reference to reduce initialization costs + * + * @var array[] + * @since 1.0.0 + */ + protected static array $initObjects = []; + + /** + * Constructor. + * + * @since 1.0.0 + * @codeCoverageIgnore + */ + private function __construct() + { + } + + /** + * Clone. + * + * @return void + * + * @since 1.0.0 + * @codeCoverageIgnore + */ + private function __clone() + { + } + + public static function reader(ConnectionAbstract $db = null) : ReadMapper + { + return new ReadMapper(new static(), $db ?? self::$db); + } + + public static function get(ConnectionAbstract $db = null) : ReadMapper + { + return (new ReadMapper(new static(), $db ?? self::$db))->get(); + } + + public static function getRaw(ConnectionAbstract $db = null) : ReadMapper + { + return (new ReadMapper(new static(), $db ?? self::$db))->getRaw(); + } + + public static function getRandom(ConnectionAbstract $db = null) : ReadMapper + { + return (new ReadMapper(new static(), $db ?? self::$db))->getRandom(); + } + + public static function count(ConnectionAbstract $db = null) : ReadMapper + { + return (new ReadMapper(new static(), $db ?? self::$db))->count(); + } + + public static function getQuery(ConnectionAbstract $db = null) : Builder + { + return (new ReadMapper(new static(), $db ?? self::$db))->getQuery(); + } + + public static function getAll(ConnectionAbstract $db = null) : ReadMapper + { + return (new ReadMapper(new static(), $db ?? self::$db))->getAll(); + } + + public static function writer(ConnectionAbstract $db = null) : WriteMapper + { + return new WriteMapper(new static(), $db ?? self::$db); + } + + public static function create(ConnectionAbstract $db = null) : WriteMapper + { + return (new WriteMapper(new static(), $db ?? self::$db))->create(); + } + + public static function updater(ConnectionAbstract $db = null) : UpdateMapper + { + return new UpdateMapper(new static(), $db ?? self::$db); + } + + public static function update(ConnectionAbstract $db = null) : UpdateMapper + { + return (new UpdateMapper(new static(), $db ?? self::$db))->update(); + } + + public static function remover(ConnectionAbstract $db = null) : DeleteMapper + { + return new DeleteMapper(new static(), $db ?? self::$db); + } + + public static function delete(ConnectionAbstract $db = null) : DeleteMapper + { + return (new DeleteMapper(new static(), $db ?? self::$db))->delete(); + } + + /** + * Add initialized object to local cache + * + * @param string $mapper Mapper name + * @param mixed $id Object id + * @param object $obj Model to cache locally + * + * @return void + * + * @since 1.0.0 + */ + public static function addInitialized(string $mapper, mixed $id, object $obj = null) : void + { + if (!isset(self::$initObjects[$mapper])) { + self::$initObjects[$mapper] = []; + } + + self::$initObjects[$mapper][$id] = [ + 'obj' => $obj, + ]; + } + + /** + * Check if a object is initialized + * + * @param string $mapper Mapper name + * @param mixed $id Object id + * + * @return bool + * + * @since 1.0.0 + */ + public static function isInitialized(string $mapper, mixed $id) : bool + { + return !empty($id) + && isset(self::$initObjects[$mapper], self::$initObjects[$mapper][$id]); + } + + /** + * Clear cache + * + * @return void + * + * @since 1.0.0 + */ + public static function clearCache() : void + { + self::$initObjects = []; + } + + + /** + * Get initialized object + * + * @param string $mapper Mapper name + * @param mixed $id Object id + * + * @return mixed + * + * @since 1.0.0 + */ + public static function getInitialized(string $mapper, mixed $id) : mixed + { + if (!self::isInitialized($mapper, $id)) { + return null; + } + + return self::$initObjects[$mapper][$id]['obj'] ?? null; + } + + /** + * Remove initialized object + * + * @param string $mapper Mapper name + * @param mixed $id Object id + * + * @return void + * + * @since 1.0.0 + */ + public static function removeInitialized(string $mapper, mixed $id) : void + { + if (isset(self::$initObjects[$mapper][$id])) { + unset(self::$initObjects[$mapper][$id]); + } + } + + /** + * Test if object is null object + * + * @param mixed $obj Object to check + * + * @return bool + * + * @since 1.0.0 + */ + public static function isNullModel(mixed $obj) : bool + { + return \is_object($obj) && \strpos(\get_class($obj), '\Null') !== false; + } + + /** + * Creates the current null object + * + * @param mixed $id Model id + * + * @return mixed + * + * @since 1.0.0 + */ + public static function createNullModel(mixed $id = null) : mixed + { + $class = empty(static::MODEL) ? \substr(static::class, 0, -6) : static::MODEL; + $parts = \explode('\\', $class); + $name = $parts[$c = (\count($parts) - 1)]; + $parts[$c] = 'Null' . $name; + $class = \implode('\\', $parts); + + return $id !== null ? new $class($id) : new $class(); + } + + /** + * Create the empty base model + * + * @return mixed + * + * @since 1.0.0 + */ + public static function createBaseModel() : mixed + { + $class = empty(static::MODEL) ? \substr(static::class, 0, -6) : static::MODEL; + + /** + * @todo Orange-Management/phpOMS#67 + * Since some models require special initialization a model factory should be implemented. + * This could be a simple initialize() function in the mapper where the default initialize() is the current defined empty initialization in the DataMapperAbstract. + */ + return new $class(); + } + + /** + * Get model from mapper + * + * @return string + * + * @since 1.0.0 + */ + public static function getModelName() : string + { + return empty(static::MODEL) ? \substr(static::class, 0, -6) : static::MODEL; + } + + /** + * Get id of object + * + * @param object $obj Model to create + * @param \ReflectionClass $refClass Reflection class + * @param string $member Member name for the id, if it is not the primary key + * + * @return mixed + * + * @since 1.0.0 + */ + public static function getObjectId(object $obj, \ReflectionClass $refClass = null, string $member = null) : mixed + { + $refClass ??= new \ReflectionClass($obj); + $propertyName = $member ?? static::COLUMNS[static::PRIMARYFIELD]['internal']; + $refProp = $refClass->getProperty($propertyName); + + if (!$refProp->isPublic()) { + $refProp->setAccessible(true); + $objectId = $refProp->getValue($obj); + $refProp->setAccessible(false); + } else { + $objectId = $obj->{$propertyName}; + } + + return $objectId; + } + + /** + * Set id to model + * + * @param \ReflectionClass $refClass Reflection class + * @param object $obj Object to create + * @param mixed $objId Id to set + * + * @return void + * + * @since 1.0.0 + */ + public static function setObjectId(\ReflectionClass $refClass, object $obj, mixed $objId) : void + { + $propertyName = static::COLUMNS[static::PRIMARYFIELD]['internal']; + $refProp = $refClass->getProperty($propertyName); + + \settype($objId, static::COLUMNS[static::PRIMARYFIELD]['type']); + if (!$refProp->isPublic()) { + $refProp->setAccessible(true); + $refProp->setValue($obj, $objId); + $refProp->setAccessible(false); + } else { + $obj->{$propertyName} = $objId; + } + } + + /** + * Find database column name by member name + * + * @param string $name member name + * + * @return null|string + * + * @since 1.0.0 + */ + public static function getColumnByMember(string $name) : ?string + { + foreach (static::COLUMNS as $cName => $column) { + if ($column['internal'] === $name) { + return $cName; + } + } + + return null; + } +} diff --git a/DataStorage/Database/Mapper/DeleteMapper.php b/DataStorage/Database/Mapper/DeleteMapper.php new file mode 100644 index 000000000..2864c7661 --- /dev/null +++ b/DataStorage/Database/Mapper/DeleteMapper.php @@ -0,0 +1,256 @@ +type = MapperType::DELETE; + + return $this; + } + + public function execute(...$options) : mixed + { + switch($this->type) { + case MapperType::DELETE: + return $this->executeDelete(...$options); + default: + return null; + } + } + + public function executeDelete(mixed $obj) : mixed + { + if ($obj === null) { + $obj = $this->mapper::get()->execute(); // todo: pass where conditions to read mapper + } + + $refClass = new \ReflectionClass($obj); + $objId = $this->mapper::getObjectId($obj, $refClass); + + if (empty($objId)) { + return null; + } + + $this->mapper::removeInitialized(static::class, $objId); + $this->deleteHasMany($refClass, $obj, $objId); + $this->deleteModel($obj, $objId, $refClass); + + return $objId; + } + + private function deleteModel(object $obj, mixed $objId, \ReflectionClass $refClass = null) : void + { + $query = new Builder(self::$db); + $query->delete() + ->from($this->mapper::TABLE) + ->where($this->mapper::TABLE . '.' . $this->mapper::PRIMARYFIELD, '=', $objId); + + $refClass = $refClass ?? new \ReflectionClass($obj); + $properties = $refClass->getProperties(); + + foreach ($properties as $property) { + $propertyName = $property->getName(); + + if (isset($this->mapper::HAS_MANY[$propertyName])) { + continue; + } + + if (!($isPublic = $property->isPublic())) { + $property->setAccessible(true); + } + + /** + * @todo Orange-Management/phpOMS#233 + * On delete the relations and relation tables need to be deleted first + * The exception is of course the belongsTo relation. + */ + foreach ($this->mapper::COLUMNS as $key => $column) { + $value = $isPublic ? $obj->{$propertyName} : $property->getValue($obj); + if (isset($this->mapper::OWNS_ONE[$propertyName]) + && $column['internal'] === $propertyName + ) { + $this->deleteOwnsOne($propertyName, $value); + break; + } elseif (isset($this->mapper::BELONGS_TO[$propertyName]) + && $column['internal'] === $propertyName + ) { + $this->deleteBelongsTo($propertyName, $value); + break; + } + } + + if (!$isPublic) { + $property->setAccessible(false); + } + } + + $sth = self::$db->con->prepare($query->toSql()); + if ($sth !== false) { + $sth->execute(); + } + } + + private function deleteBelongsTo(string $propertyName, mixed $obj) : mixed + { + if (!\is_object($obj)) { + return $obj; + } + + /** @var class-string $mapper */ + $mapper = $this->mapper::BELONGS_TO[$propertyName]['mapper']; + + /** @var self $relMapper */ + $relMapper = $this->createRelationMapper($mapper::delete(db: $this->db), $propertyName); + $relMapper->depth = $this->depth + 1; + + return $relMapper->execute($obj); + } + + private function deleteOwnsOne(string $propertyName, mixed $obj) : mixed + { + if (!\is_object($obj)) { + return $obj; + } + + /** @var class-string $mapper */ + $mapper = $this->mapper::OWNS_ONE[$propertyName]['mapper']; + + /** + * @todo Orange-Management/phpOMS#??? [p:low] [t:question] [d:expert] + * Deleting a owned one object is not recommended since it can be owned by something else? + * Or does owns one mean that nothing else can have a relation to this model? + */ + + /** @var self $relMapper */ + $relMapper = $this->createRelationMapper($mapper::delete(db: $this->db), $propertyName); + $relMapper->depth = $this->depth + 1; + + return $relMapper->execute($obj); + } + + private function deleteHasMany(\ReflectionClass $refClass, object $obj, mixed $objId) : void + { + if (empty($this->with) || empty($this->mapper::HAS_MANY)) { + return; + } + + foreach ($this->mapper::HAS_MANY as $propertyName => $rel) { + if (!isset($this->mapper::HAS_MANY[$propertyName]['mapper'])) { + throw new InvalidMapperException(); + } + + if (isset($rel['column']) || !isset($this->with[$propertyName])) { + continue; + } + + $property = $refClass->getProperty($propertyName); + + if (!($isPublic = $property->isPublic())) { + $property->setAccessible(true); + $values = $property->getValue($obj); + $property->setAccessible(false); + } else { + $values = $obj->{$propertyName}; + } + + if (!\is_array($values)) { + // conditionals + continue; + } + + /** @var class-string $mapper */ + $mapper = $this->mapper::HAS_MANY[$propertyName]['mapper']; + $objsIds = []; + $relReflectionClass = !empty($values) ? new \ReflectionClass(\reset($values)) : null; + + foreach ($values as $key => &$value) { + if (!\is_object($value)) { + // Is scalar => already in database + $objsIds[$key] = $value; + + continue; + } + + $primaryKey = $mapper::getObjectId($value, $relReflectionClass); + + // already in db + if (!empty($primaryKey)) { + $objsIds[$key] = $mapper::delete(db: $this->db)->execute($value); + + continue; + } + + /** + * @todo Orange-Management/phpOMS#233 + * On delete the relations and relation tables need to be deleted first + * The exception is of course the belongsTo relation. + */ + } + + $this->deleteRelationTable($propertyName, $objsIds, $objId); + } + } + + public function deleteRelation(string $member, mixed $id1, mixed $id2) : bool + { + if (!isset($this->mapper::HAS_MANY[$member]) || !isset($this->mapper::HAS_MANY[$member]['external'])) { + return false; + } + + $this->mapper::removeInitialized(static::class, $id1); + $this->deleteRelationTable($member, \is_array($id2) ? $id2 : [$id2], $id1); + + return true; + } + + public function deleteRelationTable(string $propertyName, array $objsIds, mixed $objId) : void + { + if (empty($objsIds) + || $this->mapper::HAS_MANY[$propertyName]['table'] === $this->mapper::TABLE + || $this->mapper::HAS_MANY[$propertyName]['table'] === $this->mapper::HAS_MANY[$propertyName]['mapper']::$table + ) { + return; + } + + foreach ($objsIds as $src) { + $relQuery = new Builder(self::$db); + $relQuery->delete() + ->from($this->mapper::HAS_MANY[$propertyName]['table']) + ->where($this->mapper::HAS_MANY[$propertyName]['table'] . '.' . $this->mapper::HAS_MANY[$propertyName]['external'], '=', $src) + ->where($this->mapper::HAS_MANY[$propertyName]['table'] . '.' . $this->mapper::HAS_MANY[$propertyName]['self'], '=', $objId, 'and'); + + $sth = self::$db->con->prepare($relQuery->toSql()); + if ($sth !== false) { + $sth->execute(); + } + } + } +} diff --git a/DataStorage/Database/Mapper/MapperType.php b/DataStorage/Database/Mapper/MapperType.php new file mode 100644 index 000000000..4b99220e7 --- /dev/null +++ b/DataStorage/Database/Mapper/MapperType.php @@ -0,0 +1,52 @@ +type = MapperType::GET; + + return $this; + } + + public function getRaw() : self + { + $this->type = MapperType::GET_RAW; + + return $this; + } + + public function getAll() : self + { + $this->type = MapperType::GET_ALL; + + return $this; + } + + public function count() : self + { + $this->type = MapperType::COUNT_MODELS; + + return $this; + } + + public function getRandom() : self + { + $this->type = MapperType::GET_RANDOM; + + return $this; + } + + public function find() : self + { + $this->type = MapperType::FIND; + + return $this; + } + + public function columns(array $columns) : self + { + $this->columns = $columns; + + return $this; + } + + public function execute(...$options) : mixed + { + switch($this->type) { + case MapperType::GET: + return $options !== null + ? $this->executeGet(...$options) + : $this->executeGet(); + case MapperType::GET_RAW: + return $options !== null + ? $this->executeGetRaw(...$options) + : $this->executeGetRaw(); + case MapperType::GET_ALL: + return $options !== null + ? $this->executeGetAll(...$options) + : $this->executeGetAll(); + case MapperType::GET_RANDOM: + return $this->executeGetRaw(); + case MapperType::COUNT_MODELS: + return $this->executeCount(); + default: + return null; + } + } + + // @todo: consider to always return an array, this way we could remove executeGetAll + public function executeGet(Builder $query = null) : mixed + { + $primaryKeys = []; + $memberOfPrimaryField = $this->mapper::COLUMNS[$this->mapper::PRIMARYFIELD]['internal']; + $emptyWhere = empty($this->where); + + if (isset($this->where[$memberOfPrimaryField])) { + $keys = $this->where[$memberOfPrimaryField][0]['value']; + $primaryKeys = \array_merge(\is_array($keys) ? $keys : [$keys], $primaryKeys); + } + + // Get initialized objects from memory cache. + $obj = []; + foreach ($primaryKeys as $index => $value) { + if (!$this->mapper::isInitialized($this->mapper::class, $value)) { + continue; + } + + $obj[$value] = $this->mapper::getInitialized($this->mapper::class, $value); + unset($this->where[$memberOfPrimaryField]); + unset($primaryKeys[$index]); + } + + // Get remaining objects (not available in memory cache) or remaining where clauses. + if (!empty($primaryKeys) || (!empty($this->where) || $emptyWhere)) { + $dbData = $this->executeGetRaw($query); + + foreach ($dbData as $row) { + $value = $row[$this->mapper::PRIMARYFIELD . '_d' . $this->depth]; + $obj[$value] = $this->mapper::createBaseModel(); + $this->mapper::addInitialized($this->mapper::class, $value, $obj[$value]); + + $obj[$value] = $this->populateAbstract($row, $obj[$value]); + $this->loadHasManyRelations($obj[$value]); + } + } + + $countResulsts = \count($obj); + + if ($countResulsts === 0) { + return $this->mapper::createNullModel(); + } elseif ($countResulsts === 1) { + return \reset($obj); + } + + return $obj; + } + + public function executeGetRaw(Builder $query = null) : array + { + $query ??= $this->getQuery($query); + + try { + $results = false; + + $sth = $this->db->con->prepare($query->toSql()); + if ($sth !== false) { + $sth->execute(); + $results = $sth->fetchAll(\PDO::FETCH_ASSOC); + } + } catch (\Throwable $t) { + $results = false; + \var_dump($q = $query->toSql()); + \var_dump($t->getMessage()); + } + + return $results === false ? [] : $results; + } + + public function executeGetAll(Builder $query = null) : array + { + $result = $this->executeGet($query); + + if (\is_object($result) && \stripos(\get_class($result), '\Null') !== false) { + return []; + } + + return !\is_array($result) ? [$result] : $result; + } + + /** + * Count the number of elements + * + * @return int + * + * @since 1.0.0 + */ + public function executeCount() : int + { + $query = $this->getQuery(); + $query->select('COUNT(*)'); + + return (int) $query->execute()->fetchColumn(); + } + + /** + * Get random object + * + * @return mixed + * + * @since 1.0.0 + */ + public function executeRandom() : mixed + { + $query = $this->getQuery(); + $query->random($this->mapper::PRIMARYFIELD); + + return $this->executeGet($query); + } + + /** + * Get mapper specific builder + * + * @param Builder $query Query to fill + * @param array $columns Columns to use + * + * @return Builder + * + * @since 1.0.0 + */ + public function getQuery(Builder $query = null, array $columns = []) : Builder + { + $query ??= new Builder($this->db); + $columns = empty($columns) + ? (empty($this->columns) ? $this->mapper::COLUMNS : $this->columns) + : $columns; + + foreach ($columns as $key => $values) { + if ($values['writeonly'] ?? false === false) { + $query->selectAs($this->mapper::TABLE . '_d' . $this->depth . '.' . $key, $key . '_d' . $this->depth); + } + } + + if (empty($query->from)) { + $query->fromAs($this->mapper::TABLE, $this->mapper::TABLE . '_d' . $this->depth); + } + + // where + foreach ($this->where as $member => $values) { + if(($col = $this->mapper::getColumnByMember($member)) !== null) { + /* variable in model */ + foreach ($values as $index => $where) { + // @todo: the has many, etc. if checks only work if it is a relation on the first level, if we have a deeper where condition nesting this fails + if ($where['child'] !== '') { + continue; + } + + $comparison = \is_array($where['value']) && \count($where['value']) > 1 ? 'in' : $where['logic']; + $query->where($this->mapper::TABLE . '_d' . $this->depth . '.' . $col, $comparison, $where['value'], $where['comparison']); + } + } elseif (isset($this->mapper::HAS_MANY[$member])) { + /* variable in has many */ + foreach ($values as $index => $where) { + // @todo: the has many, etc. if checks only work if it is a relation on the first level, if we have a deeper where condition nesting this fails + if ($where['child'] !== '') { + continue; + } + + $comparison = \is_array($where['value']) && \count($where['value']) > 1 ? 'in' : $where['logic']; + $query->where($this->mapper::HAS_MANY[$member]['table'] . '_d' . $this->depth . '.' . $this->mapper::HAS_MANY[$member]['external'], $comparison, $where['value'], $where['comparison']); + + $query->leftJoin($this->mapper::HAS_MANY[$member]['table'], $this->mapper::HAS_MANY[$member]['table'] . '_d' . $this->depth); + if ($this->mapper::HAS_MANY[$member]['external'] !== null) { + $query->on( + $this->mapper::TABLE . '_d' . $this->depth . '.' . $this->mapper::HAS_MANY[$member][$this->mapper::PRIMARYFIELD], '=', + $this->mapper::HAS_MANY[$member]['table'] . '_d' . $this->depth . '.' . $this->mapper::HAS_MANY[$member]['self'], 'AND', + $this->mapper::HAS_MANY[$member]['table'] . '_d' . $this->depth + ); + } else { + $query->on( + $this->mapper::TABLE . '_d' . $this->depth . '.' . $this->mapper::PRIMARYFIELD, '=', + $this->mapper::HAS_MANY[$member]['table'] . '_d' . $this->depth . '.' . $this->mapper::HAS_MANY[$member]['self'], 'AND', + $this->mapper::HAS_MANY[$member]['table'] . '_d' . $this->depth + ); + } + } + } elseif (isset($this->mapper::BELONGS_TO[$member])) { + /* variable in belogns to */ + foreach ($values as $index => $where) { + // @todo: the has many, etc. if checks only work if it is a relation on the first level, if we have a deeper where condition nesting this fails + if ($where['child'] !== '') { + continue; + } + + $comparison = \is_array($where['value']) && \count($where['value']) > 1 ? 'in' : $where['logic']; + $query->where($this->mapper::TABLE . '_d' . $this->depth . '.' . $member, $comparison, $where['value'], $where['comparison']); + + $query->leftJoin($this->mapper::BELONGS_TO[$member]['mapper']::TABLE, $this->mapper::BELONGS_TO[$member]['mapper']::TABLE . '_d' . $this->depth) + ->on( + $this->mapper::TABLE . '_d' . $this->depth . '.' . $this->mapper::BELONGS_TO[$member]['external'], '=', + $this->mapper::BELONGS_TO[$member]['mapper']::TABLE . '_d' . $this->depth . '.' . $this->mapper::BELONGS_TO[$member]['mapper']::PRIMARYFIELD , 'AND', + $this->mapper::BELONGS_TO[$member]['mapper']::TABLE . '_d' . $this->depth + ); + } + } elseif (isset($this->mapper::OWNS_ONE[$member])) { + /* variable in owns one */ + foreach ($values as $index => $where) { + // @todo: the has many, etc. if checks only work if it is a relation on the first level, if we have a deeper where condition nesting this fails + if ($where['child'] !== '') { + continue; + } + + $comparison = \is_array($where['value']) && \count($where['value']) > 1 ? 'in' : $where['logic']; + $query->where($this->mapper::TABLE . '_d' . $this->depth . '.' . $member, $comparison, $where['value'], $where['comparison']); + $query->leftJoin($this->mapper::OWNS_ONE[$member]['mapper']::TABLE, $this->mapper::OWNS_ONE[$member]['mapper']::TABLE . '_d' . $this->depth) + ->on( + $this->mapper::TABLE . '_d' . $this->depth . '.' . $this->mapper::OWNS_ONE[$member]['external'], '=', + $this->mapper::OWNS_ONE[$member]['mapper']::TABLE . '_d' . $this->depth . '.' . $this->mapper::OWNS_ONE[$member]['mapper']::PRIMARYFIELD , 'AND', + $this->mapper::OWNS_ONE[$member]['mapper']::TABLE . '_d' . $this->depth + ); + } + } + } + + // load relations + foreach ($this->with as $member => $data) { + $rel = null; + if ((isset($this->mapper::OWNS_ONE[$member]) || isset($this->mapper::BELONGS_TO[$member])) + || (!isset($this->mapper::HAS_MANY[$member]['external']) && isset($this->mapper::HAS_MANY[$member]['column'])) + ) { + $rel = $this->mapper::OWNS_ONE[$member] ?? ($this->mapper::BELONGS_TO[$member] ?? ($this->mapper::HAS_MANY[$member] ?? null)); + } else { + break; + } + + foreach ($data as $index => $with) { + if ($with['child'] !== '') { + continue; + } + + if (isset($this->mapper::OWNS_ONE[$member]) || isset($this->mapper::BELONGS_TO[$member])) { + $query->leftJoin($rel['mapper']::TABLE, $rel['mapper']::TABLE . '_d' . ($this->depth + 1)) + ->on( + $this->mapper::TABLE . '_d' . $this->depth . '.' . $rel['external'], '=', + $rel['mapper']::TABLE . '_d' . ($this->depth + 1) . '.' . ( + isset($rel['by']) ? $rel['mapper']::getColumnByMember($rel['by']) : $rel['mapper']::PRIMARYFIELD + ), 'and', + $rel['mapper']::TABLE . '_d' . ($this->depth + 1) + ); + } elseif (!isset($this->mapper::HAS_MANY[$member]['external']) && isset($this->mapper::HAS_MANY[$member]['column'])) { + // get HasManyQuery (but only for elements which have a 'column' defined) + + // todo: handle self and self === null + $query->leftJoin($rel['mapper']::TABLE, $rel['mapper']::TABLE . '_d' . ($this->depth + 1)) + ->on( + $this->mapper::TABLE . '_d' . $this->depth . '.' . ($rel['external'] ?? $this->mapper::PRIMARYFIELD), '=', + $rel['mapper']::TABLE . '_d' . ($this->depth + 1) . '.' . ( + isset($rel['by']) ? $rel['mapper']::getColumnByMember($rel['by']) : $rel['self'] + ), 'and', + $rel['mapper']::TABLE . '_d' . ($this->depth + 1) + ); + } + + /** @var self $relMapper */ + $relMapper = $this->createRelationMapper($rel['mapper']::reader(db: $this->db), $member); + $relMapper->depth = $this->depth + 1; + + $query = $relMapper->getQuery( + $query, + isset($rel['column']) ? [$rel['mapper']::getColumnByMember($rel['column']) => []] : [] + ); + + break; // there is only one root element (one element with child === '') + } + } + + // handle sort, the column name order is very important. Therefore it cannot be done in the foreach loop above! + foreach ($this->sort as $member => $data) { + foreach ($data as $index => $sort) { + if (($column = $this->mapper::getColumnByMember($member)) === null + || ($sort['child'] !== '') + ) { + continue; + } + + $query->orderBy($this->mapper::TABLE . '_d' . $this->depth . '.' . $column, $sort['order']); + + break; // there is only one root element (one element with child === '') + } + } + + // handle limit + foreach ($this->limit as $member => $data) { + if ($member !== '') { + continue; + } + + foreach ($data as $index => $limit) { + if ($limit['child'] === '') { + $query->limit($limit['limit']); + + break 2; // there is only one root element (one element with child === '') + } + } + } + + return $query; + } + + /** + * Populate data. + * + * @param array $result Query result set + * @param mixed $obj Object to populate + * + * @return mixed + * + * @throws \UnexpectedValueException + * + * @since 1.0.0 + */ + public function populateAbstract(array $result, mixed $obj) : mixed + { + $refClass = new \ReflectionClass($obj); + + foreach ($this->mapper::COLUMNS as $column => $def) { + $alias = $column . '_d' . $this->depth; + + if (!\array_key_exists($alias, $result)) { + continue; + } + + $value = $result[$alias]; + + $hasPath = false; + $aValue = []; + $arrayPath = ''; + + if (\stripos($def['internal'], '/') !== false) { + $hasPath = true; + $path = \explode('/', $def['internal']); + $member = $path[0]; + $refProp = $refClass->getProperty($path[0]); + + if (!($isPublic = $refProp->isPublic())) { + $refProp->setAccessible(true); + } + + \array_shift($path); + $arrayPath = \implode('/', $path); + $aValue = $isPublic ? $obj->{$path[0]} : $refProp->getValue($obj); + } else { + $refProp = $refClass->getProperty($def['internal']); + $member = $def['internal']; + + if (!($isPublic = $refProp->isPublic())) { + $refProp->setAccessible(true); + } + } + + if (isset($this->mapper::OWNS_ONE[$def['internal']])) { + $default = null; + if (!isset($this->with[$member]) && $refProp->isInitialized($obj)) { + $default = $isPublic ? $obj->{$def['internal']} : $refProp->getValue($obj); + } + + $value = $this->populateOwnsOne($def['internal'], $result, $default); + + // loads has many relations. other relations are loaded in the populateOwnsOne + if (\is_object($value) && isset($this->mapper::OWNS_ONE[$def['internal']]['mapper'])) { + $this->mapper::OWNS_ONE[$def['internal']]['mapper']::reader(db: $this->db)->loadHasManyRelations($value); + } + + if (!empty($value)) { + // todo: find better solution. this was because of a bug with the sales billing list query depth = 4. The address was set (from the client, referral or creator) but then somehow there was a second address element which was all null and null cannot be asigned to a string variable (e.g. country). The problem with this solution is that if the model expects an initialization (e.g. at lest set the elements to null, '', 0 etc.) this is now not done. + $refProp->setValue($obj, $value); + } + } elseif (isset($this->mapper::BELONGS_TO[$def['internal']])) { + $default = null; + if (!isset($this->with[$member]) && $refProp->isInitialized($obj)) { + $default = $isPublic ? $obj->{$def['internal']} : $refProp->getValue($obj); + } + + $value = $this->populateBelongsTo($def['internal'], $result, $default); + + // loads has many relations. other relations are loaded in the populateBelongsTo + if (\is_object($value) && isset($this->mapper::BELONGS_TO[$def['internal']]['mapper'])) { + $this->mapper::BELONGS_TO[$def['internal']]['mapper']::reader(db: $this->db)->loadHasManyRelations($value); + } + + $refProp->setValue($obj, $value); + } elseif (\in_array($def['type'], ['string', 'int', 'float', 'bool'])) { + if ($value !== null || $refProp->getValue($obj) !== null) { + \settype($value, $def['type']); + } + + if ($hasPath) { + $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true); + } + + $refProp->setValue($obj, $value); + } elseif ($def['type'] === 'DateTime') { + $value = $value === null ? null : new \DateTime($value); + if ($hasPath) { + $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true); + } + + $refProp->setValue($obj, $value); + } elseif ($def['type'] === 'DateTimeImmutable') { + $value = $value === null ? null : new \DateTimeImmutable($value); + if ($hasPath) { + $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true); + } + + $refProp->setValue($obj, $value); + } elseif ($def['type'] === 'Json') { + if ($hasPath) { + $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true); + } + + $refProp->setValue($obj, \json_decode($value, true)); + } elseif ($def['type'] === 'Serializable') { + $member = $isPublic ? $obj->{$def['internal']} : $refProp->getValue($obj); + + if ($member === null || $value === null) { + $obj->{$def['internal']} = $value; + } else { + $member->unserialize($value); + } + } + + if (!$isPublic) { + $refProp->setAccessible(false); + } + } + + foreach ($this->mapper::HAS_MANY as $member => $def) { + $column = $def['mapper']::getColumnByMember($def['column'] ?? $member); + $alias = $column . '_d' . ($this->depth + 1); + + if (!\array_key_exists($alias, $result) || !isset($def['column'])) { + continue; + } + + $value = $result[$alias]; + $hasPath = false; + $aValue = null; + $arrayPath = '/'; + + if (\stripos($member, '/') !== false) { + $hasPath = true; + $path = \explode('/', $member); + $refProp = $refClass->getProperty($path[0]); + + if (!($isPublic = $refProp->isPublic())) { + $refProp->setAccessible(true); + } + + \array_shift($path); + $arrayPath = \implode('/', $path); + $aValue = $isPublic ? $obj->{$path[0]} : $refProp->getValue($obj); + } else { + $refProp = $refClass->getProperty($member); + + if (!($isPublic = $refProp->isPublic())) { + $refProp->setAccessible(true); + } + } + + if (\in_array($def['mapper']::COLUMNS[$column]['type'], ['string', 'int', 'float', 'bool'])) { + if ($value !== null || $refProp->getValue($obj) !== null) { + \settype($value, $def['mapper']::COLUMNS[$column]['type']); + } + + if ($hasPath) { + $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true); + } + + $refProp->setValue($obj, $value); + } elseif ($def['mapper']::COLUMNS[$column]['type'] === 'DateTime') { + $value = $value === null ? null : new \DateTime($value); + if ($hasPath) { + $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true); + } + + $refProp->setValue($obj, $value); + } elseif ($def['mapper']::COLUMNS[$column]['type'] === 'DateTimeImmutable') { + $value = $value === null ? null : new \DateTimeImmutable($value); + if ($hasPath) { + $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true); + } + + $refProp->setValue($obj, $value); + } elseif ($def['mapper']::COLUMNS[$column]['type'] === 'Json') { + if ($hasPath) { + $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true); + } + + $refProp->setValue($obj, \json_decode($value, true)); + } elseif ($def['mapper']::COLUMNS[$column]['type'] === 'Serializable') { + $member = $isPublic ? $obj->{$member} : $refProp->getValue($obj); + $member->unserialize($value); + } + + if (!$isPublic) { + $refProp->setAccessible(false); + } + } + + return $obj; + } + + /** + * Populate data. + * + * @param string $member Member name + * @param array $result Result data + * @param mixed $default Default value + * + * @return mixed + * + * @todo: in the future we could pass not only the $id ref but all of the data as a join!!! and save an additional select!!! + * @todo: parent and child elements however must be loaded because they are not loaded + * + * @since 1.0.0 + */ + public function populateOwnsOne(string $member, array $result, mixed $default = null) : mixed + { + /** @var class-string $mapper */ + $mapper = $this->mapper::OWNS_ONE[$member]['mapper']; + + if (!isset($this->with[$member])) { + if (\array_key_exists($this->mapper::OWNS_ONE[$member]['external'] . '_d' . ($this->depth), $result)) { + return isset($this->mapper::OWNS_ONE[$member]['column']) + ? $result[$this->mapper::OWNS_ONE[$member]['external'] . '_d' . ($this->depth)] + : $mapper::createNullModel($result[$this->mapper::OWNS_ONE[$member]['external'] . '_d' . ($this->depth)]); + } else { + return $default; + } + } + + // @todo: MUST handle if member is in with here!!! + + if (isset($this->mapper::OWNS_ONE[$member]['column'])) { + return $result[$mapper::getColumnByMember($this->mapper::OWNS_ONE[$member]['column']) . '_d' . $this->depth]; + } + + if (!isset($result[$mapper::PRIMARYFIELD . '_d' . ($this->depth + 1)])) { + return $mapper::createNullModel(); + } + + $obj = $mapper::getInitialized($mapper, $result[$mapper::PRIMARYFIELD . '_d' . ($this->depth + 1)]); + if ($obj !== null) { + return $obj; + } + + /** @var class-string $ownsOneMapper */ + $ownsOneMapper = $this->createRelationMapper($mapper::get($this->db), $member); + $ownsOneMapper->depth = $this->depth + 1; + + return $ownsOneMapper->populateAbstract($result, $mapper::createBaseModel()); + } + + /** + * Populate data. + * + * @param string $member Member name + * @param array $result Result data + * @param mixed $default Default value + * + * @return mixed + * + * @todo: in the future we could pass not only the $id ref but all of the data as a join!!! and save an additional select!!! + * @todo: only the belongs to model gets populated the children of the belongsto model are always null models. either this function needs to call the get for the children, it should call get for the belongs to right away like the has many, or i find a way to recursevily load the data for all sub models and then populate that somehow recursively, probably too complex. + * + * @since 1.0.0 + */ + public function populateBelongsTo(string $member, array $result, mixed $default = null) : mixed + { + /** @var class-string $mapper */ + $mapper = $this->mapper::BELONGS_TO[$member]['mapper']; + + if (!isset($this->with[$member])) { + if (\array_key_exists($this->mapper::BELONGS_TO[$member]['external'] . '_d' . ($this->depth), $result)) { + return isset($this->mapper::BELONGS_TO[$member]['column']) + ? $result[$this->mapper::BELONGS_TO[$member]['external'] . '_d' . ($this->depth)] + : $mapper::createNullModel($result[$this->mapper::BELONGS_TO[$member]['external'] . '_d' . ($this->depth)]); + } else { + return $default; + } + } + + // @todo: MUST handle if member is in with here!!! ??? + + if (isset($this->mapper::BELONGS_TO[$member]['column'])) { + return $result[$mapper::getColumnByMember($this->mapper::BELONGS_TO[$member]['column']) . '_d' . $this->depth]; + } + + if (!isset($result[$mapper::PRIMARYFIELD . '_d' . ($this->depth + 1)])) { + return $mapper::createNullModel(); + } + + // get the belongs to based on a different column (not primary key) + // this is often used if the value is actually a different model: + // you want the profile but the account id is referenced + // in this case you can get the profile by loading the profile based on the account reference column + if (isset($this->mapper::BELONGS_TO[$member]['by'])) { + /** @var class-string $belongsToMapper */ + $belongsToMapper = $this->createRelationMapper($mapper::get($this->db), $member); + $belongsToMapper->depth = $this->depth + 1; + $belongsToMapper->where($this->mapper::BELONGS_TO[$member]['by'], $result[$mapper::getColumnByMember($this->mapper::BELONGS_TO[$member]['by']) . '_d' . $this->depth], '='); + + return $belongsToMapper->execute(); + } + + $obj = $mapper::getInitialized($mapper, $result[$mapper::PRIMARYFIELD . '_d' . ($this->depth + 1)]); + if ($obj !== null) { + return $obj; + } + + /** @var class-string $belongsToMapper */ + $belongsToMapper = $this->createRelationMapper($mapper::get($this->db), $member); + $belongsToMapper->depth = $this->depth + 1; + + return $belongsToMapper->populateAbstract($result, $mapper::createBaseModel()); + } + + /** + * Fill object with relations + * + * @param mixed $obj Object to fill + * + * @return void + * + * @since 1.0.0 + */ + public function loadHasManyRelations(mixed $obj) : void + { + if (empty($this->with) || empty($this->mapper::HAS_MANY)) { + return; + } + + $primaryKey = $this->mapper::getObjectId($obj); + if (empty($primaryKey)) { + return; + } + + $refClass = new \ReflectionClass($obj); + + // @todo: check if there are more cases where the relation is already loaded with joins etc. + // there can be pseudo has many elements like localizations. They are has manies but these are already loaded with joins! + foreach ($this->mapper::HAS_MANY as $member => $many) { + if (isset($many['column']) || !isset($this->with[$member])) { + continue; + } + + $query = new Builder($this->db); + $src = $many['external'] ?? $many['mapper']::PRIMARYFIELD; + + // @todo: what if a specific column name is defined instead of primaryField for the join? Fix, it should be stored in 'column' + $query->select($many['table'] . '.' . $src) + ->from($many['table']) + ->where($many['table'] . '.' . $many['self'], '=', $primaryKey); + + if ($many['mapper']::TABLE !== $many['table']) { + $query->leftJoin($many['mapper']::TABLE) + ->on($many['table'] . '.' . $src, '=', $many['mapper']::TABLE . '.' . $many['mapper']::PRIMARYFIELD); + } + + $sth = $this->db->con->prepare($query->toSql()); + if ($sth === false) { + continue; + } + + $sth->execute(); + $result = $sth->fetchAll(\PDO::FETCH_COLUMN); + + $objects = $this->createRelationMapper($many['mapper']::get(db: $this->db), $member) + ->where($many['mapper']::COLUMNS[$many['mapper']::PRIMARYFIELD]['internal'], $result, 'in') + ->execute(); + + $refProp = $refClass->getProperty($member); + if (!$refProp->isPublic()) { + $refProp->setAccessible(true); + $refProp->setValue($obj, !\is_array($objects) && !isset($this->mapper::HAS_MANY[$member]['conditional']) + ? [$many['mapper']::getObjectId($objects) => $objects] + : $objects + ); + $refProp->setAccessible(false); + } else { + $obj->{$member} = !\is_array($objects) && !isset($this->mapper::HAS_MANY[$member]['conditional']) + ? [$many['mapper']::getObjectId($objects) => $objects] + : $objects; + } + } + } +} diff --git a/DataStorage/Database/Mapper/UpdateMapper.php b/DataStorage/Database/Mapper/UpdateMapper.php new file mode 100644 index 000000000..64c823f49 --- /dev/null +++ b/DataStorage/Database/Mapper/UpdateMapper.php @@ -0,0 +1,306 @@ +type = MapperType::UPDATE; + + return $this; + } + + public function execute(...$options) : mixed + { + switch($this->type) { + case MapperType::UPDATE: + return $this->executeUpdate(...$options); + default: + return null; + } + } + + public function executeUpdate(mixed $obj) : mixed + { + if (!isset($obj)) { + return null; + } + + $refClass = new \ReflectionClass($obj); + $objId = $this->mapper::getObjectId($obj, $refClass); + + if ($this->mapper::isNullModel($obj)) { + return $objId === 0 ? null : $objId; + } + + $this->mapper::addInitialized(static::class, $objId, $obj); + + $this->updateHasMany($refClass, $obj, $objId); + + if (empty($objId)) { + return $this->mapper::create(db: $this->db)->execute($obj); + } + + $this->updateModel($obj, $objId, $refClass); + + return $objId; + } + + private function updateModel(object $obj, mixed $objId, \ReflectionClass $refClass = null) : void + { + // Model doesn't have anything to update + if (\count($this->mapper::COLUMNS) < 2) { + return; + } + + $query = new Builder(self::$db); + $query->update($this->mapper::TABLE) + ->where($this->mapper::TABLE . '.' . $this->mapper::PRIMARYFIELD, '=', $objId); + + foreach ($this->mapper::COLUMNS as $column) { + $propertyName = \stripos($column['internal'], '/') !== false ? \explode('/', $column['internal'])[0] : $column['internal']; + if (isset($this->mapper::HAS_MANY[$propertyName]) + || $column['internal'] === $this->mapper::PRIMARYFIELD + || ($column['readonly'] ?? false === true) + ) { + continue; + } + + $refClass = $refClass ?? new \ReflectionClass($obj); + $property = $refClass->getProperty($propertyName); + + if (!($isPublic = $property->isPublic())) { + $property->setAccessible(true); + $tValue = $property->getValue($obj); + $property->setAccessible(false); + } else { + $tValue = $obj->{$propertyName}; + } + + if (isset($this->mapper::OWNS_ONE[$propertyName])) { + $id = $this->updateOwnsOne($propertyName, $tValue); + $value = $this->parseValue($column['type'], $id); + + /** + * @todo Orange-Management/phpOMS#232 + * If a model gets updated all it's relations are also updated. + * This should be prevented if the relations didn't change. + * No solution yet. + */ + $query->set([$this->mapper::TABLE . '.' . $column['name'] => $value]); + } elseif (isset($this->mapper::BELONGS_TO[$propertyName])) { + $id = $this->updateBelongsTo($propertyName, $tValue); + $value = $this->parseValue($column['type'], $id); + + /** + * @todo Orange-Management/phpOMS#232 + * If a model gets updated all it's relations are also updated. + * This should be prevented if the relations didn't change. + * No solution yet. + */ + $query->set([$this->mapper::TABLE . '.' . $column['name'] => $value]); + } elseif ($column['name'] !== $this->mapper::PRIMARYFIELD) { + if (\stripos($column['internal'], '/') !== false) { + $path = \substr($column['internal'], \stripos($column['internal'], '/') + 1); + $tValue = ArrayUtils::getArray($path, $tValue, '/'); + } + + $value = $this->parseValue($column['type'], $tValue); + + $query->set([$this->mapper::TABLE . '.' . $column['name'] => $value]); + } + } + + try { + $sth = self::$db->con->prepare($query->toSql()); + if ($sth !== false) { + $sth->execute(); + } + } catch (\Throwable $t) { + echo $t->getMessage(); + echo $query->toSql(); + } + } + + private function updateBelongsTo(string $propertyName, mixed $obj) : mixed + { + if (!\is_object($obj)) { + return $obj; + } + + /** @var class-string $mapper */ + $mapper = $this->mapper::BELONGS_TO[$propertyName]['mapper']; + + /** @var self $relMapper */ + $relMapper = $this->createRelationMapper($mapper::update(db: $this->db), $propertyName); + $relMapper->depth = $this->depth + 1; + + return $relMapper->execute($obj); + } + + private function updateOwnsOne(string $propertyName, mixed $obj) : mixed + { + if (!\is_object($obj)) { + return $obj; + } + + /** @var class-string $mapper */ + $mapper = $this->mapper::OWNS_ONE[$propertyName]['mapper']; + + /** @var self $relMapper */ + $relMapper = $this->createRelationMapper($mapper::update(db: $this->db), $propertyName); + $relMapper->depth = $this->depth + 1; + + return $relMapper->execute($obj); + } + + private function updateHasMany(\ReflectionClass $refClass, object $obj, mixed $objId) : void + { + if (empty($this->with) || empty($this->mapper::HAS_MANY)) { + return; + } + + $objsIds = []; + + foreach ($this->mapper::HAS_MANY as $propertyName => $rel) { + if ($rel['readonly'] ?? false === true) { + continue; + } + + if (!isset($this->mapper::HAS_MANY[$propertyName]['mapper'])) { + throw new InvalidMapperException(); + } + + $property = $refClass->getProperty($propertyName); + + if (!($isPublic = $property->isPublic())) { + $property->setAccessible(true); + $values = $property->getValue($obj); + $property->setAccessible(false); + } else { + $values = $obj->{$propertyName}; + } + + if (!\is_array($values) || empty($values)) { + continue; + } + + /** @var class-string $mapper */ + $mapper = $this->mapper::HAS_MANY[$propertyName]['mapper']; + $relReflectionClass = new \ReflectionClass(\reset($values)); + $objsIds[$propertyName] = []; + + foreach ($values as $key => &$value) { + if (!\is_object($value)) { + // Is scalar => already in database + $objsIds[$propertyName][$key] = $value; + + continue; + } + + $primaryKey = $mapper::getObjectId($value, $relReflectionClass); + + // already in db + if (!empty($primaryKey)) { + /** @var self $relMapper */ + $relMapper = $this->createRelationMapper($mapper::update(db: $this->db), $propertyName); + $relMapper->depth = $this->depth + 1; + + $relMapper->execute($value); + + $objsIds[$propertyName][$key] = $value; + + continue; + } + + // create if not existing + if ($this->mapper::HAS_MANY[$propertyName]['table'] === $this->mapper::HAS_MANY[$propertyName]['mapper']::$table + && isset($mapper::COLUMNS[$this->mapper::HAS_MANY[$propertyName]['self']]) + ) { + $relProperty = $relReflectionClass->getProperty($mapper::COLUMNS[$this->mapper::HAS_MANY[$propertyName]['self']]['internal']); + + if (!$isPublic) { + $relProperty->setAccessible(true); + $relProperty->setValue($value, $objId); + $relProperty->setAccessible(false); + } else { + $value->{$mapper::COLUMNS[$this->mapper::HAS_MANY[$propertyName]['self']]['internal']} = $objId; + } + } + + $objsIds[$propertyName][$key] = $mapper::create(db: $this->db)->execute($value); // @todo: pass where + } + } + + $this->updateRelationTable($objsIds, $objId); + } + + private function updateRelationTable(array $objsIds, mixed $objId) : void + { + foreach ($this->mapper::HAS_MANY as $member => $many) { + if (isset($many['column']) || !isset($this->with[$member])) { + continue; + } + + $query = new Builder($this->db); + $src = $many['external'] ?? $many['mapper']::PRIMARYFIELD; + + // @todo: what if a specific column name is defined instead of primaryField for the join? Fix, it should be stored in 'column' + $query->select($many['table'] . '.' . $src) + ->from($many['table']) + ->where($many['table'] . '.' . $many['self'], '=', $objId); + + if ($many['mapper']::TABLE !== $many['table']) { + $query->leftJoin($many['mapper']::TABLE) + ->on($many['table'] . '.' . $src, '=', $many['mapper']::TABLE . '.' . $many['mapper']::PRIMARYFIELD); + } + + $sth = $this->db->con->prepare($query->toSql()); + if ($sth === false) { + continue; + } + + $sth->execute(); + $result = $sth->fetchAll(\PDO::FETCH_COLUMN); + + $removes = \array_diff($result, \array_keys($objsIds[$member] ?? [])); + $adds = \array_diff(\array_keys($objsIds[$member] ?? []), $result); + + if (!empty($removes)) { + $this->mapper::remover()->deleteRelationTable($member, $removes, $objId); + } + + if (!empty($adds)) { + $this->mapper::writer()->createRelationTable($member, $adds, $objId); + } + } + } +} diff --git a/DataStorage/Database/Mapper/WriteMapper.php b/DataStorage/Database/Mapper/WriteMapper.php new file mode 100644 index 000000000..f05442e8e --- /dev/null +++ b/DataStorage/Database/Mapper/WriteMapper.php @@ -0,0 +1,343 @@ +type = MapperType::CREATE; + + return $this; + } + + public function execute(...$options) : mixed + { + switch($this->type) { + case MapperType::CREATE: + return $this->executeCreate(...$options); + default: + return null; + } + } + + public function executeCreate(mixed $obj) : mixed + { + if (!isset($obj)) { + return null; + } + + $refClass = new \ReflectionClass($obj); + + if ($this->mapper::isNullModel($obj)) { + $objId = $this->mapper::getObjectId($obj, $refClass); + + return $objId === 0 ? null : $objId; + } + + if (!empty($id = $this->mapper::getObjectId($obj, $refClass)) && $this->mapper::AUTOINCREMENT) { + $objId = $id; + } else { + $objId = $this->createModel($obj, $refClass); + $this->mapper::setObjectId($refClass, $obj, $objId); + } + + $this->createHasMany($refClass, $obj, $objId); + + return $objId; + } + + private function createModel(object $obj, \ReflectionClass $refClass) : mixed + { + $query = new Builder(self::$db); + $query->into($this->mapper::TABLE); + + foreach ($this->mapper::COLUMNS as $column) { + $propertyName = \stripos($column['internal'], '/') !== false ? \explode('/', $column['internal'])[0] : $column['internal']; + if (isset($this->mapper::HAS_MANY[$propertyName])) { + continue; + } + + $property = $refClass->getProperty($propertyName); + if (!$property->isPublic()) { + $property->setAccessible(true); + $tValue = $property->getValue($obj); + $property->setAccessible(false); + } else { + $tValue = $obj->{$propertyName}; + } + + if (isset($this->mapper::OWNS_ONE[$propertyName])) { + $id = $this->createOwnsOne($propertyName, $tValue); + $value = $this->parseValue($column['type'], $id); + + $query->insert($column['name'])->value($value); + } elseif (isset($this->mapper::BELONGS_TO[$propertyName])) { + $id = $this->createBelongsTo($propertyName, $tValue); + $value = $this->parseValue($column['type'], $id); + + $query->insert($column['name'])->value($value); + } elseif ($column['name'] !== $this->mapper::PRIMARYFIELD || !empty($tValue)) { + if (\stripos($column['internal'], '/') !== false) { + $path = \substr($column['internal'], \stripos($column['internal'], '/') + 1); + $tValue = ArrayUtils::getArray($path, $tValue, '/'); + } + + /* + if (($column['type'] === 'int' || $column['type'] === 'string') + && \is_object($tValue) && \property_exists($tValue, 'id') + ) { + $tValue = + } + */ + + $value = $this->parseValue($column['type'], $tValue); + + $query->insert($column['name'])->value($value); + } + } + + // if a table only has a single column = primary key column. This must be done otherwise the query is empty + if ($query->getType() === QueryType::NONE) { + $query->insert($this->mapper::PRIMARYFIELD)->value(0); + } + + try { + $sth = self::$db->con->prepare($query->toSql()); + $sth->execute(); + } catch (\Throwable $t) { + \var_dump($t->getMessage()); + \var_dump($a = $query->toSql()); + return -1; + } + + $objId = empty($id = $this->mapper::getObjectId($obj, $refClass)) ? self::$db->con->lastInsertId() : $id; + \settype($objId, $this->mapper::COLUMNS[$this->mapper::PRIMARYFIELD]['type']); + + return $objId; + } + + private function createOwnsOne(string $propertyName, mixed $obj) : mixed + { + if (!\is_object($obj)) { + return $obj; + } + + /** @var class-string $mapper */ + $mapper = $this->mapper::OWNS_ONE[$propertyName]['mapper']; + $primaryKey = $mapper::getObjectId($obj); + + if (empty($primaryKey)) { + return $mapper::create(db: $this->db)->execute($obj); + } + + return $primaryKey; + } + + private function createBelongsTo(string $propertyName, mixed $obj) : mixed + { + if (!\is_object($obj)) { + return $obj; + } + + $mapper = ''; + $primaryKey = 0; + + if (isset($this->mapper::BELONGS_TO[$propertyName]['by'])) { + // has by (obj is stored as a different model e.g. model = profile but reference/db is account) + + $refClass = new \ReflectionClass($obj); + $refProp = $refClass->getProperty($this->mapper::BELONGS_TO[$propertyName]['by']); + + if (!$refProp->isPublic()) { + $refProp->setAccessible(true); + $obj = $refProp->getValue($obj); + $refProp->setAccessible(false); + } else { + $obj = $obj->{$this->mapper::BELONGS_TO[$propertyName]['by']}; + } + + /** @var class-string $mapper */ + $mapper = $this->mapper::BELONGS_TO[$propertyName]['mapper']::getBelongsTo($this->mapper::BELONGS_TO[$propertyName]['by'])['mapper']; + $primaryKey = $mapper::getObjectId($obj); + } else { + /** @var class-string $mapper */ + $mapper = $this->mapper::BELONGS_TO[$propertyName]['mapper']; + $primaryKey = $mapper::getObjectId($obj); + } + + // @todo: the $mapper::create() might cause a problem is 'by' is set. because we don't want to create this obj but the child obj. + return empty($primaryKey) ? $mapper::create(db: $this->db)->execute($obj) : $primaryKey; + } + + private function createHasMany(\ReflectionClass $refClass, object $obj, mixed $objId) : void + { + foreach ($this->mapper::HAS_MANY as $propertyName => $rel) { + if (!isset($this->mapper::HAS_MANY[$propertyName]['mapper'])) { + throw new InvalidMapperException(); + } + + $property = $refClass->getProperty($propertyName); + + if (!($isPublic = $property->isPublic())) { + $property->setAccessible(true); + $values = $property->getValue($obj); + } else { + $values = $obj->{$propertyName}; + } + + /** @var class-string $mapper */ + $mapper = $this->mapper::HAS_MANY[$propertyName]['mapper']; + $internalName = isset($mapper::COLUMNS[$this->mapper::HAS_MANY[$propertyName]['self']]) + ? $mapper::COLUMNS[$this->mapper::HAS_MANY[$propertyName]['self']]['internal'] + : 'ERROR'; + + if (\is_object($values)) { + // conditionals + $relReflectionClass = new \ReflectionClass($values); + $relProperty = $relReflectionClass->getProperty($internalName); + + if (!$relProperty->isPublic()) { + $relProperty->setAccessible(true); + $relProperty->setValue($values, $objId); + $relProperty->setAccessible(false); + } else { + $values->{$internalName} = $objId; + } + + if (!$isPublic) { + $property->setAccessible(false); + } + + $mapper::create(db: $this->db)->execute($values); + continue; + } elseif (!\is_array($values)) { + if (!$isPublic) { + $property->setAccessible(false); + } + + // conditionals + continue; + } + + if (!$isPublic) { + $property->setAccessible(false); + } + + $objsIds = []; + $relReflectionClass = !empty($values) ? new \ReflectionClass(\reset($values)) : null; + + foreach ($values as $key => $value) { + if (!\is_object($value)) { + // Is scalar => already in database + $objsIds[$key] = $value; + + continue; + } + + /** @var \ReflectionClass $relReflectionClass */ + $primaryKey = $mapper::getObjectId($value, $relReflectionClass); + + // already in db + if (!empty($primaryKey)) { + $objsIds[$key] = $value; + + continue; + } + + // Setting relation value (id) for relation (since the relation is not stored in an extra relation table) + if (!isset($this->mapper::HAS_MANY[$propertyName]['external'])) { + $relProperty = $relReflectionClass->getProperty($internalName); + + if (!$isPublic) { + $relProperty->setAccessible(true); + } + + // todo maybe consider to just set the column type to object, and then check for that (might be faster) + if (isset($mapper::$belongsTo[$internalName]) + || isset($mapper::$ownsOne[$internalName]) + ) { + if (!$isPublic) { + $relProperty->setValue($value, $this->mapper::createNullModel($objId)); + } else { + $value->{$internalName} = $this->mapper::createNullModel($objId); + } + } else { + if (!$isPublic) { + $relProperty->setValue($value, $objId); + } else { + $value->{$internalName} = $objId; + } + } + + if (!$isPublic) { + $relProperty->setAccessible(false); + } + } + + $objsIds[$key] = $mapper::create(db: $this->db)->execute($value); + } + + $this->createRelationTable($propertyName, $objsIds, $objId); + } + } + + public function createRelationTable(string $propertyName, array $objsIds, mixed $objId) : void + { + if (empty($objsIds) || !isset($this->mapper::HAS_MANY[$propertyName]['external'])) { + return; + } + + $relQuery = new Builder(self::$db); + $relQuery->into($this->mapper::HAS_MANY[$propertyName]['table']) + ->insert($this->mapper::HAS_MANY[$propertyName]['external'], $this->mapper::HAS_MANY[$propertyName]['self']); + + foreach ($objsIds as $src) { + if (\is_object($src)) { + $mapper = (\stripos($mapper = \get_class($src), '\Null') !== false + ? \str_replace('\Null', '\\', $mapper) + : $mapper) + . 'Mapper'; + + $src = $mapper::getObjectId($src); + } + + $relQuery->values($src, $objId); + } + + try { + $sth = self::$db->con->prepare($relQuery->toSql()); + if ($sth !== false) { + $sth->execute(); + } + } catch (\Throwable $e) { + \var_dump($e->getMessage()); + \var_dump($relQuery->toSql()); + } + } +}