summaryrefslogtreecommitdiff
path: root/src/application/mystic/forum/Database.php
diff options
context:
space:
mode:
Diffstat (limited to 'src/application/mystic/forum/Database.php')
-rw-r--r--src/application/mystic/forum/Database.php318
1 files changed, 318 insertions, 0 deletions
diff --git a/src/application/mystic/forum/Database.php b/src/application/mystic/forum/Database.php
new file mode 100644
index 0000000..f574386
--- /dev/null
+++ b/src/application/mystic/forum/Database.php
@@ -0,0 +1,318 @@
+<?php
+declare(strict_types=1);
+
+namespace mystic\forum;
+
+use DateTime;
+use DateTimeImmutable;
+use mystic\forum\attributes\Column;
+use mystic\forum\attributes\NotNull;
+use mystic\forum\attributes\PrimaryKey;
+use mystic\forum\attributes\References;
+use mystic\forum\attributes\Table;
+use mystic\forum\orm\Entity;
+use mystic\forum\utils\ArrayUtils;
+use mystic\forum\utils\StringUtils;
+use PgSql\Connection;
+use ReflectionClass;
+use ReflectionNamedType;
+use ReflectionProperty;
+
+class Database {
+ private Connection $connection;
+
+ private const PRIMARY_KEY = 0b0000_0001;
+ private const NOT_NULL = 0b0000_0010;
+ private const REFERENCES = 0b0000_0100;
+
+ public function __construct(string $connectionString) {
+ $this->connection = \pg_connect($connectionString);
+ }
+
+ public static function getConnectionString(string $host, string $user, string $password, string $dbname, int $port = 5432): string {
+ return self::buildConnectionString([
+ "host" => $host,
+ "dbname" => $dbname,
+ "user" => $user,
+ "password" => $password,
+ "port" => strval($port),
+ ]);
+ }
+
+ public static function buildConnectionString(array $params): string {
+ $items = [];
+ foreach ($params as $key => $value) {
+ $items []= $key . "='" . self::escapeConnectionStringValue($value) . "'";
+ }
+ return implode(" ", $items);
+ }
+
+ private static function escapeConnectionStringValue(string $value): string {
+ return str_replace([
+ '\\',
+ "'",
+ ], [
+ '\\\\',
+ "\\'",
+ ], $value);
+ }
+
+ private static function getTableName(string $entityClassName): string {
+ $reflClass = new ReflectionClass($entityClassName);
+ $attrs = $reflClass->getAttributes(Table::class);
+ foreach ($attrs as $attr)
+ return $attr->newInstance()->tableName;
+ return "public." . StringUtils::camelToSnake($reflClass->getShortName());
+ }
+
+ private static function getColumnName(ReflectionProperty &$prop): string {
+ $attrs = $prop->getAttributes(Column::class);
+ foreach ($attrs as $attr) {
+ $name = $attr->newInstance()->columnName;
+ if ($name !== null)
+ return $name;
+ }
+ return StringUtils::camelToSnake($prop->getName());
+ }
+
+ private static function getColumnType(ReflectionProperty &$prop): string {
+ $attrs = $prop->getAttributes(Column::class);
+ foreach ($attrs as $attr) {
+ $type = $attr->newInstance()->columnType;
+ if ($type !== null)
+ return $type;
+ }
+ $type = $prop->getType();
+ if (!($type instanceof ReflectionNamedType))
+ throw new \RuntimeException("Union types or intersection types cannot be converted to a "
+ . "database type. Please specify one manually using the #[Column] attribute!");
+ $typeName = $type->getName();
+ if (!$type->isBuiltin() && $typeName !== \DateTime::class && $typeName !== \DateTimeImmutable::class)
+ throw new \RuntimeException("User-defined types cannot be converted to a "
+ . "database type. Please specify one manually using the #[Column] attribute!");
+ switch ($typeName) {
+ case \DateTime::class:
+ case \DateTimeImmutable::class:
+ return "timestamp with time zone";
+ case "true":
+ case "false":
+ case "bool":
+ return "boolean";
+ case "float":
+ return "double precision";
+ case "int":
+ return "bigint";
+ case "string":
+ case "null":
+ return "text";
+ case "object":
+ case "array":
+ case "iterable":
+ case "callable":
+ case "mixed":
+ case "never":
+ case "void":
+ default:
+ throw new \RuntimeException("The type \"$typeName\" cannot be stored in the database");
+ }
+ }
+
+ private static function getColumnDefinitions(string $entityClassName): array {
+ $statements = [];
+
+ // TODO Foreign keys
+
+ $rflEntity = new \ReflectionClass($entityClassName);
+ $cols = self::getColumns($rflEntity);
+ foreach ($cols as $colName => $colInfo) {
+ $rflProp = new \ReflectionProperty($entityClassName, $colInfo["propertyName"]);
+ $colType = self::getColumnType($rflProp);
+ $statement = "$colName $colType";
+
+ if (($colInfo["flags"] & self::NOT_NULL) !== 0) {
+ $statement .= " NOT NULL";
+ }
+
+ if (($colInfo["flags"] & self::PRIMARY_KEY) !== 0) {
+ $statement .= " PRIMARY KEY";
+ }
+
+ if (($colInfo["flags"] & self::REFERENCES) !== 0) {
+ $statement .= " REFERENCES " . $colInfo["reference"];
+ }
+
+ $statements []= $statement;
+ }
+
+ return $statements;
+ }
+
+ private static function getColumns(ReflectionClass &$rflEntity): array {
+ return ArrayUtils::assocFromPairs(array_map(function(ReflectionProperty $prop): array {
+ $flags = 0;
+ $attrs = $prop->getAttributes();
+ $reference = "";
+ foreach ($attrs as $attr) {
+ $attrName = $attr->getName();
+ if ($attrName === PrimaryKey::class) {
+ $flags |= self::PRIMARY_KEY;
+ } elseif ($attrName === NotNull::class) {
+ $flags |= self::NOT_NULL;
+ } elseif ($attrName === References::class) {
+ $flags |= self::REFERENCES;
+ $reference = $attr->newInstance()->__toString();
+ }
+ }
+
+ return [self::getColumnName($prop), [
+ "propertyName" => $prop->getName(),
+ "flags" => $flags,
+ "reference" => $reference,
+ ]];
+ }, $rflEntity->getProperties(ReflectionProperty::IS_PUBLIC)));
+ }
+
+ private static function getPrimaryKeyColumn(array &$cols): ?string {
+ foreach ($cols as $col => ["flags" => $flags])
+ if (($flags & self::PRIMARY_KEY) !== 0)
+ return $col;
+ return null;
+ }
+
+ private static function stringifyValue(mixed $value): ?string {
+ if (is_null($value))
+ return null;
+ elseif (is_bool($value))
+ return $value ? "true" : "false";
+ elseif (is_scalar($value))
+ return strval($value);
+ elseif (is_a($value, \DateTimeInterface::class))
+ return $value->format("c");
+ else
+ throw new \RuntimeException("Don't know how to stringify " . is_object($value) ? get_class($value) : gettype($value));
+ }
+
+ private static function assignValue(Entity &$entity, array $colProps, ?string $value): void {
+ $propName = $colProps["propertyName"];
+ $prop = new \ReflectionProperty($entity, $propName);
+
+ $type = $prop->getType();
+ $typedValue = null;
+ if (!($type instanceof ReflectionNamedType))
+ throw new \RuntimeException("Union types or intersection types cannot be converted to from a database type.");
+ $typeName = $type->getName();
+ if (!$type->isBuiltin() && $typeName !== \DateTime::class && $typeName !== \DateTimeImmutable::class)
+ throw new \RuntimeException("User-defined types cannot be converted to from a database type.");
+ switch ($typeName) {
+ case \DateTime::class:
+ $typedValue = new \DateTime($value);
+ break;
+ case \DateTimeImmutable::class:
+ $typedValue = new \DateTimeImmutable($value);
+ break;
+ case "true":
+ case "false":
+ case "bool":
+ $typedValue = in_array(strtolower($value), ["true","yes","on","1"]) ? true : (in_array(strtolower($value), ["false","no","off","0"]) ? false : null);
+ break;
+ case "float":
+ $typedValue = floatval($value);
+ break;
+ case "int":
+ $typedValue = intval($value);
+ break;
+ case "string":
+ case "null":
+ $typedValue = strval($value);
+ break;
+ case "object":
+ case "array":
+ case "iterable":
+ case "callable":
+ case "mixed":
+ case "never":
+ case "void":
+ default:
+ throw new \RuntimeException("The type \"$typeName\" cannot be restored from the database");
+ }
+ $entity->{$propName} = $typedValue;
+ }
+
+ private static function getColumnValues(Entity &$entity, array &$cols): array {
+ $values = [];
+
+ foreach ($cols as $colInfo) {
+ $values []= self::stringifyValue($entity->{$colInfo["propertyName"]} ?? null);
+ }
+
+ return $values;
+ }
+
+ public function insert(Entity &$entity): void {
+ $entityClassName = get_class($entity);
+ $tableName = self::getTableName($entityClassName);
+ $reflClass = new ReflectionClass($entityClassName);
+ $cols = self::getColumns($reflClass);
+ $query = "INSERT INTO $tableName VALUES (" . implode(",", ArrayUtils::fill(fn($i) => "$" . ($i + 1), count($cols))) . ");";
+ $result = \pg_query_params($this->connection, $query, self::getColumnValues($entity, $cols));
+ if ($result === false)
+ throw new \RuntimeException("Insert failed: " . \pg_last_error($this->connection));
+ \pg_free_result($result);
+ }
+
+ public function fetch(Entity &$entity): bool {
+ $entityClassName = get_class($entity);
+ $tableName = self::getTableName($entityClassName);
+ $reflClass = new ReflectionClass($entityClassName);
+ $cols = self::getColumns($reflClass);
+ $primaryCol = self::getPrimaryKeyColumn($cols);
+ if ($primaryCol === null)
+ throw new \RuntimeException("Fetching an entity requires a primary key column to be specified");
+ $query = "SELECT * FROM $tableName WHERE $primaryCol = \$1 LIMIT 1;";
+ $result = \pg_query_params($this->connection, $query, [ $entity->{$cols[$primaryCol]["propertyName"]} ]);
+ if ($result === false)
+ throw new \RuntimeException("Fetch failed: " . \pg_last_error($this->connection));
+ $row = \pg_fetch_assoc($result);
+ \pg_free_result($result);
+ if ($row === false)
+ return false;
+ foreach ($cols as $colName => $colProps)
+ self::assignValue($entity, $colProps, $row[$colName]);
+ return true;
+ }
+
+ public function update(Entity &$entity): bool {
+ $tableName = self::getTableName(get_class($entity));
+ $reflClass = new ReflectionClass($entity);
+ $cols = self::getColumns($reflClass);
+ $primaryCol = self::getPrimaryKeyColumn($cols);
+ if ($primaryCol === null)
+ throw new \RuntimeException("Updating an entity requires a primary key column to be specified");
+
+ $filteredCols = array_filter(ArrayUtils::asPairs($cols), function($pair) {
+ return ($pair[1]["flags"] & self::PRIMARY_KEY) === 0;
+ });
+ $colDef = implode(", ", array_map(function(array $pair): string {
+ return implode("=", [ $pair[0], '$' . $pair[1] ]);
+ }, $filteredCols));
+ $query = "UPDATE $tableName SET $colDef WHERE $primaryCol = \$1;";
+ $result = \pg_query_params($this->connection, $query, array_map(fn($c) => $entity->{$c["propertyName"]}, $filteredCols));
+ if ($result === false)
+ throw new \RuntimeException("Update failed: " . \pg_last_error($this->connection));
+ $num_affected_rows = \pg_affected_rows($result);
+ \pg_free_result($result);
+ return $num_affected_rows >= 1;
+ }
+
+ public function ensureTable(string $entityClassName): void {
+ $tableName = self::getTableName($entityClassName);
+ $colDefs = self::getColumnDefinitions($entityClassName);
+ $query = "CREATE TABLE IF NOT EXISTS $tableName ();";
+ foreach ($colDefs as $colDef)
+ $query .= "ALTER TABLE $tableName ADD COLUMN IF NOT EXISTS $colDef;";
+ $result = \pg_query($this->connection, $query);
+ if ($result === false)
+ throw new \RuntimeException("Table creation failed: " . \pg_last_error($this->connection));
+ \pg_free_result($result);
+ }
+}