Passed
Push — main ( acd765...cb159e )
by Pranjal
02:24
created

Model::handleOwnRelation()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 18
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 1 Features 0
Metric Value
cc 2
eloc 11
c 3
b 1
f 0
nc 2
nop 2
dl 0
loc 18
rs 9.9
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\ArrayParameterType;
17
use Doctrine\DBAL\Connection;
18
use Doctrine\DBAL\ParameterType;
19
use Doctrine\DBAL\Types\Type;
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
 * @property array<string,array<string,mixed>> $__properties
34
 * @property array<string,mixed>               $__meta
35
 */
36
class Model implements \Stringable, \IteratorAggregate, \ArrayAccess
37
{
38
    use Iterator;
39
    use Stringable;
40
    use ArrayAccess;
41
    use Getter;
42
    use Setter;
43
44
    /**
45
     * Relation type constants.
46
     */
47
    private const RELATION_ONE_TO_MANY = 'otm';
48
    private const RELATION_ONE_TO_ONE = 'oto';
49
    private const RELATION_MANY_TO_MANY = 'mtm';
50
51
    /**
52
     * Property type constants.
53
     */
54
    private const TYPE_JSON = 'json_document';
55
    private const TYPE_TEXT = 'text';
56
    private const TYPE_FLOAT = 'float';
57
58
    /**
59
     * Relation keywords.
60
     */
61
    private const KEYWORD_OWN = 'own';
62
    private const KEYWORD_SHARED = 'shared';
63
    private const KEYWORD_LIST = 'list';
64
65
    /**
66
     * Valid relation types.
67
     */
68
    private const RELATION_TYPES = [
69
        self::RELATION_ONE_TO_MANY,
70
        self::RELATION_ONE_TO_ONE,
71
        self::RELATION_MANY_TO_MANY,
72
    ];
73
74
    /**
75
     * @var array<string,array<string,mixed>>
76
     */
77
    private array $__properties = [];
78
79
    /**
80
     * @var array<string,mixed>
81
     */
82
    private array $__meta = [];
83
84
    /**
85
     * Cache for relation table names.
86
     *
87
     * @var array<string,string>
88
     */
89
    private array $relationTableCache = [];
90
91
    private string $table;
92
93
    /**
94
     * Get table name from class name if not specified
95
     */
96
    public function __construct(
97
        ?string $table = null,
98
        private Connection $connection,
99
        private RecordManager $recordManager,
100
        private TableManager $tableManager,
101
        private WriteManager $writeManager,
102
    ) {
103
        $this->table = $table ?? $this->getDefaultTableName();
104
        $this->initializeProperties();
105
        $this->initialize();
106
    }
107
108
    /**
109
     * Get default table name from class name
110
     */
111
    private function getDefaultTableName(): string
112
    {
113
        $className = (new \ReflectionClass($this))->getShortName();
114
        return strtolower($className);
115
    }
116
117
    /**
118
     * Hook called after model initialization
119
     */
120
    protected function initialize(): void
121
    {
122
        // Override in child class if needed
123
    }
124
125
    /**
126
     * Hook called before saving the model
127
     */
128
    protected function beforeSave(): void
129
    {
130
        // Override in child class if needed
131
    }
132
133
    /**
134
     * Hook called after saving the model
135
     */
136
    protected function afterSave(): void
137
    {
138
        // Override in child class if needed
139
    }
140
141
    /**
142
     * Hook called before deleting the model
143
     */
144
    protected function beforeDelete(): void
145
    {
146
        // Override in child class if needed
147
    }
148
149
    /**
150
     * Hook called after deleting the model
151
     */
152
    protected function afterDelete(): void
153
    {
154
        // Override in child class if needed
155
    }
156
157
    /**
158
     * Initialize model properties and metadata.
159
     */
160
    private function initializeProperties(): void
161
    {
162
        $this->__properties = [
163
            'all' => [],
164
            'self' => [],
165
            'type' => [],
166
        ];
167
168
        $this->__meta = [
169
            'is_loaded' => false,
170
            'id_error' => false,
171
            'foreign_models' => [
172
                self::RELATION_ONE_TO_MANY => null,
173
                self::RELATION_ONE_TO_ONE => null,
174
                self::RELATION_MANY_TO_MANY => null,
175
            ],
176
            'id' => 0,
177
        ];
178
    }
179
180
    /**
181
     * adds the key to properties.
182
     */
183
    public function __set(string $key, mixed $val): void
184
    {
185
        $this->set($key, $val);
186
    }
187
188
    /**
189
     * Adds the key to properties.
190
     */
191
    public function set(string $key, mixed $val): void
192
    {
193
        // Handle ID setting
194
        if ('id' === $key) {
195
            $this->__meta['id'] = $val;
196
            $this->__meta['id_error'] = true;
197
            $this->setRegularProperty('id', $val);
198
199
            return;
200
        }
201
202
        // Handle model relations
203
        if ($val instanceof Model) {
204
            $this->handleModelRelation($key, $val);
205
206
            return;
207
        }
208
209
        // Handle complex relations (own/shared)
210
        if (0 !== \Safe\preg_match('/[A-Z]/', $key)) {
211
            if ($this->handleComplexRelation($key, $val)) {
212
                return;
213
            }
214
        }
215
216
        // Handle regular properties
217
        $this->setRegularProperty($key, $val);
218
    }
219
220
    private function handleRelationalKey(string $key, array $parts): mixed
221
    {
222
        if (self::KEYWORD_OWN === strtolower((string) $parts[0])) {
223
            return $this->handleOwnRelation($key, $parts);
224
        }
225
226
        if (self::KEYWORD_SHARED === strtolower((string) $parts[0])) {
227
            return $this->handleSharedRelation($key, $parts);
228
        }
229
230
        return null;
231
    }
232
233
    private function handleOwnRelation(string $key, array $parts): mixed
234
    {
235
        if (self::KEYWORD_LIST !== strtolower((string) $parts[2])) {
236
            return null;
237
        }
238
239
        $db = $this->recordManager->find(strtolower((string) $parts[1]));
240
        $db->where($this->getName() . '_id = :id')
241
            ->setParameter(
242
                'id',
243
                $this->__meta['id'],
244
                $this->determineIdType($this->__meta['id'])
245
            );
246
247
        $result = $db->get();
248
        $this->set($key, $result);
249
250
        return $result;
251
    }
252
253
    private function handleSharedRelation(string $key, array $parts): mixed
254
    {
255
        if ('list' !== strtolower((string) $parts[2])) {
256
            return null;
257
        }
258
259
        $targetTable = strtolower((string) $parts[1]);
260
        $relTable = $this->getRelationTable($targetTable);
261
262
        $db = $this->recordManager->find($relTable);
263
        $db->where($this->getName() . '_id = :id')
264
            ->setParameter(
265
                'id',
266
                $this->__meta['id'],
267
                $this->determineIdType($this->__meta['id'])
268
            );
269
270
        $relations = $db->get();
271
        $relIds = $this->extractRelationIds($relations, $targetTable);
272
273
        if (empty($relIds)) {
274
            return Collection::fromIterable([]);
275
        }
276
277
        $db = $this->recordManager->find($targetTable);
278
        $db->where('id IN (:ids)')
279
            ->setParameter(
280
                'ids',
281
                $relIds,
282
                $this->determineIdsType($relIds)
283
            );
284
285
        $result = $db->get();
286
287
        $this->set($key, $result);
288
289
        return $result;
290
    }
291
292
    private function getRelationTable(string $targetTable): string
293
    {
294
        $cacheKey = $this->table . '_' . $targetTable;
295
296
        if (!isset($this->relationTableCache[$cacheKey])) {
297
            $this->relationTableCache[$cacheKey] = $this->tableManager->tableExists($cacheKey)
298
                ? $cacheKey
299
                : $targetTable . '_' . $this->table;
300
        }
301
302
        return $this->relationTableCache[$cacheKey];
303
    }
304
305
    private function extractRelationIds(Collection $relations, string $targetTable): array
306
    {
307
        return $relations->map(function ($relation) use ($targetTable) {
308
            $key = $targetTable . '_id';
309
310
            return $relation->$key;
311
        })->toArray();
312
    }
313
314
    /**
315
     * Get a key from properties, keys can be relational
316
     * like sharedList,ownList or foreign table.
317
     */
318
    public function __get(string $key): mixed
319
    {
320
        return $this->get($key);
321
    }
322
323
    /**
324
     * Get a key from properties, keys can be relational
325
     * like sharedList,ownList or foreign table.
326
     */
327
    public function get(string $key): mixed
328
    {
329
        // Early return for cached properties
330
        if (array_key_exists($key, $this->__properties['all'])) {
331
            return $this->__properties['all'][$key];
332
        }
333
334
        // Handle foreign key relations
335
        if (array_key_exists($key . '_id', $this->__properties['self'])) {
336
            $result = $this->recordManager->getById($key, $this->__properties['self'][$key . '_id']);
337
            $this->set($key, $result);
338
339
            return $result;
340
        }
341
342
        // Handle complex relations (own/shared)
343
        if (0 !== \Safe\preg_match('/[A-Z]/', $key)) {
344
            $parts = \Safe\preg_split('/(?=[A-Z])/', $key, -1, PREG_SPLIT_NO_EMPTY);
345
            $result = $this->handleRelationalKey($key, $parts);
346
            if (null !== $result) {
347
                return $result;
348
            }
349
        }
350
351
        throw new Exception\KeyNotFoundException();
352
    }
353
354
    /**
355
     * Eager Load relation variable.
356
     *
357
     * @param array<string> $relations
358
     */
359
    public function with(array $relations): Model
360
    {
361
        foreach ($relations as $relation) {
362
            $this->get($relation);
363
        }
364
365
        return $this;
366
    }
367
368
    /**
369
     * Refresh the current model from database.
370
     */
371
    public function refresh(): void
372
    {
373
        $model = $this->recordManager->getById($this->getName(), $this->getId());
374
        if (!is_null($model)) {
375
            $this->cleanModel();
376
            $this->setLoadedProperties($model->getSelfProperties());
377
            $this->setLoaded();
378
        }
379
    }
380
381
    /**
382
     * Unset a property from model.
383
     */
384
    public function __unset(string $key): void
385
    {
386
        $this->unset($key);
387
    }
388
389
    /**
390
     * Unset a property from model.
391
     */
392
    public function unset(string $key): void
393
    {
394
        unset($this->__properties['self'][$key]);
395
        unset($this->__properties['all'][$key]);
396
        unset($this->__properties['type'][$key]);
397
    }
398
399
    /**
400
     * Check if property exists.
401
     */
402
    public function __isset(string $key): bool
403
    {
404
        return $this->isset($key);
405
    }
406
407
    /**
408
     * Check if property exists.
409
     */
410
    public function isset(string $key): bool
411
    {
412
        return array_key_exists($key, $this->__properties['all']);
413
    }
414
415
    /**
416
     * check if model loaded from db.
417
     */
418
    public function isLoaded(): bool
419
    {
420
        return $this->__meta['is_loaded'];
421
    }
422
423
    /**
424
     * Check if model has id error.
425
     */
426
    public function hasIdError(): bool
427
    {
428
        return $this->__meta['id_error'];
429
    }
430
431
    /**
432
     * Save model to database.
433
     */
434
    public function save(): mixed
435
    {
436
        $this->beforeSave();
437
        $this->id = $this->writeManager->save($this);
438
        $this->afterSave();
439
        return $this->getId();
440
    }
441
442
    /**
443
     * Cleans up model internal state to be consistent after save.
444
     */
445
    public function cleanModel(): void
446
    {
447
        $this->__properties['all'] = $this->__properties['self'];
448
        $this->__meta['id_error'] = false;
449
        $this->__meta['foreign_models']['otm'] = null;
450
        $this->__meta['foreign_models']['oto'] = null;
451
        $this->__meta['foreign_models']['mtm'] = null;
452
    }
453
454
    /**
455
     * Delete model data.
456
     */
457
    public function delete(): void
458
    {
459
        $this->beforeDelete();
460
        $this->recordManager->delete($this);
461
        $this->afterDelete();
462
    }
463
464
    /**
465
     * Function used to compare to models.
466
     */
467
    public function equals(self $other): bool
468
    {
469
        return $this->getId() === $other->getId() && $this->toString() === $other->toString();
470
    }
471
472
    /**
473
     * Get the type of value.
474
     */
475
    private function getDataType(mixed $value): string
476
    {
477
        $type = gettype($value);
478
479
        return match ($type) {
480
            'array', 'object' => self::TYPE_JSON,
481
            'string' => self::TYPE_TEXT,
482
            'double' => self::TYPE_FLOAT,
483
            default => $type,
484
        };
485
    }
486
487
    /**
488
     * Check if array passed is instance of model.
489
     *
490
     * @param array<mixed>|Collection $models
491
     *
492
     * @throws Exception\InvalidModelException
493
     */
494
    private function createCollection(?Collection $collection, array|Collection $models): Collection
495
    {
496
        if (is_null($collection)) {
497
            $collection = Collection::fromIterable([]);
498
        }
499
500
        if ($models instanceof Collection) {
0 ignored issues
show
introduced by
$models is never a sub-type of Scrawler\Arca\Collection.
Loading history...
501
            return $collection->merge($models);
502
        }
503
504
        if ([] !== array_filter($models, fn($d): bool => !$d instanceof Model)) {
505
            throw new Exception\InvalidModelException();
506
        }
507
508
        return $collection->merge(Collection::fromIterable($models));
509
    }
510
511
    /**
512
     * Get the database value from PHP value.
513
     */
514
    private function getDbValue(mixed $val, string $type): mixed
515
    {
516
        if ('boolean' === $type) {
517
            return ($val) ? 1 : 0;
518
        }
519
520
        return Type::getType($type)->convertToDatabaseValue($val, $this->connection->getDatabasePlatform());
521
    }
522
523
    private function handleModelRelation(string $key, Model $val): void
524
    {
525
        if (isset($this->__properties['all'][$key . '_id'])) {
526
            unset($this->__properties['all'][$key . '_id']);
527
        }
528
529
        $this->__meta['foreign_models']['oto'] = $this->createCollection(
530
            $this->__meta['foreign_models']['oto'],
531
            Collection::fromIterable([$val])
532
        );
533
        $this->__properties['all'][$key] = $val;
534
    }
535
536
    private function handleComplexRelation(string $key, mixed $val): bool
537
    {
538
        $parts = \Safe\preg_split('/(?=[A-Z])/', $key, -1, PREG_SPLIT_NO_EMPTY);
539
        $type = strtolower((string) $parts[0]);
540
541
        if (self::KEYWORD_OWN === $type) {
542
            $this->__meta['foreign_models'][self::RELATION_ONE_TO_MANY] = $this->createCollection(
543
                $this->__meta['foreign_models'][self::RELATION_ONE_TO_MANY],
544
                $val
545
            );
546
            $this->__properties['all'][$key] = $val;
547
548
            return true;
549
        }
550
551
        if (self::KEYWORD_SHARED === $type) {
552
            $this->__meta['foreign_models'][self::RELATION_MANY_TO_MANY] = $this->createCollection(
553
                $this->__meta['foreign_models'][self::RELATION_MANY_TO_MANY],
554
                $val
555
            );
556
            $this->__properties['all'][$key] = $val;
557
558
            return true;
559
        }
560
561
        return false;
562
    }
563
564
    private function setRegularProperty(string $key, mixed $val): void
565
    {
566
        $type = $this->getDataType($val);
567
        $this->__properties['self'][$key] = $this->getDbValue($val, $type);
568
        $this->__properties['all'][$key] = $val;
569
        $this->__properties['type'][$key] = $type;
570
    }
571
572
    /**
573
     * Determines the ParameterType for an ID value.
574
     */
575
    private function determineIdType(int|string $id): ParameterType
576
    {
577
        return is_int($id) ? ParameterType::INTEGER : ParameterType::STRING;
578
    }
579
580
    /**
581
     * Get all properties of model.
582
     */
583
    private function determineIdsType(array $ids): ArrayParameterType
584
    {
585
        if (empty($ids)) {
586
            return ArrayParameterType::STRING;
587
        }
588
589
        $firstIdType = $this->determineIdType($ids[0]);
590
591
        return match ($firstIdType) {
592
            ParameterType::INTEGER => ArrayParameterType::INTEGER,
593
            ParameterType::STRING => ArrayParameterType::STRING,
594
        };
595
    }
596
597
    /**
598
     * Validates if the given relation type is valid.
599
     *
600
     * @throws Exception\InvalidRelationTypeException
601
     */
602
    private function validateRelationType(string $type): void
603
    {
604
        if (!in_array($type, self::RELATION_TYPES, true)) {
605
            throw new Exception\InvalidRelationTypeException($type);
0 ignored issues
show
Bug introduced by
The type Scrawler\Arca\Exception\...idRelationTypeException was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
606
        }
607
    }
608
609
    public function hasForeign(string $type): bool
610
    {
611
        $this->validateRelationType($type);
612
613
        return !is_null($this->__meta['foreign_models'][$type]);
614
    }
615
}
616