Passed
Push — main ( a6fd66...f42625 )
by Pranjal
17:38
created

Model::handleRelationalKey()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 3
eloc 5
c 1
b 1
f 0
nc 3
nop 2
dl 0
loc 11
rs 10
1
<?php
2
3
/*
4
 * This file is part of the Scrawler package.
5
 *
6
 * (c) Pranjal Pandey <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
declare(strict_types=1);
13
14
namespace Scrawler\Arca;
15
16
use Doctrine\DBAL\Connection;
17
use Doctrine\DBAL\Types\Type;
18
use Doctrine\DBAL\ParameterType;
19
use Doctrine\DBAL\ArrayParameterType;
20
use Scrawler\Arca\Manager\RecordManager;
21
use Scrawler\Arca\Manager\TableManager;
22
use Scrawler\Arca\Manager\WriteManager;
23
use Scrawler\Arca\Traits\Model\ArrayAccess;
24
use Scrawler\Arca\Traits\Model\Getter;
25
use Scrawler\Arca\Traits\Model\Iterator;
26
use Scrawler\Arca\Traits\Model\Setter;
27
use Scrawler\Arca\Traits\Model\Stringable;
28
29
/**
30
 * Model class that represents single record in database.
31
 *
32
 * @property int|string $id
33
 */
