Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
59.53% covered (warning)
59.53%
228 / 383
17.65% covered (danger)
17.65%
3 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReadMapper
59.53% covered (warning)
59.53%
228 / 383
17.65% covered (danger)
17.65%
3 / 17
2038.76
0.00% covered (danger)
0.00%
0 / 1
 get
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRaw
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getAll
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 count
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getRandom
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 columns
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 execute
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
16.14
 executeGet
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 executeGetRaw
43.75% covered (danger)
43.75%
7 / 16
0.00% covered (danger)
0.00%
0 / 1
6.85
 executeGetAll
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
5.20
 executeCount
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 executeRandom
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getQuery
56.35% covered (warning)
56.35%
71 / 126
0.00% covered (danger)
0.00%
0 / 1
239.63
 populateAbstract
59.63% covered (warning)
59.63%
65 / 109
0.00% covered (danger)
0.00%
0 / 1
253.98
 populateOwnsOne
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
6.84
 populateBelongsTo
47.83% covered (danger)
47.83%
11 / 23
0.00% covered (danger)
0.00%
0 / 1
13.96
 loadHasManyRelations
72.92% covered (warning)
72.92%
35 / 48
0.00% covered (danger)
0.00%
0 / 1
31.62
1<?php
2/**
3 * Jingga
4 *
5 * PHP Version 8.1
6 *
7 * @package   phpOMS\DataStorage\Database\Mapper
8 * @copyright Dennis Eichhorn
9 * @license   OMS License 2.0
10 * @version   1.0.0
11 * @link      https://jingga.app
12 */
13declare(strict_types=1);
14
15namespace phpOMS\DataStorage\Database\Mapper;
16
17use phpOMS\DataStorage\Database\Query\Builder;
18use phpOMS\DataStorage\Database\Query\Where;
19use phpOMS\Utils\ArrayUtils;
20
21/**
22 * Read mapper (SELECTS).
23 *
24 * @package phpOMS\DataStorage\Database\Mapper
25 * @license OMS License 2.0
26 * @link    https://jingga.app
27 * @since   1.0.0
28 *
29 * @todo Add memory cache per read mapper parent call (These should be cached: attribute types, file types, etc.)
30 * @todo Add getArray functions to get array instead of object
31 * @todo Allow to define columns in all functions instead of members?
32 *
33 * @template R
34 */
35final class ReadMapper extends DataMapperAbstract
36{
37    /**
38     * Columns to load
39     *
40     * @var array
41     * @since 1.0.0
42     */
43    private array $columns = [];
44
45    /**
46     * Create get mapper
47     *
48     * This makes execute() return a single object or an array of object depending the result size
49     *
50     * @return self
51     *
52     * @since 1.0.0
53     */
54    public function get() : self
55    {
56        $this->type = MapperType::GET;
57
58        return $this;
59    }
60
61    /**
62     * Get raw result set
63     *
64     * @return self
65     *
66     * @since 1.0.0
67     */
68    public function getRaw() : self
69    {
70        $this->type = MapperType::GET_RAW;
71
72        return $this;
73    }
74
75    /**
76     * Create get mapper
77     *
78     * This makes execute() always return an array of objects (or an empty array)
79     *
80     * @return self
81     *
82     * @since 1.0.0
83     */
84    public function getAll() : self
85    {
86        $this->type = MapperType::GET_ALL;
87
88        return $this;
89    }
90
91    /**
92     * Create count mapper
93     *
94     * @return self
95     *
96     * @since 1.0.0
97     */
98    public function count() : self
99    {
100        $this->type = MapperType::COUNT_MODELS;
101
102        return $this;
103    }
104
105    /**
106     * Create random mapper
107     *
108     * @return self
109     *
110     * @since 1.0.0
111     */
112    public function getRandom() : self
113    {
114        $this->type = MapperType::GET_RANDOM;
115
116        return $this;
117    }
118
119    /**
120     * Define the columns to load
121     *
122     * @param array $columns Columns to load
123     *
124     * @return self
125     *
126     * @since 1.0.0
127     * @todo: consider to accept properties instead and then check ::COLUMNS which contian the property and ADD that array into $this->columns. Maybe also consider a rename from columns() to property()
128     */
129    public function columns(array $columns) : self
130    {
131        $this->columns = $columns;
132
133        return $this;
134    }
135
136    /**
137     * Execute mapper
138     *
139     * @param mixed ...$options Options to pass to read mapper
140     *
141     * @return R
142     *
143     * @since 1.0.0
144     */
145    public function execute(mixed ...$options) : mixed
146    {
147        switch($this->type) {
148            case MapperType::GET:
149                /** @var null|Builder ...$options */
150                return $this->executeGet(...$options);
151            case MapperType::GET_RAW:
152                /** @var null|Builder ...$options */
153                return $this->executeGetRaw(...$options);
154            case MapperType::GET_ALL:
155                /** @var null|Builder ...$options */
156                return $this->executeGetAll(...$options);
157            case MapperType::GET_RANDOM:
158                return $this->executeGetRaw();
159            case MapperType::COUNT_MODELS:
160                return $this->executeCount();
161            default:
162                return null;
163        }
164    }
165
166    /**
167     * Execute mapper
168     *
169     * @param null|Builder $query Query to use instead of the internally generated query
170     *                            Careful, this doesn't merge with the internal query.
171     *                            If you want to merge it use ->query() instead
172     *
173     * @return R
174     *
175     * @since 1.0.0
176     */
177    public function executeGet(Builder $query = null) : mixed
178    {
179        $primaryKeys          = [];
180        $memberOfPrimaryField = $this->mapper::COLUMNS[$this->mapper::PRIMARYFIELD]['internal'];
181
182        if (isset($this->where[$memberOfPrimaryField])) {
183            $keys        = $this->where[$memberOfPrimaryField][0]['value'];
184            $primaryKeys = \array_merge(\is_array($keys) ? $keys : [$keys], $primaryKeys);
185        }
186
187        // Get initialized objects from memory cache.
188        $obj = [];
189
190        // Get remaining objects (not available in memory cache) or remaining where clauses.
191        $dbData = $this->executeGetRaw($query);
192
193        foreach ($dbData as $row) {
194            $value       = $row[$this->mapper::PRIMARYFIELD . '_d' . $this->depth];
195            $obj[$value] = $this->mapper::createBaseModel($row);
196
197            $obj[$value] = $this->populateAbstract($row, $obj[$value]);
198            $this->loadHasManyRelations($obj[$value]);
199        }
200
201        $countResulsts = \count($obj);
202
203        if ($countResulsts === 0) {
204            return $this->mapper::createNullModel();
205        } elseif ($countResulsts === 1) {
206            return \reset($obj);
207        }
208
209        return $obj;
210    }
211
212    /**
213     * Execute mapper
214     *
215     * @param null|Builder $query Query to use instead of the internally generated query
216     *                            Careful, this doesn't merge with the internal query.
217     *                            If you want to merge it use ->query() instead
218     *
219     * @return array
220     *
221     * @since 1.0.0
222     */
223    public function executeGetRaw(Builder $query = null) : array
224    {
225        $query ??= $this->getQuery();
226
227        try {
228            $results = false;
229
230            $sth = $this->db->con->prepare($a = $query->toSql());
231            if ($sth !== false) {
232                $sth->execute();
233                $results = $sth->fetchAll(\PDO::FETCH_ASSOC);
234            }
235        } catch (\Throwable $t) {
236            $results = false;
237
238            \phpOMS\Log\FileLogger::getInstance()->error(
239                \phpOMS\Log\FileLogger::MSG_FULL, [
240                    'message' => $t->getMessage() . ':' . $query->toSql(),
241                    'line'    => __LINE__,
242                    'file'    => self::class,
243                ]
244            );
245        }
246
247        return $results === false ? [] : $results;
248    }
249
250    /**
251     * Execute mapper
252     *
253     * @param null|Builder $query Query to use instead of the internally generated query
254     *                            Careful, this doesn't merge with the internal query.
255     *                            If you want to merge it use ->query() instead
256     *
257     * @return array
258     *
259     * @since 1.0.0
260     */
261    public function executeGetAll(Builder $query = null) : array
262    {
263        $result = $this->executeGet($query);
264
265        if (\is_object($result)
266            && (\str_starts_with($class = \get_class($result), 'Null') || \stripos($class, '\Null') !== false)
267        ) {
268            return [];
269        }
270
271        return \is_array($result) ? $result : [$result];
272    }
273
274    /**
275     * Count the number of elements
276     *
277     * @return int
278     *
279     * @since 1.0.0
280     */
281    public function executeCount() : int
282    {
283        $query = $this->getQuery(null, ['COUNT(*)' => 'count']);
284
285        return (int) $query->execute()?->fetchColumn();
286    }
287
288    /**
289     * Get random object
290     *
291     * @return mixed
292     *
293     * @since 1.0.0
294     */
295    public function executeRandom() : mixed
296    {
297        $query = $this->getQuery();
298        $query->random($this->mapper::PRIMARYFIELD);
299
300        return $this->executeGet($query);
301    }
302
303    /**
304     * Get mapper specific builder
305     *
306     * @param Builder $query   Query to fill
307     * @param array   $columns Columns to use
308     *
309     * @return Builder
310     *
311     * @since 1.0.0
312     */
313    public function getQuery(Builder $query = null, array $columns = []) : Builder
314    {
315        $query ??= $this->query ?? new Builder($this->db, true);
316        $columns = empty($columns)
317            ? (empty($this->columns) ? $this->mapper::COLUMNS : $this->columns)
318            : $columns;
319
320        foreach ($columns as $key => $values) {
321            if (\is_string($values)) {
322                $query->selectAs($key, $values);
323            } elseif (($values['writeonly'] ?? false) === false || isset($this->with[$values['internal']])) {
324                $query->selectAs($this->mapper::TABLE . '_d' . $this->depth . '.' . $key, $key . '_d' . $this->depth);
325            }
326        }
327
328        if (empty($query->from)) {
329            $query->fromAs($this->mapper::TABLE, $this->mapper::TABLE . '_d' . $this->depth);
330        }
331
332        // Join tables manually without using "with()" (NOT has many/owns one etc.)
333        // This is necessary for special cases, e.g. when joining in the other direction
334        // Example: Show all profiles who have written a news article.
335        //          "with()" only allows to go from articles to accounts but we want to go the other way
336        foreach ($this->join as $member => $values) {
337            if (($col = $this->mapper::getColumnByMember($member)) === null) {
338                continue;
339            }
340
341            /* variable in model */
342            // @todo: join handling is extremely ugly, needs to be refactored
343            foreach ($values as $join) {
344                // @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
345                if ($join['child'] !== '') {
346                    continue;
347                }
348
349                if (isset($join['mapper']::HAS_MANY[$join['value']])) {
350                    if (isset($join['mapper']::HAS_MANY[$join['value']]['external'])) {
351                        // join with relation table
352                        $query->join($join['mapper']::HAS_MANY[$join['value']]['table'], $join['type'], $join['mapper']::HAS_MANY[$join['value']]['table'] . '_d' . ($this->depth + 1))
353                            ->on(
354                                $this->mapper::TABLE . '_d' . $this->depth . '.' . $col,
355                                '=',
356                                $join['mapper']::HAS_MANY[$join['value']]['table'] . '_d' . ($this->depth + 1) . '.' . $join['mapper']::HAS_MANY[$join['value']]['external'],
357                                'AND',
358                                $join['mapper']::HAS_MANY[$join['value']]['table'] . '_d' . ($this->depth + 1)
359                            );
360
361                        // join with model table
362                        $query->join($join['mapper']::TABLE, $join['type'], $join['mapper']::TABLE . '_d' . ($this->depth + 1))
363                            ->on(
364                                $join['mapper']::HAS_MANY[$join['value']]['table'] . '_d' . ($this->depth + 1) . '.' . $join['mapper']::HAS_MANY[$join['value']]['self'],
365                                '=',
366                                $join['mapper']::TABLE . '_d' . ($this->depth + 1) . '.' . $join['mapper']::PRIMARYFIELD,
367                                'AND',
368                                $join['mapper']::TABLE . '_d' . ($this->depth + 1)
369                            );
370
371                        if (isset($this->on[$join['value']])) {
372                            foreach ($this->on[$join['value']] as $on) {
373                                $query->where(
374                                    $join['mapper']::TABLE . '_d' . ($this->depth + 1) . '.' . $join['mapper']::getColumnByMember($on['member']),
375                                    '=',
376                                    $on['value'],
377                                    'AND'
378                                );
379                            }
380                        }
381                    }
382                } else {
383                    $query->join($join['mapper']::TABLE, $join['type'], $join['mapper']::TABLE . '_d' . ($this->depth + 1))
384                        ->on(
385                            $this->mapper::TABLE . '_d' . $this->depth . '.' . $col,
386                            '=',
387                            $join['mapper']::TABLE . '_d' . ($this->depth + 1) . '.' . $join['mapper']::getColumnByMember($join['value']),
388                            'AND',
389                            $join['mapper']::TABLE . '_d' . ($this->depth + 1)
390                        );
391                }
392            }
393        }
394
395        // where
396        foreach ($this->where as $member => $values) {
397            // handle where query
398            if ($member === '' && $values[0]['value'] instanceof Where) {
399                $query->where($values[0]['value'], boolean: $values[0]['comparison']);
400
401                continue;
402            }
403
404            if (($col = $this->mapper::getColumnByMember($member)) === null) {
405                continue;
406            }
407
408            // In case alternative where values are allowed
409            // This is different from normal or conditions as these are exclusive or conditions
410            // This means they are only selected IFF the previous where clause fails
411            $alt = [];
412
413            /* variable in model */
414            $previous = null;
415            foreach ($values as $where) {
416                // @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
417                if ($where['child'] !== '') {
418                    continue;
419                }
420
421                $comparison = \is_array($where['value']) && \count($where['value']) > 1 ? 'in' : $where['logic'];
422                if ($where['comparison'] === 'ALT') {
423                    // 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).
424
425                    // is first value
426                    if (empty($alt)) {
427                        $alt[] = $previous['value'];
428                    }
429
430                    /*
431                    select * from table_name
432                        where // where starts here
433                            field1 = 'value1' // comes from normal where
434                            or ( // where1 starts here
435                                field1 = 'default'
436                                and NOT EXISTS ( // where2 starts here
437                                    select 1 from table_name where field1 = 'value1'
438                                )
439                            )
440                    */
441                    $where1 = new Where($this->db);
442                    $where1->where($this->mapper::TABLE . '_d' . $this->depth . '.' . $col, $comparison, $where['value'], 'and');
443
444                    $where2 = new Builder($this->db);
445                    $where2->select('1')
446                        ->from($this->mapper::TABLE . '_d' . $this->depth)
447                        ->where($this->mapper::TABLE . '_d' . $this->depth . '.' . $col, 'in', $alt);
448
449                    $where1->where($this->mapper::TABLE . '_d' . $this->depth . '.' . $col, 'not exists', $where2, 'and');
450
451                    $query->where($this->mapper::TABLE . '_d' . $this->depth . '.' . $col, $comparison, $where1, 'or');
452
453                    $alt[] = $where['value'];
454                } else {
455                    $previous = $where;
456                    $query->where($this->mapper::TABLE . '_d' . $this->depth . '.' . $col, $comparison, $where['value'], $where['comparison']);
457                }
458            }
459        }
460
461        // load relations
462        foreach ($this->with as $member => $data) {
463            $rel = null;
464            if ((isset($this->mapper::OWNS_ONE[$member]) || isset($this->mapper::BELONGS_TO[$member]))
465                || (!isset($this->mapper::HAS_MANY[$member]['external']) && isset($this->mapper::HAS_MANY[$member]['column']))
466            ) {
467                $rel = $this->mapper::OWNS_ONE[$member] ?? ($this->mapper::BELONGS_TO[$member] ?? ($this->mapper::HAS_MANY[$member] ?? null));
468            } else {
469                continue;
470            }
471
472            foreach ($data as $with) {
473                if ($with['child'] !== '') {
474                    continue;
475                }
476
477                if (isset($this->mapper::OWNS_ONE[$member]) || isset($this->mapper::BELONGS_TO[$member])) {
478                    $query->leftJoin($rel['mapper']::TABLE, $rel['mapper']::TABLE . '_d' . ($this->depth + 1))
479                        ->on(
480                            $this->mapper::TABLE . '_d' . $this->depth . '.' . $rel['external'], '=',
481                            $rel['mapper']::TABLE . '_d' . ($this->depth + 1) . '.' . (
482                                isset($rel['by']) ? $rel['mapper']::getColumnByMember($rel['by']) : $rel['mapper']::PRIMARYFIELD
483                            ), 'and',
484                            $rel['mapper']::TABLE . '_d' . ($this->depth + 1)
485                        );
486                } elseif (!isset($this->mapper::HAS_MANY[$member]['external']) && isset($this->mapper::HAS_MANY[$member]['column'])) {
487                    // get HasManyQuery (but only for elements which have a 'column' defined)
488
489                    // @todo: handle self and self === null
490                    $query->leftJoin($rel['mapper']::TABLE, $rel['mapper']::TABLE . '_d' . ($this->depth + 1))
491                        ->on(
492                            $this->mapper::TABLE . '_d' . $this->depth . '.' . ($rel['external'] ?? $this->mapper::PRIMARYFIELD), '=',
493                            $rel['mapper']::TABLE . '_d' . ($this->depth + 1) . '.' . (
494                                isset($rel['by']) ? $rel['mapper']::getColumnByMember($rel['by']) : $rel['self']
495                            ), 'and',
496                            $rel['mapper']::TABLE . '_d' . ($this->depth + 1)
497                        );
498                }
499
500                /** @var self $relMapper */
501                $relMapper        = $this->createRelationMapper($rel['mapper']::reader(db: $this->db), $member);
502                $relMapper->depth = $this->depth + 1;
503
504                $query = $relMapper->getQuery(
505                    $query,
506                    isset($rel['column']) ? [$rel['mapper']::getColumnByMember($rel['column']) => []] : []
507                );
508
509                break; // there is only one root element (one element with child === '')
510            }
511        }
512
513        // handle sort, the column name order is very important. Therefore it cannot be done in the foreach loop above!
514        foreach ($this->sort as $member => $data) {
515            foreach ($data as $sort) {
516                if (($column = $this->mapper::getColumnByMember($member)) === null
517                    || ($sort['child'] !== '')
518                ) {
519                    continue;
520                }
521
522                $query->orderBy($this->mapper::TABLE . '_d' . $this->depth . '.' . $column, $sort['order']);
523
524                break; // there is only one root element (one element with child === '')
525            }
526        }
527
528        // handle limit
529        foreach ($this->limit as $member => $data) {
530            if ($member !== '') {
531                continue;
532            }
533
534            foreach ($data as $limit) {
535                if ($limit['child'] === '') {
536                    $query->limit($limit['limit']);
537
538                    break 2; // there is only one root element (one element with child === '')
539                }
540            }
541        }
542
543        return $query;
544    }
545
546    /**
547     * Populate data.
548     *
549     * @param array  $result Query result set
550     * @param object $obj    Object to populate
551     *
552     * @return object
553     *
554     * @since 1.0.0
555     */
556    public function populateAbstract(array $result, object $obj) : object
557    {
558        $refClass = new \ReflectionClass($obj);
559
560        foreach ($this->mapper::COLUMNS as $column => $def) {
561            $alias = $column . '_d' . $this->depth;
562
563            if (!\array_key_exists($alias, $result)) {
564                continue;
565            }
566
567            $value = $result[$alias];
568
569            $hasPath   = false;
570            $aValue    = [];
571            $arrayPath = '';
572
573            if (\stripos($def['internal'], '/') !== false) {
574                $hasPath = true;
575                $path    = \explode('/', \ltrim($def['internal'], '/'));
576                $member  = $path[0];
577
578                $refProp  = $refClass->getProperty($path[0]);
579                $isPublic = $refProp->isPublic();
580                $aValue   = $isPublic ? $obj->{$path[0]} : $refProp->getValue($obj);
581
582                \array_shift($path);
583                $arrayPath = \implode('/', $path);
584            } else {
585                $refProp  = $refClass->getProperty($def['internal']);
586                $isPublic = $refProp->isPublic();
587                $member   = $def['internal'];
588            }
589
590            if (isset($this->mapper::OWNS_ONE[$def['internal']])) {
591                $default = null;
592                if (!isset($this->with[$member]) && $refProp->isInitialized($obj)) {
593                    $default = $isPublic ? $obj->{$def['internal']} : $refProp->getValue($obj);
594                }
595
596                $value = $this->populateOwnsOne($def['internal'], $result, $default);
597
598                // loads has many relations. other relations are loaded in the populateOwnsOne
599                if (\is_object($value) && isset($this->mapper::OWNS_ONE[$def['internal']]['mapper'])) {
600                    $this->mapper::OWNS_ONE[$def['internal']]['mapper']::reader(db: $this->db)->loadHasManyRelations($value);
601                }
602
603                if (!empty($value)) {
604                    // @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.
605                    $refProp->setValue($obj, $value);
606                }
607            } elseif (isset($this->mapper::BELONGS_TO[$def['internal']])) {
608                $default = null;
609                if (!isset($this->with[$member]) && $refProp->isInitialized($obj)) {
610                    $default = $isPublic ? $obj->{$def['internal']} : $refProp->getValue($obj);
611                }
612
613                $value = $this->populateBelongsTo($def['internal'], $result, $default);
614
615                // loads has many relations. other relations are loaded in the populateBelongsTo
616                if (\is_object($value) && isset($this->mapper::BELONGS_TO[$def['internal']]['mapper'])) {
617                    $this->mapper::BELONGS_TO[$def['internal']]['mapper']::reader(db: $this->db)->loadHasManyRelations($value);
618                }
619
620                $refProp->setValue($obj, $value);
621            } elseif (\in_array($def['type'], ['string', 'compress', 'int', 'float', 'bool'])) {
622                if ($value !== null && $def['type'] === 'compress') {
623                    $def['type'] = 'string';
624
625                    $value = \gzinflate($value);
626                }
627
628                if ($value !== null || $refProp->getValue($obj) !== null) {
629                    \settype($value, $def['type']);
630                }
631
632                if ($hasPath) {
633                    $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
634                }
635
636                $refProp->setValue($obj, $value);
637            } elseif ($def['type'] === 'DateTime') {
638                $value = $value === null ? null : new \DateTime($value);
639                if ($hasPath) {
640                    $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
641                }
642
643                $refProp->setValue($obj, $value);
644            } elseif ($def['type'] === 'DateTimeImmutable') {
645                $value = $value === null ? null : new \DateTimeImmutable($value);
646                if ($hasPath) {
647                    $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
648                }
649
650                $refProp->setValue($obj, $value);
651            } elseif ($def['type'] === 'Json') {
652                if ($hasPath) {
653                    $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
654                }
655
656                $refProp->setValue($obj, \json_decode($value, true));
657            } elseif ($def['type'] === 'Serializable') {
658                $member = $isPublic ? $obj->{$def['internal']} : $refProp->getValue($obj);
659
660                if ($member === null || $value === null) {
661                    $obj->{$def['internal']} = $value;
662                } else {
663                    $member->unserialize($value);
664                }
665            }
666        }
667
668        foreach ($this->mapper::HAS_MANY as $member => $def) {
669            $column = $def['mapper']::getColumnByMember($def['column'] ?? $member);
670            $alias  = $column . '_d' . ($this->depth + 1);
671
672            if (!\array_key_exists($alias, $result) || !isset($def['column'])) {
673                continue;
674            }
675
676            $value     = $result[$alias];
677            $hasPath   = false;
678            $aValue    = null;
679            $arrayPath = '/';
680
681            if (\stripos($member, '/') !== false) {
682                $hasPath  = true;
683                $path     = \explode('/', $member);
684                $refProp  = $refClass->getProperty($path[0]);
685                $isPublic = $refProp->isPublic();
686
687                \array_shift($path);
688                $arrayPath = \implode('/', $path);
689                $aValue    = $isPublic ? $obj->{$path[0]} : $refProp->getValue($obj);
690            } else {
691                $refProp  = $refClass->getProperty($member);
692                $isPublic = $refProp->isPublic();
693            }
694
695            if (\in_array($def['mapper']::COLUMNS[$column]['type'], ['string', 'int', 'float', 'bool'])) {
696                if ($value !== null || $refProp->getValue($obj) !== null) {
697                    \settype($value, $def['mapper']::COLUMNS[$column]['type']);
698                }
699
700                if ($hasPath) {
701                    $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
702                }
703
704                $refProp->setValue($obj, $value);
705            } elseif ($def['mapper']::COLUMNS[$column]['type'] === 'DateTime') {
706                $value = $value === null ? null : new \DateTime($value);
707                if ($hasPath) {
708                    $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
709                }
710
711                $refProp->setValue($obj, $value);
712            } elseif ($def['mapper']::COLUMNS[$column]['type'] === 'DateTimeImmutable') {
713                $value = $value === null ? null : new \DateTimeImmutable($value);
714                if ($hasPath) {
715                    $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
716                }
717
718                $refProp->setValue($obj, $value);
719            } elseif ($def['mapper']::COLUMNS[$column]['type'] === 'Json') {
720                if ($hasPath) {
721                    $value = ArrayUtils::setArray($arrayPath, $aValue, $value, '/', true);
722                }
723
724                $refProp->setValue($obj, \json_decode($value, true));
725            } elseif ($def['mapper']::COLUMNS[$column]['type'] === 'Serializable') {
726                $member = $isPublic ? $obj->{$member} : $refProp->getValue($obj);
727                $member->unserialize($value);
728            }
729        }
730
731        return $obj;
732    }
733
734    /**
735     * Populate data.
736     *
737     * @param string $member  Member name
738     * @param array  $result  Result data
739     * @param mixed  $default Default value
740     *
741     * @return mixed
742     *
743     * @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!!!
744     * @todo: parent and child elements however must be loaded because they are not loaded
745     *
746     * @since 1.0.0
747     */
748    public function populateOwnsOne(string $member, array $result, mixed $default = null) : mixed
749    {
750        /** @var class-string<DataMapperFactory> $mapper */
751        $mapper = $this->mapper::OWNS_ONE[$member]['mapper'];
752
753        if (!isset($this->with[$member])) {
754            if (\array_key_exists($this->mapper::OWNS_ONE[$member]['external'] . '_d' . ($this->depth), $result)) {
755                return isset($this->mapper::OWNS_ONE[$member]['column'])
756                    ? $result[$this->mapper::OWNS_ONE[$member]['external'] . '_d' . ($this->depth)]
757                    : $mapper::createNullModel($result[$this->mapper::OWNS_ONE[$member]['external'] . '_d' . ($this->depth)]);
758            } else {
759                return $default;
760            }
761        }
762
763        if (isset($this->mapper::OWNS_ONE[$member]['column'])) {
764            return $result[$mapper::getColumnByMember($this->mapper::OWNS_ONE[$member]['column']) . '_d' . $this->depth];
765        }
766
767        if (!isset($result[$mapper::PRIMARYFIELD . '_d' . ($this->depth + 1)])) {
768            return $mapper::createNullModel();
769        }
770
771        /** @var self $ownsOneMapper */
772        $ownsOneMapper        = $this->createRelationMapper($mapper::get($this->db), $member);
773        $ownsOneMapper->depth = $this->depth + 1;
774
775        return $ownsOneMapper->populateAbstract($result, $mapper::createBaseModel($result));
776    }
777
778    /**
779     * Populate data.
780     *
781     * @param string $member  Member name
782     * @param array  $result  Result data
783     * @param mixed  $default Default value
784     *
785     * @return mixed
786     *
787     * @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!!!
788     * @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.
789     *
790     * @since 1.0.0
791     */
792    public function populateBelongsTo(string $member, array $result, mixed $default = null) : mixed
793    {
794        /** @var class-string<DataMapperFactory> $mapper */
795        $mapper = $this->mapper::BELONGS_TO[$member]['mapper'];
796
797        if (!isset($this->with[$member])) {
798            if (\array_key_exists($this->mapper::BELONGS_TO[$member]['external'] . '_d' . ($this->depth), $result)) {
799                return isset($this->mapper::BELONGS_TO[$member]['column'])
800                    ? $result[$this->mapper::BELONGS_TO[$member]['external'] . '_d' . ($this->depth)]
801                    : $mapper::createNullModel($result[$this->mapper::BELONGS_TO[$member]['external'] . '_d' . ($this->depth)]);
802            } else {
803                return $default;
804            }
805        }
806
807        if (isset($this->mapper::BELONGS_TO[$member]['column'])) {
808            return $result[$mapper::getColumnByMember($this->mapper::BELONGS_TO[$member]['column']) . '_d' . $this->depth];
809        }
810
811        if (!isset($result[$mapper::PRIMARYFIELD . '_d' . ($this->depth + 1)])) {
812            return $mapper::createNullModel();
813        }
814
815        // get the belongs to based on a different column (not primary key)
816        // this is often used if the value is actually a different model:
817        //      you want the profile but the account id is referenced
818        //      in this case you can get the profile by loading the profile based on the account reference column
819        if (isset($this->mapper::BELONGS_TO[$member]['by'])) {
820            /** @var self $belongsToMapper */
821            $belongsToMapper        = $this->createRelationMapper($mapper::get($this->db), $member);
822            $belongsToMapper->depth = $this->depth + 1;
823            $belongsToMapper->where(
824                $this->mapper::BELONGS_TO[$member]['by'],
825                $result[$mapper::getColumnByMember($this->mapper::BELONGS_TO[$member]['by']) . '_d' . ($this->depth + 1)],
826                '='
827            );
828
829            return $belongsToMapper->execute();
830        }
831
832        /** @var self $belongsToMapper */
833        $belongsToMapper        = $this->createRelationMapper($mapper::get($this->db), $member);
834        $belongsToMapper->depth = $this->depth + 1;
835
836        return $belongsToMapper->populateAbstract($result, $mapper::createBaseModel($result));
837    }
838
839    /**
840     * Fill object with relations
841     *
842     * @param object $obj Object to fill
843     *
844     * @return void
845     *
846     * @since 1.0.0
847     */
848    public function loadHasManyRelations(object $obj) : void
849    {
850        if (empty($this->with)) {
851            return;
852        }
853
854        $primaryKey = $this->mapper::getObjectId($obj);
855        if (empty($primaryKey)) {
856            return;
857        }
858
859        $refClass = null;
860
861        // @todo: check if there are more cases where the relation is already loaded with joins etc.
862        // there can be pseudo has many elements like localizations. They are has manies but these are already loaded with joins!
863        foreach ($this->with as $member => $withData) {
864            if (isset($this->mapper::HAS_MANY[$member])) {
865                $many = $this->mapper::HAS_MANY[$member];
866                if (isset($many['column'])) {
867                    continue;
868                }
869
870                $objectMapper = $this->createRelationMapper($many['mapper']::get(db: $this->db), $member);
871                if ($many['external'] === null/* same as $many['table'] !== $many['mapper']::TABLE */) {
872                    $objectMapper->where($many['mapper']::COLUMNS[$many['self']]['internal'], $primaryKey);
873                } else {
874                    $query = new Builder($this->db, true);
875                    $query->leftJoin($many['table'])
876                        ->on($many['mapper']::TABLE . '_d1.' . $many['mapper']::PRIMARYFIELD, '=', $many['table'] . '.' . $many['external'])
877                        ->where($many['table'] . '.' . $many['self'], '=', $primaryKey);
878
879                    // Cannot use join, because join only works on members and we don't have members for a relation table
880                    // This is why we need to create a "base" query which contians the join on table columns
881                    $objectMapper->query($query);
882                }
883
884                $objects = $objectMapper->execute();
885                if (empty($objects) || (!\is_array($objects) && $objects->id === 0)) {
886                    continue;
887                }
888
889                if ($refClass === null) {
890                    $refClass = new \ReflectionClass($obj);
891                }
892
893                $refProp = $refClass->getProperty($member);
894                if (!$refProp->isPublic()) {
895                    $refProp->setValue($obj, !\is_array($objects) && ($many['conditional'] ?? false) === false
896                        ? [$many['mapper']::getObjectId($objects) => $objects]
897                        : $objects // if conditional === true the obj will be asigned (e.g. has many localizations but only one is loaded for the model)
898                    );
899                } else {
900                    $obj->{$member} = !\is_array($objects) && ($many['conditional'] ?? false) === false
901                        ? [$many['mapper']::getObjectId($objects) => $objects]
902                        : $objects; // if conditional === true the obj will be asigned (e.g. has many localizations but only one is loaded for the model)
903                }
904
905                continue;
906            } elseif (isset($this->mapper::OWNS_ONE[$member])
907                || isset($this->mapper::BELONGS_TO[$member])
908            ) {
909                $relation = isset($this->mapper::OWNS_ONE[$member])
910                    ? $this->mapper::OWNS_ONE[$member]
911                    : $this->mapper::BELONGS_TO[$member];
912
913                if (\count($withData) < 2) {
914                    continue;
915                }
916
917                if ($refClass === null) {
918                    $refClass = new \ReflectionClass($obj);
919                }
920
921                /** @var ReadMapper $relMapper */
922                $relMapper = $this->createRelationMapper($relation['mapper']::reader($this->db), $member);
923
924                $refProp = $refClass->getProperty($member);
925                if (!$refProp->isPublic()) {
926                    $relMapper->loadHasManyRelations($refProp->getValue($obj));
927                } else {
928                    $relMapper->loadHasManyRelations($obj->{$member});
929                }
930            }
931        }
932    }
933}