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 |
Initial commit
21 files changed, 583 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5a14c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/data +.DS_Store +*.DS_Store +vendor/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..90e5b28 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/Thumbs.db": true, + "data/": true + } +}
\ No newline at end of file diff --git a/000-default.conf b/000-default.conf new file mode 100644 index 0000000..4e90b92 --- /dev/null +++ b/000-default.conf @@ -0,0 +1,18 @@ +ServerTokens Prod +ServerSignature Off + +<VirtualHost *:80> + ServerName app + + ServerAdmin mystic@jonaskohl.de + DocumentRoot /var/www/html + + <Directory /var/www/html> + Options FollowSymLinks + AllowOverride All + Require all granted + </Directory> + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined +</VirtualHost> diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..03f50b5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM php:8.3-apache-bookworm AS base +RUN a2enmod rewrite +RUN apt update && apt install -y \ + curl \ + git \ + libzip-dev \ + unzip \ + libpng-dev \ + libwebp-dev \ + libjpeg62-turbo-dev \ + libxmp-dev \ + libfreetype6-dev \ + libpq-dev +RUN docker-php-ext-configure gd \ + --with-webp \ + --with-jpeg \ + --with-freetype +RUN docker-php-ext-install gd zip pgsql +#RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + +FROM base +COPY ./000-default.conf /etc/apache2/sites-available/000-default.conf diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..eb4f8c7 --- /dev/null +++ b/compose.yml @@ -0,0 +1,19 @@ +services: + app: + build: . + restart: unless-stopped + depends_on: + - db + ports: + - 8313:80 + volumes: + - "./src/:/var/www/html/:ro" + db: + image: postgres + restart: unless-stopped + ports: + - 5432:5432 + environment: + POSTGRES_PASSWORD: "postgres" + volumes: + - ./data/db:/var/lib/postgresql/data diff --git a/src/application/.htaccess b/src/application/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/src/application/.htaccess @@ -0,0 +1 @@ +Require all denied 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, '_'); + } +} diff --git a/src/composer.json b/src/composer.json new file mode 100644 index 0000000..93c4e2f --- /dev/null +++ b/src/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "mystic\\forum\\": "application/mystic/forum/" + } + } +} diff --git a/src/index.php b/src/index.php new file mode 100644 index 0000000..62a300a --- /dev/null +++ b/src/index.php @@ -0,0 +1,13 @@ +<?php + +use mystic\forum\Database; +use mystic\forum\orm\Post; +use mystic\forum\orm\Topic; +use mystic\forum\orm\User; + +require_once __DIR__ . "/vendor/autoload.php"; + +$db = new Database(Database::getConnectionString("db", "postgres", "postgres", "postgres")); +$db->ensureTable(User::class); +$db->ensureTable(Topic::class); +$db->ensureTable(Post::class); |