first commit
This commit is contained in:
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
10
.idea/Database.iml
generated
Normal file
10
.idea/Database.iml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
14
.idea/discord.xml
generated
Normal file
14
.idea/discord.xml
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DiscordProjectSettings">
|
||||
<option name="show" value="ASK" />
|
||||
<option name="description" value="" />
|
||||
<option name="applicationTheme" value="default" />
|
||||
<option name="iconsTheme" value="default" />
|
||||
<option name="button1Title" value="" />
|
||||
<option name="button1Url" value="" />
|
||||
<option name="button2Title" value="" />
|
||||
<option name="button2Url" value="" />
|
||||
<option name="customApplicationId" value="" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/Database.iml" filepath="$PROJECT_DIR$/.idea/Database.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
22
.idea/php.xml
generated
Normal file
22
.idea/php.xml
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MessDetectorOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PHPCSFixerOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PHPCodeSnifferOptionsConfiguration">
|
||||
<option name="highlightLevel" value="WARNING" />
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4">
|
||||
<option name="suggestChangeDefaultLanguageLevel" value="false" />
|
||||
</component>
|
||||
<component name="PhpStanOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PsalmOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
142
README.md
Normal file
142
README.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Database (PocketMine-MP)
|
||||
|
||||
Плагин для PMMP 5.36+ с поддержкой PostgreSQL/MySQL через PDO, пулом соединений, миграциями, ORM на атрибутах и кэшем сущностей.
|
||||
|
||||
## Требования
|
||||
- PHP 8.1+ (как у PMMP 5.x).
|
||||
- Расширения: `pdo` + `pdo_pgsql` (PostgreSQL) или `pdo_mysql` (MySQL). Проверяется при запуске.
|
||||
|
||||
## Установка
|
||||
1. Соберите плагин (zip папку `pmmp/Database` или сборка через DevTools).
|
||||
2. Поместите архив/папку в `plugins/`.
|
||||
3. Запустите сервер один раз для генерации `config.yml`.
|
||||
4. Заполните `config.yml`, перезапустите сервер.
|
||||
|
||||
## Конфигурация (`config.yml`)
|
||||
```yaml
|
||||
driver: "postgres" # postgres | mysql
|
||||
host: "127.0.0.1"
|
||||
port: 5432 # 3306 для mysql по умолчанию
|
||||
database: "server"
|
||||
username: "postgres"
|
||||
password: ""
|
||||
options:
|
||||
persistent: false # persistent соединения PDO
|
||||
timeoutSeconds: 10
|
||||
pool:
|
||||
size: 4 # размер пула PDO
|
||||
|
||||
cache:
|
||||
enabled: true
|
||||
writeBehind: true
|
||||
flushIntervalSeconds: 10
|
||||
maxDirty: 100
|
||||
ttlSeconds: 0
|
||||
maxEntries: 0
|
||||
preloadBatchSize: 20
|
||||
preloadDelayTicks: 20
|
||||
preloadAsync: true
|
||||
|
||||
migrations:
|
||||
enabled: true
|
||||
table: "db_migrations"
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
```php
|
||||
use andrewkydev\Database\DatabaseProvider;
|
||||
use andrewkydev\Database\ORM\Attributes as ORM;
|
||||
use andrewkydev\Database\ORM\Conditions;
|
||||
|
||||
$db = DatabaseProvider::get();
|
||||
|
||||
// Raw SQL
|
||||
$db->query()->execute("UPDATE players SET level = level + 1 WHERE username = ?", ["Steve"]);
|
||||
|
||||
// ORM-модель
|
||||
#[ORM\Entity(table: "players")]
|
||||
final class Player {
|
||||
#[ORM\Id(autoIncrement: true)]
|
||||
#[ORM\Column(name: "id")]
|
||||
public int $id;
|
||||
|
||||
#[ORM\Column(unique: true, length: 32)]
|
||||
#[andrewkydev\Database\ORM\Attributes\CacheKey]
|
||||
public string $username;
|
||||
|
||||
#[ORM\Column]
|
||||
public int $level;
|
||||
}
|
||||
|
||||
$orm = $db->orm();
|
||||
$orm->enableCache(Player::class, preload: true);
|
||||
|
||||
$player = $orm->findOneWhere(Player::class, "username = ?", ["Steve"]);
|
||||
$top = $orm->query(Player::class)
|
||||
->where(Conditions::gt("level", 10))
|
||||
->orderBy("level DESC")
|
||||
->limit(10)
|
||||
->list();
|
||||
```
|
||||
|
||||
## Компоненты API
|
||||
- `DatabaseApi` (через `DatabaseProvider::get()`):
|
||||
- `config()` — текущий `DatabaseConfig`.
|
||||
- `schema()` — `SchemaManager` (DDL: create/drop db/table, колонки, индексы).
|
||||
- `query()` — `QueryRunner` (сырой SQL sync/async).
|
||||
- `orm()` — `EntityManager` (ORM, кэш, QueryBuilder).
|
||||
- `migrations()` — `MigrationManager`.
|
||||
|
||||
### Сырые запросы (`QueryRunner`)
|
||||
- `execute($sql, $params=[])` — `rowCount()`.
|
||||
- `query($sql, $params=[], $mapper=null)` — массив строк или маппер.
|
||||
- `executeAsync/queryAsync($sql, $params, $onSuccess, $onError=null)` — выполняется в async-пуле PMMP, создаёт отдельный PDO.
|
||||
|
||||
### ORM и атрибуты
|
||||
- Основные атрибуты: `Entity`, `Id(autoIncrement)`, `Column(name, nullable, length, unique, type)`, `Json`, `CacheKey`, `Transient`.
|
||||
- Связи: `OneToOne`, `ManyToOne`, `OneToMany`, `ManyToMany` (`target`, `mappedBy`, `joinColumn`, `referencedColumn`, `joinTable`, `inverseJoinColumn`, `cascadePersist/remove`, `lazy`).
|
||||
- Ленивая загрузка свойств: `LazyRelationsTrait`.
|
||||
|
||||
### Операции ORM (`EntityManager`)
|
||||
- CRUD: `insert/update/delete/save` + async аналоги.
|
||||
- Поиск: `findById/findAll/findOneWhere/findWhere/count/exists/deleteWhere` (+ async).
|
||||
- Построитель: `query(Class)::QueryBuilder` (select/where/and/or/group/having/order/limit, `joinFetch` для manyToOne/oneToOne, `withRelations` для загрузки связей, async `listAsync/oneAsync/countAsync/existsAsync/deleteAsync`).
|
||||
- Загрузка связей: `loadRelation(s)` и батч `loadRelationsFor`.
|
||||
|
||||
### Условия (`Conditions`)
|
||||
`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `like`, `in`, `isNull`, `isNotNull`, `raw`. Комбинации через `Condition->and()/or()`.
|
||||
|
||||
### Кэш сущностей
|
||||
- Включается `cache.enabled`.
|
||||
- Регистрация: `$orm->enableCache(Model::class, ['id','username'], preload: true);`
|
||||
- Ключи по умолчанию: Id, поля с `CacheKey`, уникальные колонки.
|
||||
- `writeBehind=true`: `update/delete` складываются в очередь (`CacheManager`) и сбрасываются периодически (`CacheFlushTask`) или при достижении `maxDirty`.
|
||||
- `ttlSeconds` и `maxEntries` контролируют размер/старение. Прогрев — `CachePreloadTask`/`CachePreloadAsyncTask`.
|
||||
|
||||
### Миграции
|
||||
- Интерфейс `Migration { version(): int; up(SchemaManager $schema, QueryRunner $query): void; }`
|
||||
- Регистрация: `$db->migrations()->register(new class implements Migration {...});`
|
||||
- Запуск: `$db->migrations()->runPending();` (создаёт таблицу `migrations.table` при необходимости).
|
||||
- Генерация схемы по модели: `OrmSchema::fromEntity(Model::class, SqlDialect::POSTGRES|MYSQL)` → `SchemaManager::createTable(...)`.
|
||||
|
||||
### DDL (`SchemaManager`)
|
||||
- `createDatabase/dropDatabase`
|
||||
- `createTable(TableSpec)`, `dropTable`
|
||||
- `addColumn/updateColumn/dropColumn`
|
||||
- `addIndex/dropIndex` (учитывает синтаксис Postgres/MySQL).
|
||||
|
||||
## Асинхронность
|
||||
- Все async методы используют `Server::getInstance()->getAsyncPool()` и собственный PDO, не блокируя main thread.
|
||||
- Колбэки: `onSuccess($rows|$entity|$count)` и опциональный `onError($message)`.
|
||||
|
||||
## Практические советы
|
||||
- Всегда задавайте `#[Id]` и уникальные поля — это улучшает кэш и генерацию схемы.
|
||||
- Для критичных данных можно отключить `writeBehind`, чтобы обновления уходили сразу.
|
||||
- `joinFetch` используйте для `manyToOne/oneToOne`, а большие коллекции грузите через `withRelations` или отдельные запросы.
|
||||
- Postgres: автоинкремент переводится в `SERIAL/BIGSERIAL`; MySQL — `AUTO_INCREMENT`.
|
||||
|
||||
## Отладка и проблемы
|
||||
- Ошибка о расширениях — установите/включите `pdo`, `pdo_pgsql` или `pdo_mysql`.
|
||||
- `DatabaseApi is not initialized` — плагин ещё не загрузился; обращайтесь к `DatabaseProvider::get()` после `onEnable`.
|
||||
- Пустой пул — проверьте доступ к БД и креды в конфиге.
|
||||
|
||||
27
config.yml
Normal file
27
config.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
driver: "postgres"
|
||||
host: "127.0.0.1"
|
||||
port: 5432
|
||||
database: "server"
|
||||
username: "test"
|
||||
password: ""
|
||||
options:
|
||||
persistent: false
|
||||
timeoutSeconds: 10
|
||||
|
||||
pool:
|
||||
size: 4
|
||||
|
||||
cache:
|
||||
enabled: true
|
||||
writeBehind: true
|
||||
flushIntervalSeconds: 10
|
||||
maxDirty: 100
|
||||
ttlSeconds: 0
|
||||
maxEntries: 0
|
||||
preloadBatchSize: 20
|
||||
preloadDelayTicks: 20
|
||||
preloadAsync: true
|
||||
|
||||
migrations:
|
||||
enabled: false
|
||||
table: "db_migrations"
|
||||
6
plugin.yml
Normal file
6
plugin.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: Database
|
||||
main: andrewkydev\Database\Loader
|
||||
version: 0.1.0
|
||||
api: [5.36.0]
|
||||
description: "Database plugin with PDO + ORM for PMMP"
|
||||
author: andrewkydev
|
||||
31
src/andrewkydev/Database/Config/DatabaseConfig.php
Normal file
31
src/andrewkydev/Database/Config/DatabaseConfig.php
Normal 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
63
src/andrewkydev/Database/Config/DatabaseConfigLoader.php
Normal file
63
src/andrewkydev/Database/Config/DatabaseConfigLoader.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
67
src/andrewkydev/Database/Connection/ConnectionManager.php
Normal file
67
src/andrewkydev/Database/Connection/ConnectionManager.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
51
src/andrewkydev/Database/DatabaseApi.php
Normal file
51
src/andrewkydev/Database/DatabaseApi.php
Normal 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();
|
||||
}
|
||||
}
|
||||
20
src/andrewkydev/Database/DatabaseProvider.php
Normal file
20
src/andrewkydev/Database/DatabaseProvider.php
Normal 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;
|
||||
}
|
||||
}
|
||||
54
src/andrewkydev/Database/Loader.php
Normal file
54
src/andrewkydev/Database/Loader.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/andrewkydev/Database/Migration/Migration.php
Normal file
14
src/andrewkydev/Database/Migration/Migration.php
Normal 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;
|
||||
}
|
||||
64
src/andrewkydev/Database/Migration/MigrationManager.php
Normal file
64
src/andrewkydev/Database/Migration/MigrationManager.php
Normal 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;
|
||||
}
|
||||
}
|
||||
11
src/andrewkydev/Database/ORM/Attributes/CacheKey.php
Normal file
11
src/andrewkydev/Database/ORM/Attributes/CacheKey.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace andrewkydev\Database\ORM\Attributes;
|
||||
|
||||
use Attribute;
|
||||
|
||||
#[Attribute(Attribute::TARGET_PROPERTY)]
|
||||
final class CacheKey {
|
||||
}
|
||||
19
src/andrewkydev/Database/ORM/Attributes/Column.php
Normal file
19
src/andrewkydev/Database/ORM/Attributes/Column.php
Normal 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 = ''
|
||||
) {
|
||||
}
|
||||
}
|
||||
13
src/andrewkydev/Database/ORM/Attributes/Entity.php
Normal file
13
src/andrewkydev/Database/ORM/Attributes/Entity.php
Normal 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 = '') {
|
||||
}
|
||||
}
|
||||
13
src/andrewkydev/Database/ORM/Attributes/Id.php
Normal file
13
src/andrewkydev/Database/ORM/Attributes/Id.php
Normal 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) {
|
||||
}
|
||||
}
|
||||
11
src/andrewkydev/Database/ORM/Attributes/Json.php
Normal file
11
src/andrewkydev/Database/ORM/Attributes/Json.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace andrewkydev\Database\ORM\Attributes;
|
||||
|
||||
use Attribute;
|
||||
|
||||
#[Attribute(Attribute::TARGET_PROPERTY)]
|
||||
final class Json {
|
||||
}
|
||||
21
src/andrewkydev/Database/ORM/Attributes/ManyToMany.php
Normal file
21
src/andrewkydev/Database/ORM/Attributes/ManyToMany.php
Normal 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
20
src/andrewkydev/Database/ORM/Attributes/ManyToOne.php
Normal file
20
src/andrewkydev/Database/ORM/Attributes/ManyToOne.php
Normal 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
19
src/andrewkydev/Database/ORM/Attributes/OneToMany.php
Normal file
19
src/andrewkydev/Database/ORM/Attributes/OneToMany.php
Normal 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
21
src/andrewkydev/Database/ORM/Attributes/OneToOne.php
Normal file
21
src/andrewkydev/Database/ORM/Attributes/OneToOne.php
Normal 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
11
src/andrewkydev/Database/ORM/Attributes/Transient.php
Normal file
11
src/andrewkydev/Database/ORM/Attributes/Transient.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace andrewkydev\Database\ORM\Attributes;
|
||||
|
||||
use Attribute;
|
||||
|
||||
#[Attribute(Attribute::TARGET_PROPERTY)]
|
||||
final class Transient {
|
||||
}
|
||||
20
src/andrewkydev/Database/ORM/Cache/CacheFlushTask.php
Normal file
20
src/andrewkydev/Database/ORM/Cache/CacheFlushTask.php
Normal 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);
|
||||
}
|
||||
}
|
||||
156
src/andrewkydev/Database/ORM/Cache/CacheManager.php
Normal file
156
src/andrewkydev/Database/ORM/Cache/CacheManager.php
Normal 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));
|
||||
}
|
||||
}
|
||||
55
src/andrewkydev/Database/ORM/Cache/CachePreloadAsyncTask.php
Normal file
55
src/andrewkydev/Database/ORM/Cache/CachePreloadAsyncTask.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
43
src/andrewkydev/Database/ORM/Cache/CachePreloadTask.php
Normal file
43
src/andrewkydev/Database/ORM/Cache/CachePreloadTask.php
Normal 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);
|
||||
}
|
||||
}
|
||||
124
src/andrewkydev/Database/ORM/Cache/EntityCache.php
Normal file
124
src/andrewkydev/Database/ORM/Cache/EntityCache.php
Normal 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;
|
||||
}
|
||||
}
|
||||
31
src/andrewkydev/Database/ORM/Condition.php
Normal file
31
src/andrewkydev/Database/ORM/Condition.php
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
61
src/andrewkydev/Database/ORM/Conditions.php
Normal file
61
src/andrewkydev/Database/ORM/Conditions.php
Normal 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');
|
||||
}
|
||||
}
|
||||
1208
src/andrewkydev/Database/ORM/EntityManager.php
Normal file
1208
src/andrewkydev/Database/ORM/EntityManager.php
Normal file
File diff suppressed because it is too large
Load Diff
208
src/andrewkydev/Database/ORM/EntityMetadata.php
Normal file
208
src/andrewkydev/Database/ORM/EntityMetadata.php
Normal 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();
|
||||
}
|
||||
}
|
||||
22
src/andrewkydev/Database/ORM/FieldMapping.php
Normal file
22
src/andrewkydev/Database/ORM/FieldMapping.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
src/andrewkydev/Database/ORM/LazyRelationsTrait.php
Normal file
25
src/andrewkydev/Database/ORM/LazyRelationsTrait.php
Normal 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);
|
||||
}
|
||||
}
|
||||
54
src/andrewkydev/Database/ORM/OrmSchema.php
Normal file
54
src/andrewkydev/Database/ORM/OrmSchema.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
419
src/andrewkydev/Database/ORM/QueryBuilder.php
Normal file
419
src/andrewkydev/Database/ORM/QueryBuilder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
23
src/andrewkydev/Database/ORM/RelationMapping.php
Normal file
23
src/andrewkydev/Database/ORM/RelationMapping.php
Normal 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);
|
||||
}
|
||||
}
|
||||
92
src/andrewkydev/Database/ORM/RelationResolver.php
Normal file
92
src/andrewkydev/Database/ORM/RelationResolver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
11
src/andrewkydev/Database/ORM/TypeAdapter.php
Normal file
11
src/andrewkydev/Database/ORM/TypeAdapter.php
Normal 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;
|
||||
}
|
||||
25
src/andrewkydev/Database/Query/AsyncCallbackRegistry.php
Normal file
25
src/andrewkydev/Database/Query/AsyncCallbackRegistry.php
Normal 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;
|
||||
}
|
||||
}
|
||||
70
src/andrewkydev/Database/Query/QueryRunner.php
Normal file
70
src/andrewkydev/Database/Query/QueryRunner.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
89
src/andrewkydev/Database/Query/SqlTask.php
Normal file
89
src/andrewkydev/Database/Query/SqlTask.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/andrewkydev/Database/Schema/ColumnSpec.php
Normal file
17
src/andrewkydev/Database/Schema/ColumnSpec.php
Normal 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
17
src/andrewkydev/Database/Schema/IndexSpec.php
Normal file
17
src/andrewkydev/Database/Schema/IndexSpec.php
Normal 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
125
src/andrewkydev/Database/Schema/SchemaManager.php
Normal file
125
src/andrewkydev/Database/Schema/SchemaManager.php
Normal 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;
|
||||
}
|
||||
}
|
||||
10
src/andrewkydev/Database/Schema/SqlDialect.php
Normal file
10
src/andrewkydev/Database/Schema/SqlDialect.php
Normal 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';
|
||||
}
|
||||
30
src/andrewkydev/Database/Schema/TableSpec.php
Normal file
30
src/andrewkydev/Database/Schema/TableSpec.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user