impl. draft for new datamapper

This commit is contained in:
Dennis Eichhorn 2021-11-28 20:02:35 +01:00
parent 162c30fb64
commit 44ea04b2c6
7 changed files with 2391 additions and 0 deletions

View File

@ -0,0 +1,190 @@
<?php
/**
* Orange Management
*
* PHP Version 8.0
*
* @package phpOMS\DataStorage\Database\Mapper
* @copyright Dennis Eichhorn
* @license OMS License 1.0
* @version 1.0.0
* @link https://orange-management.org
*/
declare(strict_types=1);
namespace phpOMS\DataStorage\Database\Mapper;
use phpOMS\DataStorage\Database\Connection\ConnectionAbstract;
use phpOMS\DataStorage\Database\Query\OrderType;
/**
* Mapper abstract.
*
* @package phpOMS\DataStorage\Database\Mapper
* @license OMS License 1.0
* @link https://orange-management.org
* @since 1.0.0
*/
abstract class DataMapperAbstract
{
protected DataMapperFactory $mapper;
protected int $type = 0;
protected array $with = [];
protected array $sort = [];
protected array $limit = [];
protected array $where = [];
/**
* Database connection.
*
* @var ConnectionAbstract
* @since 1.0.0
*/
protected ConnectionAbstract $db;
public function __construct(DataMapperFactory $mapper, ConnectionAbstract $db)
{
$this->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);
}

View File

@ -0,0 +1,451 @@
<?php
/**
* Orange Management
*
* PHP Version 8.0
*
* @package phpOMS\DataStorage\Database\Mapper
* @copyright Dennis Eichhorn
* @license OMS License 1.0
* @version 1.0.0
* @link https://orange-management.org
*/
declare(strict_types=1);
namespace phpOMS\DataStorage\Database\Mapper;
use phpOMS\DataStorage\Database\Connection\ConnectionAbstract;
use phpOMS\DataStorage\Database\Query\Builder;
/**
* Mapper factory.
*
* @package phpOMS\DataStorage\Database\Mapper
* @license OMS License 1.0
* @link https://orange-management.org
* @since 1.0.0
*/
class DataMapperFactory
{
/**
* Datetime format of the database datetime
*
* This is only for the datetime stored in the database not the generated query.
* For the query check the datetime in Grammar:$datetimeFormat
*
* @var string
* @since 1.0.0
*/
public static string $datetimeFormat = 'Y-m-d H:i:s';
/**
* Primary field name.
*
* @var string
* @since 1.0.0
*/
public const PRIMARYFIELD = '';
/**
* Autoincrement primary field.
*
* @var bool
* @since 1.0.0
*/
public const AUTOINCREMENT = true;
/**
* Primary field name.
*
* @var string
* @since 1.0.0
*/
public const CREATED_AT = '';
/**
* Columns.
*
* @var array<string, array{name:string, type:string, internal:string, autocomplete?:bool, readonly?:bool, writeonly?:bool, annotations?:array}>
* @since 1.0.0
*/
public const COLUMNS = [];
/**
* Has many relation.
*
* @var array<string, array>
* @since 1.0.0
*/
public const HAS_MANY = [];
/**
* Relations.
*
* Relation is defined in current mapper
*
* @var array<string, array{mapper:string, external:string, by?:string, column?:string, conditional?:bool}>
* @since 1.0.0
*/
public const OWNS_ONE = [];
/**
* Belongs to.
*
* @var array<string, array{mapper:string, external:string}>
* @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;
}
}

View File

@ -0,0 +1,256 @@
<?php
/**
* Orange Management
*
* PHP Version 8.0
*
* @package phpOMS\DataStorage\Database\Mapper
* @copyright Dennis Eichhorn
* @license OMS License 1.0
* @version 1.0.0
* @link https://orange-management.org
*/
declare(strict_types=1);
namespace phpOMS\DataStorage\Database\Mapper;
use phpOMS\DataStorage\Database\Exception\InvalidMapperException;
use phpOMS\DataStorage\Database\Query\Builder;
/**
* Delete mapper (DELETE).
*
* @todo: allow to define where clause if no object is loaded yet
*
* @package phpOMS\DataStorage\Database\Mapper
* @license OMS License 1.0
* @link https://orange-management.org
* @since 1.0.0
*/
class DeleteMapper extends DataMapperAbstract
{
public function delete() : self
{
$this->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<DataMapperFactory> $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<DataMapperFactory> $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<DataMapperFactory> $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();
}
}
}
}

View File

