diff options
| author | Jonas Kohl | 2024-09-08 17:53:55 +0200 | 
|---|---|---|
| committer | Jonas Kohl | 2024-09-08 17:53:55 +0200 | 
| commit | 415a0a96a76afbe7f1ad2f51862641793caf1b6c (patch) | |
| tree | 1168316bff6a2ec04ee27db5ada5431ba6631ce4 /src/application/mystic/forum | |
Initial commit
Diffstat (limited to 'src/application/mystic/forum')
| -rw-r--r-- | src/application/mystic/forum/Database.php | 318 | ||||
| -rw-r--r-- | src/application/mystic/forum/attributes/Column.php | 11 | ||||
| -rw-r--r-- | src/application/mystic/forum/attributes/NotNull.php | 6 | ||||
| -rw-r--r-- | src/application/mystic/forum/attributes/PrimaryKey.php | 6 | ||||
| -rw-r--r-- | src/application/mystic/forum/attributes/References.php | 15 | ||||
| -rw-r--r-- | src/application/mystic/forum/attributes/Table.php | 10 | ||||
| -rw-r--r-- | src/application/mystic/forum/orm/Entity.php | 6 | ||||
| -rw-r--r-- | src/application/mystic/forum/orm/Post.php | 17 | ||||
| -rw-r--r-- | src/application/mystic/forum/orm/Topic.php | 15 | ||||
| -rw-r--r-- | src/application/mystic/forum/orm/User.php | 17 | ||||
| -rw-r--r-- | src/application/mystic/forum/utils/ArrayUtils.php | 33 | ||||
| -rw-r--r-- | src/application/mystic/forum/utils/StaticClass.php | 10 | ||||
| -rw-r--r-- | src/application/mystic/forum/utils/StringUtils.php | 24 | 
13 files changed, 488 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); +    } +} diff --git a/src/application/mystic/forum/attributes/Column.php b/src/application/mystic/forum/attributes/Column.php new file mode 100644 index 0000000..aee47a2 --- /dev/null +++ b/src/application/mystic/forum/attributes/Column.php @@ -0,0 +1,11 @@ +<?php + +namespace mystic\forum\attributes; + +#[\Attribute(\Attribute::TARGET_PROPERTY)] +class Column { +    public function __construct( +        public readonly ?string $columnName = null, +        public readonly ?string $columnType = null, +    ) {} +} diff --git a/src/application/mystic/forum/attributes/NotNull.php b/src/application/mystic/forum/attributes/NotNull.php new file mode 100644 index 0000000..2c90da0 --- /dev/null +++ b/src/application/mystic/forum/attributes/NotNull.php @@ -0,0 +1,6 @@ +<?php + +namespace mystic\forum\attributes; + +#[\Attribute(\Attribute::TARGET_PROPERTY)] +class NotNull {} diff --git a/src/application/mystic/forum/attributes/PrimaryKey.php b/src/application/mystic/forum/attributes/PrimaryKey.php new file mode 100644 index 0000000..a8f0fc7 --- /dev/null +++ b/src/application/mystic/forum/attributes/PrimaryKey.php @@ -0,0 +1,6 @@ +<?php + +namespace mystic\forum\attributes; + +#[\Attribute(\Attribute::TARGET_PROPERTY)] +class PrimaryKey {} diff --git a/src/application/mystic/forum/attributes/References.php b/src/application/mystic/forum/attributes/References.php new file mode 100644 index 0000000..ac431ff --- /dev/null +++ b/src/application/mystic/forum/attributes/References.php @@ -0,0 +1,15 @@ +<?php + +namespace mystic\forum\attributes; + +#[\Attribute(\Attribute::TARGET_PROPERTY)] +class References { +    public function __construct( +        public readonly string $foreignTableName, +        public readonly ?string $foreignColumnName = null, +    ) {} + +    public function __toString(): string { +        return $this->foreignTableName . ($this->foreignColumnName !== null ? " ({$this->foreignColumnName})" : ""); +    } +} diff --git a/src/application/mystic/forum/attributes/Table.php b/src/application/mystic/forum/attributes/Table.php new file mode 100644 index 0000000..60fe1ae --- /dev/null +++ b/src/application/mystic/forum/attributes/Table.php @@ -0,0 +1,10 @@ +<?php + +namespace mystic\forum\attributes; + +#[\Attribute(\Attribute::TARGET_CLASS)] +class Table { +    public function __construct( +        public readonly string $tableName, +    ) {} +} diff --git a/src/application/mystic/forum/orm/Entity.php b/src/application/mystic/forum/orm/Entity.php new file mode 100644 index 0000000..9afc1b3 --- /dev/null +++ b/src/application/mystic/forum/orm/Entity.php @@ -0,0 +1,6 @@ +<?php +declare(strict_types=1); + +namespace mystic\forum\orm; + +abstract class Entity {} diff --git a/src/application/mystic/forum/orm/Post.php b/src/application/mystic/forum/orm/Post.php new file mode 100644 index 0000000..db297e5 --- /dev/null +++ b/src/application/mystic/forum/orm/Post.php @@ -0,0 +1,17 @@ +<?php +declare(strict_types=1); + +namespace mystic\forum\orm; + +use mystic\forum\attributes\PrimaryKey; +use mystic\forum\attributes\References; +use mystic\forum\attributes\Table; + +#[Table("public.posts")] +class Post extends Entity { +    #[PrimaryKey] public string $id; +    public string $content; +    #[References("public.users")] public string $authorId; +    public \DateTimeImmutable $postDate; +    #[References("public.topics")] public string $topicId; +} diff --git a/src/application/mystic/forum/orm/Topic.php b/src/application/mystic/forum/orm/Topic.php new file mode 100644 index 0000000..202fc50 --- /dev/null +++ b/src/application/mystic/forum/orm/Topic.php @@ -0,0 +1,15 @@ +<?php +declare(strict_types=1); + +namespace mystic\forum\orm; + +use mystic\forum\attributes\PrimaryKey; +use mystic\forum\attributes\Table; + +#[Table("public.topics")] +class Topic extends Entity { +    #[PrimaryKey] +    public string $id; +    public string $title; +    public \DateTimeImmutable $creationDate; +} diff --git a/src/application/mystic/forum/orm/User.php b/src/application/mystic/forum/orm/User.php new file mode 100644 index 0000000..3b531b4 --- /dev/null +++ b/src/application/mystic/forum/orm/User.php @@ -0,0 +1,17 @@ +<?php +declare(strict_types=1); + +namespace mystic\forum\orm; + +use mystic\forum\attributes\PrimaryKey; +use mystic\forum\attributes\Table; + +#[Table("public.users")] +class User extends Entity { +    #[PrimaryKey] +    public string $id; +    public string $name; +    public string $displayName; +    public \DateTimeImmutable $created; +    public string $passwordHash; +} diff --git a/src/application/mystic/forum/utils/ArrayUtils.php b/src/application/mystic/forum/utils/ArrayUtils.php new file mode 100644 index 0000000..ed77c6d --- /dev/null +++ b/src/application/mystic/forum/utils/ArrayUtils.php @@ -0,0 +1,33 @@ +<?php +declare(strict_types=1); + +namespace mystic\forum\utils; + +final class ArrayUtils { +    use StaticClass; + +    public static function repeat(mixed $value, int $count): array { +        $arr = []; +        for ($i = 0; $i < $count; ++$i) +            $arr []= $value; +        return $arr; +    } + +    public static function fill(\Closure $factory, int $count): array { +        $arr = []; +        for ($i = 0; $i < $count; ++$i) +            $arr []= $factory($i, $count); +        return $arr; +    } + +    public static function assocFromPairs(array $pairs): array { +        return array_combine(array_column($pairs, 0), array_column($pairs, 1)); +    } + +    public static function asPairs(array $arr): array { +        $out = []; +        foreach ($arr as $k => $v) +            $out []= [$k, $v]; +        return $out; +    } +} diff --git a/src/application/mystic/forum/utils/StaticClass.php b/src/application/mystic/forum/utils/StaticClass.php new file mode 100644 index 0000000..e55ada9 --- /dev/null +++ b/src/application/mystic/forum/utils/StaticClass.php @@ -0,0 +1,10 @@ +<?php +declare(strict_types=1); + +namespace mystic\forum\utils; + +trait StaticClass { +    public function __construct() { +        throw new \RuntimeException("Cannot instantiate static class " . self::class); +    } +} diff --git a/src/application/mystic/forum/utils/StringUtils.php b/src/application/mystic/forum/utils/StringUtils.php new file mode 100644 index 0000000..7d4bf9d --- /dev/null +++ b/src/application/mystic/forum/utils/StringUtils.php @@ -0,0 +1,24 @@ +<?php +declare(strict_types=1); + +namespace mystic\forum\utils; + +final class StringUtils { +    use StaticClass; + +    public static function camelToSnake(string $camelCase): string { +        $result = ''; + +        for ($i = 0; $i < strlen($camelCase); $i++) { +            $char = $camelCase[$i]; + +            if (ctype_upper($char)) { +                $result .= '_' . strtolower($char); +            } else { +                $result .= $char; +            } +        } + +        return ltrim($result, '_'); +    } +} |