aboutsummaryrefslogtreecommitdiffhomepage
path: root/Digitigrade/Model.php
blob: 71cfa5c7cf04d05cfb1fd0fd35b75d0023aefa9f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
<?php
namespace Digitigrade;

use Digitigrade\Model\FetchableModel;

abstract class Model {
    private bool $saved = false;

    protected function setOwnerId(int $id) {
    }

    /**
     * @return ?string a condition for a WHERE clause that uniquely identifies
     * this object if it already exists in the database, or null if not
     */
    protected function getUpdateWhereClause(\PDO $db): ?string {
        if (isset($this->id) && static::find($this->{'id'}) != null) {
            return 'id = ' . $this->{'id'};
        }
        return null;
    }

    /**
     * Converts a field or type name into the version used in the database
     */
    private static function mangleName(string $name): string {
        $chars = str_split($name);
        $chars[0] = strtolower($chars[0]);
        for ($i = 0; $i < count($chars); $i++) {
            $c = $chars[$i];
            if (ctype_upper($c)) {
                array_splice($chars, $i, 1, ['_', strtolower($c)]);
            }
        }
        return implode($chars);
    }

    /**
     * Converts a database column name into the corresponding field name
     */
    private static function unmangleName(string $name): string {
        $chars = str_split($name);
        for ($i = 0; $i < count($chars); $i++) {
            if ($chars[$i] == '_') {
                array_splice($chars, $i, 2, [strtoupper($chars[$i + 1])]);
            }
        }
        return implode($chars);
    }

    public function toDbRow(): array {
        $refl = new \ReflectionObject($this);
        $props = $refl->getProperties();
        $result = [];
        foreach ($props as $p) {
            $pName = $p->getName();
            // set all nullable properties to null if they aren't already
            if (!isset($this->{$pName})) {
                try {
                    $this->{$pName} = null;
                } catch (\TypeError $e) {
                    // if it can't be set, just ignore it
                    continue;
                }
            }
            // we need to determine the plain value to insert ...
            $value = $this->{$pName};
            $discarded = false;
            if ($value instanceof \DateTimeInterface) {
                // datetime objects should be turned into iso 8601 timestamps
                $value = $value->format('c');
            } elseif ($value instanceof Model) {
                // models should generally be turned into their id if there is one or removed if not
                if (property_exists($value, 'id') && isset($value->id))
                    $value = $value->{'id'}; // doing it like this to shut up the warning (lol)
                else
                    $discarded = true;
            } elseif (is_array($value)) {
                // arrays ought to be handled separately when saving (different db table)
                $discarded = true;
            } elseif ($value == null && $pName == 'id') {
                // don't overwrite an existing primary key with null ..
                // this is kinda janky but it hopefully will work
                $discarded = true;
            }
            if (!$discarded)
                $result[self::mangleName($pName)] = $value;
        }
        return $result;
    }

    public static function fromDbRow(array|false $columns): ?static {
        if ($columns === false)
            return null;
        // create the model
        $obj = new static();
        $refl = new \ReflectionObject($obj);
        // for each field of the db row ...
        foreach (array_keys($columns) as $name) {
            $value = $columns[$name];
            $name = self::unmangleName($name); // guess the appropriate field in the model
            try {
                // try to assign it directly (works for simple values)
                $obj->{$name} = $value;
            } catch (\TypeError $e) {
                // if it's not the right type we have to try to wrangle it into the right one
                // there's a few strategies for this
                $type = $refl->getProperty($name)->getType();
                if ($type == null || !is_a($type, \ReflectionNamedType::class))
                    throw $e;
                $typeClass = $type->getName();
                if (enum_exists($typeClass)) {
                    // if it's a backed enum we can find the enum case with the same value
                    $obj->{$name} = $typeClass::from($value);
                } elseif (is_subclass_of($typeClass, Model::class) && is_int($value)) {
                    // if it's another model we can try to look it up by id automatically
                    $obj->{$name} = $typeClass::find($value);
                } else {
                    // otherwise try to instantiate the correct class and pass it the simple value
                    $obj->{$name} = new $typeClass($value);
                }
            }
        }
        // set up any referenced fields if needed
        $obj->hydrate();
        $obj->saved = true;
        return $obj;
    }

    /**
     * Initialises fields of the model that contain referenced / foreign keyed objects
     * which couldn't be initialised automatically
     * @return void
     */
    protected function hydrate() {
    }

    /**
     * Checks that all fields of the model are valid and allowed
     * @return bool true if the model is valid, false otherwise
     */
    protected function validate(): bool {
        return true;
    }

    public static function findWhere(string $whereClause, array $parameters): ?static {
        $classNameParts = explode('\\', static::class);
        $className = $classNameParts[count($classNameParts) - 1];
        $tableName = self::mangleName($className);

        $stmt = Db::getInstance()->getPdo()->prepare("SELECT * FROM $tableName WHERE $whereClause LIMIT 1");
        $stmt->execute($parameters);
        return static::fromDbRow($stmt->fetch(\PDO::FETCH_ASSOC));
    }