34
class Model implements \Stringable, \IteratorAggregate, \ArrayAccess
35
{
36
    use Iterator;
37
    use Stringable;
38
    use ArrayAccess;
39
    use Getter;
40
    use Setter;
41
42
    /**
43
     * Store all properties of model.
44
     *
45
     * @var array<string,array<string,mixed>>
46
     */
47
    private array $__properties = [];
48
    /**
49
     * Store the metadata of model.
50
     *
51
     * @var array<string,mixed>
52
     */
53
    private array $__meta = [];
54
55
    /**
56
     * Create a new model.
57
     */
58
    public function __construct(
59
        /**
60
         * Store the table name of model.
61
         */
62
        private string $table,
63
        private Connection $connection,
64
        private RecordManager $recordManager,
65
        private TableManager $tableManager,
66
        private WriteManager $writeManager,
67
    ) {
68
        $this->__properties['all'] = [];
69
        $this->__properties['self'] = [];
70
        $this->__meta['is_loaded'] = false;
71
        $this->__meta['id_error'] = false;
72
        $this->__meta['foreign_models']['otm'] = null;
73
        $this->__meta['foreign_models']['oto'] = null;
74
        $this->__meta['foreign_models']['mtm'] = null;
75
        $this->__meta['id'] = 0;
76
    }
77
78
    /**
79
     * adds the key to properties.
80
     */
81
    public function __set(string $key, mixed $val): void
82
    {
83
        $this->set($key, $val);
84
    }
85
86
    /**
87
     * Adds the key to properties.
88
     */
89
    public function set(string $key, mixed $val): void
90
    {
91
        // Handle ID setting
92
        if ('id' === $key) {
93
            $this->__meta['id'] = $val;
94
            $this->__meta['id_error'] = true;
95
            $this->setRegularProperty('id', $val);
96
            return;
97
        }
98
99
        // Handle model relations
100
        if ($val instanceof Model) {
101
            $this->handleModelRelation($key, $val);
102
            return;
103
        }
104
105
        // Handle complex relations (own/shared)
106
        if (0 !== \Safe\preg_match('/[A-Z]/', $key)) {
107
            if ($this->handleComplexRelation($key, $val)) {
108
                return;
109
            }
110
        }
111
112
        // Handle regular properties
113
        $this->setRegularProperty($key, $val);
114
    }
115
116
    private function handleRelationalKey(string $key, array $parts): mixed
117
    {
118
        if ('own' === strtolower((string) $parts[0])) {
119
            return $this->handleOwnRelation($key, $parts);
120
        }
121
122
        if ('shared' === strtolower((string) $parts[0])) {
123
            return $this->handleSharedRelation($key, $parts);
124
        }
125
126
        return null;
127
    }
128
129
    private function handleOwnRelation(string $key, array $parts): mixed
130
    {
131
        if ('list' !== strtolower((string) $parts[2])) {
132
            return null;
133
        }
134
135
        $qb = $this->recordManager->find(strtolower((string) $parts[1]));
136
        $qb->where($this->getName() . '_id = :id')
137
           ->setParameter('id', $this->__meta['id'], 
138
                $this->determineIdType($this->__meta['id'])
139
           );
140
        
141
        $result = $qb->get();
142
143
        $this->set($key, $result);
144
        return $result;
145
    }
146
147
    private function handleSharedRelation(string $key, array $parts): mixed
148
    {
149
        if ('list' !== strtolower((string) $parts[2])) {
150
            return null;
151
        }
152
153
        $targetTable = strtolower((string) $parts[1]);
154
        $relTable = $this->getRelationTable($targetTable);
155
156
        $db = $this->recordManager->find($relTable);
157
        $db->where($this->getName() . '_id = :id')
158
           ->setParameter('id', $this->__meta['id'], 
159
                $this->determineIdType($this->__meta['id'])
160
           );
161
        
162
        $relations = $db->get();
163
        $relIds = $this->extractRelationIds($relations, $targetTable);
164
165
        if (empty($relIds)) {
166
            return Collection::fromIterable([]);
167
        }
168
169
        $db = $this->recordManager->find($targetTable);
170
        $db->where('id IN (:ids)')
171
           ->setParameter('ids', $relIds, 
172
                $this->determineIdsType($relIds)
173
           );
174
175
        $result = $db->get();
176
177
        $this->set($key, $result);
178
        return $result;
179
    }
180
181
    private function getRelationTable(string $targetTable): string
182
    {
183
        return $this->tableManager->tableExists($this->table . '_' . $targetTable)
184
            ? $this->table . '_' . $targetTable
185
            : $targetTable . '_' . $this->table;
186
    }
187
188
    private function extractRelationIds(Collection $relations, string $targetTable): array
189
    {
190
        return $relations->map(function ($relation) use ($targetTable) {
191
            $key = $targetTable . '_id';
192
193
            return $relation->$key;
194
        })->toArray();
195
    }
196
197
    /**
198
     * Get a key from properties, keys can be relational
199
     * like sharedList,ownList or foreign table.
200
     */
201
    public function __get(string $key): mixed
202
    {
203
        return $this->get($key);
204
    }
205
206
    /**
207
     * Get a key from properties, keys can be relational
208
     * like sharedList,ownList or foreign table.
209
     */
210
    public function get(string $key): mixed
211
    {
212
        // Early return for cached properties
213
        if (array_key_exists($key, $this->__properties['all'])) {
214
            return $this->__properties['all'][$key];
215
        }
216
217
        // Handle foreign key relations
218
        if (array_key_exists($key . '_id', $this->__properties['self'])) {
219
            $result = $this->recordManager->getById($key, $this->__properties['self'][$key . '_id']);
220
            $this->set($key, $result);
221
222
            return $result; 
223
        }
224
225
        // Handle complex relations (own/shared)
226
        if (0 !== \Safe\preg_match('/[A-Z]/', $key)) {
227
            $parts = \Safe\preg_split('/(?=[A-Z])/', $key, -1, PREG_SPLIT_NO_EMPTY);
228
            $result = $this->handleRelationalKey($key, $parts);
229
            if (null !== $result) {
230
                return $result;
231
            }
232
        }
233
234
        throw new Exception\KeyNotFoundException();
235
    }
236
237
    /**
238
     * Eager Load relation variable.
239
     *
240
     * @param array<string> $relations
241
     */
242
    public function with(array $relations): Model
243
    {
244
        foreach ($relations as $relation) {
245
            $this->get($relation);
246
        }
247
248
        return $this;
249
    }
250
251
    /**
252
     * Refresh the current model from database.
253
     */
254
    public function refresh(): void
255
    {
256
        $model = $this->recordManager->getById($this->getName(), $this->getId());
257
        if (!is_null($model)) {
258
            $this->cleanModel();
259
            $this->setLoadedProperties($model->getSelfProperties());
260
            $this->setLoaded();
261
        }
262
    }
263
264
    /**
265
     * Unset a property from model.
266
     */
267
    public function __unset(string $key): void
268
    {
269
        $this->unset($key);
270
    }
271
272
    /**
273
     * Unset a property from model.
274
     */
275
    public function unset(string $key): void
276
    {
277
        unset($this->__properties['self'][$key]);
278
        unset($this->__properties['all'][$key]);
279
        unset($this->__properties['type'][$key]);
280
    }
281
282
    /**
283
     * Check if property exists.
284
     */
285
    public function __isset(string $key): bool
286
    {
287
        return $this->isset($key);
288
    }
289
290
    /**
291
     * Check if property exists.
292
     */
293
    public function isset(string $key): bool
294
    {
295
        return array_key_exists($key, $this->__properties['all']);
296
    }
297
298
    /**
299
     * check if model loaded from db.
300
     */
301
    public function isLoaded(): bool
302
    {
303
        return $this->__meta['is_loaded'];
304
    }
305
306
    /**
307
     * Check if model has id error.
308
     */
309
    public function hasIdError(): bool
310
    {
311
        return $this->__meta['id_error'];
312
    }
313
314
    /**
315
     * Save model to database.
316
     */
317
    public function save(): mixed
318
    {
319
        $this->id = $this->writeManager->save($this);
320
321
        return $this->getId();
322
    }
323
324
    /**
325
     * Cleans up model internal state to be consistent after save.
326
     */
327
    public function cleanModel(): void
328
    {
329
        $this->__properties['all'] = $this->__properties['self'];
330
        $this->__meta['id_error'] = false;
331
        $this->__meta['foreign_models']['otm'] = null;
332
        $this->__meta['foreign_models']['oto'] = null;
333
        $this->__meta['foreign_models']['mtm'] = null;
334
    }
335
336
    /**
337
     * Delete model data.
338
     */
339
    public function delete(): void
340
    {
341
        $this->recordManager->delete($this);
342
    }
343
344
    /**
345
     * Function used to compare to models.
346
     */
347
    public function equals(self $other): bool
348
    {
349
        return $this->getId() === $other->getId() && $this->toString() === $other->toString();
350
    }
351
352
    /**
353
     * Check if model has any relations.
354
     */
355
    public function hasForeign(string $type): bool
356
    {
357
        return !is_null($this->__meta['foreign_models'][$type]);
358
    }
359
360
    /**
361
     * Get the type of value.
362
     */
363
    private function getDataType(mixed $value): string
364
    {
365
        $type = gettype($value);
366
367
        if ('array' === $type || 'object' === $type) {
368
            return 'json_document';
369
        }
370
371
        if ('string' === $type) {
372
            return 'text';
373
        }
374
375
        if ('double' === $type) {
376
            return 'float';
377
        }
378
379
        return $type;
380
    }
381
382
    /**
383
     * Check if array passed is instance of model.
384
     *
385
     * @param array<mixed>|Collection $models
386
     *
387
     * @throws Exception\InvalidModelException
388
     */
389
    private function createCollection(?Collection $collection, array|Collection $models): Collection
390
    {
391
        if (is_null($collection)) {
392
            $collection = Collection::fromIterable([]);
393
        }
394
395
        if ($models instanceof Collection) {
0 ignored issues
show
introduced by
$models is never a sub-type of Scrawler\Arca\Collection.
Loading history...
396
            return $collection->merge($models);
397
        }
398
399
        if ([] !== array_filter($models, fn($d): bool => !$d instanceof Model)) {
400
            throw new Exception\InvalidModelException();
401
        }
402
403
        return $collection->merge(Collection::fromIterable($models));
404
    }
405
406
    /**
407
     * Get the database value from PHP value.
408
     */
409
    private function getDbValue(mixed $val, string $type): mixed
410
    {
411
        if ('boolean' === $type) {
412
            return ($val) ? 1 : 0;
413
        }
414
415
        return Type::getType($type)->convertToDatabaseValue($val, $this->connection->getDatabasePlatform());
416
    }
417
418
    private function handleModelRelation(string $key, Model $val): void
419
    {
420
        if (isset($this->__properties['all'][$key . '_id'])) {
421
            unset($this->__properties['all'][$key . '_id']);
422
        }
423
424
        $this->__meta['foreign_models']['oto'] = $this->createCollection(
425
            $this->__meta['foreign_models']['oto'],
426
            Collection::fromIterable([$val])
427
        );
428
        $this->__properties['all'][$key] = $val;
429
    }
430
431
    private function handleComplexRelation(string $key, mixed $val): bool
432
    {
433
        $parts = \Safe\preg_split('/(?=[A-Z])/', $key, -1, PREG_SPLIT_NO_EMPTY);
434
        $type = strtolower((string) $parts[0]);
435
436
        if ('own' === $type) {
437
            $this->__meta['foreign_models']['otm'] = $this->createCollection(
438
                $this->__meta['foreign_models']['otm'],
439
                $val
440
            );
441
            $this->__properties['all'][$key] = $val;
442
443
            return true;
444
        }
445
446
        if ('shared' === $type) {
447
            $this->__meta['foreign_models']['mtm'] = $this->createCollection(
448
                $this->__meta['foreign_models']['mtm'],
449
                $val
450
            );
451
            $this->__properties['all'][$key] = $val;
452
453
            return true;
454
        }
455
456
        return false;
457
    }
458
459
    private function setRegularProperty(string $key, mixed $val): void
460
    {
461
        $type = $this->getDataType($val);
462
        $this->__properties['self'][$key] = $this->getDbValue($val, $type);
463
        $this->__properties['all'][$key] = $val;
464
        $this->__properties['type'][$key] = $type;
465
    }
466
467
    /**
468
     * Determines the ParameterType for an ID value
469
     */
470
    private function determineIdType(int|string $id): ParameterType
471
    {
472
        return is_int($id) ? ParameterType::INTEGER : ParameterType::STRING;
473
    }
474
475
    /**
476
     * Get all properties of model.
477
     *
478
     * @return ArrayParameterType
479
     */
480
    private function determineIdsType(array $ids): ArrayParameterType
481
    {
482
        if (empty($ids)) {
483
            return ArrayParameterType::STRING;
484
        }
485
486
        $firstIdType = $this->determineIdType($ids[0]);
487
488
        return match ($firstIdType) {
489
            ParameterType::INTEGER => ArrayParameterType::INTEGER,
490
            ParameterType::STRING => ArrayParameterType::STRING,
491
        };
492
    }
493
494
}
495