Model::delete()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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