    public static function findAllWhere(
        string $whereClause,
        array $parameters,
        ?int $limit = null,
        int $offset = 0,
        ?string $orderByClause = null
    ): array {
        $classNameParts = explode('\\', static::class);
        $className = $classNameParts[count($classNameParts) - 1];
        $tableName = self::mangleName($className);

        $query = "SELECT * FROM $tableName WHERE $whereClause";
        if ($orderByClause != null) {
            $query .= " ORDER BY $orderByClause";
        }
        if ($limit != null) {
            $query .= " LIMIT $limit OFFSET $offset";
        }
        $stmt = Db::getInstance()->getPdo()->prepare($query);
        $stmt->execute($parameters);
        return array_map(function ($row) {
            return static::fromDbRow($row);
        }, $stmt->fetchAll(\PDO::FETCH_ASSOC));
    }

    public static function findAll(): array {
        // FIXME: anything but this...
        return static::findAllWhere('1 = 1', []);
    }

    public static function countWhere(string $whereClause, array $parameters): int {
        $classNameParts = explode('\\', static::class);
        $className = $classNameParts[count($classNameParts) - 1];
        $tableName = self::mangleName($className);

        $query = "SELECT count(*) FROM $tableName WHERE $whereClause";
        $stmt = Db::getInstance()->getPdo()->prepare($query);
        $stmt->execute($parameters);
        return $stmt->fetchColumn(0);
    }

    public static function count(): int {
        // FIXME
        return static::countWhere('1 = 1', []);
    }

    public static function find(int $id): ?static {
        return static::findWhere('id = ?', [$id]);
    }

    /**
     * Inserts or updates the model's corresponding database record.
     * Also sets the model's id field if it has one.
     */
    public function save() {
        // figure out all the parameters and such for the query
        $paramData = $this->toDbRow();
        $columnNames = array_keys($paramData);
        $parameterNames = array_map(function (string $column) {
            return ":$column";
        }, $columnNames);
        $columns = implode(', ', $columnNames);
        $parameters = implode(', ', $parameterNames);

        $classNameParts = explode('\\', static::class);
        $className = $classNameParts[count($classNameParts) - 1];
        $tableName = self::mangleName($className);

        $db = Db::getInstance()->getPdo();

        $whereClause = $this->getUpdateWhereClause($db);
        $shouldUpdate = $whereClause != null;
        $stmt = $db->prepare(
            $shouldUpdate
            ? "UPDATE $tableName SET ($columns) = ROW($parameters) WHERE $whereClause"
            : "INSERT INTO $tableName ($columns) VALUES ($parameters)"
        );

        // pdo is really stupid and casts `false`/0 to an empty string, which breaks the query
        // so i have to cast `false`s and `0`s manually. hooray
        // i also have to convert backed enums to their value because they dont implement Stringable
        $paramData = array_map(function ($item) {
            if ($item === false || $item === 0)
                return '0';
            if (is_a($item, \BackedEnum::class))
                return $item->value;
            return $item;
        }, $paramData);
        // actually save it !!!! yay
        $stmt->execute($paramData);

        if (property_exists($this, 'id')) {
            $this->id = $shouldUpdate
                ? static::findWhere($whereClause, [])->id
                : (int) $db->lastInsertId();
        }

        // recursively save inner models
        // but not fetchable ones because they should really be saved separately
        $reflProps = (new \ReflectionObject($this))->getProperties();
        foreach ($reflProps as $reflProp) {
            assert($reflProp instanceof \ReflectionProperty);
            $reflType = $reflProp->getType();
            $propName = $reflProp->getName();
            if (!($reflType instanceof \ReflectionNamedType))
                continue;
            $type = $reflType->getName();
            if (is_subclass_of($type, Model::class) && !is_subclass_of($type, FetchableModel::class)) {
                if (isset($this->{$propName}) && property_exists($this, 'id')) {
                    $this->{$propName}->setOwnerId($this->id);
                    $this->{$propName}->save();
                }
            }
        }

        $this->saved = true;
    }

    /**
     * Removes this record from the database.
     * @return void
     */
    public function remove() {
        $db = Db::getInstance()->getPdo();
        $where = $this->getUpdateWhereClause($db);
        if ($where == null) {
            throw new \RuntimeException("object doesn't seem to exist in the database so i can't delete it");
        }
        $classNameParts = explode('\\', static::class);
        $className = $classNameParts[count($classNameParts) - 1];
        $tableName = self::mangleName($className);

        $db->exec("DELETE FROM $tableName WHERE $where");
    }

    /**
     * Converts the model to an opaque string, to be used for equality checking or debugging purposes only.
     * @return string
     */
    public function __tostring(): string {
        return '<Model:' . static::class . '[' . $this->getUpdateWhereClause(Db::getInstance()->getPdo()) . ']>';
    }
}