Passed
Push — main ( f42625...abdbfe )
by Pranjal
02:39
created

Model::initializeProperties()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 17
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

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