aboutsummaryrefslogtreecommitdiffhomepage
path: root/Digitigrade/Model.php
diff options
context:
space:
mode:
Diffstat (limited to 'Digitigrade/Model.php')
-rw-r--r--Digitigrade/Model.php219
1 files changed, 219 insertions, 0 deletions
diff --git a/Digitigrade/Model.php b/Digitigrade/Model.php
new file mode 100644
index 0000000..8a593eb
--- /dev/null
+++ b/Digitigrade/Model.php
@@ -0,0 +1,219 @@
+<?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 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;
+ }
+} \ No newline at end of file