commit 74964c74f3644cb7acfd167921cbbd091ab8a896 Author: Andrewkydev Date: Sat Feb 7 15:54:21 2026 +0300 first commit diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/Database.iml b/.idea/Database.iml new file mode 100644 index 0000000..77098d0 --- /dev/null +++ b/.idea/Database.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/discord.xml b/.idea/discord.xml new file mode 100644 index 0000000..104c42f --- /dev/null +++ b/.idea/discord.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..f333a0b --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml new file mode 100644 index 0000000..76dce03 --- /dev/null +++ b/.idea/php.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..b2bdec2 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8afa9ee --- /dev/null +++ b/README.md @@ -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`. +- Пустой пул — проверьте доступ к БД и креды в конфиге. + diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..27c8d6d --- /dev/null +++ b/config.yml @@ -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" diff --git a/plugin.yml b/plugin.yml new file mode 100644 index 0000000..1acc630 --- /dev/null +++ b/plugin.yml @@ -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 diff --git a/src/andrewkydev/Database/Config/DatabaseConfig.php b/src/andrewkydev/Database/Config/DatabaseConfig.php new file mode 100644 index 0000000..3b3462a --- /dev/null +++ b/src/andrewkydev/Database/Config/DatabaseConfig.php @@ -0,0 +1,31 @@ +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 + ); + } +} diff --git a/src/andrewkydev/Database/Connection/ConnectionManager.php b/src/andrewkydev/Database/Connection/ConnectionManager.php new file mode 100644 index 0000000..ab532e2 --- /dev/null +++ b/src/andrewkydev/Database/Connection/ConnectionManager.php @@ -0,0 +1,67 @@ +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 + ); + } +} diff --git a/src/andrewkydev/Database/DatabaseApi.php b/src/andrewkydev/Database/DatabaseApi.php new file mode 100644 index 0000000..9474c3e --- /dev/null +++ b/src/andrewkydev/Database/DatabaseApi.php @@ -0,0 +1,51 @@ +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(); + } +} diff --git a/src/andrewkydev/Database/DatabaseProvider.php b/src/andrewkydev/Database/DatabaseProvider.php new file mode 100644 index 0000000..27a66be --- /dev/null +++ b/src/andrewkydev/Database/DatabaseProvider.php @@ -0,0 +1,20 @@ +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'); + } + } +} diff --git a/src/andrewkydev/Database/Migration/Migration.php b/src/andrewkydev/Database/Migration/Migration.php new file mode 100644 index 0000000..d800a93 --- /dev/null +++ b/src/andrewkydev/Database/Migration/Migration.php @@ -0,0 +1,14 @@ + */ + 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; + } +} diff --git a/src/andrewkydev/Database/ORM/Attributes/CacheKey.php b/src/andrewkydev/Database/ORM/Attributes/CacheKey.php new file mode 100644 index 0000000..79b0270 --- /dev/null +++ b/src/andrewkydev/Database/ORM/Attributes/CacheKey.php @@ -0,0 +1,11 @@ +cache->flush($this->orm); + } +} diff --git a/src/andrewkydev/Database/ORM/Cache/CacheManager.php b/src/andrewkydev/Database/ORM/Cache/CacheManager.php new file mode 100644 index 0000000..05e6759 --- /dev/null +++ b/src/andrewkydev/Database/ORM/Cache/CacheManager.php @@ -0,0 +1,156 @@ + */ + private array $caches = []; + /** @var array */ + 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)); + } +} diff --git a/src/andrewkydev/Database/ORM/Cache/CachePreloadAsyncTask.php b/src/andrewkydev/Database/ORM/Cache/CachePreloadAsyncTask.php new file mode 100644 index 0000000..2cf5568 --- /dev/null +++ b/src/andrewkydev/Database/ORM/Cache/CachePreloadAsyncTask.php @@ -0,0 +1,55 @@ +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; + }); + } +} diff --git a/src/andrewkydev/Database/ORM/Cache/CachePreloadTask.php b/src/andrewkydev/Database/ORM/Cache/CachePreloadTask.php new file mode 100644 index 0000000..7ac54cd --- /dev/null +++ b/src/andrewkydev/Database/ORM/Cache/CachePreloadTask.php @@ -0,0 +1,43 @@ +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); + } +} diff --git a/src/andrewkydev/Database/ORM/Cache/EntityCache.php b/src/andrewkydev/Database/ORM/Cache/EntityCache.php new file mode 100644 index 0000000..8cb01c9 --- /dev/null +++ b/src/andrewkydev/Database/ORM/Cache/EntityCache.php @@ -0,0 +1,124 @@ +> */ + private array $byColumn = []; + /** @var array */ + 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; + } +} diff --git a/src/andrewkydev/Database/ORM/Condition.php b/src/andrewkydev/Database/ORM/Condition.php new file mode 100644 index 0000000..36db2be --- /dev/null +++ b/src/andrewkydev/Database/ORM/Condition.php @@ -0,0 +1,31 @@ + $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) + ); + } +} diff --git a/src/andrewkydev/Database/ORM/Conditions.php b/src/andrewkydev/Database/ORM/Conditions.php new file mode 100644 index 0000000..694f15b --- /dev/null +++ b/src/andrewkydev/Database/ORM/Conditions.php @@ -0,0 +1,61 @@ + $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 $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'); + } +} diff --git a/src/andrewkydev/Database/ORM/EntityManager.php b/src/andrewkydev/Database/ORM/EntityManager.php new file mode 100644 index 0000000..8e30a96 --- /dev/null +++ b/src/andrewkydev/Database/ORM/EntityManager.php @@ -0,0 +1,1208 @@ + */ + private array $metadata = []; + /** @var array */ + private array $adapters = []; + private CacheManager $cache; + private \WeakMap $loadedRelations; + + public function __construct( + private ConnectionManager $connections, + private DatabaseConfig $config + ) { + $this->cache = new CacheManager($config); + $this->loadedRelations = new \WeakMap(); + } + + public function cache(): CacheManager { + return $this->cache; + } + + /** + * @param string[] $keyColumns + */ + public function enableCache(string $className, array $keyColumns = [], bool $preload = false): void { + if (!$this->cache->enabled()) { + return; + } + $meta = $this->meta($className); + $this->cache->register($className, $meta, $keyColumns, $preload, $this); + } + + public function registerAdapter(string $className, TypeAdapter $adapter): void { + $this->adapters[$className] = $adapter; + } + + public function insert(object $entity): void { + $this->cascadePersist($entity, true); + $meta = $this->meta(get_class($entity)); + $columns = []; + $values = []; + foreach ($meta->fields as $field) { + $value = $field->property->getValue($entity); + if ($field->isId && $field->autoIncrement && !$this->hasValue($value)) { + continue; + } + $columns[] = $field->column; + $values[] = $this->toDatabaseValue($field, $value); + } + + $placeholders = implode(', ', array_fill(0, count($columns), '?')); + $sql = 'INSERT INTO ' . $meta->table . ' (' . implode(', ', $columns) . ') VALUES (' . $placeholders . ')'; + $stmt = $this->connections->getConnection()->prepare($sql); + $stmt->execute($values); + + if ($meta->idField !== null && $meta->idField->autoIncrement) { + $id = $this->connections->getConnection()->lastInsertId(); + if ($id !== false && $id !== '') { + $meta->idField->property->setValue($entity, $this->castValue($meta->idField->property, $id)); + } + } + + if ($this->cache->enabled()) { + $this->cache->put(get_class($entity), $entity); + } + + $this->cascadePersistAfter($entity); + } + + public function update(object $entity): void { + $this->cascadePersist($entity, false); + $meta = $this->meta(get_class($entity)); + if ($meta->idField === null) { + throw new \RuntimeException('Entity has no Id field'); + } + $idValue = $meta->idField->property->getValue($entity); + if (!$this->hasValue($idValue)) { + throw new \RuntimeException('Entity id is required for update'); + } + $sets = []; + $values = []; + foreach ($meta->fields as $field) { + if ($field->isId) { + continue; + } + $sets[] = $field->column . ' = ?'; + $values[] = $this->toDatabaseValue($field, $field->property->getValue($entity)); + } + $values[] = $idValue; + $sql = 'UPDATE ' . $meta->table . ' SET ' . implode(', ', $sets) . ' WHERE ' . $meta->idField->column . ' = ?'; + if ($this->cache->enabled() && $this->cache->writeBehind()) { + $this->cache->put(get_class($entity), $entity); + $this->cache->queueUpdate(get_class($entity), $entity, $this); + return; + } + + $this->executeUpdate($sql, $values); + + if ($this->cache->enabled()) { + $this->cache->put(get_class($entity), $entity); + } + + $this->cascadePersistAfter($entity); + } + + public function delete(object $entity): void { + $meta = $this->meta(get_class($entity)); + if ($meta->idField === null) { + throw new \RuntimeException('Entity has no Id field'); + } + $idValue = $meta->idField->property->getValue($entity); + if (!$this->hasValue($idValue)) { + throw new \RuntimeException('Entity id is required for delete'); + } + $sql = 'DELETE FROM ' . $meta->table . ' WHERE ' . $meta->idField->column . ' = ?'; + if ($this->cache->enabled() && $this->cache->writeBehind()) { + $this->cache->remove(get_class($entity), $entity); + $this->cache->queueDelete(get_class($entity), $entity, $this); + return; + } + + $this->executeUpdate($sql, [$idValue]); + + if ($this->cache->enabled()) { + $this->cache->remove(get_class($entity), $entity); + } + + $this->cascadeRemove($entity); + } + + public function findById(string $className, mixed $id): ?object { + $meta = $this->meta($className); + if ($meta->idField === null) { + throw new \RuntimeException('Entity has no Id field'); + } + if ($this->cache->enabled()) { + $cached = $this->cache->get($className, $meta->idField->column, $id); + if ($cached !== null) { + return $cached; + } + } + $rows = $this->queryRows( + 'SELECT * FROM ' . $meta->table . ' WHERE ' . $meta->idField->column . ' = ?', + [$id] + ); + if ($rows === []) { + return null; + } + $entity = $this->mapRow($className, $rows[0]); + if ($this->cache->enabled()) { + $this->cache->put($className, $entity); + } + return $entity; + } + + public function findAll(string $className): array { + $meta = $this->meta($className); + $rows = $this->queryRows('SELECT * FROM ' . $meta->table, []); + return $this->mapRows($className, $rows); + } + + public function findOneWhere(string $className, string $where, array $params = []): ?object { + if ($this->cache->enabled() && count($params) === 1) { + $column = $this->parseEqualityColumn($where); + if ($column !== null) { + $cached = $this->cache->get($className, $column, $params[0]); + if ($cached !== null) { + return $cached; + } + } + } + $rows = $this->findWhere($className, $where, $params, '', 1, 0); + $entity = $rows[0] ?? null; + if ($entity !== null && $this->cache->enabled()) { + $this->cache->put($className, $entity); + } + return $entity; + } + + public function findWhere( + string $className, + string $where, + array $params = [], + string $orderBy = '', + int $limit = 0, + int $offset = 0 + ): array { + $meta = $this->meta($className); + $sql = 'SELECT * FROM ' . $meta->table; + if ($where !== '') { + $sql .= ' WHERE ' . $where; + } + if ($orderBy !== '') { + $sql .= ' ORDER BY ' . $orderBy; + } + if ($limit > 0) { + $sql .= ' LIMIT ' . $limit; + } + if ($offset > 0) { + if ($limit === 0) { + $sql .= ' LIMIT 2147483647'; + } + $sql .= ' OFFSET ' . $offset; + } + $rows = $this->queryRows($sql, $params); + $entities = $this->mapRows($className, $rows); + if ($this->cache->enabled()) { + foreach ($entities as $entity) { + $this->cache->put($className, $entity); + } + } + return $entities; + } + + public function count(string $className, string $where = '', array $params = []): int { + $meta = $this->meta($className); + $sql = 'SELECT COUNT(*) AS c FROM ' . $meta->table; + if ($where !== '') { + $sql .= ' WHERE ' . $where; + } + $rows = $this->queryRows($sql, $params); + return (int) ($rows[0]['c'] ?? 0); + } + + public function exists(string $className, string $where, array $params = []): bool { + $meta = $this->meta($className); + $sql = 'SELECT 1 FROM ' . $meta->table . ' WHERE ' . $where . ' LIMIT 1'; + $rows = $this->queryRows($sql, $params); + return $rows !== []; + } + + public function deleteWhere(string $className, string $where, array $params = []): int { + if ($where === '') { + throw new \RuntimeException('deleteWhere requires a WHERE clause'); + } + $meta = $this->meta($className); + $sql = 'DELETE FROM ' . $meta->table . ' WHERE ' . $where; + return $this->executeUpdate($sql, $params); + } + + public function query(string $className): QueryBuilder { + return new QueryBuilder($this, $className); + } + + public function insertAsync(object $entity, callable $onSuccess, ?callable $onError = null): void { + $meta = $this->meta(get_class($entity)); + $columns = []; + $values = []; + foreach ($meta->fields as $field) { + $value = $field->property->getValue($entity); + if ($field->isId && $field->autoIncrement && !$this->hasValue($value)) { + continue; + } + $columns[] = $field->column; + $values[] = $this->toDatabaseValue($field, $value); + } + $placeholders = implode(', ', array_fill(0, count($columns), '?')); + $sql = 'INSERT INTO ' . $meta->table . ' (' . implode(', ', $columns) . ') VALUES (' . $placeholders . ')'; + $task = new SqlTask($this->dsn(), $this->config->username, $this->config->password, $sql, $values, false); + $task->onResult(function (int $rows) use ($entity, $onSuccess): void { + if ($this->cache->enabled()) { + $this->cache->put(get_class($entity), $entity); + } + $onSuccess($rows); + }, $onError); + Server::getInstance()->getAsyncPool()->submitTask($task); + } + + public function updateAsync(object $entity, callable $onSuccess, ?callable $onError = null): void { + if ($this->cache->enabled() && $this->cache->writeBehind()) { + $this->cache->put(get_class($entity), $entity); + $this->cache->queueUpdate(get_class($entity), $entity, $this); + $onSuccess(1); + return; + } + $meta = $this->meta(get_class($entity)); + if ($meta->idField === null) { + throw new \RuntimeException('Entity has no Id field'); + } + $idValue = $meta->idField->property->getValue($entity); + if (!$this->hasValue($idValue)) { + throw new \RuntimeException('Entity id is required for update'); + } + $sets = []; + $values = []; + foreach ($meta->fields as $field) { + if ($field->isId) { + continue; + } + $sets[] = $field->column . ' = ?'; + $values[] = $this->toDatabaseValue($field, $field->property->getValue($entity)); + } + $values[] = $idValue; + $sql = 'UPDATE ' . $meta->table . ' SET ' . implode(', ', $sets) . ' WHERE ' . $meta->idField->column . ' = ?'; + $task = new SqlTask($this->dsn(), $this->config->username, $this->config->password, $sql, $values, false); + $task->onResult(function (int $rows) use ($entity, $onSuccess): void { + if ($this->cache->enabled()) { + $this->cache->put(get_class($entity), $entity); + } + $onSuccess($rows); + }, $onError); + Server::getInstance()->getAsyncPool()->submitTask($task); + } + + public function deleteAsync(object $entity, callable $onSuccess, ?callable $onError = null): void { + if ($this->cache->enabled() && $this->cache->writeBehind()) { + $this->cache->remove(get_class($entity), $entity); + $this->cache->queueDelete(get_class($entity), $entity, $this); + $onSuccess(1); + return; + } + $meta = $this->meta(get_class($entity)); + if ($meta->idField === null) { + throw new \RuntimeException('Entity has no Id field'); + } + $idValue = $meta->idField->property->getValue($entity); + if (!$this->hasValue($idValue)) { + throw new \RuntimeException('Entity id is required for delete'); + } + $sql = 'DELETE FROM ' . $meta->table . ' WHERE ' . $meta->idField->column . ' = ?'; + $task = new SqlTask($this->dsn(), $this->config->username, $this->config->password, $sql, [$idValue], false); + $task->onResult(function (int $rows) use ($entity, $onSuccess): void { + if ($this->cache->enabled()) { + $this->cache->remove(get_class($entity), $entity); + } + $onSuccess($rows); + }, $onError); + Server::getInstance()->getAsyncPool()->submitTask($task); + } + + public function findByIdAsync(string $className, mixed $id, callable $onSuccess, ?callable $onError = null): void { + $meta = $this->meta($className); + if ($meta->idField === null) { + throw new \RuntimeException('Entity has no Id field'); + } + if ($this->cache->enabled()) { + $cached = $this->cache->get($className, $meta->idField->column, $id); + if ($cached !== null) { + $onSuccess($cached); + return; + } + } + $sql = 'SELECT * FROM ' . $meta->table . ' WHERE ' . $meta->idField->column . ' = ?'; + $task = new SqlTask($this->dsn(), $this->config->username, $this->config->password, $sql, [$id], true); + $task->onResult(function (array $rows) use ($className, $onSuccess): void { + if ($rows === []) { + $onSuccess(null); + return; + } + $entity = $this->mapRow($className, $rows[0]); + if ($this->cache->enabled()) { + $this->cache->put($className, $entity); + } + $onSuccess($entity); + }, $onError); + Server::getInstance()->getAsyncPool()->submitTask($task); + } + + public function findWhereAsync( + string $className, + string $where, + array $params, + string $orderBy, + int $limit, + int $offset, + callable $onSuccess, + ?callable $onError = null + ): void { + $meta = $this->meta($className); + $sql = 'SELECT * FROM ' . $meta->table; + if ($where !== '') { + $sql .= ' WHERE ' . $where; + } + if ($orderBy !== '') { + $sql .= ' ORDER BY ' . $orderBy; + } + if ($limit > 0) { + $sql .= ' LIMIT ' . $limit; + } + if ($offset > 0) { + if ($limit === 0) { + $sql .= ' LIMIT 2147483647'; + } + $sql .= ' OFFSET ' . $offset; + } + $task = new SqlTask($this->dsn(), $this->config->username, $this->config->password, $sql, $params, true); + $task->onResult(function (array $rows) use ($className, $onSuccess): void { + $entities = $this->mapRows($className, $rows); + if ($this->cache->enabled()) { + foreach ($entities as $entity) { + $this->cache->put($className, $entity); + } + } + $onSuccess($entities); + }, $onError); + Server::getInstance()->getAsyncPool()->submitTask($task); + } + + public function findOneWhereAsync(string $className, string $where, array $params, callable $onSuccess, ?callable $onError = null): void { + if ($this->cache->enabled() && count($params) === 1) { + $column = $this->parseEqualityColumn($where); + if ($column !== null) { + $cached = $this->cache->get($className, $column, $params[0]); + if ($cached !== null) { + $onSuccess($cached); + return; + } + } + } + $this->findWhereAsync($className, $where, $params, '', 1, 0, function (array $rows) use ($onSuccess, $className): void { + $entity = $rows[0] ?? null; + if ($entity !== null && $this->cache->enabled()) { + $this->cache->put($className, $entity); + } + $onSuccess($entity); + }, $onError); + } + + public function countAsync(string $className, string $where, array $params, callable $onSuccess, ?callable $onError = null): void { + $meta = $this->meta($className); + $sql = 'SELECT COUNT(*) AS c FROM ' . $meta->table; + if ($where !== '') { + $sql .= ' WHERE ' . $where; + } + $task = new SqlTask($this->dsn(), $this->config->username, $this->config->password, $sql, $params, true); + $task->onResult(function (array $rows) use ($onSuccess): void { + $onSuccess((int) ($rows[0]['c'] ?? 0)); + }, $onError); + Server::getInstance()->getAsyncPool()->submitTask($task); + } + + public function existsAsync(string $className, string $where, array $params, callable $onSuccess, ?callable $onError = null): void { + $meta = $this->meta($className); + $sql = 'SELECT 1 FROM ' . $meta->table . ' WHERE ' . $where . ' LIMIT 1'; + $task = new SqlTask($this->dsn(), $this->config->username, $this->config->password, $sql, $params, true); + $task->onResult(function (array $rows) use ($onSuccess): void { + $onSuccess($rows !== []); + }, $onError); + Server::getInstance()->getAsyncPool()->submitTask($task); + } + + public function deleteWhereAsync(string $className, string $where, array $params, callable $onSuccess, ?callable $onError = null): void { + if ($where === '') { + throw new \RuntimeException('deleteWhere requires a WHERE clause'); + } + $meta = $this->meta($className); + $sql = 'DELETE FROM ' . $meta->table . ' WHERE ' . $where; + $task = new SqlTask($this->dsn(), $this->config->username, $this->config->password, $sql, $params, false); + $task->onResult($onSuccess, $onError); + Server::getInstance()->getAsyncPool()->submitTask($task); + } + + public function findAllAsync(string $className, callable $onSuccess, ?callable $onError = null): void { + $meta = $this->meta($className); + $sql = 'SELECT * FROM ' . $meta->table; + $task = new SqlTask($this->dsn(), $this->config->username, $this->config->password, $sql, [], true); + $task->onResult(function (array $rows) use ($className, $onSuccess): void { + $entities = $this->mapRows($className, $rows); + if ($this->cache->enabled()) { + foreach ($entities as $entity) { + $this->cache->put($className, $entity); + } + } + $onSuccess($entities); + }, $onError); + Server::getInstance()->getAsyncPool()->submitTask($task); + } + + public function updateDirect(object $entity): void { + $this->updateDirectInternal($entity); + } + + public function deleteDirect(object $entity): void { + $this->deleteDirectInternal($entity); + } + + public function tableFor(string $className): string { + return $this->meta($className)->table; + } + + public function fetchRows(string $sql, array $params): array { + return $this->queryRows($sql, $params); + } + + public function mapRowsFor(string $className, array $rows): array { + return $this->mapRows($className, $rows); + } + + public function cachePutAll(string $className, array $entities): void { + if (!$this->cache->enabled()) { + return; + } + foreach ($entities as $entity) { + $this->cache->put($className, $entity); + } + } + + public function runSqlAsync(string $sql, array $params, bool $fetchAll, callable $onSuccess, ?callable $onError = null): void { + $task = new SqlTask($this->dsn(), $this->config->username, $this->config->password, $sql, $params, $fetchAll); + $task->onResult($onSuccess, $onError); + Server::getInstance()->getAsyncPool()->submitTask($task); + } + + public function metaFor(string $className): EntityMetadata { + return $this->meta($className); + } + + public function loadRelations(object $entity, array $fields = []): void { + $meta = $this->meta(get_class($entity)); + if ($meta->relations === []) { + return; + } + $resolver = new RelationResolver($this); + foreach ($meta->relations as $relation) { + if ($fields !== [] && !in_array($relation->property->getName(), $fields, true)) { + continue; + } + if ($this->isRelationLoaded($entity, $relation->property->getName())) { + continue; + } + $resolver->load($entity, $relation); + $this->markRelationLoaded($entity, $relation->property->getName()); + } + } + + public function loadRelation(object $entity, string $field): void { + $this->loadRelations($entity, [$field]); + } + + /** + * @param object[] $entities + * @param string[] $fields + */ + public function loadRelationsFor(array $entities, array $fields = []): void { + if ($entities === []) { + return; + } + $groups = []; + foreach ($entities as $entity) { + $groups[get_class($entity)][] = $entity; + } + foreach ($groups as $className => $items) { + $meta = $this->meta($className); + if ($meta->relations === []) { + continue; + } + foreach ($meta->relations as $relation) { + if ($fields !== [] && !in_array($relation->property->getName(), $fields, true)) { + continue; + } + $this->batchLoadRelation($items, $meta, $relation); + } + } + } + + public function valueByColumn(object $entity, string $column): mixed { + $meta = $this->meta(get_class($entity)); + foreach ($meta->fields as $field) { + if ($field->column === $column) { + return $field->property->getValue($entity); + } + } + return null; + } + + public function markRelationLoaded(object $entity, string $field): void { + $loaded = $this->loadedRelations[$entity] ?? []; + $loaded[$field] = true; + $this->loadedRelations[$entity] = $loaded; + } + + private function isRelationLoaded(object $entity, string $field): bool { + $loaded = $this->loadedRelations[$entity] ?? []; + return isset($loaded[$field]); + } + + public function save(object $entity): void { + $meta = $this->meta(get_class($entity)); + if ($meta->idField === null) { + $this->insert($entity); + return; + } + $idValue = $meta->idField->property->getValue($entity); + if ($this->hasValue($idValue)) { + $this->update($entity); + } else { + $this->insert($entity); + } + } + + private function cascadePersist(object $entity, bool $beforeInsert): void { + $meta = $this->meta(get_class($entity)); + if ($meta->relations === []) { + return; + } + foreach ($meta->relations as $relation) { + if (!$relation->cascadePersist) { + continue; + } + if (in_array($relation->type, ['manyToOne', 'oneToOne'], true) && $relation->joinColumn !== '') { + $target = $relation->property->getValue($entity); + if ($target === null) { + continue; + } + $this->save($target); + $targetId = $this->extractIdValue($target, $relation->referencedColumn); + if ($targetId !== null) { + $this->setValueByColumn($entity, $relation->joinColumn, $targetId); + } + } elseif ($relation->type === 'oneToMany' && $beforeInsert) { + // Defer to after insert/update to have parent id + continue; + } elseif ($relation->type === 'manyToMany' && $beforeInsert) { + // Defer to after insert/update to have parent id + continue; + } + } + } + + private function cascadePersistAfter(object $entity): void { + $meta = $this->meta(get_class($entity)); + if ($meta->relations === []) { + return; + } + foreach ($meta->relations as $relation) { + if (!$relation->cascadePersist) { + continue; + } + if ($relation->type === 'oneToMany') { + $items = $relation->property->getValue($entity); + if (!is_array($items)) { + continue; + } + $idValue = $this->extractIdValue($entity, 'id'); + if ($idValue === null) { + continue; + } + foreach ($items as $child) { + if (!is_object($child)) { + continue; + } + $this->setValueByColumn($child, $relation->mappedBy, $idValue); + $this->save($child); + } + } elseif ($relation->type === 'manyToMany') { + $items = $relation->property->getValue($entity); + if (!is_array($items)) { + continue; + } + $ownerId = $this->extractIdValue($entity, 'id'); + if ($ownerId === null) { + continue; + } + $this->syncJoinTable($relation, $ownerId, $items); + } + } + } + + private function cascadeRemove(object $entity): void { + $meta = $this->meta(get_class($entity)); + if ($meta->relations === []) { + return; + } + foreach ($meta->relations as $relation) { + if (!$relation->cascadeRemove) { + continue; + } + if (in_array($relation->type, ['manyToOne', 'oneToOne'], true)) { + $target = $relation->property->getValue($entity); + if (is_object($target)) { + $this->delete($target); + } + } elseif ($relation->type === 'oneToMany') { + $idValue = $this->extractIdValue($entity, 'id'); + if ($idValue === null) { + continue; + } + $children = $this->findWhere($relation->target, $relation->mappedBy . ' = ?', [$idValue]); + foreach ($children as $child) { + $this->delete($child); + } + } elseif ($relation->type === 'manyToMany') { + $ownerId = $this->extractIdValue($entity, 'id'); + if ($ownerId === null) { + continue; + } + $this->executeUpdate( + 'DELETE FROM ' . $relation->joinTable . ' WHERE ' . $relation->joinColumn . ' = ?', + [$ownerId] + ); + $items = $relation->property->getValue($entity); + if (is_array($items)) { + foreach ($items as $item) { + if (is_object($item)) { + $this->delete($item); + } + } + } + } + } + } + + private function extractIdValue(object $entity, string $column): mixed { + $meta = $this->meta(get_class($entity)); + foreach ($meta->fields as $field) { + if ($field->column === $column || ($field->isId && $column === 'id')) { + $value = $field->property->getValue($entity); + return $this->hasValue($value) ? $value : null; + } + } + return null; + } + + private function setValueByColumn(object $entity, string $column, mixed $value): void { + $meta = $this->meta(get_class($entity)); + foreach ($meta->fields as $field) { + if ($field->column === $column) { + $field->property->setValue($entity, $this->castValue($field->property, $value)); + return; + } + } + } + + private function syncJoinTable(RelationMapping $relation, mixed $ownerId, array $items): void { + $this->executeUpdate( + 'DELETE FROM ' . $relation->joinTable . ' WHERE ' . $relation->joinColumn . ' = ?', + [$ownerId] + ); + foreach ($items as $item) { + if (!is_object($item)) { + continue; + } + $this->save($item); + $targetId = $this->extractIdValue($item, $relation->inverseJoinColumn); + if ($targetId === null) { + continue; + } + $this->executeUpdate( + 'INSERT INTO ' . $relation->joinTable . ' (' . $relation->joinColumn . ', ' . $relation->inverseJoinColumn . ') VALUES (?, ?)', + [$ownerId, $targetId] + ); + } + } + + private function meta(string $className): EntityMetadata { + if (!isset($this->metadata[$className])) { + $this->metadata[$className] = EntityMetadata::fromClass($className); + if ($this->cache->enabled()) { + $this->cache->register($className, $this->metadata[$className], [], false, $this); + } + } + return $this->metadata[$className]; + } + + private function queryRows(string $sql, array $params): array { + $stmt = $this->connections->getConnection()->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(); + } + + private function mapRows(string $className, array $rows): array { + $out = []; + foreach ($rows as $row) { + $out[] = $this->mapRow($className, $row); + } + return $out; + } + + private function mapRow(string $className, array $row): object { + $meta = $this->meta($className); + $entity = new $className(); + foreach ($meta->fields as $field) { + if (!array_key_exists($field->column, $row)) { + continue; + } + $value = $row[$field->column]; + $value = $this->fromDatabaseValue($field, $value); + $field->property->setValue($entity, $this->castValue($field->property, $value)); + } + return $entity; + } + + public function mapRowWithPrefix(string $className, array $row, string $prefix): ?object { + $meta = $this->meta($className); + $entity = new $className(); + $hasData = false; + foreach ($meta->fields as $field) { + $key = $prefix . $field->column; + if (!array_key_exists($key, $row)) { + continue; + } + $value = $row[$key]; + if ($value !== null) { + $hasData = true; + } + $value = $this->fromDatabaseValue($field, $value); + $field->property->setValue($entity, $this->castValue($field->property, $value)); + } + return $hasData ? $entity : null; + } + + private function parseEqualityColumn(string $where): ?string { + $matches = []; + if (preg_match('/^\s*([a-zA-Z0-9_\.]+)\s*=\s*\?\s*$/', $where, $matches)) { + $column = $matches[1]; + if (str_contains($column, '.')) { + $parts = explode('.', $column); + return $parts[count($parts) - 1]; + } + return $column; + } + return null; + } + + private function toDatabaseValue(FieldMapping $field, mixed $value): mixed { + if ($value === null) { + return null; + } + if ($field->json) { + return json_encode($value, JSON_UNESCAPED_UNICODE); + } + if ($this->isBooleanField($field)) { + $normalized = $this->normalizeBooleanValue($value, $field->nullable); + if ($normalized === null) { + return null; + } + if ($this->config->driver === 'postgres') { + return $normalized ? 1 : 0; + } + return $normalized; + } + $type = $field->property->getType(); + if ($type instanceof \ReflectionNamedType) { + $typeName = $type->getName(); + if (isset($this->adapters[$typeName])) { + return $this->adapters[$typeName]->toDatabase($value); + } + } + return $value; + } + + private function fromDatabaseValue(FieldMapping $field, mixed $value): mixed { + if ($value === null) { + return null; + } + if ($field->json) { + return json_decode((string) $value, true); + } + $type = $field->property->getType(); + if ($type instanceof \ReflectionNamedType) { + $typeName = $type->getName(); + if (isset($this->adapters[$typeName])) { + return $this->adapters[$typeName]->fromDatabase($value); + } + } + return $value; + } + + private function castValue(\ReflectionProperty $property, mixed $value): mixed { + $type = $property->getType(); + if (!$type instanceof \ReflectionNamedType) { + return $value; + } + $name = $type->getName(); + if ($value === null && $type->allowsNull()) { + return null; + } + return match ($name) { + 'int' => (int) $value, + 'float' => (float) $value, + 'string' => (string) $value, + 'bool' => $this->normalizeBooleanValue($value, $type->allowsNull()), + default => $value, + }; + } + + private function isBooleanField(FieldMapping $field): bool { + if ($field->typeOverride !== '') { + $type = strtolower($field->typeOverride); + if (str_contains($type, 'bool') || str_contains($type, 'tinyint(1)')) { + return true; + } + } + $type = $field->property->getType(); + return $type instanceof \ReflectionNamedType && $type->getName() === 'bool'; + } + + private function normalizeBooleanValue(mixed $value, bool $nullable): ?bool { + if ($value === null) { + return $nullable ? null : false; + } + if (is_bool($value)) { + return $value; + } + if (is_int($value) || is_float($value)) { + return $value != 0; + } + if (is_string($value)) { + $trimmed = strtolower(trim($value)); + if ($trimmed === '') { + return $nullable ? null : false; + } + if (in_array($trimmed, ['1', 'true', 't', 'yes', 'y', 'on'], true)) { + return true; + } + if (in_array($trimmed, ['0', 'false', 'f', 'no', 'n', 'off'], true)) { + return false; + } + } + return (bool) $value; + } + + private function hasValue(mixed $value): bool { + if ($value === null) { + return false; + } + if (is_int($value) || is_float($value)) { + return $value != 0; + } + return true; + } + + 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 + ); + } + + private function executeUpdate(string $sql, array $params): int { + $stmt = $this->connections->getConnection()->prepare($sql); + $stmt->execute($params); + return $stmt->rowCount(); + } + + /** + * @param object[] $entities + */ + private function batchLoadRelation(array $entities, EntityMetadata $sourceMeta, RelationMapping $relation): void { + if ($relation->type === 'manyToOne' || ($relation->type === 'oneToOne' && $relation->mappedBy === '')) { + $this->batchLoadToOne($entities, $sourceMeta, $relation); + return; + } + if ($relation->type === 'oneToOne' && $relation->mappedBy !== '') { + $this->batchLoadOneToOneMapped($entities, $sourceMeta, $relation); + return; + } + if ($relation->type === 'oneToMany') { + $this->batchLoadOneToMany($entities, $sourceMeta, $relation); + return; + } + if ($relation->type === 'manyToMany') { + $this->batchLoadManyToMany($entities, $sourceMeta, $relation); + } + } + + /** + * @param object[] $entities + */ + private function batchLoadToOne(array $entities, EntityMetadata $sourceMeta, RelationMapping $relation): void { + if ($relation->joinColumn === '') { + return; + } + $keys = []; + foreach ($entities as $entity) { + $value = $this->valueByColumn($entity, $relation->joinColumn); + if ($value !== null) { + $keys[] = $value; + } + } + $keys = array_values(array_unique($keys)); + if ($keys === []) { + return; + } + $targetMeta = $this->meta($relation->target); + $placeholder = implode(', ', array_fill(0, count($keys), '?')); + $rows = $this->fetchRows( + 'SELECT * FROM ' . $targetMeta->table . ' WHERE ' . $relation->referencedColumn . ' IN (' . $placeholder . ')', + $keys + ); + $targets = $this->mapRowsFor($relation->target, $rows); + $this->cachePutAll($relation->target, $targets); + $byKey = []; + foreach ($targets as $target) { + $key = $this->valueByColumn($target, $relation->referencedColumn); + if ($key !== null) { + $byKey[(string) $key] = $target; + } + } + foreach ($entities as $entity) { + $key = $this->valueByColumn($entity, $relation->joinColumn); + $relation->property->setValue($entity, $key !== null ? ($byKey[(string) $key] ?? null) : null); + $this->markRelationLoaded($entity, $relation->property->getName()); + } + } + + /** + * @param object[] $entities + */ + private function batchLoadOneToOneMapped(array $entities, EntityMetadata $sourceMeta, RelationMapping $relation): void { + if ($sourceMeta->idField === null) { + return; + } + $ids = []; + foreach ($entities as $entity) { + $id = $sourceMeta->idField->property->getValue($entity); + if ($this->hasValue($id)) { + $ids[] = $id; + } + } + $ids = array_values(array_unique($ids)); + if ($ids === []) { + return; + } + $targetMeta = $this->meta($relation->target); + $placeholder = implode(', ', array_fill(0, count($ids), '?')); + $rows = $this->fetchRows( + 'SELECT * FROM ' . $targetMeta->table . ' WHERE ' . $relation->mappedBy . ' IN (' . $placeholder . ')', + $ids + ); + $targets = $this->mapRowsFor($relation->target, $rows); + $this->cachePutAll($relation->target, $targets); + $byOwner = []; + foreach ($targets as $target) { + $ownerId = $this->valueByColumn($target, $relation->mappedBy); + if ($ownerId !== null) { + $byOwner[(string) $ownerId] = $target; + } + } + foreach ($entities as $entity) { + $id = $sourceMeta->idField->property->getValue($entity); + $relation->property->setValue($entity, $id !== null ? ($byOwner[(string) $id] ?? null) : null); + $this->markRelationLoaded($entity, $relation->property->getName()); + } + } + + /** + * @param object[] $entities + */ + private function batchLoadOneToMany(array $entities, EntityMetadata $sourceMeta, RelationMapping $relation): void { + if ($sourceMeta->idField === null) { + return; + } + $ids = []; + foreach ($entities as $entity) { + $id = $sourceMeta->idField->property->getValue($entity); + if ($this->hasValue($id)) { + $ids[] = $id; + } + } + $ids = array_values(array_unique($ids)); + if ($ids === []) { + return; + } + $targetMeta = $this->meta($relation->target); + $placeholder = implode(', ', array_fill(0, count($ids), '?')); + $rows = $this->fetchRows( + 'SELECT * FROM ' . $targetMeta->table . ' WHERE ' . $relation->mappedBy . ' IN (' . $placeholder . ')', + $ids + ); + $targets = $this->mapRowsFor($relation->target, $rows); + $this->cachePutAll($relation->target, $targets); + $grouped = []; + foreach ($targets as $target) { + $ownerId = $this->valueByColumn($target, $relation->mappedBy); + if ($ownerId !== null) { + $grouped[(string) $ownerId][] = $target; + } + } + foreach ($entities as $entity) { + $id = $sourceMeta->idField->property->getValue($entity); + $relation->property->setValue($entity, $id !== null ? ($grouped[(string) $id] ?? []) : []); + $this->markRelationLoaded($entity, $relation->property->getName()); + } + } + + /** + * @param object[] $entities + */ + private function batchLoadManyToMany(array $entities, EntityMetadata $sourceMeta, RelationMapping $relation): void { + if ($sourceMeta->idField === null) { + return; + } + $ids = []; + foreach ($entities as $entity) { + $id = $sourceMeta->idField->property->getValue($entity); + if ($this->hasValue($id)) { + $ids[] = $id; + } + } + $ids = array_values(array_unique($ids)); + if ($ids === []) { + return; + } + $placeholder = implode(', ', array_fill(0, count($ids), '?')); + $joinRows = $this->fetchRows( + 'SELECT ' . $relation->joinColumn . ', ' . $relation->inverseJoinColumn + . ' FROM ' . $relation->joinTable + . ' WHERE ' . $relation->joinColumn . ' IN (' . $placeholder . ')', + $ids + ); + if ($joinRows === []) { + foreach ($entities as $entity) { + $relation->property->setValue($entity, []); + } + return; + } + $ownerToTargets = []; + $targetIds = []; + foreach ($joinRows as $row) { + $ownerId = (string) $row[$relation->joinColumn]; + $targetId = $row[$relation->inverseJoinColumn]; + $ownerToTargets[$ownerId][] = $targetId; + $targetIds[] = $targetId; + } + $targetIds = array_values(array_unique($targetIds)); + $targetMeta = $this->meta($relation->target); + $targetIdColumn = $targetMeta->idField?->column ?? 'id'; + $targetPlaceholder = implode(', ', array_fill(0, count($targetIds), '?')); + $targetRows = $this->fetchRows( + 'SELECT * FROM ' . $targetMeta->table . ' WHERE ' . $targetIdColumn . ' IN (' . $targetPlaceholder . ')', + $targetIds + ); + $targets = $this->mapRowsFor($relation->target, $targetRows); + $this->cachePutAll($relation->target, $targets); + $targetMap = []; + foreach ($targets as $target) { + $key = $this->valueByColumn($target, $targetIdColumn); + if ($key !== null) { + $targetMap[(string) $key] = $target; + } + } + foreach ($entities as $entity) { + $id = $sourceMeta->idField->property->getValue($entity); + if ($id === null) { + $relation->property->setValue($entity, []); + $this->markRelationLoaded($entity, $relation->property->getName()); + continue; + } + $list = []; + foreach ($ownerToTargets[(string) $id] ?? [] as $targetId) { + $target = $targetMap[(string) $targetId] ?? null; + if ($target !== null) { + $list[] = $target; + } + } + $relation->property->setValue($entity, $list); + $this->markRelationLoaded($entity, $relation->property->getName()); + } + } + + private function updateDirectInternal(object $entity): void { + $meta = $this->meta(get_class($entity)); + if ($meta->idField === null) { + throw new \RuntimeException('Entity has no Id field'); + } + $idValue = $meta->idField->property->getValue($entity); + if (!$this->hasValue($idValue)) { + throw new \RuntimeException('Entity id is required for update'); + } + $sets = []; + $values = []; + foreach ($meta->fields as $field) { + if ($field->isId) { + continue; + } + $sets[] = $field->column . ' = ?'; + $values[] = $this->toDatabaseValue($field, $field->property->getValue($entity)); + } + $values[] = $idValue; + $sql = 'UPDATE ' . $meta->table . ' SET ' . implode(', ', $sets) . ' WHERE ' . $meta->idField->column . ' = ?'; + $this->executeUpdate($sql, $values); + } + + private function deleteDirectInternal(object $entity): void { + $meta = $this->meta(get_class($entity)); + if ($meta->idField === null) { + throw new \RuntimeException('Entity has no Id field'); + } + $idValue = $meta->idField->property->getValue($entity); + if (!$this->hasValue($idValue)) { + throw new \RuntimeException('Entity id is required for delete'); + } + $sql = 'DELETE FROM ' . $meta->table . ' WHERE ' . $meta->idField->column . ' = ?'; + $this->executeUpdate($sql, [$idValue]); + } +} diff --git a/src/andrewkydev/Database/ORM/EntityMetadata.php b/src/andrewkydev/Database/ORM/EntityMetadata.php new file mode 100644 index 0000000..28393ad --- /dev/null +++ b/src/andrewkydev/Database/ORM/EntityMetadata.php @@ -0,0 +1,208 @@ +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(); + } +} diff --git a/src/andrewkydev/Database/ORM/FieldMapping.php b/src/andrewkydev/Database/ORM/FieldMapping.php new file mode 100644 index 0000000..e52f7cc --- /dev/null +++ b/src/andrewkydev/Database/ORM/FieldMapping.php @@ -0,0 +1,22 @@ +property->setAccessible(true); + } +} diff --git a/src/andrewkydev/Database/ORM/LazyRelationsTrait.php b/src/andrewkydev/Database/ORM/LazyRelationsTrait.php new file mode 100644 index 0000000..bcff9c3 --- /dev/null +++ b/src/andrewkydev/Database/ORM/LazyRelationsTrait.php @@ -0,0 +1,25 @@ +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); + } +} diff --git a/src/andrewkydev/Database/ORM/OrmSchema.php b/src/andrewkydev/Database/ORM/OrmSchema.php new file mode 100644 index 0000000..c941226 --- /dev/null +++ b/src/andrewkydev/Database/ORM/OrmSchema.php @@ -0,0 +1,54 @@ +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', + }; + } +} diff --git a/src/andrewkydev/Database/ORM/QueryBuilder.php b/src/andrewkydev/Database/ORM/QueryBuilder.php new file mode 100644 index 0000000..64b0197 --- /dev/null +++ b/src/andrewkydev/Database/ORM/QueryBuilder.php @@ -0,0 +1,419 @@ + */ + 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} + */ + 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> $rows + * @param array $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); + } +} diff --git a/src/andrewkydev/Database/ORM/RelationMapping.php b/src/andrewkydev/Database/ORM/RelationMapping.php new file mode 100644 index 0000000..75d723a --- /dev/null +++ b/src/andrewkydev/Database/ORM/RelationMapping.php @@ -0,0 +1,23 @@ +property->setAccessible(true); + } +} diff --git a/src/andrewkydev/Database/ORM/RelationResolver.php b/src/andrewkydev/Database/ORM/RelationResolver.php new file mode 100644 index 0000000..ed34907 --- /dev/null +++ b/src/andrewkydev/Database/ORM/RelationResolver.php @@ -0,0 +1,92 @@ +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); + } +} diff --git a/src/andrewkydev/Database/ORM/TypeAdapter.php b/src/andrewkydev/Database/ORM/TypeAdapter.php new file mode 100644 index 0000000..7b75373 --- /dev/null +++ b/src/andrewkydev/Database/ORM/TypeAdapter.php @@ -0,0 +1,11 @@ + */ + 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; + } +} diff --git a/src/andrewkydev/Database/Query/QueryRunner.php b/src/andrewkydev/Database/Query/QueryRunner.php new file mode 100644 index 0000000..ac5e45c --- /dev/null +++ b/src/andrewkydev/Database/Query/QueryRunner.php @@ -0,0 +1,70 @@ +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 + ); + } +} diff --git a/src/andrewkydev/Database/Query/SqlTask.php b/src/andrewkydev/Database/Query/SqlTask.php new file mode 100644 index 0000000..abb9c31 --- /dev/null +++ b/src/andrewkydev/Database/Query/SqlTask.php @@ -0,0 +1,89 @@ +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'); + } + } +} diff --git a/src/andrewkydev/Database/Schema/ColumnSpec.php b/src/andrewkydev/Database/Schema/ColumnSpec.php new file mode 100644 index 0000000..7ce4718 --- /dev/null +++ b/src/andrewkydev/Database/Schema/ColumnSpec.php @@ -0,0 +1,17 @@ +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; + } +} diff --git a/src/andrewkydev/Database/Schema/SqlDialect.php b/src/andrewkydev/Database/Schema/SqlDialect.php new file mode 100644 index 0000000..0dfb5d7 --- /dev/null +++ b/src/andrewkydev/Database/Schema/SqlDialect.php @@ -0,0 +1,10 @@ +columns = $columns; + $this->primaryKey = $primaryKey; + $this->indexes = $indexes; + } +}