@ -0,0 +1,52 @@
<?php
/**
* Orange Management
*
* PHP Version 8.0
*
* @package phpOMS\DataStorage\Database\Mapper
* @copyright Dennis Eichhorn
* @license OMS License 1.0
* @version 1.0.0
* @link https://orange-management.org
*/
declare(strict_types=1);
namespace phpOMS\DataStorage\Database\Mapper;
use phpOMS\Stdlib\Base\Enum;
/**
* Mapper type enum.
*
* @package phpOMS\DataStorage\Database\Mapper
* @license OMS License 1.0
* @link https://orange-management.org
* @since 1.0.0
*/
abstract class MapperType extends Enum
{
public const GET = 1;
public const GET_ALL = 4;
public const FIND = 7;
public const GET_RAW = 8;
public const GET_RANDOM = 11;
public const COUNT_MODELS = 12;
// -------------------------------------------- //
public const CREATE = 1001;
// -------------------------------------------- //
public const UPDATE = 2001;
// -------------------------------------------- //
public const DELETE = 3001;
}

View File

@ -0,0 +1,793 @@
<?php
/**
* Orange Management
*
* PHP Version 8.0
*
* @package phpOMS\DataStorage\Database\Mapper
* @copyright Dennis Eichhorn
* @license OMS License 1.0
* @version 1.0.0
* @link https://orange-management.org
*/
declare(strict_types=1);
namespace phpOMS\DataStorage\Database\Mapper;
use phpOMS\DataStorage\Database\Query\Builder;
use phpOMS\Utils\ArrayUtils;
/**
* Read mapper (SELECTS).
*
* @package phpOMS\DataStorage\Database\Mapper
* @license OMS License 1.0
* @link https://orange-management.org
* @since 1.0.0
*/
class ReadMapper extends DataMapperAbstract
{
private int $depth = 1;
private $columns = [];
public function get() : self
{
$this->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<DataMapperFactory> $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<DataMapperFactory> $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<DataMapperFactory> $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<DataMapperFactory> $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<DataMapperFactory> $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;
}
}
}
}

View File

@ -0,0 +1,306 @@
<?php
/**
* Orange Management
*
* PHP Version 8.0
*
* @package phpOMS\DataStorage\Database\Mapper
* @copyright Dennis Eichhorn
* @license OMS License 1.0
* @version 1.0.0
* @link https://orange-management.org
*/
declare(strict_types=1);
namespace phpOMS\DataStorage\Database\Mapper;
use phpOMS\DataStorage\Database\Exception\InvalidMapperException;
use phpOMS\DataStorage\Database\Query\Builder;
use phpOMS\Utils\ArrayUtils;
/**
* Update mapper (CREATE).
*
* @todo: allow to define single fields which should be updated (e.g. only description)
* @todo: allow to define where clause if no object is loaded yet
*
* @package phpOMS\DataStorage\Database\Mapper
* @license OMS License 1.0
* @link https://orange-management.org
* @since 1.0.0
*/
class UpdateMapper extends DataMapperAbstract
{
public function update() : self
{
$this->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<DataMapperFactory> $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<DataMapperFactory> $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<DataMapperFactory> $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);
}
}
}
}

View File

@ -0,0 +1,343 @@
<?php
/**
* Orange Management
*
* PHP Version 8.0
*
* @package phpOMS\DataStorage\Database\Mapper
* @copyright Dennis Eichhorn
* @license OMS License 1.0
* @version 1.0.0
* @link https://orange-management.org
*/
declare(strict_types=1);
namespace phpOMS\DataStorage\Database\Mapper;
use phpOMS\DataStorage\Database\Exception\InvalidMapperException;
use phpOMS\DataStorage\Database\Query\Builder;
use phpOMS\DataStorage\Database\Query\QueryType;
use phpOMS\Utils\ArrayUtils;
/**
* Write mapper (CREATE).
*
* @package phpOMS\DataStorage\Database\Mapper
* @license OMS License 1.0
* @link https://orange-management.org
* @since 1.0.0
*/
class WriteMapper extends DataMapperAbstract
{
public function create() : self
{
$this->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<DataMapperFactory> $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<DataMapperFactory> $mapper */
$mapper = $this->mapper::BELONGS_TO[$propertyName]['mapper']::getBelongsTo($this->mapper::BELONGS_TO[$propertyName]['by'])['mapper'];
$primaryKey = $mapper::getObjectId($obj);
} else {
/** @var class-string<DataMapperFactory> $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<DataMapperFactory> $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());
}
}
}