phpOMS/DataStorage/Database/Mapper/ReadMapper.php
Dennis Eichhorn 5230892673 fix tests
2024-04-24 20:20:57 +00:00

1354 lines
48 KiB
PHP
Executable File

<?php
/**
* Jingga
*
* PHP Version 8.2
*
* @package phpOMS\DataStorage\Database\Mapper
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\DataStorage\Database\Mapper;
use phpOMS\DataStorage\Database\Query\Builder;
use phpOMS\DataStorage\Database\Query\Where;
use phpOMS\Utils\ArrayUtils;
/**
* Read mapper (SELECTS).
*
* @package phpOMS\DataStorage\Database\Mapper
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*
* @todo Add getArray functions to get array instead of object
* https://github.com/Karaka-Management/phpOMS/issues/350
*
* @todo Allow to define columns in all functions instead of members?
*
* @template R
*/
final class ReadMapper extends DataMapperAbstract
{
/**
* Columns to load
*
* @var array
* @since 1.0.0
*/
private array $columns = [];
/**
* Create get mapper
*
* This makes execute() return a single object or an array of object depending the result size
*
* @return self
*
* @since 1.0.0
*/
public function get() : self
{
$this->type = MapperType::GET;
return $this;
}
/**
* Create yield mapper
*
* This makes execute() return a single object or an array of object depending the result size
*
* @return self
*
* @since 1.0.0
*/
public function yield() : self
{
$this->type = MapperType::GET_YIELD;
return $this;
}
/**
* Get raw result set
*
* @return self
*
* @since 1.0.0
*/
public function getRaw() : self
{
$this->type = MapperType::GET_RAW;
return $this;
}
/**
* Create get mapper
*
* This makes execute() always return an array of objects (or an empty array)
*
* @return self
*
* @since 1.0.0
*/
public function getAll() : self
{
$this->type = MapperType::GET_ALL;
return $this;
}
/**
* Create count mapper
*
* @return self
*
* @since 1.0.0
*/
public function count() : self
{
$this->type = MapperType::COUNT_MODELS;
return $this;
}
/**
* Create sum mapper
*
* @return self
*
* @since 1.0.0
*/
public function sum() : self
{
$this->type = MapperType::SUM_MODELS;
return $this;
}
/**
* Create exists mapper
*
* @return self
*
* @since 1.0.0
*/
public function exists() : self
{
$this->type = MapperType::MODEL_EXISTS;
return $this;
}
/**
* Create has mapper
*
* @return self
*
* @since 1.0.0
*/
public function has() : self
{
$this->type = MapperType::MODEL_HAS_RELATION;
return $this;
}
/**
* Create random mapper
*
* @return self
*
* @since 1.0.0
*/
public function getRandom() : self
{
$this->type = MapperType::GET_RANDOM;
return $this;
}
/**
* Define the columns to load
*
* @param array $columns Columns to load
*
* @return self
*
* @since 1.0.0
*/
public function columns(array $columns) : self
{
$this->columns = $columns;
return $this;
}
/**
* Define the properties to load
*
* @param array $properties Properties to load
*
* @return self
*
* @since 1.0.0
*/
public function properties(array $properties) : self
{
foreach ($properties as $property) {
$this->columns[] = $this->mapper::getColumnByMember($property);
}
return $this;
}
/**
* Execute mapper
*
* @param mixed ...$options Options to pass to read mapper
*
* @return R
*
* @since 1.0.0
*/
public function execute(mixed ...$options) : mixed
{
switch($this->type) {
case MapperType::GET:
/** @var null|Builder ...$options */
return $this->executeGet(...$options);
case MapperType::GET_YIELD:
/** @var null|Builder ...$options */
return $this->executeYield(...$options);
case MapperType::GET_RAW:
/** @var null|Builder ...$options */
return $this->executeGetRaw(...$options);
case MapperType::GET_ALL:
/** @var null|Builder ...$options */
return $this->executeGetArray(...$options);
case MapperType::GET_RANDOM:
return $this->executeRandom();
case MapperType::COUNT_MODELS:
return $this->executeCount();
case MapperType::SUM_MODELS:
return $this->executeSum();
case MapperType::MODEL_EXISTS:
return $this->executeExists();
case MapperType::MODEL_HAS_RELATION:
return $this->executeHas();
default:
return null;
}
}
/**
* Execute mapper
*
* @param null|Builder $query Query to use instead of the internally generated query
* Careful, this doesn't merge with the internal query.
* If you want to merge it use ->query() instead
*
* @return R
*
* @since 1.0.0
*/
public function executeGet(?Builder $query = null) : mixed
{
$objs = [];
$indexed = [];
$hasFactory = $this->mapper::hasFactory();
$baseClass = $hasFactory ? null : $this->mapper::getBaseModelClass();
foreach ($this->executeGetRawYield($query) as $row) {
if ($row === []) {
continue;
}
$value = $row[$this->mapper::PRIMARYFIELD . '_d' . $this->depth . $this->joinAlias];
$objs[$value] = $hasFactory ? $this->mapper::createBaseModel($row) : new $baseClass();
$objs[$value] = $this->populateAbstract($row, $objs[$value]);
if (!empty($this->indexedBy) && isset($row[$this->indexedBy . '_d' . $this->depth . $this->joinAlias])) {
if (!isset($indexed[$row[$this->indexedBy . '_d' . $this->depth . $this->joinAlias]])) {
$indexed[$row[$this->indexedBy . '_d' . $this->depth . $this->joinAlias]] = [];
}
$indexed[$row[$this->indexedBy . '_d' . $this->depth . $this->joinAlias]][] = $objs[$value];
}
}
if (!empty($this->with) && !empty($objs)) {
$this->loadHasManyRelations($objs);
}
if (!empty($this->indexedBy)) {
return $indexed;
} elseif ($this->type === MapperType::GET_ALL) {
return $objs;
}
$countResults = \count($objs);
if ($countResults === 0) {
return $this->mapper::createNullModel();
} elseif ($countResults === 1) {
return \reset($objs);
}
return $objs;
}
/**
* Execute mapper
*
* @param null|Builder $query Query to use instead of the internally generated query
* Careful, this doesn't merge with the internal query.
* If you want to merge it use ->query() instead
*
* @return \Generator<R>
*
* @since 1.0.0
*/
public function executeYield(?Builder $query = null) : \Generator
{
foreach ($this->executeGetRawYield($query) as $row) {
$obj = $this->mapper::createBaseModel($row);
$obj = $this->populateAbstract($row, $obj);
if (!empty($this->with)) {
$this->loadHasManyRelations([$obj]);
}
yield $obj;
}
}
/**
* Execute mapper
*
* @param null|Builder $query Query to use instead of the internally generated query
* Careful, this doesn't merge with the internal query.
* If you want to merge it use ->query() instead
*
* @return array
*
* @since 1.0.0
*/
public function executeGetRaw(?Builder $query = null) : array
{
$query ??= $this->getQuery();
$results = false;
try {
$sth = $this->db->con->prepare($query->toSql());
if ($sth !== false) {
$sth->execute();
$results = $sth->fetchAll(\PDO::FETCH_ASSOC);
}
} catch (\Throwable $t) {
\phpOMS\Log\FileLogger::getInstance()->error(
\phpOMS\Log\FileLogger::MSG_FULL, [
'message' => $t->getMessage() . ':' . $query->toSql(),
'line' => __LINE__,
'file' => self::class,
]
);
}
return $results === false ? [] : $results;
}
/**
* Execute mapper
*
* @param null|Builder $query Query to use instead of the internally generated query
* Careful, this doesn't merge with the internal query.
* If you want to merge it use ->query() instead
*
* @return array
*
* @since 1.0.0
*/
public function executeGetRawYield(?Builder $query = null)
{
$query ??= $this->getQuery();
try {
$sth = $this->db->con->prepare($query->toSql());
if ($sth === false) {
yield [];
return;
}
$sth->execute();
while ($row = $sth->fetch(\PDO::FETCH_ASSOC)) {
yield $row;
}
} catch (\Throwable $t) {
\phpOMS\Log\FileLogger::getInstance()->error(
\phpOMS\Log\FileLogger::MSG_FULL, [
'message' => $t->getMessage() . ':' . $query->toSql(),
'line' => __LINE__,
'file' => self::class,
]
);
yield [];
}
}
/**
* Execute mapper
*
* @param null|Builder $query Query to use instead of the internally generated query
* Careful, this doesn't merge with the internal query.
* If you want to merge it use ->query() instead
*
* @return R[]
*
* @since 1.0.0
*/
public function executeGetArray(?Builder $query = null) : array
{
$this->getAll();
$result = $this->executeGet($query);
if (\is_object($result)
&& (\str_starts_with($class = \get_class($result), 'Null') || \stripos($class, '\Null') !== false)
) {
return [];
}
return \is_array($result) ? $result : [$result];
}
/**
* Count the number of elements
*
* @return int
*
* @since 1.0.0
*/
public function executeCount() : int
{
$this->count();
$query = $this->getQuery(
null,
[
'COUNT(' . (empty($this->columns) ? '*' : \implode(',', $this->columns)) . ')' => 'count',
]
);
return (int) $query->execute()?->fetchColumn();
}
/**
* Sum the number of elements
*
* @return int|float
*
* @since 1.0.0
*/
public function executeSum() : int|float
{
$this->sum();
$query = $this->getQuery(
null,
[
'SUM(' . (empty($this->columns) ? '*' : \implode(',', $this->columns)) . ')' => 'sum',
]
);
$result = $query->execute()?->fetchColumn();
if (empty($result)) {
return 0;
}
return \stripos($result, '.') === false ? (int) $result : (float) $result;
}
/**
* Check if any element exists
*
* @return bool
*
* @since 1.0.0
*/
public function executeExists() : bool
{
$this->exists();
$query = $this->getQuery(null, [1]);
return ($query->execute()?->fetchColumn() ?? 0) > 0;
}
/**
* Check if any element exists
*
* @return bool
*
* @since 1.0.0
*/
public function executeHas() : bool
{
$obj = isset($this->where[$this->mapper::COLUMNS[$this->mapper::PRIMARYFIELD]['internal']])
? $this->mapper::createNullModel($this->where[$this->mapper::COLUMNS[$this->mapper::PRIMARYFIELD]['internal']][0]['value'])
: $this->columns([1])->executeGet();
return $this->hasManyRelations($obj);
}
/**
* 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 ??= $this->query ?? new Builder($this->db, true);
if (empty($columns) && $this->type < MapperType::COUNT_MODELS) {
$columns = empty($this->columns) ? $this->mapper::COLUMNS : $this->columns;
}
foreach ($columns as $key => $values) {
if (\is_array($values)
&& (($values['writeonly'] ?? false) === false || isset($this->with[$values['internal']]))
) {
if (\is_int($key)) {
$query->select($key);
} else {
$query->selectAs(
$this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $key,
$key . '_d' . $this->depth . $this->joinAlias
);
}
} elseif (\is_int($values)) {
$query->select($values);
} elseif (\is_string($values)) {
$query->selectAs($key, $values);
}
}
if (empty($query->from)) {
$query->fromAs($this->mapper::TABLE, $this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias);
}
// Join tables manually without using "with()" (NOT hasMany/owns one etc.)
// This is necessary for special cases, e.g. when joining in the other direction
// Example: Show all profiles who have written a news article.
// "with()" only allows to go from articles to accounts but we want to go the other way
//
// @feature Create join functionality for mappers which supports joining and filtering based on other tables
// Example: show all profiles which have written a news article
// https://github.com/Karaka-Management/phpOMS/issues/253
foreach ($this->join as $member => $values) {
if (($col = $this->mapper::getColumnByMember($member)) === null) {
continue;
}
/* variable in model */
// @todo join handling is extremely ugly, needs to be refactored
foreach ($values as $join) {
// @todo the hasMany, 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 ($join['child'] !== '') {
continue;
}
if (isset($join['mapper']::HAS_MANY[$join['value']])) {
if (isset($join['mapper']::HAS_MANY[$join['value']]['external'])) {
$relJoinTable = $join['mapper']::HAS_MANY[$join['value']]['table'];
// join with relation table
$query->join($relJoinTable, $join['type'], $relJoinTable . '_d' . ($this->depth + 1) . $this->joinAlias)
->on(
$this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $col,
'=',
$relJoinTable . '_d' . ($this->depth + 1) . $this->joinAlias . '.' . $join['mapper']::HAS_MANY[$join['value']]['external'],
'AND',
$relJoinTable . '_d' . ($this->depth + 1) . $this->joinAlias
);
// join with model table
$query->join($join['mapper']::TABLE, $join['type'], $join['mapper']::TABLE . '_d' . ($this->depth + 1) . $this->joinAlias)
->on(
$relJoinTable . '_d' . ($this->depth + 1) . $this->joinAlias . '.' . $join['mapper']::HAS_MANY[$join['value']]['self'],
'=',
$join['mapper']::TABLE . '_d' . ($this->depth + 1) . $this->joinAlias . '.' . $join['mapper']::PRIMARYFIELD,
'AND',
$join['mapper']::TABLE . '_d' . ($this->depth + 1) . $this->joinAlias
);
if (isset($this->on[$join['value']])) {
foreach ($this->on[$join['value']] as $on) {
$query->where(
$join['mapper']::TABLE . '_d' . ($this->depth + 1) . $this->joinAlias . '.' . $join['mapper']::getColumnByMember($on['member']),
'=',
$on['value'],
'AND'
);
}
}
}
} else {
$query->join($join['mapper']::TABLE, $join['type'], $join['mapper']::TABLE . '_d' . ($this->depth + 1) . $this->joinAlias)
->on(
$this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $col,
'=',
$join['mapper']::TABLE . '_d' . ($this->depth + 1) . $this->joinAlias . '.' . $join['mapper']::getColumnByMember($join['value']),
'AND',
$join['mapper']::TABLE . '_d' . ($this->depth + 1) . $this->joinAlias
);
}
}
}
// where
foreach ($this->where as $member => $values) {
// handle where query
if ($member === '' && $values[0]['value'] instanceof Where) {
$query->where($values[0]['value'], boolean: $values[0]['comparison']);
continue;
}
if (($col = $this->mapper::getColumnByMember($member)) === null) {
continue;
}
// In case alternative where values are allowed
// This is different from normal or conditions as these are exclusive or conditions
// This means they are only selected IFF the previous where clause fails
$alt = [];
/* variable in model */
$previous = null;
foreach ($values as $where) {
// @todo the hasMany, 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'];
if ($where['comparison'] === 'ALT') {
// This uses an alternative value if the previous value(s) in the where clause don't exist (e.g. for localized results where you allow a user language, alternatively a primary language, and then alternatively any language if the first two don't exist).
// is first value
if (empty($alt)) {
$alt[] = $previous['value'];
}
/*
select * from table_name
where // where starts here
field1 = 'value1' // comes from normal where
or ( // where1 starts here
field1 = 'default'
and NOT EXISTS ( // where2 starts here
select 1 from table_name where field1 = 'value1'
)
)
*/
$where1 = new Where($this->db);
$where1->where($this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $col, $comparison, $where['value'], 'and');
$where2 = new Builder($this->db);
$where2->select(1)
->from($this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias)
->where($this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $col, 'in', $alt);
$where1->where($this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $col, 'not exists', $where2, 'and');
$query->where($this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $col, $comparison, $where1, 'or');
$alt[] = $where['value'];
} else {
$previous = $where;
$query->where($this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $col, $comparison, $where['value'], $where['comparison']);
}
}
}
// 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 {
continue;
}
foreach ($data as $with) {
if ($with['child'] !== '') {
continue;
}
if (isset($this->mapper::OWNS_ONE[$member]) || isset($this->mapper::BELONGS_TO[$member])) {
$tableAlias = $rel['mapper']::TABLE . '_d' . ($this->depth + 1) . '_' . $member;
$query->leftJoin($rel['mapper']::TABLE, $tableAlias)
->on(
$this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $rel['external'], '=',
$tableAlias . '.' . (
isset($rel['by']) ? $rel['mapper']::getColumnByMember($rel['by']) : $rel['mapper']::PRIMARYFIELD
), 'and',
$tableAlias
);
} 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)
$tableAlias = $rel['mapper']::TABLE . '_d' . ($this->depth + 1) . '_' . $member;
// @todo handle self and self === null
$query->leftJoin($rel['mapper']::TABLE, $tableAlias)
->on(
$this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . ($rel['external'] ?? $this->mapper::PRIMARYFIELD), '=',
$tableAlias . '.' . (
isset($rel['by']) ? $rel['mapper']::getColumnByMember($rel['by']) : $rel['self']
), 'and',
$tableAlias
);
}
/** @var self $relMapper */
$relMapper = $this->createRelationMapper($rel['mapper']::reader(db: $this->db), $member);
$relMapper->depth = $this->depth + 1;
$relMapper->type = $this->type;
$relMapper->joinAlias = '_' . $member;
// Here we go further into the depth of the model (e.g. a hasMany/ownsOne can again have ownsOne...)
$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 $sort) {
if (($column = $this->mapper::getColumnByMember($member)) === null
|| ($sort['child'] !== '')
) {
continue;
}
$query->orderBy($this->mapper::TABLE . '_d' . $this->depth . $this->joinAlias . '.' . $column, $sort['order']);
// @bug It looks like that only one sort parameter is supported despite SQL supporting multiple
// https://github.com/Karaka-Management/phpOMS/issues/364
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 $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 object $obj Object to populate
*
* @return object
*
* @since 1.0.0
*/
public function populateAbstract(array $result, object $obj) : object
{
$refClass = null;
$aValue = null;
$arrayPath = '';
foreach ($this->mapper::COLUMNS as $column => $def) {
$alias = $column . '_d' . $this->depth . $this->joinAlias;
if (!\array_key_exists($alias, $result)) {
continue;
}
$value = $result[$alias];
$hasPath = false;
$refProp = null;
$isPrivate = $def['private'] ?? false;
$member = $def['internal'];
if ($isPrivate && $refClass === null) {
$refClass = new \ReflectionClass($obj);
}
if (\stripos($member, '/') !== false) {
$hasPath = true;
$path = \explode('/', \ltrim($member, '/'));
$member = $path[0];
if ($isPrivate) {
$refProp = $refClass->getProperty($path[0]);
$aValue = $refProp->getValue($obj);
} else {
$aValue = $obj->{$path[0]};
}
\array_shift($path);
$arrayPath = \implode('/', $path);
} elseif ($isPrivate) {
$refProp = $refClass->getProperty($member);
}
$type = $def['type'];
if (isset($this->mapper::OWNS_ONE[$member])) {
$default = null;
if (!isset($this->with[$member])
&& ($isPrivate ? $refProp->isInitialized($obj) : isset($obj->{$member}))
) {
$default = $isPrivate ? $refProp->getValue($obj) : $obj->{$member};
}
$value = $this->populateOwnsOne($member, $result, $default);
} elseif (isset($this->mapper::BELONGS_TO[$member])) {
$default = null;
if (!isset($this->with[$member])
&& ($isPrivate ? $refProp->isInitialized($obj) : isset($obj->{$member}))
) {
$default = $isPrivate ? $refProp->getValue($obj) : $obj->{$member};
}
$value = $this->populateBelongsTo($member, $result, $default);
} elseif (\in_array($type, ['string', 'compress', 'int', 'float', 'bool'])) {
if ($value !== null && $type === 'compress') {
$type = 'string';
$value = \gzinflate($value);
}
if ($value !== null
|| ($isPrivate ? $refProp->getValue($obj) !== null : $obj->{$member} !== null)
) {
\settype($value, $type);
}
if ($hasPath) {
$value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
}
} elseif ($type === 'DateTime') {
$value = $value === null ? null : new \DateTime($value);
if ($hasPath) {
$value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
}
} elseif ($type === 'DateTimeImmutable') {
$value = $value === null ? null : new \DateTimeImmutable($value);
if ($hasPath) {
$value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
}
} elseif ($type === 'Json') {
if ($hasPath) {
$value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
}
$value = \json_decode($value, true);
} elseif ($type === 'Serializable') {
$mObj = $isPrivate ? $refProp->getValue($obj) : $obj->{$member};
if ($mObj !== null && $value !== null) {
$mObj->unserialize($value);
$value = $mObj;
}
}
if ($isPrivate) {
$refProp->setValue($obj, $value);
} else {
$obj->{$member} = $value;
}
}
if (empty($this->with)) {
return $obj;
}
// This is only for hasMany elements where only one hasMany child object is loaded
// Example: A model usually only loads one l11n element despite having localizations for multiple languages
// @todo The code below is basically a copy of the foreach from above.
// Maybe we can combine them in a smart way without adding much overhead
foreach ($this->mapper::HAS_MANY as $member => $def) {
// Only if column is defined do we have a pseudo 1-to-1 relation
// The content of the column will be loaded directly in the member variable
if (!isset($this->with[$member])
|| !isset($def['column'])
) {
continue;
}
$column = $def['mapper']::getColumnByMember($def['column'] ?? $member);
$alias = $column . '_d' . ($this->depth + 1) . '_' . $member;
if (!\array_key_exists($alias, $result)) {
continue;
}
$value = $result[$alias];
$hasPath = false;
$refProp = null;
$isPrivate = $def['private'] ?? false;
if ($isPrivate && $refClass === null) {
$refClass = new \ReflectionClass($obj);
}
if (\stripos($member, '/') !== false) {
$hasPath = true;
$path = \explode('/', \ltrim($member, '/'));
$member = $path[0];
if ($isPrivate) {
$refProp = $refClass->getProperty($path[0]);
$aValue = $refProp->getValue($obj);
} else {
$aValue = $obj->{$path[0]};
}
\array_shift($path);
$arrayPath = \implode('/', $path);
} elseif ($isPrivate) {
$refProp = $refClass->getProperty($member);
}
$type = $def['mapper']::COLUMNS[$column]['type'];
if (\in_array($type, ['string', 'compress', 'int', 'float', 'bool'])) {
if ($value !== null && $type === 'compress') {
$type = 'string';
$value = \gzinflate($value);
}
if ($value !== null
|| ($isPrivate ? $refProp->getValue($obj) !== null : $obj->{$member} !== null)
) {
\settype($value, $type);
}
if ($hasPath) {
$value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
}
} elseif ($type === 'DateTime') {
$value = $value === null ? null : new \DateTime($value);
if ($hasPath) {
$value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
}
} elseif ($type === 'DateTimeImmutable') {
$value = $value === null ? null : new \DateTimeImmutable($value);
if ($hasPath) {
$value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
}
} elseif ($type === 'Json') {
if ($hasPath) {
$value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
}
$value = \json_decode($value, true);
} elseif ($type === 'Serializable') {
$mObj = $isPrivate ? $refProp->getValue($obj) : $obj->{$member};
if ($mObj !== null && $value !== null) {
$mObj->unserialize($value);
$value = $mObj;
}
}
if ($isPrivate) {
$refProp->setValue($obj, $value);
} else {
$obj->{$member} = $value;
}
}
return $obj;
}
/**
* Populate data.
*
* @param string $member Member name
* @param array $result Result data
* @param mixed $default Default value
*
* @return mixed
*
* @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 . $this->joinAlias, $result)) {
return isset($this->mapper::OWNS_ONE[$member]['column'])
? $result[$this->mapper::OWNS_ONE[$member]['external'] . '_d' . $this->depth . $this->joinAlias]
: $mapper::createNullModel(
$result[$this->mapper::OWNS_ONE[$member]['external'] . '_d' . $this->depth . $this->joinAlias],
$this->mapper::OWNS_ONE[$member]['by'] ?? null
);
} else {
return $default;
}
} elseif (isset($this->mapper::OWNS_ONE[$member]['column'])) {
return $result[$mapper::getColumnByMember($this->mapper::OWNS_ONE[$member]['column']) . '_d' . $this->depth . '_' . $member];
} elseif (!isset($result[$mapper::PRIMARYFIELD . '_d' . ($this->depth + 1) . '_' . $member])) {
return $mapper::createNullModel();
}
/** @var self $ownsOneMapper */
$ownsOneMapper = $this->createRelationMapper($mapper::get($this->db), $member);
$ownsOneMapper->depth = $this->depth + 1;
$ownsOneMapper->joinAlias = '_' . $member;
return $ownsOneMapper->populateAbstract($result, $mapper::createBaseModel($result));
}
/**
* Populate data.
*
* @param string $member Member name
* @param array $result Result data
* @param mixed $default Default value
*
* @return mixed
*
* @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 . $this->joinAlias, $result)) {
return isset($this->mapper::BELONGS_TO[$member]['column'])
? $result[$this->mapper::BELONGS_TO[$member]['external'] . '_d' . $this->depth . $this->joinAlias]
: $mapper::createNullModel(
$result[$this->mapper::BELONGS_TO[$member]['external'] . '_d' . $this->depth . $this->joinAlias],
$this->mapper::BELONGS_TO[$member]['by'] ?? null
);
} else {
return $default;
}
} elseif (isset($this->mapper::BELONGS_TO[$member]['column'])) {
return $result[$mapper::getColumnByMember($this->mapper::BELONGS_TO[$member]['column']) . '_d' . $this->depth . '_' . $member];
} elseif (!isset($result[$mapper::PRIMARYFIELD . '_d' . ($this->depth + 1) . '_' . $member])) {
return $mapper::createNullModel();
} elseif (isset($this->mapper::BELONGS_TO[$member]['by'])) {
// 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
/** @var self $belongsToMapper */
$belongsToMapper = $this->createRelationMapper($mapper::get($this->db), $member);
$belongsToMapper->depth = $this->depth + 1;
$belongsToMapper->joinAlias = '_' . $member;
$belongsToMapper->where(
$this->mapper::BELONGS_TO[$member]['by'],
$result[$mapper::getColumnByMember($this->mapper::BELONGS_TO[$member]['by']) . '_d' . ($this->depth + 1) . '_' . $member],
'='
);
return $belongsToMapper->execute();
}
/** @var self $belongsToMapper */
$belongsToMapper = $this->createRelationMapper($mapper::get($this->db), $member);
$belongsToMapper->depth = $this->depth + 1;
$belongsToMapper->joinAlias = '_' . $member;
return $belongsToMapper->populateAbstract($result, $mapper::createBaseModel($result));
}
/**
* Fill object with relations
*
* @param object[] $objs Object to fill
*
* @return void
*
* @since 1.0.0
*/
public function loadHasManyRelations(array $objs) : void
{
$primaryKeys = [];
foreach ($objs as $idx => $obj) {
$key = $this->mapper::getObjectId($obj);
if (!empty($key)) {
$primaryKeys[$idx] = $key;
}
}
if (empty($primaryKeys)) {
return;
}
$refClass = null;
// @todo Check if there are more cases where the relation is already loaded with joins etc.
// There can be pseudo hasMany elements like localizations.
// They are hasMany but these are already loaded with joins!
foreach ($this->with as $member => $withData) {
if (isset($this->mapper::HAS_MANY[$member])) {
$many = $this->mapper::HAS_MANY[$member];
if (isset($many['column'])) {
continue;
}
$isPrivate = $withData['private'] ?? false;
$objectMapper = $this->createRelationMapper($many['mapper']::get(db: $this->db), $member);
if ($many['external'] === null) {
$objectMapper->where($many['mapper']::COLUMNS[$many['self']]['internal'], $primaryKeys);
$objectMapper->indexedBy($many['self']);
} else {
$query = new Builder($this->db, true);
$query
->selectAs($many['table'] . '.' . $many['self'], $many['self'] . '_d' . $this->depth . $this->joinAlias)
->leftJoin($many['table'])
->on($many['mapper']::TABLE . '_d1.' . $many['mapper']::PRIMARYFIELD, '=', $many['table'] . '.' . $many['external'])
->where($many['table'] . '.' . $many['self'], 'IN', $primaryKeys);
// Cannot use join, because join only works on members and we don't have members for a relation table
// This is why we need to create a "base" query which contains the join on table columns
$objectMapper->query($query);
$objectMapper->indexedBy($many['self']);
}
$objects = $objectMapper->execute();
if (empty($objects) || !\is_array($objects)) {
continue;
}
if ($isPrivate) {
$refClass ??= new \ReflectionClass($obj);
foreach ($primaryKeys as $idx => $key) {
if (!isset($objects[$key])) {
continue;
}
if (($many['conditional'] ?? false) && \is_array($objects[$key])) {
$objects[$key] = \reset($objects[$key]);
}
$refProp = $refClass->getProperty($member);
$refProp->setValue($objs[$idx], !\is_array($objects[$key]) && ($many['conditional'] ?? false) === false
? [$many['mapper']::getObjectId($objects[$key]) => $objects[$key]]
: $objects[$key] // if conditional === true the obj will be assigned (e.g. hasMany localizations but only one is loaded for the model)
);
}
} else {
foreach ($primaryKeys as $idx => $key) {
if (!isset($objects[$key])) {
continue;
}
if (($many['conditional'] ?? false) && \is_array($objects[$key])) {
$objects[$key] = \reset($objects[$key]);
}
$objs[$idx]->{$member} = !\is_array($objects[$key]) && ($many['conditional'] ?? false) === false
? [$many['mapper']::getObjectId($objects[$key]) => $objects[$key]]
: $objects[$key]; // if conditional === true the obj will be assigned (e.g. hasMany localizations but only one is loaded for the model)
}
}
continue;
} elseif (isset($this->mapper::OWNS_ONE[$member])
|| isset($this->mapper::BELONGS_TO[$member])
) {
if (\count($withData) < 2) {
continue;
}
$relation = isset($this->mapper::OWNS_ONE[$member])
? $this->mapper::OWNS_ONE[$member]
: $this->mapper::BELONGS_TO[$member];
/** @var ReadMapper $relMapper */
$relMapper = $this->createRelationMapper($relation['mapper']::reader($this->db), $member);
$isPrivate = $relation['private'] ?? false;
$tempObjs = [];
if ($isPrivate) {
$refClass ??= new \ReflectionClass($obj);
$refProp = $refClass->getProperty($member);
foreach ($objs as $obj) {
$tempObjs[] = $refProp->getValue($obj);
}
} else {
foreach ($objs as $obj) {
$tempObjs[] = $obj->{$member};
}
}
$relMapper->loadHasManyRelations($tempObjs);
}
}
}
/**
* Checks if object has certain relations
*
* @param object $obj Object to check
*
* @return bool
*
* @since 1.0.0
*/
public function hasManyRelations(object $obj) : bool
{
if (empty($this->with)) {
return true;
}
$primaryKey = $this->mapper::getObjectId($obj);
if (empty($primaryKey)) {
return false;
}
$refClass = null;
// @performance Check if there are more cases where the relation is already loaded with joins etc.
// There can be pseudo hasMany elements like localizations. They are hasMany but these are already loaded with joins!
// Variation of https://github.com/Karaka-Management/phpOMS/issues/363
foreach ($this->with as $member => $withData) {
if (isset($this->mapper::HAS_MANY[$member])) {
$many = $this->mapper::HAS_MANY[$member];
if (isset($many['column'])) {
continue;
}
$isPrivate = $many['private'] ?? false;
$objectMapper = $this->createRelationMapper($many['mapper']::exists(db: $this->db), $member);
if ($many['external'] === null/* same as $many['table'] !== $many['mapper']::TABLE */) {
$objectMapper->where($many['mapper']::COLUMNS[$many['self']]['internal'], $primaryKey);
} else {
$query = new Builder($this->db, true);
$query->leftJoin($many['table'])
->on($many['mapper']::TABLE . '_d1.' . $many['mapper']::PRIMARYFIELD, '=', $many['table'] . '.' . $many['external'])
->where($many['table'] . '.' . $many['self'], '=', $primaryKey);
// Cannot use join, because join only works on members and we don't have members for a relation table
// This is why we need to create a "base" query which contains the join on table columns
$objectMapper->query($query);
}
$objects = $objectMapper->execute();
return !empty($objects) && $objects !== false;
} elseif (isset($this->mapper::OWNS_ONE[$member])
|| isset($this->mapper::BELONGS_TO[$member])
) {
if (\count($withData) < 2) {
continue;
}
$relation = isset($this->mapper::OWNS_ONE[$member])
? $this->mapper::OWNS_ONE[$member]
: $this->mapper::BELONGS_TO[$member];
/** @var ReadMapper $relMapper */
$relMapper = $this->createRelationMapper($relation['mapper']::reader($this->db), $member);
$isPrivate = $relation['private'] ?? false;
if ($isPrivate) {
$refClass ??= new \ReflectionClass($obj);
$refProp = $refClass->getProperty($member);
return $relMapper->hasManyRelations($refProp->getValue($obj));
} else {
return $relMapper->hasManyRelations($obj->{$member});
}
}
}
}
/**
* Paginate results
*
* @param string $member Member to use for pagination
* @param null|string $ptype Pagination type (previous/next)
* @param mixed $offset Offset
*
* @return self
*
* @since 1.0.0
*/
public function paginate(string $member, ?string $ptype, mixed $offset) : self
{
if ($ptype === 'p') {
$this->where($member, $offset ?? 0, '<');
} elseif ($ptype === 'n') {
$this->where($member, $offset ?? 0, '>');
} else {
$this->where($member, 0, '>');
}
return $this;
}
}