first commit

This commit is contained in:
2026-02-07 15:54:21 +03:00
commit 74964c74f3
51 changed files with 3701 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\Config;
final class DatabaseConfig {
public function __construct(
public string $driver,
public string $host,
public int $port,
public string $database,
public string $username,
public string $password,
public bool $persistent,
public int $timeoutSeconds,
public int $poolSize,
public bool $cacheEnabled,
public bool $cacheWriteBehind,
public int $cacheFlushIntervalSeconds,
public int $cacheMaxDirty,
public int $cacheTtlSeconds,
public int $cacheMaxEntries,
public int $cachePreloadBatchSize,
public int $cachePreloadDelayTicks,
public bool $cachePreloadAsync,
public bool $migrationsEnabled,
public string $migrationsTable
) {
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\Config;
use pocketmine\utils\Config;
final class DatabaseConfigLoader {
public static function load(Config $config): DatabaseConfig {
$driver = strtolower((string) $config->get('driver', 'postgres'));
$host = (string) $config->get('host', '127.0.0.1');
$port = (int) $config->get('port', $driver === 'postgres' ? 5432 : 3306);
$database = (string) $config->get('database', 'primalix');
$username = (string) $config->get('username', 'postgres');
$password = (string) $config->get('password', '');
$options = (array) $config->get('options', []);
$persistent = (bool) ($options['persistent'] ?? false);
$timeoutSeconds = (int) ($options['timeoutSeconds'] ?? 10);
$pool = (array) $config->get('pool', []);
$poolSize = (int) ($pool['size'] ?? 4);
$cache = (array) $config->get('cache', []);
$cacheEnabled = (bool) ($cache['enabled'] ?? true);
$cacheWriteBehind = (bool) ($cache['writeBehind'] ?? true);
$cacheFlushIntervalSeconds = (int) ($cache['flushIntervalSeconds'] ?? 10);
$cacheMaxDirty = (int) ($cache['maxDirty'] ?? 100);
$cacheTtlSeconds = (int) ($cache['ttlSeconds'] ?? 0);
$cacheMaxEntries = (int) ($cache['maxEntries'] ?? 0);
$cachePreloadBatchSize = (int) ($cache['preloadBatchSize'] ?? 20);
$cachePreloadDelayTicks = (int) ($cache['preloadDelayTicks'] ?? 20);
$cachePreloadAsync = (bool) ($cache['preloadAsync'] ?? true);
$migrations = (array) $config->get('migrations', []);
$migrationsEnabled = (bool) ($migrations['enabled'] ?? true);
$migrationsTable = (string) ($migrations['table'] ?? 'db_migrations');
return new DatabaseConfig(
$driver,
$host,
$port,
$database,
$username,
$password,
$persistent,
$timeoutSeconds,
$poolSize,
$cacheEnabled,
$cacheWriteBehind,
$cacheFlushIntervalSeconds,
$cacheMaxDirty,
$cacheTtlSeconds,
$cacheMaxEntries,
$cachePreloadBatchSize,
$cachePreloadDelayTicks,
$cachePreloadAsync,
$migrationsEnabled,
$migrationsTable
);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\Connection;
use andrewkydev\Database\Config\DatabaseConfig;
final class ConnectionManager {
/** @var \PDO[] */
private array $pool = [];
private int $cursor = 0;
public function __construct(private DatabaseConfig $config) {
$this->initialize();
}
public function getConnection(): \PDO {
if ($this->pool === []) {
throw new \RuntimeException('No database connections available');
}
$conn = $this->pool[$this->cursor];
$this->cursor = ($this->cursor + 1) % count($this->pool);
return $conn;
}
public function close(): void {
$this->pool = [];
$this->cursor = 0;
}
private function initialize(): void {
$size = max(1, $this->config->poolSize);
for ($i = 0; $i < $size; $i++) {
$this->pool[] = $this->createConnection();
}
}
private function createConnection(): \PDO {
$dsn = $this->dsn();
$options = [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_TIMEOUT => $this->config->timeoutSeconds,
\PDO::ATTR_PERSISTENT => $this->config->persistent,
];
return new \PDO($dsn, $this->config->username, $this->config->password, $options);
}
private function dsn(): string {
if ($this->config->driver === 'postgres') {
return sprintf(
'pgsql:host=%s;port=%d;dbname=%s',
$this->config->host,
$this->config->port,
$this->config->database
);
}
return sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
$this->config->host,
$this->config->port,
$this->config->database
);
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database;
use andrewkydev\Database\Config\DatabaseConfig;
use andrewkydev\Database\Connection\ConnectionManager;
use andrewkydev\Database\Migration\MigrationManager;
use andrewkydev\Database\ORM\EntityManager;
use andrewkydev\Database\Query\QueryRunner;
use andrewkydev\Database\Schema\SchemaManager;
final class DatabaseApi {
public function __construct(
private SchemaManager $schema,
private QueryRunner $query,
private EntityManager $orm,
private ConnectionManager $connections,
private DatabaseConfig $config,
private MigrationManager $migrations
) {
}
public function schema(): SchemaManager {
return $this->schema;
}
public function query(): QueryRunner {
return $this->query;
}
public function orm(): EntityManager {
return $this->orm;
}
public function config(): DatabaseConfig {
return $this->config;
}
public function migrations(): MigrationManager {
return $this->migrations;
}
public function close(): void {
if ($this->orm->cache()->enabled()) {
$this->orm->cache()->flush($this->orm);
}
$this->connections->close();
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database;
final class DatabaseProvider {
private static ?DatabaseApi $api = null;
public static function set(?DatabaseApi $api): void {
self::$api = $api;
}
public static function get(): DatabaseApi {
if (self::$api === null) {
throw new \RuntimeException('DatabaseApi is not initialized');
}
return self::$api;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database;
use andrewkydev\Database\Config\DatabaseConfigLoader;
use andrewkydev\Database\Connection\ConnectionManager;
use andrewkydev\Database\Migration\MigrationManager;
use andrewkydev\Database\ORM\EntityManager;
use andrewkydev\Database\Query\QueryRunner;
use andrewkydev\Database\Schema\SchemaManager;
use pocketmine\plugin\PluginBase;
final class Loader extends PluginBase {
private ?DatabaseApi $api = null;
public function onEnable(): void {
$this->saveDefaultConfig();
$config = DatabaseConfigLoader::load($this->getConfig());
$this->assertExtensions($config->driver);
$connections = new ConnectionManager($config);
$schema = new SchemaManager($connections, $config);
$query = new QueryRunner($connections, $config);
$orm = new EntityManager($connections, $config);
$orm->cache()->attachPlugin($this, $orm);
$migrations = new MigrationManager($schema, $query, $config);
$this->api = new DatabaseApi($schema, $query, $orm, $connections, $config, $migrations);
DatabaseProvider::set($this->api);
}
public function onDisable(): void {
if ($this->api !== null) {
$this->api->close();
}
DatabaseProvider::set(null);
$this->api = null;
}
private function assertExtensions(string $driver): void {
if (!extension_loaded('pdo')) {
throw new \RuntimeException('PDO extension is required');
}
if ($driver === 'mysql' && !extension_loaded('pdo_mysql')) {
throw new \RuntimeException('pdo_mysql extension is required for MySQL');
}
if ($driver === 'postgres' && !extension_loaded('pdo_pgsql')) {
throw new \RuntimeException('pdo_pgsql extension is required for PostgreSQL');
}
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\Migration;
use andrewkydev\Database\Query\QueryRunner;
use andrewkydev\Database\Schema\SchemaManager;
interface Migration {
public function version(): int;
public function up(SchemaManager $schema, QueryRunner $query): void;
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\Migration;
use andrewkydev\Database\Config\DatabaseConfig;
use andrewkydev\Database\Query\QueryRunner;
use andrewkydev\Database\Schema\SchemaManager;
final class MigrationManager {
/** @var array<int, Migration> */
private array $migrations = [];
public function __construct(
private SchemaManager $schema,
private QueryRunner $query,
private DatabaseConfig $config
) {
}
public function register(Migration $migration): void {
$this->migrations[$migration->version()] = $migration;
}
public function runPending(): void {
if (!$this->config->migrationsEnabled) {
return;
}
$this->ensureTable();
$applied = $this->appliedVersions();
ksort($this->migrations);
foreach ($this->migrations as $version => $migration) {
if (in_array($version, $applied, true)) {
continue;
}
$migration->up($this->schema, $this->query);
$this->query->execute(
'INSERT INTO ' . $this->config->migrationsTable . ' (version, applied_at) VALUES (?, ?)',
[$version, date('Y-m-d H:i:s')]
);
}
}
private function ensureTable(): void {
$sql = 'CREATE TABLE IF NOT EXISTS ' . $this->config->migrationsTable . ' ('
. 'version INT PRIMARY KEY, '
. 'applied_at VARCHAR(32) NOT NULL'
. ')';
$this->query->execute($sql);
}
/**
* @return int[]
*/
private function appliedVersions(): array {
$rows = $this->query->query('SELECT version FROM ' . $this->config->migrationsTable);
$versions = [];
foreach ($rows as $row) {
$versions[] = (int) $row['version'];
}
return $versions;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY)]
final class CacheKey {
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY)]
final class Column {
public function __construct(
public string $name = '',
public bool $nullable = true,
public int $length = 255,
public bool $unique = false,
public string $type = ''
) {
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
final class Entity {
public function __construct(public string $table = '') {
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY)]
final class Id {
public function __construct(public bool $autoIncrement = true) {
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY)]
final class Json {
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY)]
final class ManyToMany {
public function __construct(
public string $target,
public string $joinTable,
public string $joinColumn,
public string $inverseJoinColumn,
public bool $cascadePersist = false,
public bool $cascadeRemove = false,
public bool $lazy = true
) {
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY)]
final class ManyToOne {
public function __construct(
public string $target,
public string $joinColumn,
public string $referencedColumn = 'id',
public bool $cascadePersist = false,
public bool $cascadeRemove = false,
public bool $lazy = true
) {
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY)]
final class OneToMany {
public function __construct(
public string $target,
public string $mappedBy,
public bool $cascadePersist = false,
public bool $cascadeRemove = false,
public bool $lazy = true
) {
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY)]
final class OneToOne {
public function __construct(
public string $target,
public string $mappedBy = '',
public string $joinColumn = '',
public string $referencedColumn = 'id',
public bool $cascadePersist = false,
public bool $cascadeRemove = false,
public bool $lazy = true
) {
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY)]
final class Transient {
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM\Cache;
use andrewkydev\Database\ORM\EntityManager;
use pocketmine\scheduler\Task;
final class CacheFlushTask extends Task {
public function __construct(
private readonly CacheManager $cache,
private readonly EntityManager $orm
) {
}
public function onRun(): void {
$this->cache->flush($this->orm);
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM\Cache;
use andrewkydev\Database\Config\DatabaseConfig;
use andrewkydev\Database\ORM\EntityManager;
use andrewkydev\Database\ORM\EntityMetadata;
use pocketmine\plugin\PluginBase;
use pocketmine\scheduler\TaskHandler;
final class CacheManager {
/** @var array<string, EntityCache> */
private array $caches = [];
/** @var array<int, array{op: string, entity: object, class: string}> */
private array $dirty = [];
private ?PluginBase $plugin = null;
private ?TaskHandler $flushTask = null;
public function __construct(private readonly DatabaseConfig $config) {
}
public function attachPlugin(PluginBase $plugin, EntityManager $orm): void {
$this->plugin = $plugin;
if ($this->config->cacheEnabled && $this->config->cacheWriteBehind) {
$interval = max(1, $this->config->cacheFlushIntervalSeconds) * 20;
$this->flushTask = $plugin->getScheduler()->scheduleRepeatingTask(
new CacheFlushTask($this, $orm),
$interval
);
}
}
public function enabled(): bool {
return $this->config->cacheEnabled;
}
public function writeBehind(): bool {
return $this->config->cacheWriteBehind;
}
/**
* @param string[] $keyColumns
*/
public function register(string $className, EntityMetadata $meta, array $keyColumns, bool $preload, EntityManager $orm): void {
$keys = $keyColumns !== [] ? $keyColumns : $this->defaultKeyColumns($meta);
$this->caches[$className] = new EntityCache(
$className,
$meta,
$keys,
$this->config->cacheTtlSeconds,
$this->config->cacheMaxEntries
);
if ($preload && $this->plugin !== null) {
$batchSize = max(1, $this->config->cachePreloadBatchSize);
$delay = max(1, $this->config->cachePreloadDelayTicks);
if ($this->config->cachePreloadAsync) {
$this->plugin->getScheduler()->scheduleRepeatingTask(
new CachePreloadAsyncTask($className, $orm, $this, $batchSize),
$delay
);
} else {
$this->plugin->getScheduler()->scheduleRepeatingTask(
new CachePreloadTask($className, $orm, $this, $batchSize),
$delay
);
}
}
}
public function get(string $className, string $column, mixed $value): ?object {
if (!isset($this->caches[$className])) {
return null;
}
return $this->caches[$className]->get($column, $value);
}
public function cacheFor(string $className): ?EntityCache {
return $this->caches[$className] ?? null;
}
public function put(string $className, object $entity): void {
if (!isset($this->caches[$className])) {
return;
}
$this->caches[$className]->put($entity);
}
public function remove(string $className, object $entity): void {
if (!isset($this->caches[$className])) {
return;
}
$this->caches[$className]->remove($entity);
}
public function queueUpdate(string $className, object $entity, EntityManager $orm): void {
$this->dirty[] = ['op' => 'update', 'entity' => $entity, 'class' => $className];
if (count($this->dirty) >= $this->config->cacheMaxDirty) {
$this->flushNow($orm);
}
}
public function queueDelete(string $className, object $entity, EntityManager $orm): void {
$this->dirty[] = ['op' => 'delete', 'entity' => $entity, 'class' => $className];
if (count($this->dirty) >= $this->config->cacheMaxDirty) {
$this->flushNow($orm);
}
}
public function flush(EntityManager $orm): void {
if ($this->dirty === []) {
return;
}
$batch = $this->dirty;
$this->dirty = [];
foreach ($batch as $entry) {
if ($entry['op'] === 'update') {
$orm->updateDirect($entry['entity']);
} elseif ($entry['op'] === 'delete') {
$orm->deleteDirect($entry['entity']);
}
}
}
public function flushNow(EntityManager $orm): void {
if ($this->plugin === null) {
return;
}
$plugin = $this->plugin;
$plugin->getScheduler()->scheduleDelayedTask(
new CacheFlushTask($this, $orm),
1
);
}
/**
* @return string[]
*/
private function defaultKeyColumns(EntityMetadata $meta): array {
$columns = [];
if ($meta->cacheKeyColumns !== []) {
return $meta->cacheKeyColumns;
}
if ($meta->idField !== null) {
$columns[] = $meta->idField->column;
}
foreach ($meta->fields as $field) {
if ($field->unique) {
$columns[] = $field->column;
}
}
return array_values(array_unique($columns));
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM\Cache;
use andrewkydev\Database\ORM\EntityManager;
use pocketmine\scheduler\Task;
final class CachePreloadAsyncTask extends Task {
private int $offset = 0;
private bool $inFlight = false;
public function __construct(
private readonly string $className,
private readonly EntityManager $orm,
private readonly CacheManager $cache,
private readonly int $batchSize
) {
}
public function onRun(): void {
if ($this->inFlight) {
return;
}
$idColumn = null;
foreach ($this->orm->metaFor($this->className)->fields as $field) {
if ($field->isId) {
$idColumn = $field->column;
break;
}
}
$orderBy = $idColumn !== null ? $idColumn . ' ASC' : '';
$table = $this->orm->tableFor($this->className);
$sql = 'SELECT * FROM ' . $table;
if ($orderBy !== '') {
$sql .= ' ORDER BY ' . $orderBy;
}
$sql .= ' LIMIT ' . $this->batchSize . ' OFFSET ' . $this->offset;
$this->inFlight = true;
$this->orm->runSqlAsync($sql, [], true, function (array $rows): void {
$this->inFlight = false;
if ($rows === []) {
$this->getHandler()?->cancel();
return;
}
$entities = $this->orm->mapRowsFor($this->className, $rows);
$this->orm->cachePutAll($this->className, $entities);
$this->offset += count($rows);
}, function (): void {
$this->inFlight = false;
});
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM\Cache;
use andrewkydev\Database\ORM\EntityManager;
use pocketmine\scheduler\Task;
final class CachePreloadTask extends Task {
private int $offset = 0;
public function __construct(
private readonly string $className,
private readonly EntityManager $orm,
private readonly CacheManager $cache,
private readonly int $batchSize
) {
}
public function onRun(): void {
$orderBy = '';
$idColumn = null;
foreach ($this->orm->metaFor($this->className)->fields as $field) {
if ($field->isId) {
$idColumn = $field->column;
break;
}
}
if ($idColumn !== null) {
$orderBy = $idColumn . ' ASC';
}
$rows = $this->orm->findWhere($this->className, '', [], $orderBy, $this->batchSize, $this->offset);
if ($rows === []) {
$this->getHandler()?->cancel();
return;
}
foreach ($rows as $entity) {
$this->cache->put($this->className, $entity);
}
$this->offset += count($rows);
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM\Cache;
use andrewkydev\Database\ORM\EntityMetadata;
use ReflectionProperty;
final class EntityCache {
/** @var array<string, array<string, array{entity: object, ts: int}>> */
private array $byColumn = [];
/** @var array<string, ReflectionProperty> */
private array $columnToProperty = [];
/**
* @param string[] $keyColumns
*/
public function __construct(
private readonly string $className,
private readonly EntityMetadata $meta,
private readonly array $keyColumns,
private readonly int $ttlSeconds,
private readonly int $maxEntries
) {
foreach ($meta->fields as $field) {
$this->columnToProperty[$field->column] = $field->property;
}
foreach ($keyColumns as $column) {
$this->byColumn[$column] = [];
}
}
public function className(): string {
return $this->className;
}
/**
* @return string[]
*/
public function keyColumns(): array {
return $this->keyColumns;
}
public function get(string $column, mixed $value): ?object {
if (!isset($this->byColumn[$column])) {
return null;
}
$key = $this->key($value);
if (!isset($this->byColumn[$column][$key])) {
return null;
}
$entry = $this->byColumn[$column][$key];
if ($this->ttlSeconds > 0 && (time() - $entry['ts']) > $this->ttlSeconds) {
unset($this->byColumn[$column][$key]);
return null;
}
return $entry['entity'];
}
public function put(object $entity): void {
foreach ($this->keyColumns as $column) {
$prop = $this->columnToProperty[$column] ?? null;
if ($prop === null) {
continue;
}
$value = $prop->getValue($entity);
if ($value === null) {
continue;
}
$this->byColumn[$column][$this->key($value)] = [
'entity' => $entity,
'ts' => time(),
];
$this->evictIfNeeded($column);
}
}
public function remove(object $entity): void {
foreach ($this->keyColumns as $column) {
$prop = $this->columnToProperty[$column] ?? null;
if ($prop === null) {
continue;
}
$value = $prop->getValue($entity);
if ($value === null) {
continue;
}
$key = $this->key($value);
unset($this->byColumn[$column][$key]);
}
}
private function evictIfNeeded(string $column): void {
if ($this->maxEntries <= 0) {
return;
}
if (!isset($this->byColumn[$column])) {
return;
}
$entries = $this->byColumn[$column];
if (count($entries) <= $this->maxEntries) {
return;
}
$oldestKey = null;
$oldestTs = PHP_INT_MAX;
foreach ($entries as $key => $entry) {
if ($entry['ts'] < $oldestTs) {
$oldestTs = $entry['ts'];
$oldestKey = $key;
}
}
if ($oldestKey !== null) {
unset($this->byColumn[$column][$oldestKey]);
}
}
private function key(mixed $value): string {
if (is_bool($value)) {
return $value ? '1' : '0';
}
return (string) $value;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM;
final class Condition {
/**
* @param array<int, mixed> $params
*/
public function __construct(
public string $sql,
public array $params = []
) {
}
public function and(Condition $other): Condition {
return $this->combine('AND', $other);
}
public function or(Condition $other): Condition {
return $this->combine('OR', $other);
}
private function combine(string $op, Condition $other): Condition {
return new Condition(
'(' . $this->sql . ' ' . $op . ' ' . $other->sql . ')',
array_merge($this->params, $other->params)
);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM;
final class Conditions {
/**
* @param array<int, mixed> $params
*/
public static function raw(string $sql, array $params = []): Condition {
return new Condition($sql, $params);
}
public static function eq(string $column, mixed $value): Condition {
return new Condition($column . ' = ?', [$value]);
}
public static function ne(string $column, mixed $value): Condition {
return new Condition($column . ' <> ?', [$value]);
}
public static function gt(string $column, mixed $value): Condition {
return new Condition($column . ' > ?', [$value]);
}
public static function gte(string $column, mixed $value): Condition {
return new Condition($column . ' >= ?', [$value]);
}
public static function lt(string $column, mixed $value): Condition {
return new Condition($column . ' < ?', [$value]);
}
public static function lte(string $column, mixed $value): Condition {
return new Condition($column . ' <= ?', [$value]);
}
public static function like(string $column, mixed $value): Condition {
return new Condition($column . ' LIKE ?', [$value]);
}
/**
* @param array<int, mixed> $values
*/
public static function in(string $column, array $values): Condition {
if ($values === []) {
return new Condition('1=0', []);
}
$placeholders = implode(', ', array_fill(0, count($values), '?'));
return new Condition($column . ' IN (' . $placeholders . ')', array_values($values));
}
public static function isNull(string $column): Condition {
return new Condition($column . ' IS NULL');
}
public static function isNotNull(string $column): Condition {
return new Condition($column . ' IS NOT NULL');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM;
use andrewkydev\Database\ORM\Attributes\Column;
use andrewkydev\Database\ORM\Attributes\CacheKey;
use andrewkydev\Database\ORM\Attributes\Entity;
use andrewkydev\Database\ORM\Attributes\Id;
use andrewkydev\Database\ORM\Attributes\Json;
use andrewkydev\Database\ORM\Attributes\ManyToMany;
use andrewkydev\Database\ORM\Attributes\ManyToOne;
use andrewkydev\Database\ORM\Attributes\OneToMany;
use andrewkydev\Database\ORM\Attributes\OneToOne;
use andrewkydev\Database\ORM\Attributes\Transient;
final class EntityMetadata {
/** @var FieldMapping[] */
public array $fields;
public ?FieldMapping $idField;
/** @var string[] */
public array $cacheKeyColumns;
/** @var RelationMapping[] */
public array $relations;
private function __construct(
public string $table,
array $fields,
?FieldMapping $idField,
array $cacheKeyColumns,
array $relations
) {
$this->fields = $fields;
$this->idField = $idField;
$this->cacheKeyColumns = $cacheKeyColumns;
$this->relations = $relations;
}
public static function fromClass(string $className): self {
$ref = new \ReflectionClass($className);
$table = self::resolveTable($ref);
$fields = [];
$idField = null;
$cacheKeyColumns = [];
$relations = [];
foreach ($ref->getProperties() as $property) {
if ($property->isStatic()) {
continue;
}
if ($property->getAttributes(Transient::class) !== []) {
continue;
}
$columnAttr = self::firstAttribute($property, Column::class);
$idAttr = self::firstAttribute($property, Id::class);
$jsonAttr = self::firstAttribute($property, Json::class);
$cacheKeyAttr = self::firstAttribute($property, CacheKey::class);
$oneToOne = self::firstAttribute($property, OneToOne::class);
$manyToOne = self::firstAttribute($property, ManyToOne::class);
$oneToMany = self::firstAttribute($property, OneToMany::class);
$manyToMany = self::firstAttribute($property, ManyToMany::class);
if ($oneToOne !== null || $manyToOne !== null || $oneToMany !== null || $manyToMany !== null) {
if ($oneToOne !== null) {
/** @var OneToOne $oneToOne */
$relations[] = new RelationMapping(
$property,
'oneToOne',
$oneToOne->target,
$oneToOne->mappedBy,
$oneToOne->joinColumn,
$oneToOne->referencedColumn,
'',
'',
$oneToOne->cascadePersist,
$oneToOne->cascadeRemove,
$oneToOne->lazy
);
} elseif ($manyToOne !== null) {
/** @var ManyToOne $manyToOne */
$relations[] = new RelationMapping(
$property,
'manyToOne',
$manyToOne->target,
'',
$manyToOne->joinColumn,
$manyToOne->referencedColumn,
'',
'',
$manyToOne->cascadePersist,
$manyToOne->cascadeRemove,
$manyToOne->lazy
);
} elseif ($oneToMany !== null) {
/** @var OneToMany $oneToMany */
$relations[] = new RelationMapping(
$property,
'oneToMany',
$oneToMany->target,
$oneToMany->mappedBy,
'',
'id',
'',
'',
$oneToMany->cascadePersist,
$oneToMany->cascadeRemove,
$oneToMany->lazy
);
} elseif ($manyToMany !== null) {
/** @var ManyToMany $manyToMany */
$relations[] = new RelationMapping(
$property,
'manyToMany',
$manyToMany->target,
'',
$manyToMany->joinColumn,
'id',
$manyToMany->joinTable,
$manyToMany->inverseJoinColumn,
$manyToMany->cascadePersist,
$manyToMany->cascadeRemove,
$manyToMany->lazy
);
}
continue;
}
$columnName = ($columnAttr !== null && $columnAttr->name !== '')
? $columnAttr->name
: self::toSnakeCase($property->getName());
$nullable = $columnAttr?->nullable ?? true;
$length = $columnAttr?->length ?? 255;
$unique = $columnAttr?->unique ?? false;
$typeOverride = $columnAttr?->type ?? '';
$autoIncrement = $idAttr?->autoIncrement ?? false;
$isId = $idAttr !== null;
$isJson = $jsonAttr !== null;
$mapping = new FieldMapping(
$property,
$columnName,
$nullable,
$length,
$unique,
$typeOverride,
$isId,
$autoIncrement,
$isJson,
$cacheKeyAttr !== null
);
$fields[] = $mapping;
if ($isId) {
if ($idField !== null) {
throw new \RuntimeException('Multiple Id fields in ' . $className);
}
$idField = $mapping;
}
if ($cacheKeyAttr !== null) {
$cacheKeyColumns[] = $columnName;
}
}
if ($fields === []) {
throw new \RuntimeException('No mappable fields in ' . $className);
}
return new self($table, $fields, $idField, $cacheKeyColumns, $relations);
}
private static function resolveTable(\ReflectionClass $ref): string {
$attr = $ref->getAttributes(Entity::class);
if ($attr !== []) {
/** @var Entity $entity */
$entity = $attr[0]->newInstance();
if ($entity->table !== '') {
return $entity->table;
}
}
return self::toSnakeCase($ref->getShortName());
}
private static function toSnakeCase(string $value): string {
$out = '';
$len = strlen($value);
for ($i = 0; $i < $len; $i++) {
$c = $value[$i];
if (ctype_upper($c)) {
if ($i > 0) {
$out .= '_';
}
$out .= strtolower($c);
} else {
$out .= $c;
}
}
return $out;
}
private static function firstAttribute(\ReflectionProperty $property, string $className): ?object {
$attrs = $property->getAttributes($className);
if ($attrs === []) {
return null;
}
return $attrs[0]->newInstance();
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM;
final class FieldMapping {
public function __construct(
public \ReflectionProperty $property,
public string $column,
public bool $nullable,
public int $length,
public bool $unique,
public string $typeOverride,
public bool $isId,
public bool $autoIncrement,
public bool $json,
public bool $cacheKey
) {
$this->property->setAccessible(true);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM;
use andrewkydev\Database\DatabaseProvider;
trait LazyRelationsTrait {
public function __get(string $name): mixed {
$orm = DatabaseProvider::get()->orm();
$meta = $orm->metaFor(static::class);
foreach ($meta->relations as $relation) {
if ($relation->property->getName() !== $name) {
continue;
}
if (!$relation->lazy) {
break;
}
$orm->loadRelation($this, $name);
return $relation->property->getValue($this);
}
throw new \RuntimeException('Undefined property: ' . static::class . '::$' . $name);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM;
use andrewkydev\Database\Schema\ColumnSpec;
use andrewkydev\Database\Schema\IndexSpec;
use andrewkydev\Database\Schema\SqlDialect;
use andrewkydev\Database\Schema\TableSpec;
final class OrmSchema {
public static function fromEntity(string $className, string $dialect): TableSpec {
$meta = EntityMetadata::fromClass($className);
$columns = [];
$primary = [];
$indexes = [];
foreach ($meta->fields as $field) {
$type = $field->typeOverride !== '' ? $field->typeOverride : self::resolveType($field, $dialect);
$columns[] = new ColumnSpec(
$field->column,
$type,
$field->nullable,
null,
$field->autoIncrement,
$field->isId
);
if ($field->isId) {
$primary[] = $field->column;
}
if ($field->unique) {
$indexes[] = new IndexSpec($meta->table . '_' . $field->column . '_uk', [$field->column], true);
}
}
return new TableSpec($meta->table, $columns, $primary, $indexes);
}
private static function resolveType(FieldMapping $field, string $dialect): string {
if ($field->json) {
return $dialect === SqlDialect::POSTGRES ? 'JSONB' : 'JSON';
}
$type = $field->property->getType();
$name = $type instanceof \ReflectionNamedType ? $type->getName() : '';
return match ($name) {
'string' => 'VARCHAR(' . $field->length . ')',
'int' => 'INT',
'float' => 'DOUBLE',
'bool' => $dialect === SqlDialect::POSTGRES ? 'BOOLEAN' : 'TINYINT(1)',
default => 'TEXT',
};
}
}

View File

@@ -0,0 +1,419 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM;
final class QueryBuilder {
private array $select = [];
private string $join = '';
private string $where = '';
private array $params = [];
private string $groupBy = '';
private string $having = '';
private array $havingParams = [];
private string $orderBy = '';
private int $limit = 0;
private int $offset = 0;
/** @var string[] */
private array $withRelations = [];
/** @var string[] */
private array $joinFetch = [];
/** @var array<string, array{alias: string, relation: \andrewkydev\Database\ORM\RelationMapping}> */
private array $joinFetchMap = [];
public function __construct(
private EntityManager $orm,
private string $className
) {
}
public function select(string ...$columns): self {
$this->select = $columns;
return $this;
}
public function join(string $sql): self {
$this->join = $sql;
return $this;
}
public function where(string|Condition $where, array $params = []): self {
if ($where instanceof Condition) {
$this->where = $where->sql;
$this->params = $where->params;
return $this;
}
$this->where = $where;
$this->params = $params;
return $this;
}
public function and(Condition $condition): self {
if ($this->where === '') {
return $this->where($condition);
}
$this->where = '(' . $this->where . ' AND ' . $condition->sql . ')';
$this->params = array_merge($this->params, $condition->params);
return $this;
}
public function or(Condition $condition): self {
if ($this->where === '') {
return $this->where($condition);
}
$this->where = '(' . $this->where . ' OR ' . $condition->sql . ')';
$this->params = array_merge($this->params, $condition->params);
return $this;
}
public function groupBy(string $sql): self {
$this->groupBy = $sql;
return $this;
}
public function having(string|Condition $having, array $params = []): self {
if ($having instanceof Condition) {
$this->having = $having->sql;
$this->havingParams = $having->params;
return $this;
}
$this->having = $having;
$this->havingParams = $params;
return $this;
}
public function orderBy(string $sql): self {
$this->orderBy = $sql;
return $this;
}
public function limit(int $limit, int $offset = 0): self {
$this->limit = $limit;
$this->offset = $offset;
return $this;
}
/**
* @param string[] $relations
*/
public function withRelations(array $relations): self {
$this->withRelations = $relations;
return $this;
}
public function withRelation(string $relation): self {
$this->withRelations[] = $relation;
return $this;
}
/**
* @param string[] $relations
*/
public function joinFetch(array $relations): self {
$this->joinFetch = $relations;
return $this;
}
public function joinFetchRelation(string $relation): self {
$this->joinFetch[] = $relation;
return $this;
}
public function list(): array {
[$sql, $map] = $this->buildSelectWithJoins();
$rows = $this->orm->fetchRows($sql, $this->mergeParams());
$entities = $this->mapJoinRows($rows, $map);
$this->orm->cachePutAll($this->className, $entities);
if ($this->withRelations !== []) {
$this->orm->loadRelationsFor($entities, $this->withRelations);
}
return $entities;
}
public function listAsync(callable $onSuccess, ?callable $onError = null): void {
[$sql, $map] = $this->buildSelectWithJoins();
$params = $this->mergeParams();
$this->orm->runSqlAsync($sql, $params, true, function (array $rows) use ($onSuccess, $map): void {
$entities = $this->mapJoinRows($rows, $map);
$this->orm->cachePutAll($this->className, $entities);
if ($this->withRelations !== []) {
$this->orm->loadRelationsFor($entities, $this->withRelations);
}
$onSuccess($entities);
}, $onError);
}
public function one(): ?object {
[$sql, $map] = $this->buildSelectWithJoins(1);
$rows = $this->orm->fetchRows($sql, $this->mergeParams());
if ($rows === []) {
return null;
}
$entities = $this->mapJoinRows([$rows[0]], $map);
if ($entities === []) {
return null;
}
$entity = $entities[0];
$this->orm->cachePutAll($this->className, [$entity]);
if ($this->withRelations !== []) {
$this->orm->loadRelations($entity, $this->withRelations);
}
return $entity;
}
public function oneAsync(callable $onSuccess, ?callable $onError = null): void {
[$sql, $map] = $this->buildSelectWithJoins(1);
$params = $this->mergeParams();
$this->orm->runSqlAsync($sql, $params, true, function (array $rows) use ($onSuccess, $map): void {
if ($rows === []) {
$onSuccess(null);
return;
}
$entities = $this->mapJoinRows([$rows[0]], $map);
if ($entities === []) {
$onSuccess(null);
return;
}
$entity = $entities[0];
$this->orm->cachePutAll($this->className, [$entity]);
if ($this->withRelations !== []) {
$this->orm->loadRelations($entity, $this->withRelations);
}
$onSuccess($entity);
}, $onError);
}
public function count(): int {
$sql = $this->buildCount();
$rows = $this->orm->fetchRows($sql, $this->mergeParams());
return (int) ($rows[0]['c'] ?? 0);
}
public function countAsync(callable $onSuccess, ?callable $onError = null): void {
$sql = $this->buildCount();
$params = $this->mergeParams();
$this->orm->runSqlAsync($sql, $params, true, function (array $rows) use ($onSuccess): void {
$onSuccess((int) ($rows[0]['c'] ?? 0));
}, $onError);
}
public function exists(): bool {
$sql = $this->buildExists();
$rows = $this->orm->fetchRows($sql, $this->mergeParams());
return $rows !== [];
}
public function existsAsync(callable $onSuccess, ?callable $onError = null): void {
$sql = $this->buildExists();
$params = $this->mergeParams();
$this->orm->runSqlAsync($sql, $params, true, function (array $rows) use ($onSuccess): void {
$onSuccess($rows !== []);
}, $onError);
}
public function delete(): int {
if ($this->join !== '' || $this->groupBy !== '' || $this->having !== '') {
throw new \RuntimeException('delete does not support join/group/having');
}
if ($this->where === '') {
throw new \RuntimeException('delete requires a WHERE clause');
}
return $this->orm->deleteWhere($this->className, $this->where, $this->mergeParams());
}
public function deleteAsync(callable $onSuccess, ?callable $onError = null): void {
if ($this->join !== '' || $this->groupBy !== '' || $this->having !== '') {
throw new \RuntimeException('delete does not support join/group/having');
}
if ($this->where === '') {
throw new \RuntimeException('delete requires a WHERE clause');
}
$table = $this->orm->tableFor($this->className);
$sql = 'DELETE FROM ' . $table . ' WHERE ' . $this->where;
$params = $this->mergeParams();
$this->orm->runSqlAsync($sql, $params, false, $onSuccess, $onError);
}
private function buildSelect(?int $overrideLimit = null): string {
$table = $this->orm->tableFor($this->className);
$select = $this->select === [] ? '*' : implode(', ', $this->select);
$sql = 'SELECT ' . $select . ' FROM ' . $table;
if ($this->join !== '') {
$sql .= ' ' . $this->join;
}
if ($this->where !== '') {
$sql .= ' WHERE ' . $this->where;
}
if ($this->groupBy !== '') {
$sql .= ' GROUP BY ' . $this->groupBy;
}
if ($this->having !== '') {
$sql .= ' HAVING ' . $this->having;
}
if ($this->orderBy !== '') {
$sql .= ' ORDER BY ' . $this->orderBy;
}
$limit = $overrideLimit ?? $this->limit;
if ($limit > 0) {
$sql .= ' LIMIT ' . $limit;
}
if ($this->offset > 0) {
if ($limit === 0) {
$sql .= ' LIMIT 2147483647';
}
$sql .= ' OFFSET ' . $this->offset;
}
return $sql;
}
/**
* @return array{0: string, 1: array<string, array{alias: string, relation: \andrewkydev\Database\ORM\RelationMapping}>}
*/
private function buildSelectWithJoins(?int $overrideLimit = null): array {
$meta = $this->orm->metaFor($this->className);
$baseAlias = 't0';
$selectParts = [];
foreach ($meta->fields as $field) {
$selectParts[] = $baseAlias . '.' . $field->column . ' AS ' . $baseAlias . '__' . $field->column;
}
$joins = '';
$map = [];
$index = 1;
foreach ($this->joinFetch as $relationName) {
$relation = null;
foreach ($meta->relations as $rel) {
if ($rel->property->getName() === $relationName) {
$relation = $rel;
break;
}
}
if ($relation === null) {
$this->withRelations[] = $relationName;
continue;
}
if (!in_array($relation->type, ['manyToOne', 'oneToOne'], true) || $relation->joinColumn === '') {
$this->withRelations[] = $relationName;
continue;
}
$alias = 'r' . $index++;
$targetMeta = $this->orm->metaFor($relation->target);
foreach ($targetMeta->fields as $field) {
$selectParts[] = $alias . '.' . $field->column . ' AS ' . $alias . '__' . $field->column;
}
$joins .= ' LEFT JOIN ' . $targetMeta->table . ' ' . $alias
. ' ON ' . $baseAlias . '.' . $relation->joinColumn . ' = ' . $alias . '.' . $relation->referencedColumn;
$map[$relation->property->getName()] = ['alias' => $alias, 'relation' => $relation];
}
$sql = 'SELECT ' . implode(', ', $selectParts) . ' FROM ' . $meta->table . ' ' . $baseAlias;
$sql .= $joins;
if ($this->join !== '') {
$sql .= ' ' . $this->join;
}
if ($this->where !== '') {
$sql .= ' WHERE ' . $this->where;
}
if ($this->groupBy !== '') {
$sql .= ' GROUP BY ' . $this->groupBy;
}
if ($this->having !== '') {
$sql .= ' HAVING ' . $this->having;
}
if ($this->orderBy !== '') {
$sql .= ' ORDER BY ' . $this->orderBy;
}
$limit = $overrideLimit ?? $this->limit;
if ($limit > 0) {
$sql .= ' LIMIT ' . $limit;
}
if ($this->offset > 0) {
if ($limit === 0) {
$sql .= ' LIMIT 2147483647';
}
$sql .= ' OFFSET ' . $this->offset;
}
return [$sql, $map];
}
/**
* @param array<int, array<string, mixed>> $rows
* @param array<string, array{alias: string, relation: \andrewkydev\Database\ORM\RelationMapping}> $map
* @return object[]
*/
private function mapJoinRows(array $rows, array $map): array {
$entities = [];
foreach ($rows as $row) {
$entity = $this->orm->mapRowWithPrefix($this->className, $row, 't0__');
if ($entity === null) {
continue;
}
foreach ($map as $name => $info) {
$relation = $info['relation'];
$alias = $info['alias'];
$related = $this->orm->mapRowWithPrefix($relation->target, $row, $alias . '__');
$relation->property->setValue($entity, $related);
$this->orm->markRelationLoaded($entity, $name);
if ($related !== null) {
$this->orm->cachePutAll($relation->target, [$related]);
}
}
$entities[] = $entity;
}
return $entities;
}
private function buildCount(): string {
$table = $this->orm->tableFor($this->className);
if ($this->groupBy === '') {
$sql = 'SELECT COUNT(*) AS c FROM ' . $table;
if ($this->join !== '') {
$sql .= ' ' . $this->join;
}
if ($this->where !== '') {
$sql .= ' WHERE ' . $this->where;
}
if ($this->having !== '') {
$sql .= ' HAVING ' . $this->having;
}
return $sql;
}
$sql = 'SELECT COUNT(*) AS c FROM (SELECT 1 FROM ' . $table;
if ($this->join !== '') {
$sql .= ' ' . $this->join;
}
if ($this->where !== '') {
$sql .= ' WHERE ' . $this->where;
}
$sql .= ' GROUP BY ' . $this->groupBy;
if ($this->having !== '') {
$sql .= ' HAVING ' . $this->having;
}
$sql .= ') t';
return $sql;
}
private function buildExists(): string {
$table = $this->orm->tableFor($this->className);
$sql = 'SELECT 1 FROM ' . $table;
if ($this->join !== '') {
$sql .= ' ' . $this->join;
}
if ($this->where !== '') {
$sql .= ' WHERE ' . $this->where;
}
if ($this->groupBy !== '') {
$sql .= ' GROUP BY ' . $this->groupBy;
}
if ($this->having !== '') {
$sql .= ' HAVING ' . $this->having;
}
$sql .= ' LIMIT 1';
return $sql;
}
private function mergeParams(): array {
return array_merge($this->params, $this->havingParams);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM;
final class RelationMapping {
public function __construct(
public \ReflectionProperty $property,
public string $type,
public string $target,
public string $mappedBy = '',
public string $joinColumn = '',
public string $referencedColumn = 'id',
public string $joinTable = '',
public string $inverseJoinColumn = '',
public bool $cascadePersist = false,
public bool $cascadeRemove = false,
public bool $lazy = true
) {
$this->property->setAccessible(true);
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM;
final class RelationResolver {
public function __construct(private EntityManager $orm) {
}
public function load(object $entity, RelationMapping $relation): void {
$value = match ($relation->type) {
'oneToOne' => $this->loadOneToOne($entity, $relation),
'manyToOne' => $this->loadManyToOne($entity, $relation),
'oneToMany' => $this->loadOneToMany($entity, $relation),
'manyToMany' => $this->loadManyToMany($entity, $relation),
default => null,
};
$relation->property->setValue($entity, $value);
}
private function loadOneToOne(object $entity, RelationMapping $relation): ?object {
if ($relation->mappedBy !== '') {
$targetMeta = $this->orm->metaFor($relation->target);
$sourceMeta = $this->orm->metaFor(get_class($entity));
if ($sourceMeta->idField === null) {
return null;
}
$idValue = $sourceMeta->idField->property->getValue($entity);
$rows = $this->orm->fetchRows(
'SELECT * FROM ' . $targetMeta->table . ' WHERE ' . $relation->mappedBy . ' = ? LIMIT 1',
[$idValue]
);
if ($rows === []) {
return null;
}
return $this->orm->mapRowsFor($relation->target, [$rows[0]])[0];
}
if ($relation->joinColumn === '') {
return null;
}
$joinValue = $this->orm->valueByColumn($entity, $relation->joinColumn);
if ($joinValue === null) {
return null;
}
return $this->orm->findOneWhere($relation->target, $relation->referencedColumn . ' = ?', [$joinValue]);
}
private function loadManyToOne(object $entity, RelationMapping $relation): ?object {
if ($relation->joinColumn === '') {
return null;
}
$joinValue = $this->orm->valueByColumn($entity, $relation->joinColumn);
if ($joinValue === null) {
return null;
}
return $this->orm->findOneWhere($relation->target, $relation->referencedColumn . ' = ?', [$joinValue]);
}
private function loadOneToMany(object $entity, RelationMapping $relation): array {
$sourceMeta = $this->orm->metaFor(get_class($entity));
if ($sourceMeta->idField === null) {
return [];
}
$idValue = $sourceMeta->idField->property->getValue($entity);
return $this->orm->findWhere($relation->target, $relation->mappedBy . ' = ?', [$idValue]);
}
private function loadManyToMany(object $entity, RelationMapping $relation): array {
$sourceMeta = $this->orm->metaFor(get_class($entity));
if ($sourceMeta->idField === null) {
return [];
}
$idValue = $sourceMeta->idField->property->getValue($entity);
$rows = $this->orm->fetchRows(
'SELECT ' . $relation->inverseJoinColumn . ' FROM ' . $relation->joinTable . ' WHERE ' . $relation->joinColumn . ' = ?',
[$idValue]
);
if ($rows === []) {
return [];
}
$ids = array_map(fn(array $row): mixed => $row[$relation->inverseJoinColumn], $rows);
$placeholders = implode(', ', array_fill(0, count($ids), '?'));
$targetMeta = $this->orm->metaFor($relation->target);
$targetIdColumn = $targetMeta->idField?->column ?? 'id';
$targetRows = $this->orm->fetchRows(
'SELECT * FROM ' . $targetMeta->table . ' WHERE ' . $targetIdColumn . ' IN (' . $placeholders . ')',
$ids
);
return $this->orm->mapRowsFor($relation->target, $targetRows);
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\ORM;
interface TypeAdapter {
public function toDatabase(mixed $value): mixed;
public function fromDatabase(mixed $value): mixed;
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\Query;
final class AsyncCallbackRegistry {
/** @var array<string, array{0: callable, 1: callable|null}> */
private static array $callbacks = [];
public static function register(callable $onSuccess, ?callable $onError = null): string {
$id = bin2hex(random_bytes(8));
self::$callbacks[$id] = [$onSuccess, $onError];
return $id;
}
public static function pop(string $id): ?array {
if (!isset(self::$callbacks[$id])) {
return null;
}
$entry = self::$callbacks[$id];
unset(self::$callbacks[$id]);
return $entry;
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\Query;
use andrewkydev\Database\Config\DatabaseConfig;
use andrewkydev\Database\Connection\ConnectionManager;
use pocketmine\Server;
final class QueryRunner {
private string $dsn;
private string $username;
private string $password;
public function __construct(private ConnectionManager $connections, DatabaseConfig $config) {
$this->dsn = $this->buildDsn($config);
$this->username = $config->username;
$this->password = $config->password;
}
public function execute(string $sql, array $params = []): int {
$stmt = $this->connections->getConnection()->prepare($sql);
$stmt->execute($params);
return $stmt->rowCount();
}
public function query(string $sql, array $params = [], ?callable $mapper = null): array {
$stmt = $this->connections->getConnection()->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
if ($mapper === null) {
return $rows;
}
$mapped = [];
foreach ($rows as $row) {
$mapped[] = $mapper($row);
}
return $mapped;
}
public function executeAsync(string $sql, array $params, callable $onSuccess, ?callable $onError = null): void {
$task = new SqlTask($this->dsn, $this->username, $this->password, $sql, $params, false);
$task->onResult($onSuccess, $onError);
Server::getInstance()->getAsyncPool()->submitTask($task);
}
public function queryAsync(string $sql, array $params, callable $onSuccess, ?callable $onError = null): void {
$task = new SqlTask($this->dsn, $this->username, $this->password, $sql, $params, true);
$task->onResult($onSuccess, $onError);
Server::getInstance()->getAsyncPool()->submitTask($task);
}
private function buildDsn(DatabaseConfig $config): string {
if ($config->driver === 'postgres') {
return sprintf(
'pgsql:host=%s;port=%d;dbname=%s',
$config->host,
$config->port,
$config->database
);
}
return sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
$config->host,
$config->port,
$config->database
);
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\Query;
use pocketmine\scheduler\AsyncTask;
final class SqlTask extends AsyncTask {
private string $dsn;
private string $username;
private string $password;
private string $sql;
private array $params;
private bool $fetchAll;
private string $callbackId;
public function __construct(string $dsn, string $username, string $password, string $sql, array $params, bool $fetchAll) {
$this->dsn = $dsn;
$this->username = $username;
$this->password = $password;
$this->sql = $sql;
$this->params = $params;
$this->fetchAll = $fetchAll;
}
public function onResult(callable $onSuccess, ?callable $onError = null): void {
$this->callbackId = AsyncCallbackRegistry::register($onSuccess, $onError);
}
public function __serialize(): array {
return [
'dsn' => $this->dsn,
'username' => $this->username,
'password' => $this->password,
'sql' => $this->sql,
'params' => $this->params,
'fetchAll' => $this->fetchAll,
'callbackId' => $this->callbackId,
];
}
public function __unserialize(array $data): void {
$this->dsn = $data['dsn'];
$this->username = $data['username'];
$this->password = $data['password'];
$this->sql = $data['sql'];
$this->params = $data['params'];
$this->fetchAll = $data['fetchAll'];
$this->callbackId = $data['callbackId'];
}
public function onRun(): void {
try {
$pdo = new \PDO($this->dsn, $this->username, $this->password, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
]);
$stmt = $pdo->prepare($this->sql);
$stmt->execute($this->params);
if ($this->fetchAll) {
$this->setResult(['ok' => true, 'rows' => $stmt->fetchAll()]);
} else {
$this->setResult(['ok' => true, 'rows' => $stmt->rowCount()]);
}
} catch (\Throwable $e) {
$this->setResult(['ok' => false, 'error' => $e->getMessage()]);
}
}
public function onCompletion(): void {
$result = $this->getResult();
if (!is_array($result)) {
return;
}
$callbacks = AsyncCallbackRegistry::pop($this->callbackId);
if ($callbacks === null) {
return;
}
[$onSuccess, $onError] = $callbacks;
if ($result['ok'] === true) {
$onSuccess($result['rows']);
return;
}
if ($onError !== null) {
$onError($result['error'] ?? 'Unknown error');
}
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\Schema;
final class ColumnSpec {
public function __construct(
public string $name,
public string $type,
public bool $nullable = true,
public ?string $default = null,
public bool $autoIncrement = false,
public bool $primaryKey = false
) {
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\Schema;
final class IndexSpec {
/**
* @param string[] $columns
*/
public function __construct(
public string $name,
public array $columns,
public bool $unique = false
) {
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\Schema;
use andrewkydev\Database\Config\DatabaseConfig;
use andrewkydev\Database\Connection\ConnectionManager;
final class SchemaManager {
public function __construct(
private ConnectionManager $connections,
private DatabaseConfig $config
) {
}
public function createDatabase(string $name): void {
$this->execute('CREATE DATABASE ' . $name);
}
public function dropDatabase(string $name): void {
$this->execute('DROP DATABASE ' . $name);
}
public function createTable(TableSpec $spec): void {
$columns = [];
foreach ($spec->columns as $column) {
$columns[] = $this->columnDefinition($column);
}
$primary = $spec->primaryKey;
if ($primary === []) {
foreach ($spec->columns as $column) {
if ($column->primaryKey) {
$primary[] = $column->name;
}
}
}
if ($primary !== []) {
$columns[] = 'PRIMARY KEY (' . implode(', ', $primary) . ')';
}
$sql = 'CREATE TABLE ' . $spec->name . ' (' . implode(', ', $columns) . ')';
$this->execute($sql);
foreach ($spec->indexes as $index) {
$this->addIndex($spec->name, $index);
}
}
public function dropTable(string $table): void {
$this->execute('DROP TABLE ' . $table);
}
public function addColumn(string $table, ColumnSpec $column): void {
$this->execute('ALTER TABLE ' . $table . ' ADD COLUMN ' . $this->columnDefinition($column));
}
public function updateColumn(string $table, ColumnSpec $column): void {
if ($this->config->driver === SqlDialect::MYSQL) {
$this->execute('ALTER TABLE ' . $table . ' MODIFY COLUMN ' . $this->columnDefinition($column));
return;
}
$name = $column->name;
$this->execute('ALTER TABLE ' . $table . ' ALTER COLUMN ' . $name . ' TYPE ' . $this->resolveColumnType($column));
if ($column->nullable) {
$this->execute('ALTER TABLE ' . $table . ' ALTER COLUMN ' . $name . ' DROP NOT NULL');
} else {
$this->execute('ALTER TABLE ' . $table . ' ALTER COLUMN ' . $name . ' SET NOT NULL');
}
if ($column->default === null || $column->default === '') {
$this->execute('ALTER TABLE ' . $table . ' ALTER COLUMN ' . $name . ' DROP DEFAULT');
} else {
$this->execute('ALTER TABLE ' . $table . ' ALTER COLUMN ' . $name . ' SET DEFAULT ' . $column->default);
}
}
public function dropColumn(string $table, string $column): void {
$this->execute('ALTER TABLE ' . $table . ' DROP COLUMN ' . $column);
}
public function addIndex(string $table, IndexSpec $index): void {
$prefix = $index->unique ? 'CREATE UNIQUE INDEX ' : 'CREATE INDEX ';
$this->execute($prefix . $index->name . ' ON ' . $table . ' (' . implode(', ', $index->columns) . ')');
}
public function dropIndex(string $table, string $indexName): void {
if ($this->config->driver === SqlDialect::POSTGRES) {
$this->execute('DROP INDEX ' . $indexName);
return;
}
$this->execute('DROP INDEX ' . $indexName . ' ON ' . $table);
}
private function execute(string $sql): void {
$stmt = $this->connections->getConnection()->prepare($sql);
$stmt->execute();
}
private function columnDefinition(ColumnSpec $column): string {
$sql = $column->name . ' ' . $this->resolveColumnType($column);
if (!$column->nullable) {
$sql .= ' NOT NULL';
}
if ($column->default !== null && $column->default !== '') {
$sql .= ' DEFAULT ' . $column->default;
}
if ($column->autoIncrement && $this->config->driver === SqlDialect::MYSQL) {
$sql .= ' AUTO_INCREMENT';
}
return $sql;
}
private function resolveColumnType(ColumnSpec $column): string {
if (!$column->autoIncrement || $this->config->driver !== SqlDialect::POSTGRES) {
return $column->type;
}
$type = strtoupper($column->type);
if (str_contains($type, 'BIG')) {
return 'BIGSERIAL';
}
if (str_contains($type, 'INT')) {
return 'SERIAL';
}
return $column->type;
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\Schema;
final class SqlDialect {
public const MYSQL = 'mysql';
public const POSTGRES = 'postgres';
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace andrewkydev\Database\Schema;
final class TableSpec {
/** @var ColumnSpec[] */
public array $columns;
/** @var string[] */
public array $primaryKey;
/** @var IndexSpec[] */
public array $indexes;
/**
* @param ColumnSpec[] $columns
* @param string[] $primaryKey
* @param IndexSpec[] $indexes
*/
public function __construct(
public string $name,
array $columns,
array $primaryKey = [],
array $indexes = []
) {
$this->columns = $columns;
$this->primaryKey = $primaryKey;
$this->indexes = $indexes;
}
}