summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJonas Kohl2024-09-08 17:53:55 +0200
committerJonas Kohl2024-09-08 17:53:55 +0200
commit415a0a96a76afbe7f1ad2f51862641793caf1b6c (patch)
tree1168316bff6a2ec04ee27db5ada5431ba6631ce4
Initial commit
-rw-r--r--.gitignore4
-rw-r--r--.vscode/settings.json11
-rw-r--r--000-default.conf18
-rw-r--r--Dockerfile22
-rw-r--r--compose.yml19
-rw-r--r--src/application/.htaccess1
-rw-r--r--src/application/mystic/forum/Database.php318
-rw-r--r--src/application/mystic/forum/attributes/Column.php11
-rw-r--r--src/application/mystic/forum/attributes/NotNull.php6
-rw-r--r--src/application/mystic/forum/attributes/PrimaryKey.php6
-rw-r--r--src/application/mystic/forum/attributes/References.php15
-rw-r--r--src/application/mystic/forum/attributes/Table.php10
-rw-r--r--src/application/mystic/forum/orm/Entity.php6
-rw-r--r--src/application/mystic/forum/orm/Post.php17
-rw-r--r--src/application/mystic/forum/orm/Topic.php15
-rw-r--r--src/application/mystic/forum/orm/User.php17
-rw-r--r--src/application/mystic/forum/utils/ArrayUtils.php33
-rw-r--r--src/application/mystic/forum/utils/StaticClass.php10
-rw-r--r--src/application/mystic/forum/utils/StringUtils.php24
-rw-r--r--src/composer.json7
-rw-r--r--src/index.php13
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);