Issues (39)

src/Boson/Model.php (1 issue)

Labels
Severity
1
<?php
2
3
namespace Lepton\Boson;
4
5
use Lepton\Exceptions;
6
use Lepton\Boson\DataTypes;
7
use Lepton\Boson\DataTypes\ReverseRelation;
8
use Lepton\Core\Application;
9
10
abstract class Model
11
{
12
    /**
13
     * The fields for the model. This array stores the actual field values.
14
     * @var array
15
     */
16
    private array $fields;
17
18
19
    /**
20
     * The OneToMany and OneToOne relationships of the model.
21
     * @var array
22
     */
23
    private array $foreignKeys;
24
25
    /**
26
     * The reverse OneToMany and OneToOne relationships of the model.
27
     * @var array
28
    */
29
30
    private array $reverseForeignKeys;
31
32
    /**
33
     * The names of the database columns for the fields.
34
     * @var array
35
     */
36
37
    private array $db_columns;
38
39
40
    /**
41
     * The primary key. It's not the actual value
42
     * @var DataTypes\PrimaryKey
43
     */
44
    private DataTypes\PrimaryKey $pk;
45
46
47
    /**
48
     * The name of the field containing the actual Primary Key value
49
     * @var string
50
     */
51
    private string $pkName;
52
53
54
    /**
55
     * The list of edited field since last database sync.
56
     * @var array
57
     */
58
    private array $editedFields;
59
60
61
    /**
62
     * The name of the table in the database.
63
     * It can be overloaded by the implementation.
64
     *
65
     * @var string
66
     */
67
    protected static $tableName;
68
69
70
    /**
71
     * Create a new Model instance
72
     *
73
     * @return void
74
     */
75
    public function __construct()
76
    {
77
        $this->fields = array();
78
        $this->foreignKeys = array();
79
        $this->reverseForeignKeys = array();
80
        $this->db_columns = array();
81
        $this->editedFields = array();
82
83
        $this->checkTableName();
84
        $this->checkFieldsAreProtected();
85
        $this->extractFieldsFromAttributes();
86
87
        return $this;
88
    }
89
90
91
92
93
    /**
94
     * Check if table name is set. If not set, get it by transforming class name to sneak-case
95
     * and putting an underscore _ between words
96
     *
97
     * @return void
98
     */
99
    private function checkTableName()
100
    {
101
        if (!isset(static::$tableName)) {
102
            $class = new \ReflectionClass(get_class($this));
103
            try {
104
                $className =  explode("\\", $class->getName());
105
                static::$tableName = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', end($className)));
106
            } catch (\Exception $e) {
107
                throw new Exceptions\TableNameNotSetException($class);
108
            }
109
        }
110
    }
111
112
113
    /**
114
     * Analyze all the protected properties of the Model.
115
     *
116
     * If a property is an instance of a class extending Lepton\Boson\DataTypes\Field,
117
     * treat it as a field.
118
     *
119
     * @return void
120
     */
121
    private function extractFieldsFromAttributes()
122
    {
123
        $maybeFields = (new \ReflectionClass(get_class($this)))->getProperties(
124
            \ReflectionProperty::IS_PROTECTED
125
        );
126
127
128
        foreach ($maybeFields as $maybeField) {
129
            if ($fieldType = $this->getFieldType($maybeField)) {
130
                $field = $fieldType->newInstance();
131
132
                // Check if it's PrimaryKey
133
                if ($fieldType->getName() == DataTypes\PrimaryKey::class) {
134
                    // A model must have only one Primary Key
135
                    if (isset($this->pk)) {
136
                        throw new Exceptions\MultiplePrimaryKeyException($maybeField);
137
                    } else {
138
                        $this->pk = $field;
139
                        $this->pkName = $maybeField->getName();
140
141
                        // If column name is not set, use field name
142
                        if($field->db_column() == "") {
143
                            $field->set_db_column($maybeField->getName());
144
                        }
145
                    }
146
                    $this->db_columns[$maybeField->getName()] = $field->db_column();
147
148
                }
149
                // Check if it's a ForeignKey
150
                elseif ($fieldType->getName() == DataTypes\ForeignKey::class) {
151
                    $this->foreignKeys[$maybeField->getName()] = $field;
152
                    // If column name is not set, build it as {fieldName}_{parentPrimaryKeyName}
153
                    if($field->db_column() == "") {
154
                        $props = (new \ReflectionClass($field->parent))->getProperties(\ReflectionProperty::IS_PROTECTED);
155
                        $parentPkName = "";
156
                        foreach($props as $prop) {
157
                            $attributes = $prop->getAttributes(DataTypes\PrimaryKey::class);
158
                            if(count($attributes)) {
159
                                $parentPkName = $prop->getName();
160
                            }
161
                        }
162
                        $field->set_db_column($maybeField->getName()."_".$parentPkName);
163
164
                    }
165
                    $this->db_columns[$maybeField->getName()] = $field->db_column();
166
167
168
                }
169
                // Check if it's a Reverse Foreign Key
170
                elseif ($fieldType->getName() == DataTypes\ReverseRelation::class) {
171
                    $this->reverseForeignKeys[$maybeField->getName()] = $field;
172
                }
173
174
                // If none of the above, it's a normal Field
175
                else {
176
                    $this->fields[$maybeField->getName()] = $field;
177
178
                    // If column name is not set, use field name
179
                    if($field->db_column() == "") {
180
                        $field->set_db_column($maybeField->getName());
181
                    }
182
                    $this->db_columns[$maybeField->getName()] = $field->db_column();
183
184
                }
185
186
187
            }
188
        }
189
190
        // A model must have a PrimaryKey
191
        if (!isset($this->pk)) {
192
            throw new Exceptions\NoPrimaryKeyException(new \ReflectionClass(get_class($this)));
193
        }
194
    }
195
196
197
    final public function isReverseForeignKey($prop): bool
198
    {
199
        return array_key_exists($prop, $this->reverseForeignKeys);
200
    }
201
202
203
    final public function isForeignKey($prop): bool
204
    {
205
        return array_key_exists($prop, $this->foreignKeys);
206
    }
207
208
    /**
209
     *
210
     */
211
212
    final public function getRelationshipParentModel($prop): string
213
    {
214
        return $this->foreignKeys[$prop]->parent;
215
    }
216
217
    final public function getChild($prop): ReverseRelation
218
    {
219
        return $this->reverseForeignKeys[$prop];
220
    }
221
222
223
224
    /**
225
     * Analyze a property's attributes to check if it's a field.
226
     * If it's a field, returns the field type.
227
     * If it's not, returns true.
228
     *
229
     * @param \ReflectionProperty $prop
230
     * @return bool|Object
231
     */
232
    final public function getFieldType($prop)
233
    {
234
        $attributes = $prop->getAttributes();
235
        $fieldType = null;
236
237
        if (empty($attributes)) {
238
            return false;
239
        }
240
241
        foreach ($attributes as $k => $attribute) {
242
            if (is_subclass_of(($attribute->getName()), DataTypes\Field::class)) {
243
                // A field should have only one field type
244
                if (is_null($fieldType)) {
245
                    $fieldType =  $attribute;
246
                } else {
247
                    throw new Exceptions\MultipleFieldAttributeException($prop);
248
                }
249
            }
250
        }
251
        return $fieldType ?? false;
252
    }
253
254
255
    final public function db_columns()
256
    {
257
        return $this->db_columns;
258
    }
259
260
    /**
261
     * Check that all the attributes that have a Field attribute are declared as protected
262
     *
263
     * @return void
264
     */
265
    private function checkFieldsAreProtected()
266
    {
267
        $properties = (new \ReflectionClass(get_class($this)))->getProperties(
268
            \ReflectionProperty::IS_PUBLIC |
269
            \ReflectionProperty::IS_READONLY |
270
            \ReflectionProperty::IS_STATIC
271
        );
272
        foreach ($properties as $prop) {
273
            $attributes = $prop->getAttributes();
274
            foreach ($attributes as $attr) {
275
                if (is_subclass_of(($attr->getName()), DataTypes\Field::class)) {
276
                    throw new Exceptions\InvalidFieldVisibilityKeyword($prop);
277
                }
278
            }
279
        }
280
    }
281
282
283
284
285
286
287
    /**
288
     * When a Field is requested, return the corresponding element in $this->fields
289
     *
290
     * @param string $property
291
     * The property name
292
     *
293
     * @return mixed
294
     */
295
    final public function __get(string $property)
296
    {
297
        if (array_key_exists($property, $this->fields)) {
298
            return $this->$property;
299
        } elseif ($this->isForeignKey($property)) {
300
            $parent = $this->foreignKeys[$property]->parent::get($this->$property);
301
            return $parent;
302
        } elseif ($property == $this->pkName) {
303
            return $this->getPk();
304
        } elseif($this->isReverseForeignKey($property)) {
305
            $child = $this->reverseForeignKeys[$property]->child;
306
            $arguments = [
307
                $this->reverseForeignKeys[$property]->foreignKey => $this
308
            ];
309
310
            return $child::filter(...$arguments);
311
        }
312
        throw new Exceptions\FieldNotFoundException("Model has no field '$property'.");
313
314
    }
315
316
    /**
317
     * When setting a Field, check if the provided $value is valid.
318
     * Then set the corresponding element of $this->checkFieldsAreProtected
319
     *
320
     * @param string $property
321
     * The property name
322
     *
323
     * @param mixed $value
324
     * The value to be set
325
     *
326
     * @return void
327
     */
328
    final public function __set(string $property, mixed $value)
329
    {
330
        $this->setValue($property, $value);
331
    }
332
333
    final public function setValue(string $property, mixed $value)
334
    {
335
        if (array_key_exists($property, $this->fields)) {
336
            if ($this->setEditedField($property, $value, $this->fields)) {
337
                $this->$property = $value;
338
            }
339
        } elseif (array_key_exists($property, $this->foreignKeys)) {
340
            if(is_null($value)){
341
                $this->$property = $value;
342
                $this->editedFields[] = $property;
343
                $this->editedFields = array_unique($this->editedFields);
344
                return;
345
            }
346
            if (is_int($value)) {
347
                $value = ($this->foreignKeys[$property]->parent)::get($value);
348
            }
349
            if ($this->foreignKeys[$property]->parent == $value::class) {
350
                if ($this->setEditedField($property, $value->getPk(), $this->foreignKeys)) {
351
                    $this->$property = $value;
352
                } else {
353
                    throw new \Exception("Error in setting relationship value");
354
                }
355
            } else {
356
                throw new \Exception(sprintf("Given model is wrong type, expecting %, % given", $this->foreignKeys[$property]->parent, $value::class));
357
            }
358
        } elseif ($property == $this->pkName) {
359
            $this->$property = $value;
360
        } else {
361
            $className = get_class($this);
362
            throw new Exceptions\FieldNotFoundException("Cannot retrieve field \"$property\" of $className.");
363
        }
364
    }
365
366
367
    /**
368
     * Set the value for edited field
369
     *
370
     * @param mixed $property
371
     * @param mixed $value
372
     * @param array $conteiner
373
     *
374
     * @return bool
375
     */
376
    private function setEditedField(mixed $property, mixed $value, array &$container): bool
377
    {
378
        if ($container[$property]->validate($value)) {
379
            $this->editedFields[] = $property;
380
            $this->editedFields = array_unique($this->editedFields);
381
            return true;
382
        } else {
383
            $className = get_class($this);
384
            throw new Exceptions\InvalidFieldException("Invalid value for field \"$property\" of $className: $value ");
385
        }
386
        return false;
387
    }
388
389
390
    final public function __toString()
391
    {
392
        $toPrint = "Model ".get_class($this).":<br/>";
393
        $toPrint .= $this->pkName." [Primary Key] => ".$this->{$this->pkName}."<br/>";
394
        foreach ($this->fields as $k => $field) {
395
            $toPrint .= "$k => ".$this->$k."<br/>";
396
        }
397
398
        foreach ($this->foreignKeys as $k => $field) {
399
            $parent = new $field->parent();
400
            $class = explode("\\", $field::class);
401
            $toPrint .= "$k => ".end($class)." (".$parent->getPkName()."=".$this->$k.")<br/>";
402
        }
403
404
405
        return $toPrint;
406
    }
407
408
409
410
411
    /**
412
     * Return the primary key value
413
     * @return mixed The primary key value
414
     */
415
    final public function getPk()
416
    {
417
        return $this->{$this->pkName};
418
    }
419
420
421
    /**
422
     * Return the primary key name
423
     * @return string The primary key name
424
     */
425
    final public function getPkName()
426
    {
427
        return $this->pkName;
428
    }
429
430
431
    /**
432
     * Return the table name
433
     * @return string The table name
434
     */
435
    final public static function getTableName()
436
    {
437
        return static::$tableName;
438
    }
439
440
    /** Return the list of columns
441
     * @return array The list of columns
442
     */
443
    final public function getColumnList(): array
444
    {
445
        foreach ($this->db_columns as $column) {
446
            $columns[] = $column;
447
        }
448
449
        return $columns;
450
    }
451
452
    /**
453
     * Return the column name for a given field
454
     * @param string $field The field name
455
     * @return string The column name
456
     */
457
458
    final public function getColumnFromField(string $field): string
459
    {
460
        return $this->db_columns[$field];
461
    }
462
463
    /**
464
     * Statically initialize the model
465
     *
466
     * @param mixed ...$args
467
     * The values to be put in the fields.
468
     *
469
     * @return object $model
470
     * The model
471
     */
472
473
    public static function new(...$args)
474
    {
475
        $class = new \ReflectionClass(get_called_class());
476
        $model = new ($class->getName())();
0 ignored issues
show
A parse error occurred: Syntax error, unexpected '(' on line 476 at column 21
Loading history...
477
        foreach ($args as $prop => $value) {
478
            $model->setValue($prop, $value);
479
        }
480
481
        //$model->clearEditedFields();
482
        return $model;
483
    }
484
485
486
    public function load(...$args)
487
    {
488
        foreach ($args as $prop => $value) {
489
            $this->$prop = $value;
490
        }
491
    }
492
493
    /*
494
    ======================================================================================
495
    ***************************** DATABASE INTERACTION ***********************************
496
    ======================================================================================
497
    */
498
499
500
    /**
501
     * Save the model to the database.
502
     * This function is just a wrapper around insert and update.
503
     *
504
     * @return void
505
     */
506
    final public function save()
507
    {
508
        // If there's a primary key value, try to update
509
        if (isset($this->{$this->pkName})) {
510
            $this->update();
511
            $this->editedFields = array();
512
            return;
513
        }
514
515
        $this->{$this->pkName} = $this->insert();
516
517
        // All fields are now saved
518
        $this->editedFields = array();
519
    }
520
521
522
    /**
523
     * Update the data in the database using prepared queries.
524
     *
525
     * @return bool
526
    */
527
    private function update()
528
    {
529
        $db = Application::getDb();
530
531
532
        $values = array_map(array($this, "getFieldValues"), $this->editedFields);
533
        array_push($values, $this->getPk());
534
535
        $fieldsString = implode(", ", array_map(fn ($value) => $this->getFieldName($value)." = ?", $this->editedFields));
536
        $query = sprintf("UPDATE `%s` SET %s WHERE %s = ?", static::$tableName, $fieldsString, $this->getFieldName($this->pkName));
537
        $result = $db->query($query, ...$values);
538
        return $result->affected_rows();
539
    }
540
541
542
543
    /**
544
     * Insert the data in the database using prepared queries.
545
     *
546
     * @return int
547
     * Last inserted id
548
     */
549
    private function insert()
550
    {
551
        $db = Application::getDb();
552
553
        $values = array_map(array($this, "getFieldValues"), $this->editedFields);
554
555
        $fieldsString = implode(", ", array_map(array($this, "getFieldName"), $this->editedFields));
556
        $placeholders = implode(", ", array_fill(0, count($this->editedFields), "?"));
557
        $query = sprintf("INSERT INTO `%s` (%s) VALUES (%s)", static::$tableName, $fieldsString, $placeholders);
558
        //        die($query);
559
        $result = $db->query($query, ...$values);
560
561
        return $result->last_insert_id();
562
    }
563
564
565
    final public function delete()
566
    {
567
        $db = Application::getDb();
568
        $query = sprintf("DELETE FROM `%s` WHERE %s = ?", static::$tableName, $this->getFieldName($this->pkName));
569
        $pk = $this->getPk();
570
        $db->query($query, $pk);
571
        return;
572
    }
573
574
575
    /**
576
     * Return the name of the column for a given field
577
     * @param string $field The field name
578
     * @return string The column name
579
     */
580
    private function getFieldName($field): string
581
    {
582
        return $this->db_columns[$field];
583
    }
584
585
586
    /**
587
     * Return the value of a field
588
     *  @param string $field The field name
589
     * @return mixed The field value
590
     *
591
     */
592
593
    private function getFieldValues($field): mixed
594
    {
595
        if (array_key_exists($field, $this->fields)) {
596
            return $this->$field;
597
        } elseif (array_key_exists($field, $this->foreignKeys)) {
598
            if(is_null($this->$field)) return NULL;
599
            return ($this->$field)->getPk();
600
        }
601
    }
602
603
    /**
604
     * Get a unique result from the database. If no result is found, throw an exception.
605
     *
606
     * @param mixed $filters
607
     * The filters to be applied.  If $filters has only one element and no key, it is
608
     * supposed to be the primary key.
609
     *
610
     * @return Model
611
     * The model fulfilling the filters
612
     */
613
    public static function get(...$filters): Model|bool
614
    {
615
        if ((count($filters) == 1) && array_key_first($filters)  == 0) {
616
            $filters = array((new static())->pkName => $filters[0]);
617
        }
618
619
        $querySet = static::filter(...$filters);
620
621
622
623
        $result = $querySet->do();
624
        if ($result->count() == 0) {
625
            //throw new \Exception("No result found");
626
            return false;
627
        } elseif ($result->count() > 1) {
628
            throw new Exceptions\MultipleResultsException("Only one result allowed when using get, multiple obtained");
629
        } else {
630
            foreach ($result as $model) {
631
                return $model;
632
            }
633
        }
634
    }
635
636
    /**
637
     * Provide an interface to call the corresponding function of QuerySet
638
     *
639
     * @param string $name
640
     * The name of the function to be called
641
     *
642
     * @param iterable $arguments
643
     * The arguments to be passed to the function.
644
     *
645
     * @return QuerySet
646
     * The QuerySet that represents the result.
647
     */
648
    final public static function __callStatic(string $name, iterable $arguments): QuerySet
649
    {
650
        $querySet = new QuerySet(static::class);
651
        $querySet->$name(...$arguments);
652
        return $querySet;
653
    }
654
655
    /**
656
     * Clear the array of edited fields
657
     * @return void
658
     */
659
    final public function clearEditedFields()
660
    {
661
        $this->editedFields = array();
662
    }
663
}
664