diff options
Diffstat (limited to 'src/application/mystic/forum/Database.php')
| -rw-r--r-- | src/application/mystic/forum/Database.php | 318 | 
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); +    } +} |