aboutsummaryrefslogtreecommitdiffhomepage
path: root/WpfTest/Model.php
blob: 53b2d833dbf348b9da6d06b2537d04638890d691 (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
<?php
namespace WpfTest;

use WpfTest\Model\FetchableModel;

abstract class Model {
    private bool $saved = false;

    protected function setOwnerId(int $id) {
    }

    /**
     * @return ?string a condition for a WHERE clause if this object already
     *                 exists in the database, or null otherwise
     */
    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();
            if (!isset($this->{$pName}))
                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;
            }
            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() {
    }

    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): array {
        $classNameParts = explode('\\', static::class);
        $className = $classNameParts[count($classNameParts) - 1];
        $tableName = self::mangleName($className);

        $stmt = Db::getInstance()->getPdo()->prepare("SELECT * FROM $tableName WHERE $whereClause");
        $stmt->execute($parameters);
        return array_map(function ($row) {
            return static::fromDbRow($row);
        }, $stmt->fetchAll(\PDO::FETCH_ASSOC));
    }

    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) = ($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 (property_exists($this, 'id'))
                    $this->{$propName}->setOwnerId($this->id);
                $this->{$propName}->save();
            }
        }

        $this->saved = true;
    }
}