PERSISTENCE FEATURE
Schema Sync, Not Migration Churn
You do not hand-write busywork migrations all day. The ORM derives the plan, blocks dangerous drops by default, and records the exact SQL it ran.
Feature Guide
A quick orientation block that answers the essential questions: what this feature does, how it works, why it matters, and the key concepts behind it.
What this does
Semitexa minimizes migration churn by computing schema changes directly from code and the current database instead of requiring constant hand-written migrations.
How it works
orm:sync collects the code schema, compares it with the live database, builds an execution plan, and separates safe changes from destructive ones. Drops use a two-phase flow: first mark deprecated, later drop only with explicit approval.
Why it matters
This reduces busywork, makes destructive intent visible, and still gives teams exact SQL artifacts when they need them for review or deployment.
Key concepts
- orm:sync
- Command that computes and optionally executes the schema synchronization plan.
- Two-phase drop
- Column or table removal is delayed: first deprecate, then drop on a later explicit destructive pass.
- AuditLogger
- Writes executed sync operations as both JSON and SQL files under var/migrations/history.
Minimal Migration Surface
SQL appears only when the schema actually changed
Semitexa does not force teams into constant hand-written migration churn. The ORM computes a sync plan from code vs database, separates safe and destructive operations, and only executes what is explicitly allowed.
What this kills
- Teams waste time writing empty or obvious migrations just to mirror what the code already says.
- Column drops are dangerous when one careless migration can erase data immediately.
- Ops often needs the actual SQL plan, not a hand-wavy promise that the framework will figure it out.
Phase 1
Mark deprecated, do not drop yet
If a column disappears from code, the ORM first marks it deprecated instead of deleting it immediately.
Phase 2
Drop only when explicitly allowed
A later sync can perform the actual DROP, and only when destructive operations are explicitly allowed.
Dry-run sync plan
bin/semitexa orm:sync --dry-run
Safe operations: 3
Destructive operations: 1 (require --allow-destructive)
Export SQL plan
bin/semitexa orm:sync --dry-run --output var/migrations/history/plan.sql
Audit files written after real sync
var/migrations/history/2026-03-27_12-14-03.184_sync.json
var/migrations/history/2026-03-27_12-14-03.184_sync.sql
Verified against Semitexa Ultimate 2026.09.19.1020
Schema Sync, Not Migration Churn
Semitexa derives the schema change plan by comparing resource attribute definitions against the live database — no hand-written migration files required.
How it works
Running bin/semitexa orm:sync computes the diff between code and database. Safe operations execute immediately. Destructive operations such as DROP COLUMN are separated in the plan and require --allow-destructive to execute. A missing column triggers a two-phase flow: the first sync marks it deprecated, and a subsequent sync with the explicit flag performs the drop. Every executed sync writes an audit file as both .json and .sql to var/migrations/history/.
Why this matters
Teams waste time writing empty or obvious migrations that mirror what the code already says. Blocking destructive drops by default prevents accidental data loss, and the structured audit output gives ops a reviewable record of exactly what SQL ran and when.
© Brian Kernighan: "Controlling complexity is the essence of computer programming."
1<?php23declare(strict_types=1);45namespace Semitexa\Orm\Application\Console\Command;67use Semitexa\Core\Attribute\AsCommand;8use Semitexa\Core\Console\BaseCommand;9use Semitexa\Orm\Application\Service\Connection\ConnectionRegistry;10use Symfony\Component\Console\Command\Command;11use Symfony\Component\Console\Input\InputInterface;12use Symfony\Component\Console\Input\InputOption;13use Symfony\Component\Console\Output\OutputInterface;14use Symfony\Component\Console\Style\SymfonyStyle;1516#[AsCommand(name: 'orm:sync', description: 'Synchronize ORM schema with the database')]17class OrmSyncCommand extends BaseCommand18{19 public function __construct(20 private readonly ConnectionRegistry $connections,21 ) {22 parent::__construct();23 }2425 protected function configure(): void26 {27 $this->setName('orm:sync')28 ->setDescription('Synchronize ORM schema with the database')29 ->addOption('connection', 'c', InputOption::VALUE_REQUIRED, 'Connection name to sync', 'default')30 ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show SQL plan without executing')31 ->addOption('allow-destructive', null, InputOption::VALUE_NONE, 'Allow destructive operations (DROP, type narrowing)')32 ->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Save SQL plan to file');33 }3435 protected function execute(InputInterface $input, OutputInterface $output): int36 {37 $io = new SymfonyStyle($input, $output);38 $connectionOption = $input->getOption('connection');39 if (!is_string($connectionOption)) {40 $io->error('Invalid --connection option value.');4142 return Command::FAILURE;43 }4445 $connection = trim($connectionOption);46 if ($connection === '') {47 $io->error('The --connection option must not be empty.');4849 return Command::FAILURE;50 }5152 $dryRun = (bool) $input->getOption('dry-run');53 $allowDestructive = (bool) $input->getOption('allow-destructive');54 $outputOption = $input->getOption('output');55 $outputFile = is_string($outputOption) && $outputOption !== '' ? $outputOption : null;5657 try {58 $orm = $this->connections->manager($connection);5960 // 1. Collect schema from code61 $io->section("Collecting schema from code (connection: {$connection})...");62 $collector = $orm->getSchemaCollector();63 $codeSchema = $collector->collect();6465 $errors = $collector->getErrors();66 if ($errors !== []) {67 $io->error('Schema validation errors:');68 $io->listing($errors);69 return Command::FAILURE;70 }7172 $warnings = $collector->getWarnings();73 if ($warnings !== []) {74 $io->warning('Schema warnings:');75 $io->listing($warnings);76 }7778 $io->text(sprintf('Found %d table(s) in code.', count($codeSchema)));7980 // 2. Compare with DB81 $io->section('Comparing with database...');82 $comparator = $orm->getSchemaComparator();83 $diff = $comparator->compare($codeSchema);8485 if ($diff->isEmpty()) {86 $io->success('Database is up to date. No changes needed.');8788 return Command::SUCCESS;89 }9091 // 3. Build execution plan92 $syncEngine = $orm->getSyncEngine();93 $plan = $syncEngine->buildPlan($diff);9495 $io->text($plan->getSummary());96 $io->newLine();9798 // Show plan details99 $safeOps = $plan->getSafeOperations();100 $destructiveOps = $plan->getDestructiveOperations();101102 if ($safeOps !== []) {103 $io->section('Safe operations:');104 foreach ($safeOps as $op) {105 $io->text(" <info>[{$op->type->value}]</info> {$op->description}");106 if ($output->isVerbose()) {107 $io->text(" SQL: {$op->sql}");108 }109 }110 }111112 if ($destructiveOps !== []) {113 $io->section('Destructive operations:');114 foreach ($destructiveOps as $op) {115 $io->text(" <fg=red>[{$op->type->value}]</> {$op->description}");116 if ($output->isVerbose()) {117 $io->text(" SQL: {$op->sql}");118 }119 }120121 if (!$allowDestructive) {122 $io->warning('Destructive operations will be skipped. Use --allow-destructive to include them.');123 }124 }125126 // Save to file if requested127 if ($outputFile !== null) {128 $statements = $plan->toSqlStatements($allowDestructive);129 $content = implode(";\n", $statements) . ";\n";130 file_put_contents($outputFile, $content);131 $io->text("SQL plan saved to: {$outputFile}");132 }133134 // Execute if not dry-run135 if ($dryRun) {136 $io->note('Dry run mode — no changes applied.');137138 return Command::SUCCESS;139 }140141 $executed = $syncEngine->execute($plan, $allowDestructive);142 $io->success(sprintf('Executed %d operation(s).', count($executed)));143144 $orm->shutdown();145 return Command::SUCCESS;146 } catch (\Throwable $e) {147 $io->error('Sync failed: ' . $e->getMessage());148 if ($output->isVerbose()) {149 $io->text($e->getTraceAsString());150 }151 return Command::FAILURE;152 }153 }154}155
1<?php23declare(strict_types=1);45namespace Semitexa\Orm\Application\Service\Sync;67use Semitexa\Orm\Domain\Enum\DdlOperationType;8use Semitexa\Orm\Domain\Model\DdlOperation;9use Semitexa\Orm\Domain\Model\ExecutionPlan;1011use Semitexa\Orm\Adapter\ConnectionPoolInterface;12use Semitexa\Orm\Adapter\DatabaseAdapterInterface;13use Semitexa\Orm\Application\Service\Transaction\SingleConnectionAdapter;14use Semitexa\Orm\Adapter\MySqlType;15use Semitexa\Orm\Adapter\SqlIdentifier;16use Semitexa\Orm\Adapter\SqliteType;17use Semitexa\Orm\Domain\Model\ColumnDefinition;18use Semitexa\Orm\Domain\Model\DbColumnState;19use Semitexa\Orm\Domain\Model\ForeignKeyDefinition;20use Semitexa\Orm\Domain\Model\SchemaDiff;21use Semitexa\Orm\Domain\Model\TableDefinition;2223class SyncEngine24{25 private const DEPRECATED_COMMENT = 'SEMITEXA_DEPRECATED';2627 public function __construct(28 private readonly DatabaseAdapterInterface $adapter,29 private readonly ?AuditLogger $auditLogger = null,30 private readonly ?ConnectionPoolInterface $pool = null,31 ) {}3233 public function buildPlan(SchemaDiff $diff): ExecutionPlan34 {35 $plan = new ExecutionPlan();3637 // 1. CREATE TABLEs first (sorted by dependencies)38 $sortedTables = $this->topologicalSort($diff->getCreateTables());39 foreach ($sortedTables as $table) {40 $plan->addOperation(new DdlOperation(41 sql: $this->generateCreateTable($table),42 type: DdlOperationType::CreateTable,43 tableName: $table->name,44 isDestructive: false,45 description: "Create table '{$table->name}'",46 ));4748 if ($this->isSqlite()) {49 foreach ($table->getIndexes() as $index) {50 $name = $index->name ?? $this->generateIndexName($table->name, $index->columns, $index->unique);51 $plan->addOperation(new DdlOperation(52 sql: $this->generateAddIndex($table->name, $index, $name),53 type: DdlOperationType::AddIndex,54 tableName: $table->name,55 isDestructive: false,56 description: "Add index '{$name}' on '{$table->name}'",57 ));58 }59 }60 }6162 // 2. ADD COLUMNs (safe)63 foreach ($diff->getAddColumns() as $tableName => $columns) {64 foreach ($columns as $column) {65 $plan->addOperation(new DdlOperation(66 sql: $this->generateAddColumn($tableName, $column),67 type: DdlOperationType::AddColumn,68 tableName: $tableName,69 isDestructive: false,70 description: "Add column '{$column->name}' to '{$tableName}'",71 ));72 }73 }7475 // 3. ALTER COLUMNs (may be safe or destructive)76 foreach ($diff->getAlterColumns() as $tableName => $alterations) {77 foreach ($alterations as $alteration) {78 $column = $alteration['column'];79 $changes = $alteration['changes'];80 $isDestructive = $this->isAlterDestructive($changes);8182 $plan->addOperation(new DdlOperation(83 sql: $this->generateAlterColumn($tableName, $column),84 type: DdlOperationType::AlterColumn,85 tableName: $tableName,86 isDestructive: $isDestructive,87 description: "Alter column '{$column->name}' in '{$tableName}': " . implode(', ', $changes),88 ));89 }90 }9192 // 4. ADD FOREIGN KEYs (safe — all tables already exist at this point)93 foreach ($diff->getAddForeignKeys() as $fk) {94 $plan->addOperation(new DdlOperation(95 sql: $this->generateAddForeignKey($fk),96 type: DdlOperationType::AddForeignKey,97 tableName: $fk->table,98 isDestructive: false,99 description: "Add FK constraint '{$fk->constraintName()}' on '{$fk->table}'.{$fk->column} → '{$fk->referencedTable}'.{$fk->referencedColumn}",100 ));101 }102103 // 5–6. INDEX changes: DROP + ADD104 //105 // When an index is being recreated (same name appears in both drop and add106 // lists for the same table), MySQL may refuse to drop it if a FK constraint107 // depends on it (error 1553). Combining DROP INDEX + ADD INDEX into a single108 // ALTER TABLE statement lets MySQL atomically swap the index definition109 // without ever leaving the FK unsupported.110 $dropIndexes = $diff->getDropIndexes();111 $addIndexes = $diff->getAddIndexes();112113 // Build a lookup of add-indexes keyed by table.name for pairing114 $addByTableAndName = [];115 foreach ($addIndexes as $tableName => $indexes) {116 foreach ($indexes as $entry) {117 $addByTableAndName[$tableName . '.' . $entry['name']] = $entry;118 }119 }120121 // Track which add-indexes have been paired with a drop (emitted as combined DDL)122 $pairedAdds = [];123124 // 5. DROP INDEXes (destructive — must run before ADD to avoid duplicate key names)125 foreach ($dropIndexes as $tableName => $indexNames) {126 foreach ($indexNames as $indexName) {127 $key = $tableName . '.' . $indexName;128 $q = $this->quoteChar();129130 if (isset($addByTableAndName[$key])) {131 // Same index name is being dropped and re-added → combine into one statement132 $entry = $addByTableAndName[$key];133 $index = $entry['index'];134135 if ($this->isSqlite()) {136 // SQLite: separate DROP and CREATE137 $plan->addOperation(new DdlOperation(138 sql: 'DROP INDEX ' . SqlIdentifier::quote($indexName, $q),139 type: DdlOperationType::DropIndex,140 tableName: $tableName,141 isDestructive: true,142 description: "Drop index '{$indexName}' from '{$tableName}'",143 ));144 $plan->addOperation(new DdlOperation(145 sql: $this->generateAddIndex($tableName, $index, $indexName),146 type: DdlOperationType::AddIndex,147 tableName: $tableName,148 isDestructive: false,149 description: "Recreate index '{$indexName}' on '{$tableName}'",150 ));151 } else {152 $cols = implode(', ', SqlIdentifier::quoteAll($index->columns));153 $type = $index->unique ? 'UNIQUE INDEX' : 'INDEX';154 $quotedTable = SqlIdentifier::quote($tableName);155 $quotedIndex = SqlIdentifier::quote($indexName);156 $sql = "ALTER TABLE {$quotedTable} DROP INDEX {$quotedIndex}, ADD {$type} {$quotedIndex} ({$cols})";157158 $plan->addOperation(new DdlOperation(159 sql: $sql,160 type: DdlOperationType::DropIndex,161 tableName: $tableName,162 isDestructive: true,163 description: "Recreate index '{$indexName}' on '{$tableName}'",164 ));165 }166 $pairedAdds[$key] = true;167 } else {168 if ($this->isSqlite()) {169 $plan->addOperation(new DdlOperation(170 sql: 'DROP INDEX ' . SqlIdentifier::quote($indexName, $q),171 type: DdlOperationType::DropIndex,172 tableName: $tableName,173 isDestructive: true,174 description: "Drop index '{$indexName}' from '{$tableName}'",175 ));176 } else {177 $plan->addOperation(new DdlOperation(178 sql: 'ALTER TABLE ' . SqlIdentifier::quote($tableName)179 . ' DROP INDEX ' . SqlIdentifier::quote($indexName),180 type: DdlOperationType::DropIndex,181 tableName: $tableName,182 isDestructive: true,183 description: "Drop index '{$indexName}' from '{$tableName}'",184 ));185 }186 }187 }188 }189190 // 6. ADD INDEXes (safe) — skip any that were already emitted as part of a combined statement191 foreach ($addIndexes as $tableName => $indexes) {192 foreach ($indexes as $entry) {193 $key = $tableName . '.' . $entry['name'];194 if (isset($pairedAdds[$key])) {195 continue;196 }197198 $index = $entry['index'];199 $name = $entry['name'];200 $plan->addOperation(new DdlOperation(201 sql: $this->generateAddIndex($tableName, $index, $name),202 type: DdlOperationType::AddIndex,203 tableName: $tableName,204 isDestructive: false,205 description: "Add index '{$name}' on '{$tableName}'",206 ));207 }208 }209210 // 7. DROP COLUMNs — two-phase logic (destructive)211 foreach ($diff->getDropColumns() as $tableName => $columns) {212 foreach ($columns as $colInfo) {213 $columnName = $colInfo['name'];214 $comment = $colInfo['comment'];215 $dbState = $colInfo['dbState'];216217 if ($comment !== self::DEPRECATED_COMMENT) {218 // Column was not previously marked as deprecated → block drop, add deprecation comment instead.219 // MODIFY COLUMN requires the full column definition — reconstruct it from DbColumnState.220 $plan->addOperation(new DdlOperation(221 sql: $this->generateDeprecationDdl($tableName, $dbState),222 type: DdlOperationType::AlterColumn,223 tableName: $tableName,224 isDestructive: false,225 description: "Mark column '{$columnName}' in '{$tableName}' as deprecated (two-phase drop, phase 1)",226 ));227 } else {228 // Column was already deprecated → safe to drop229 $q = $this->quoteChar();230 $plan->addOperation(new DdlOperation(231 sql: 'ALTER TABLE ' . SqlIdentifier::quote($tableName, $q)232 . ' DROP COLUMN ' . SqlIdentifier::quote($columnName, $q),233 type: DdlOperationType::DropColumn,234 tableName: $tableName,235 isDestructive: true,236 description: "Drop deprecated column '{$columnName}' from '{$tableName}' (two-phase drop, phase 2)",237 ));238 }239 }240 }241242 // 8. DROP FOREIGN KEYs (destructive — must happen before DROP TABLE/COLUMN)243 foreach ($diff->getDropForeignKeys() as $entry) {244 if ($this->isSqlite()) {245 // SQLite: FK constraints cannot be dropped separately.246 // Table recreation would be needed — skip for now.247 continue;248 }249 $plan->addOperation(new DdlOperation(250 sql: 'ALTER TABLE ' . SqlIdentifier::quote($entry['table'])251 . ' DROP FOREIGN KEY ' . SqlIdentifier::quote($entry['constraintName']),252 type: DdlOperationType::DropForeignKey,253 tableName: $entry['table'],254 isDestructive: true,255 description: "Drop FK constraint '{$entry['constraintName']}' from '{$entry['table']}'",256 ));257 }258259 // 9. DROP TABLEs — two-phase logic (destructive)260 foreach ($diff->getDropTables() as $dbTable) {261 $tableName = $dbTable->name;262 $q = $this->quoteChar();263 if ($dbTable->tableComment !== self::DEPRECATED_COMMENT) {264 if ($this->isSqlite()) {265 // SQLite lacks table comments, so do not drop the table implicitly.266 // Force an explicit/manual follow-up instead of risking silent data loss.267 $plan->addOperation(new DdlOperation(268 sql: "-- SQLITE_DROP_TABLE_REQUIRES_MANUAL_REVIEW:{$tableName}",269 type: DdlOperationType::AlterColumn,270 tableName: $tableName,271 isDestructive: false,272 description: "Manual review required before dropping SQLite table '{$tableName}'",273 ));274 } else {275 // Table was not previously marked as deprecated → block drop, add deprecation comment instead.276 $plan->addOperation(new DdlOperation(277 sql: 'ALTER TABLE ' . SqlIdentifier::quote($tableName)278 . " COMMENT '" . self::DEPRECATED_COMMENT . "'",279 type: DdlOperationType::AlterColumn,280 tableName: $tableName,281 isDestructive: false,282 description: "Mark table '{$tableName}' as deprecated (two-phase drop, phase 1)",283 ));284 }285 } else {286 // Table was already deprecated → safe to drop287 $plan->addOperation(new DdlOperation(288 sql: 'DROP TABLE ' . SqlIdentifier::quote($tableName),289 type: DdlOperationType::DropTable,290 tableName: $tableName,291 isDestructive: true,292 description: "Drop deprecated table '{$tableName}' (two-phase drop, phase 2)",293 ));294 }295 }296297 return $plan;298 }299300 /**301 * Execute a plan against the database.302 *303 * On SQLite the whole plan runs inside one transaction, so a mid-plan304 * failure rolls back cleanly. On MySQL/MariaDB it does NOT: every DDL305 * statement carries an implicit commit, so operations are applied one by306 * one and a mid-plan failure leaves the earlier operations in place. Plans307 * are ordered (tables, columns, foreign keys, indexes) so that a partial308 * application is re-runnable — the next sync diffs from the real schema309 * and continues where this one stopped.310 *311 * With a pool present the plan still pins ONE connection for its whole312 * lifetime: statements spread across pooled connections would race with313 * concurrent request traffic and (before this) hand back a connection314 * carrying an untracked transaction.315 *316 * @return DdlOperation[] Executed operations317 */318 public function execute(ExecutionPlan $plan, bool $allowDestructive = false): array319 {320 $operations = array_filter(321 $plan->getOperations(),322 fn(DdlOperation $op) => $allowDestructive || !$op->isDestructive,323 );324325 if ($operations === []) {326 return [];327 }328329 $isSqlite = $this->isSqlite();330331 // Only SQLite gets a transaction around the plan, and the reason is a332 // server fact rather than a preference: MySQL performs an IMPLICIT333 // COMMIT before and after every DDL statement, so a plan wrapped in334 // START TRANSACTION/COMMIT was never atomic there — the first CREATE335 // TABLE already committed everything, and the closing COMMIT ran with336 // no transaction left to commit. (ServerCapability::AtomicDdl is about337 // MySQL 8.0 making a SINGLE DDL statement crash-safe; it does not make338 // DDL transactional.) Keeping the fiction also meant a pooled339 // connection could carry an untracked open transaction back into the340 // pool between the BEGIN and the first DDL. SQLite genuinely does run341 // DDL inside transactions, so it keeps the wrapper and its rollback.342 $useTransaction = $isSqlite && $this->adapter->supports(\Semitexa\Orm\Adapter\ServerCapability::AtomicDdl);343344 // The whole plan must run on ONE connection. Through a pooled adapter,345 // every query()/execute() pops a DIFFERENT connection: BEGIN, each DDL,346 // and COMMIT would land on unrelated connections — the "transaction" is347 // a fiction, and worse, the connection that received BEGIN goes back to348 // the pool with an OPEN transaction, so an unrelated coroutine inherits349 // it and its writes get committed/rolled back by whoever holds that350 // connection next. With a pool present, pin a single dedicated351 // connection for the plan's whole lifetime.352 if ($this->pool !== null && !$isSqlite) {353 // Resolve the version BEFORE holding a connection (a cold adapter354 // runs a detection query that borrows its own connection — holding355 // one while waiting for a second is the pool-deadlock shape).356 $serverVersion = $this->adapter->getServerVersion();357 $pdo = $this->pool->pop();358 try {359 return $this->executeOperationsOn(360 new SingleConnectionAdapter($pdo, $serverVersion),361 $operations,362 $useTransaction,363 $isSqlite,364 // Drive the transaction through PDO, not a raw365 // `START TRANSACTION` query: PDO::inTransaction() only366 // tracks beginTransaction(), so a raw statement would make367 // BOTH the finally guard below and ConnectionPool::push()'s368 // hygiene blind — and this connection goes back to the pool.369 $pdo,370 );371 } finally {372 // Never return a connection to the pool mid-transaction: this373 // is the last line of defense if the body threw between BEGIN374 // and its own ROLLBACK (e.g. the ROLLBACK itself failed on a375 // dead connection).376 //377 // inTransaction() is inside the try as well — on a severed378 // connection the status check itself throws, and an unguarded379 // one here would both mask the original DDL error and skip the380 // push below, leaking the connection. The push therefore lives381 // in its own finally so it runs no matter what cleanup does.382 try {383 if ($pdo->inTransaction()) {384 $pdo->rollBack();385 }386 } catch (\Throwable) {387 // A dead connection cannot be cleaned — the pool's push()388 // hygiene discards it and frees its slot.389 } finally {390 $this->pool->push($pdo);391 }392 }393 }394395 return $this->executeOperationsOn($this->adapter, $operations, $useTransaction, $isSqlite);396 }397398 /**399 * @param DdlOperation[] $operations400 * @return DdlOperation[] Executed operations401 */402 private function executeOperationsOn(403 DatabaseAdapterInterface $adapter,404 array $operations,405 bool $useTransaction,406 bool $isSqlite,407 ?\PDO $txConnection = null,408 ): array {409 $executed = [];410 try {411 if ($useTransaction) {412 if ($txConnection !== null) {413 $txConnection->beginTransaction();414 } else {415 $adapter->query($isSqlite ? 'BEGIN' : 'START TRANSACTION');416 }417 }418419 foreach ($operations as $operation) {420 if ($isSqlite && $this->isSqlitePlaceholder($operation->sql)) {421 throw new \RuntimeException(422 "SQLite sync requires table recreation for unsupported operation: {$operation->description}",423 );424 }425426 $adapter->execute($operation->sql);427 $executed[] = $operation;428 }429430 if ($useTransaction) {431 if ($txConnection !== null) {432 $txConnection->commit();433 } else {434 $adapter->query('COMMIT');435 }436 }437 } catch (\Throwable $e) {438 if ($useTransaction) {439 try {440 if ($txConnection !== null) {441 if ($txConnection->inTransaction()) {442 $txConnection->rollBack();443 }444 } else {445 $adapter->query('ROLLBACK');446 }447 } catch (\Throwable) {448 // A failed ROLLBACK (dead connection) must not mask the449 // original failure — that is the exception worth seeing.450 }451 }452 throw $e;453 }454455 $this->auditLogger?->log($executed);456457 return $executed;458 }459460 /**461 * Check if SQL is a SQLite placeholder for unsupported operations.462 */463 private function isSqlitePlaceholder(string $sql): bool464 {465 return str_starts_with($sql, '-- SQLITE_');466 }467468 private function generateCreateTable(TableDefinition $table): string469 {470 $isSqlite = $this->isSqlite();471 $lines = [];472 $pk = null;473 $inlineFks = [];474475 foreach ($table->getColumns() as $col) {476 $line = ' ' . $this->generateColumnDdl($col);477 if ($col->isPrimaryKey) {478 $pk = $col;479 }480 $lines[] = $line;481 }482483 if ($pk !== null) {484 if ($isSqlite && $pk->pkStrategy === 'auto' && $this->isSqliteAutoIncrementPrimaryKey($pk)) {485 // SQLite: INTEGER PRIMARY KEY implies AUTOINCREMENT behavior486 // Already handled in generateColumnDdl487 } else {488 $q = $isSqlite ? SqlIdentifier::DOUBLE_QUOTE : SqlIdentifier::BACKTICK;489 $lines[] = ' PRIMARY KEY (' . SqlIdentifier::quote($pk->name, $q) . ')';490 }491 }492493 foreach ($table->getIndexes() as $index) {494 $name = $index->name ?? $this->generateIndexName($table->name, $index->columns, $index->unique);495 $q = $isSqlite ? SqlIdentifier::DOUBLE_QUOTE : SqlIdentifier::BACKTICK;496 $cols = implode(', ', SqlIdentifier::quoteAll($index->columns, $q));497 if ($isSqlite) {498 // SQLite: indexes are created separately, not inline in CREATE TABLE499 // We'll handle them after table creation500 } else {501 $prefix = $index->unique ? 'UNIQUE KEY' : 'KEY';502 $lines[] = " {$prefix} " . SqlIdentifier::quote($name) . " ({$cols})";503 }504 }505506 // Add inline FK constraints for SQLite (must be in CREATE TABLE)507 if ($isSqlite) {508 foreach ($table->getForeignKeys() as $fk) {509 $d = SqlIdentifier::DOUBLE_QUOTE;510 $lines[] = ' FOREIGN KEY (' . SqlIdentifier::quote($fk->column, $d) . ') REFERENCES '511 . SqlIdentifier::quote($fk->referencedTable, $d)512 . '(' . SqlIdentifier::quote($fk->referencedColumn, $d) . ')'513 . " ON DELETE {$fk->onDelete->value} ON UPDATE {$fk->onUpdate->value}";514 }515 }516517 $body = implode(",\n", $lines);518519 if ($isSqlite) {520 $quoted = SqlIdentifier::quote($table->name, SqlIdentifier::DOUBLE_QUOTE);521522 return "CREATE TABLE {$quoted} (\n{$body}\n)";523 }524525 $quoted = SqlIdentifier::quote($table->name);526527 return "CREATE TABLE {$quoted} (\n{$body}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";528 }529530 private function generateColumnDdl(ColumnDefinition $col): string531 {532 $isSqlite = $this->isSqlite();533 $q = $this->quoteChar();534 $type = $this->sqlType($col);535 $null = $col->nullable ? 'NULL' : 'NOT NULL';536537 // Auto-increment handling538 $auto = '';539 if ($col->isPrimaryKey && $col->pkStrategy === 'auto') {540 if ($isSqlite && $this->isSqliteAutoIncrementPrimaryKey($col)) {541 // SQLite: INTEGER PRIMARY KEY auto-increments implicitly542 // Type is already INTEGER from sqlType()543 $auto = ' PRIMARY KEY AUTOINCREMENT';544 // In SQLite, we skip the separate PRIMARY KEY clause545 // and the NULL clause for autoincrement PKs546 $deprecated = $col->isDeprecated ? " -- " . self::DEPRECATED_COMMENT : '';547 return SqlIdentifier::quote($col->name, $q) . " {$type}{$auto}{$deprecated}";548 } elseif ($col->type instanceof MySqlType && in_array($col->type, [MySqlType::Int, MySqlType::Bigint], true)) {549 $auto = ' AUTO_INCREMENT';550 }551 }552553 $default = $this->defaultClause($col);554 $deprecated = !$isSqlite && $col->isDeprecated ? " COMMENT '" . self::DEPRECATED_COMMENT . "'" : '';555556 return SqlIdentifier::quote($col->name, $q) . " {$type} {$null}{$auto}{$default}{$deprecated}";557 }558559 private function sqlType(ColumnDefinition $col): string560 {561 // Delegate to the type's own toSql() method562 return $col->type->toSql($col->length, $col->precision, $col->scale);563 }564565 private function isSqliteAutoIncrementPrimaryKey(ColumnDefinition $col): bool566 {567 if ($col->type instanceof SqliteType) {568 return in_array($col->type, [SqliteType::Int, SqliteType::Bigint], true);569 }570571 if ($col->type instanceof MySqlType) {572 return in_array($col->type, [MySqlType::Int, MySqlType::Bigint], true);573 }574575 return false;576 }577578 private function defaultClause(ColumnDefinition $col): string579 {580 if ($col->default === null && !$col->nullable) {581 return '';582 }583584 if ($col->default === null) {585 return ' DEFAULT NULL';586 }587588 if (is_bool($col->default)) {589 return ' DEFAULT ' . ($col->default ? '1' : '0');590 }591592 if (is_int($col->default) || is_float($col->default)) {593 return ' DEFAULT ' . $col->default;594 }595596 return " DEFAULT '" . str_replace("'", "''", (string) $col->default) . "'";597 }598599 private function generateAddColumn(string $tableName, ColumnDefinition $col): string600 {601 $q = $this->quoteChar();602 $ddl = $this->generateColumnDdl($col);603604 return 'ALTER TABLE ' . SqlIdentifier::quote($tableName, $q) . " ADD COLUMN {$ddl}";605 }606607 private function generateAlterColumn(string $tableName, ColumnDefinition $col): string608 {609 if ($this->isSqlite()) {610 // SQLite has very limited ALTER TABLE support.611 // For column alterations, we need to recreate the table.612 // This is handled specially in the execution phase.613 return "-- SQLITE_ALTER_COLUMN:{$tableName}:{$col->name}";614 }615616 $ddl = $this->generateColumnDdl($col);617618 return 'ALTER TABLE ' . SqlIdentifier::quote($tableName) . " MODIFY COLUMN {$ddl}";619 }620621 /**622 * Generate MODIFY COLUMN DDL that marks a live DB column as deprecated.623 *624 * MySQL MODIFY COLUMN requires the complete column definition — omitting625 * the type causes MySQL to silently reset it to a default. We reconstruct626 * the full definition from DbColumnState (the live DB snapshot read via627 * INFORMATION_SCHEMA) and append the deprecation comment.628 */629 private function generateDeprecationDdl(string $tableName, DbColumnState $col): string630 {631 if ($this->isSqlite()) {632 // SQLite doesn't support column comments.633 // We skip deprecation marking for SQLite.634 return "-- SQLITE_DEPRECATION_NOT_SUPPORTED";635 }636637 // columnType from INFORMATION_SCHEMA is the authoritative full type string638 // (e.g. "varchar(255)", "decimal(10,2)", "tinyint(1)") — use it verbatim.639 $null = $col->nullable ? 'NULL' : 'NOT NULL';640 $auto = $col->isAutoIncrement ? ' AUTO_INCREMENT' : '';641 $default = '';642643 if ($col->defaultValue !== null) {644 $default = " DEFAULT '" . str_replace("'", "''", $col->defaultValue) . "'";645 } elseif ($col->nullable) {646 $default = ' DEFAULT NULL';647 }648649 $comment = " COMMENT '" . self::DEPRECATED_COMMENT . "'";650651 $ddl = SqlIdentifier::quote($col->name) . " {$col->columnType} {$null}{$auto}{$default}{$comment}";652653 return 'ALTER TABLE ' . SqlIdentifier::quote($tableName) . " MODIFY COLUMN {$ddl}";654 }655656 /**657 * @param \Semitexa\Orm\Domain\Model\IndexDefinition $index658 */659 private function generateAddIndex(string $tableName, $index, string $name): string660 {661 $q = $this->quoteChar();662 $cols = implode(', ', SqlIdentifier::quoteAll($index->columns, $q));663 $type = $index->unique ? 'UNIQUE INDEX' : 'INDEX';664665 if ($this->isSqlite()) {666 return "CREATE {$type} " . SqlIdentifier::quote($name, $q)667 . ' ON ' . SqlIdentifier::quote($tableName, $q) . " ({$cols})";668 }669670 return 'ALTER TABLE ' . SqlIdentifier::quote($tableName)671 . " ADD {$type} " . SqlIdentifier::quote($name) . " ({$cols})";672 }673674 private function generateAddForeignKey(ForeignKeyDefinition $fk): string675 {676 $q = $this->quoteChar();677678 if ($this->isSqlite()) {679 // SQLite: FK constraints must be added during table creation.680 // For existing tables, we need to recreate the table.681 return "-- SQLITE_ADD_FK:{$fk->table}:{$fk->column}:{$fk->referencedTable}:{$fk->referencedColumn}";682 }683684 $name = $fk->constraintName();685 return sprintf(686 'ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s(%s) ON DELETE %s ON UPDATE %s',687 SqlIdentifier::quote($fk->table),688 SqlIdentifier::quote($name),689 SqlIdentifier::quote($fk->column),690 SqlIdentifier::quote($fk->referencedTable),691 SqlIdentifier::quote($fk->referencedColumn),692 $fk->onDelete->value,693 $fk->onUpdate->value,694 );695 }696697 /**698 * Topological sort of tables by FK dependencies (BelongsTo relations).699 * Tables without dependencies come first.700 *701 * @param TableDefinition[] $tables702 * @return TableDefinition[]703 */704 private function topologicalSort(array $tables): array705 {706 $tableMap = [];707 foreach ($tables as $table) {708 $tableMap[$table->name] = $table;709 }710711 // Build a reverse map: FQCN resource class → table name.712 // Relations store target as a FQCN (e.g. App\Resource\UserResource), but713 // $tableMap is keyed by table name (e.g. 'users'). Without this mapping714 // the dependency lookup always misses, leaving tables in arbitrary order.715 $classToTable = [];716 foreach ($tables as $table) {717 foreach ($table->getRelations() as $relation) {718 $targetClass = $relation['target'];719 if (isset($classToTable[$targetClass])) {720 continue;721 }722 try {723 $meta = \Semitexa\Orm\Domain\Model\ResourceMetadata::for($targetClass);724 $classToTable[$targetClass] = $meta->getTableName();725 } catch (\Throwable) {726 // Target class not available in this context — skip gracefully727 }728 }729 }730731 // Build dependency graph using table names throughout732 $deps = [];733 foreach ($tables as $table) {734 $deps[$table->name] = [];735 foreach ($table->getRelations() as $relation) {736 if ($relation['type'] === 'belongs_to') {737 $targetTable = $classToTable[$relation['target']] ?? null;738 if ($targetTable !== null && isset($tableMap[$targetTable])) {739 $deps[$table->name][] = $targetTable;740 }741 }742 }743 }744745 $sorted = [];746 $visited = [];747 $visiting = [];748749 $visit = function (string $name) use (&$visit, &$sorted, &$visited, &$visiting, $tableMap, $deps): void {750 if (isset($visited[$name])) {751 return;752 }753 if (isset($visiting[$name])) {754 // Circular dependency — just add it; CREATE TABLE handles FK separately755 return;756 }757758 $visiting[$name] = true;759760 foreach ($deps[$name] ?? [] as $dep) {761 if (isset($tableMap[$dep])) {762 $visit($dep);763 }764 }765766 unset($visiting[$name]);767 $visited[$name] = true;768769 if (isset($tableMap[$name])) {770 $sorted[] = $tableMap[$name];771 }772 };773774 foreach ($tableMap as $name => $table) {775 $visit($name);776 }777778 return $sorted;779 }780781 /**782 * Determine if column alteration is destructive.783 *784 * @param string[] $changes785 */786 private function isAlterDestructive(array $changes): bool787 {788 foreach ($changes as $change) {789 if (str_starts_with($change, 'type:')) {790 // Type change is potentially destructive — check if it's widening791 if ($this->isTypeWidening($change)) {792 continue;793 }794 return true;795 }796 }797798 return false;799 }800801 private function isTypeWidening(string $change): bool802 {803 // Extract old → new from "type: old → new"804 if (!preg_match('/type:\s*(.+?)\s*→\s*(.+)/', $change, $matches)) {805 return false;806 }807808 $old = strtolower(trim($matches[1]));809 $new = strtolower(trim($matches[2]));810811 // VARCHAR(N) → VARCHAR(M) where M >= N812 if (preg_match('/^varchar\((\d+)\)$/', $old, $oldM) && preg_match('/^varchar\((\d+)\)$/', $new, $newM)) {813 return (int) $newM[1] >= (int) $oldM[1];814 }815816 // VARCHAR(any) → TEXT/MEDIUMTEXT/LONGTEXT — always wider817 if (str_starts_with($old, 'varchar(') && in_array($new, ['text', 'mediumtext', 'longtext'], true)) {818 return true;819 }820821 // TEXT → MEDIUMTEXT → LONGTEXT822 $textOrder = ['text' => 0, 'mediumtext' => 1, 'longtext' => 2];823 if (isset($textOrder[$old], $textOrder[$new])) {824 return $textOrder[$new] >= $textOrder[$old];825 }826827 // Integer widening order: TINYINT → SMALLINT → INT → BIGINT828 $intOrder = ['tinyint' => 0, 'smallint' => 1, 'int' => 2, 'bigint' => 3];829 $oldBase = preg_replace('/\(\d+\)/', '', $old); // strip (1) from tinyint(1)830 if (isset($intOrder[$oldBase], $intOrder[$new])) {831 return $intOrder[$new] >= $intOrder[$oldBase];832 }833834 // Float widening: FLOAT → DOUBLE835 if ($old === 'float' && $new === 'double') {836 return true;837 }838839 // CHAR(N) → CHAR(M) where M >= N840 if (preg_match('/^char\((\d+)\)$/', $old, $oldM) && preg_match('/^char\((\d+)\)$/', $new, $newM)) {841 return (int) $newM[1] >= (int) $oldM[1];842 }843844 // CHAR(any) → VARCHAR(any) — always wider (fixed → variable)845 if (str_starts_with($old, 'char(') && str_starts_with($new, 'varchar(')) {846 return true;847 }848849 return false;850 }851852 /**853 * @param string[] $columns854 */855 private function generateIndexName(string $tableName, array $columns, bool $unique): string856 {857 $prefix = $unique ? 'uniq' : 'idx';858 return $prefix . '_' . $tableName . '_' . implode('_', $columns);859 }860861 /**862 * Check if the current adapter is SQLite.863 */864 private function isSqlite(): bool865 {866 return $this->adapter instanceof \Semitexa\Orm\Adapter\SqliteAdapter;867 }868869 /**870 * Get the quote character for the current database.871 */872 private function quoteChar(): string873 {874 return $this->isSqlite() ? '"' : '`';875 }876}877
1<?php23declare(strict_types=1);45namespace Semitexa\Orm\Application\Service\Sync;67class AuditLogger8{9 public function __construct(10 private readonly string $historyDir,11 ) {}1213 /**14 * @param DdlOperation[] $operations15 */16 public function log(array $operations): void17 {18 if ($operations === []) {19 return;20 }2122 if (!is_dir($this->historyDir)) {23 mkdir($this->historyDir, 0755, true);24 }2526 $now = \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true)));27 $timestamp = $now !== false ? $now->format('Y-m-d_H-i-s.v') : date('Y-m-d_H-i-s') . '.' . substr((string) microtime(), 2, 3);28 $filename = $this->historyDir . '/' . $timestamp . '_sync.json';2930 $entries = [];31 foreach ($operations as $op) {32 $entries[] = [33 'type' => $op->type->value,34 'table' => $op->tableName,35 'destructive' => $op->isDestructive,36 'description' => $op->description,37 'sql' => $op->sql,38 ];39 }4041 $data = [42 'timestamp' => date('c'),43 'operations_count' => count($operations),44 'operations' => $entries,45 ];4647 file_put_contents($filename, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));4849 // Also write a .sql file for DevOps convenience50 $sqlFilename = $this->historyDir . '/' . $timestamp . '_sync.sql';51 $sqlLines = [52 '-- Semitexa ORM Sync — ' . date('c'),53 '-- Operations: ' . count($operations),54 '',55 ];5657 foreach ($operations as $op) {58 $sqlLines[] = '-- ' . $op->description;59 $sqlLines[] = $op->sql . ';';60 $sqlLines[] = '';61 }6263 file_put_contents($sqlFilename, implode("\n", $sqlLines));64 }65}66
1<?php23declare(strict_types=1);45namespace Semitexa\Orm;67use Semitexa\Core\Discovery\ClassDiscovery;8use Semitexa\Orm\Attribute\SelfManagedTable;9use Semitexa\Core\Environment;10use Semitexa\Core\Event\EventDispatcherInterface;11use Semitexa\Core\Log\StaticLoggerBridge;12use Semitexa\Core\Support\ProjectRoot;13use Semitexa\Orm\Adapter\ConnectionPool;14use Semitexa\Orm\Adapter\ConnectionPoolInterface;15use Semitexa\Orm\Domain\Model\ConnectionConfig;16use Semitexa\Orm\Adapter\DatabaseAdapterInterface;17use Semitexa\Orm\Adapter\MysqlAdapter;18use Semitexa\Orm\Adapter\QueryRecorder;19use Semitexa\Orm\Adapter\NullConnectionPool;20use Semitexa\Orm\Adapter\SingleConnectionPool;21use Semitexa\Orm\Adapter\SqliteAdapter;22use Semitexa\Orm\Application\Service\Schema\SqliteSchemaComparator;23use Semitexa\Orm\Domain\Model\OrmBootstrapReport;24use Semitexa\Orm\Application\Service\OrmBootstrapValidator;25use Semitexa\Orm\Application\Service\Hydration\ResourceModelHydrator;26use Semitexa\Orm\Application\Service\Hydration\ResourceModelRelationLoader;27use Semitexa\Orm\Application\Service\Mapping\MapperRegistry;28use Semitexa\Orm\Metadata\ResourceModelMetadataRegistry;29use Semitexa\Orm\Application\Service\Persistence\AggregateWriteEngine;30use Semitexa\Orm\Repository\DomainRepository;31use Semitexa\Orm\Application\Service\Schema\SchemaCollector;32use Semitexa\Orm\Application\Service\Schema\SchemaComparator;33use Semitexa\Orm\Domain\Contract\SchemaComparatorInterface;34use Semitexa\Orm\Application\Service\Sync\AuditLogger;35use Semitexa\Orm\Application\Service\Sync\SeedRunner;36use Semitexa\Orm\Application\Service\Sync\SyncEngine;37use Semitexa\Orm\Application\Service\Transaction\TransactionAwareAdapter;38use Semitexa\Orm\Application\Service\Transaction\TransactionManager;3940class OrmManager41{42 private ClassDiscovery $classDiscovery;43 private ?ConnectionPoolInterface $pool = null;44 private ?DatabaseAdapterInterface $adapter = null;45 private ?SchemaCollector $schemaCollector = null;46 private ?SchemaComparatorInterface $schemaComparator = null;47 private ?SyncEngine $syncEngine = null;48 private ?TransactionManager $transactionManager = null;49 private ?TransactionAwareAdapter $transactionAwareAdapter = null;50 private ?SeedRunner $seedRunner = null;51 private ?MapperRegistry $mapperRegistry = null;52 private ?ResourceModelMetadataRegistry $resourceModelMetadataRegistry = null;53 private ?ResourceModelHydrator $resourceModelHydrator = null;54 private ?ResourceModelRelationLoader $resourceModelRelationLoader = null;55 private ?AggregateWriteEngine $aggregateWriteEngine = null;56 private ?OrmBootstrapValidator $bootstrapValidator = null;5758 /**59 * Lazy resolver for the default EventDispatcher, set once per worker at bootstrap60 * by the framework container (see ContainerFactory). It is invoked lazily — at61 * first write-engine construction — because the dispatcher is a discovered service62 * that only exists after the container is built, whereas the default OrmManager is63 * constructed during bootstrap (before build). This makes EVERY default OrmManager64 * carry the dispatcher: the explicit ConnectionRegistry::manager() instance AND any65 * bare `new OrmManager()` repository fallback — without a compile-time coupling from66 * orm to the core container.67 *68 * @var (\Closure(): ?EventDispatcherInterface)|null69 */70 private static ?\Closure $defaultEventDispatcherResolver = null;7172 public function __construct(73 ?ClassDiscovery $classDiscovery = null,74 private readonly ?ConnectionConfig $config = null,75 private readonly string $connectionName = 'default',76 private readonly ?EventDispatcherInterface $events = null,77 ) {78 $this->classDiscovery = $classDiscovery ?? new ClassDiscovery();79 }8081 public function getClassDiscovery(): ClassDiscovery82 {83 return $this->classDiscovery;84 }8586 public function getAdapter(): DatabaseAdapterInterface87 {88 // A request-time getAdapter() may hold an adapter built at bootstrap over a89 // stale SingleConnectionPool. Re-check before handing it out: the upgrade90 // nulls $this->adapter so it rebuilds below over the coroutine-safe pool.91 if ($this->adapter !== null && $this->pool !== null) {92 $this->ensureCoroutineSafePool();93 }9495 if ($this->adapter === null) {96 $driver = $this->resolveDriver();9798 if ($driver === 'sqlite') {99 $this->adapter = $this->createSqliteAdapter();100 } else {101 $this->adapter = new MysqlAdapter($this->getPool());102 }103 }104105 return $this->adapter;106 }107108 /**109 * Start recording executed queries, for development tooling.110 *111 * Off by default and never enabled by the framework itself. The recorded log112 * is an unbounded in-memory array, so this is only safe for the span of one113 * request a developer is deliberately inspecting — {@see drainQueryLog()}114 * both reads and clears it.115 *116 * IMPORTANT: an OrmManager lives for the whole worker, and so does this flag.117 * Under concurrent load a trace would therefore also capture queries issued by118 * other coroutines on the same worker. That is acceptable for the one-developer119 * one-request case this exists for, and wrong for anything else — which is why120 * nothing in the framework turns it on.121 */122 public function enableQueryLog(): void123 {124 QueryRecorder::start();125 }126127 /**128 * Read and clear the recorded queries. Returns an empty list when recording129 * was never started.130 *131 * @return list<array{sql: string, params: array<mixed>, timeMs: float}>132 */133 public function drainQueryLog(): array134 {135 return QueryRecorder::drain();136 }137138 /**139 * Stop recording and drop the wrapper, so the unbounded log cannot keep140 * growing once the request that asked for it has finished.141 */142 public function disableQueryLog(): void143 {144 QueryRecorder::stop();145 }146147 public function getPool(): ConnectionPoolInterface148 {149 if ($this->pool === null) {150 $driver = $this->resolveDriver();151152 // SQLite doesn't need connection pooling153 if ($driver === 'sqlite') {154 throw new \LogicException('getPool() is not applicable for SQLite adapter. Use getAdapter() directly.');155 }156157 $this->pool = $this->createPool();158 } else {159 $this->ensureCoroutineSafePool();160 }161162 return $this->pool;163 }164165 public function getSchemaCollector(): SchemaCollector166 {167 if ($this->schemaCollector === null) {168 $this->schemaCollector = new SchemaCollector(169 $this->classDiscovery,170 $this->resolveDriver(),171 $this->connectionName,172 );173 }174175 return $this->schemaCollector;176 }177178 public function getSchemaComparator(): SchemaComparatorInterface179 {180 // Like getTransactionManager(): a comparator memoized at bootstrap181 // captured an adapter over a possibly-stale SingleConnectionPool, and182 // the sync/migration path reaches it without ever passing through183 // getPool()/getAdapter() — so re-check here or the self-heal never184 // fires for it. The swap nulls $this->schemaComparator, and the block185 // below rebuilds it over the healed adapter.186 if ($this->schemaComparator !== null && $this->pool !== null) {187 $this->ensureCoroutineSafePool();188 }189190 if ($this->schemaComparator === null) {191 if ($this->resolveDriver() === 'sqlite') {192 $this->schemaComparator = new SqliteSchemaComparator(193 $this->getAdapter(),194 $this->resolveIgnoreTables(),195 );196 } else {197 $this->schemaComparator = new SchemaComparator(198 $this->getAdapter(),199 $this->getDatabaseName(),200 $this->resolveIgnoreTables(),201 );202 }203 }204205 return $this->schemaComparator;206 }207208 public function getSyncEngine(): SyncEngine209 {210 // Same self-heal re-check as getTransactionManager(): the sync flows211 // (orm:sync, the update-system migration gateway) reach this engine212 // via getSchemaCollector()/getSchemaComparator() and never touch213 // getPool()/getAdapter() after memoization, so an engine built during214 // bootstrap over a stale SingleConnectionPool would be served into215 // coroutine context unhealed. ensureCoroutineSafePool() nulls216 // $this->syncEngine on a swap; the block below rebuilds it over the217 // healed pool + adapter.218 if ($this->syncEngine !== null && $this->pool !== null) {219 $this->ensureCoroutineSafePool();220 }221222 if ($this->syncEngine === null) {223 $historyDir = ProjectRoot::get() . '/var/migrations/history';224 // The pool lets execute() pin ONE dedicated connection for a whole225 // DDL plan (BEGIN/DDL/COMMIT must share a connection; a pooled226 // adapter would spread them and return an open-tx connection to the227 // pool). SQLite has no pool — its adapter already owns a single PDO.228 $this->syncEngine = new SyncEngine(229 $this->getAdapter(),230 new AuditLogger($historyDir),231 $this->resolveDriver() === 'sqlite' ? null : $this->getPool(),232 );233 }234235 return $this->syncEngine;236 }237238 /**239 * The adapter for the repository READ path: routes queries to the current240 * coroutine's transaction connection when one is active, so reads inside241 * TransactionManager::run() see their own uncommitted writes and never242 * borrow a second pooled connection while holding the first. Resolves the243 * real adapter/manager lazily per call, so pool self-heal is honored.244 * NOT used for getAdapter() consumers — they dialect-branch on the245 * concrete adapter class (see TransactionAwareAdapter docblock).246 */247 private function getTransactionAwareAdapter(): TransactionAwareAdapter248 {249 return $this->transactionAwareAdapter ??= new TransactionAwareAdapter(250 fn (): DatabaseAdapterInterface => $this->getAdapter(),251 fn (): TransactionManager => $this->getTransactionManager(),252 );253 }254255 public function getTransactionManager(): TransactionManager256 {257 // A memoized TransactionManager may wrap an adapter/pool built at bootstrap258 // over a stale SingleConnectionPool (before Swoole hooks were enabled). Like259 // getPool()/getAdapter(), re-check before handing it out so a request that260 // enters through the transaction path also self-heals onto the261 // coroutine-safe pool. ensureCoroutineSafePool() preserves the262 // active-transaction guard and nulls $this->transactionManager on a swap,263 // so the block below rebuilds it over the fresh pool + adapter.264 if ($this->transactionManager !== null) {265 $this->ensureCoroutineSafePool();266 }267268 if ($this->transactionManager === null) {269 $driver = $this->resolveDriver();270271 // SQLite: TransactionManager takes a dedicated SQLite code path272 // and does not actually consult the pool — supply a named273 // NullConnectionPool so any accidental pop() throws loudly.274 $pool = $driver === 'sqlite'275 ? new NullConnectionPool()276 : $this->getPool();277278 $this->transactionManager = new TransactionManager(279 $pool,280 $this->getAdapter(),281 connectionName: $this->connectionName,282 );283 }284285 return $this->transactionManager;286 }287288 public function getSeedRunner(): SeedRunner289 {290 if ($this->seedRunner === null) {291 $this->seedRunner = new SeedRunner($this->getAdapter(), $this->classDiscovery);292 }293294 return $this->seedRunner;295 }296297 public function getMapperRegistry(): MapperRegistry298 {299 if ($this->mapperRegistry === null) {300 // Build FIRST, memoize LAST. build() walks the classmap through301 // ClassDiscovery, whose autoloads are file IO — a coroutine302 // SUSPENSION point under SWOOLE_HOOK_ALL. Memoizing the empty303 // registry before build() (the old order) let a concurrent304 // coroutine on the same manager observe a half-built registry and305 // die with MissingMapperException — reproduced as intermittent306 // 500s on the first concurrent burst after a worker boot. Losing307 // the ??= race is fine: both registries are complete, the first308 // one wins, the duplicate is GC'd.309 $registry = new MapperRegistry($this->classDiscovery);310 $registry->build();311 $this->mapperRegistry ??= $registry;312 }313314 return $this->mapperRegistry;315 }316317 public function getResourceModelMetadataRegistry(): ResourceModelMetadataRegistry318 {319 if ($this->resourceModelMetadataRegistry === null) {320 $this->resourceModelMetadataRegistry = new ResourceModelMetadataRegistry();321 }322323 return $this->resourceModelMetadataRegistry;324 }325326 public function getResourceModelHydrator(): ResourceModelHydrator327 {328 if ($this->resourceModelHydrator === null) {329 $this->resourceModelHydrator = new ResourceModelHydrator(330 metadataRegistry: $this->getResourceModelMetadataRegistry(),331 );332 }333334 return $this->resourceModelHydrator;335 }336337 public function getResourceModelRelationLoader(): ResourceModelRelationLoader338 {339 if ($this->resourceModelRelationLoader === null) {340 $this->resourceModelRelationLoader = new ResourceModelRelationLoader(341 $this->getTransactionAwareAdapter(),342 $this->getResourceModelHydrator(),343 $this->getResourceModelMetadataRegistry(),344 );345 }346347 return $this->resourceModelRelationLoader;348 }349350 public function getAggregateWriteEngine(): AggregateWriteEngine351 {352 if ($this->aggregateWriteEngine === null) {353 $this->aggregateWriteEngine = new AggregateWriteEngine(354 $this->getAdapter(),355 $this->getResourceModelHydrator(),356 $this->getResourceModelMetadataRegistry(),357 // Lazy on purpose: this engine is memoized, and a dispatcher358 // captured here freezes whatever was resolvable at FIRST write —359 // in CLI workers that is before any bootstrap registered the360 // resolver, silently killing auto-publish for the whole process.361 fn (): ?EventDispatcherInterface => $this->getEventDispatcher(),362 // Lazy for the same reason: getTransactionManager() self-heals363 // onto a fresh pool; a manager captured at first write would364 // pin the stale one.365 fn (): TransactionManager => $this->getTransactionManager(),366 );367 }368369 return $this->aggregateWriteEngine;370 }371372 /**373 * Register the lazy default EventDispatcher resolver (framework bootstrap only).374 * Invoked once per worker by ContainerFactory once the container can resolve375 * EventDispatcherInterface. Pass null to clear (tests).376 *377 * @param (\Closure(): ?EventDispatcherInterface)|null $resolver378 */379 public static function setDefaultEventDispatcherResolver(?\Closure $resolver): void380 {381 self::$defaultEventDispatcherResolver = $resolver;382 }383384 /**385 * Resolve the EventDispatcher this manager dispatches resource-changed events386 * through: an explicitly injected one wins (P2's ctor param / direct tests),387 * otherwise the framework's lazy default resolver (the bootstrap-wired one),388 * otherwise null (no container bootstrapped → dispatch stays a silent no-op,389 * exactly as before this brick).390 */391 public function getEventDispatcher(): ?EventDispatcherInterface392 {393 if ($this->events !== null) {394 return $this->events;395 }396397 if (self::$defaultEventDispatcherResolver !== null) {398 return (self::$defaultEventDispatcherResolver)();399 }400401 return null;402 }403404 public function getBootstrapValidator(): OrmBootstrapValidator405 {406 if ($this->bootstrapValidator === null) {407 $this->bootstrapValidator = new OrmBootstrapValidator(408 classDiscovery: $this->classDiscovery,409 metadataRegistry: $this->getResourceModelMetadataRegistry(),410 mapperRegistry: $this->getMapperRegistry(),411 );412 }413414 return $this->bootstrapValidator;415 }416417 public function validateBootstrap(): OrmBootstrapReport418 {419 return $this->getBootstrapValidator()->validate();420 }421422 /**423 * @param class-string $resourceModelClass424 * @param class-string $domainModelClass425 */426 public function repository(string $resourceModelClass, string $domainModelClass): DomainRepository427 {428 return new DomainRepository(429 resourceModelClass: $resourceModelClass,430 domainModelClass: $domainModelClass,431 adapter: $this->getTransactionAwareAdapter(),432 mapperRegistry: $this->getMapperRegistry(),433 hydrator: $this->getResourceModelHydrator(),434 relationLoader: $this->getResourceModelRelationLoader(),435 metadataRegistry: $this->getResourceModelMetadataRegistry(),436 writeEngine: $this->getAggregateWriteEngine(),437 );438 }439440 public function getDatabaseName(): string441 {442 if ($this->config !== null) {443 if ($this->config->driver === 'sqlite') {444 return $this->config->sqliteMemory445 ? ':memory:'446 : ($this->config->sqlitePath ?? 'sqlite');447 }448449 return $this->config->database;450 }451452 if ($this->resolveDriver() === 'sqlite') {453 $memory = Environment::getEnvValue('DB_SQLITE_MEMORY');454 if (in_array(strtolower((string) $memory), ['1', 'true', 'yes'], true)) {455 return ':memory:';456 }457458 return Environment::getEnvValue('DB_SQLITE_PATH', ProjectRoot::get() . '/var/database/semitexa.sqlite')459 ?? ProjectRoot::get() . '/var/database/semitexa.sqlite';460 }461462 return Environment::getEnvValue('DB_DATABASE', 'semitexa') ?? 'semitexa';463 }464465 public function shutdown(): void466 {467 $this->pool?->close();468 $this->pool = null;469 $this->adapter = null;470 }471472 /**473 * Destructors run wherever GC happens to fire — mid-container-build, in474 * another coroutine burst's world, or with no coroutine at all — and a475 * Swoole Channel touched from the wrong context raises a C-level476 * "must call constructor first" fatal that BYPASSES try/catch (and,477 * inside a destructor, is uncatchable by any frame). So the destructor478 * only DROPS references: releasing the Channel lets refcounting free the479 * queued PDO connections exactly as a drain would, minus the fatal.480 * Deliberate teardown at a known-safe point stays {@see shutdown()}.481 */482 public function __destruct()483 {484 $this->pool = null;485 $this->adapter = null;486 }487488 /**489 * Run a callback with a managed OrmManager instance.490 * shutdown() is guaranteed via finally — even if the callback throws.491 *492 * @template T493 * @param callable(OrmManager): T $callback494 * @return T495 */496 public static function run(callable $callback): mixed497 {498 $orm = new self();499 try {500 return $callback($orm);501 } finally {502 $orm->shutdown();503 }504 }505506 /**507 * Tables sync must not consider for dropping.508 *509 * Two sources, merged. `ORM_IGNORE_TABLES` is the operator-level escape510 * hatch for tables belonging to something outside Semitexa. `#[SelfManagedTable]`511 * is the package-level statement of ownership, for tables a Semitexa package512 * creates and migrates itself and which therefore have no `#[FromTable]`513 * resource to claim them.514 *515 * Without the second source, such a table is indistinguishable from one516 * abandoned by a deleted resource, so every sync marks it deprecated and the517 * next destructive run drops it — silently, since the marker is only a518 * comment.519 *520 * @return list<string>521 */522 private function resolveIgnoreTables(): array523 {524 $tables = [];525526 $raw = Environment::getEnvValue('ORM_IGNORE_TABLES', '');527 if ($raw !== '') {528 // Filter on emptiness explicitly: a bare array_filter() would also529 // drop a table literally named "0".530 $tables = array_filter(531 array_map('trim', explode(',', $raw)),532 static fn (string $table): bool => $table !== '',533 );534 }535536 foreach ($this->discoverSelfManagedTables() as $table) {537 $tables[] = $table;538 }539540 return array_values(array_unique(array_filter(541 $tables,542 static fn (string $table): bool => $table !== '',543 )));544 }545546 /**547 * @return list<string>548 */549 private function discoverSelfManagedTables(): array550 {551 $tables = [];552553 try {554 // The configured instance, not a fresh default: a caller with custom555 // discovery roots finds its #[FromTable] resources through this one,556 // and ownership declarations must be found the same way or the two557 // views of the codebase disagree — silently re-exposing the bug this558 // attribute exists to fix.559 $classes = $this->classDiscovery->findClassesWithAttribute(SelfManagedTable::class);560 } catch (\Throwable) {561 // Discovery is a convenience here, not a correctness requirement: if562 // it cannot run, fall back to the env list rather than failing the563 // whole sync. The cost is the pre-existing behaviour, not worse.564 return [];565 }566567 foreach ($classes as $class) {568 // class_exists() also narrows string to class-string for static569 // analysis, and skips anything discovery listed that cannot load.570 if (!class_exists($class)) {571 continue;572 }573574 foreach ((new \ReflectionClass($class))->getAttributes(SelfManagedTable::class) as $attribute) {575 $table = trim($attribute->newInstance()->table);576 if ($table !== '') {577 $tables[] = $table;578 }579 }580 }581582 return $tables;583 }584585 /**586 * PDO options for pooled MySQL connections.587 *588 * ATTR_TIMEOUT is the CONNECT timeout for pdo_mysql: without it a589 * hung/unreachable server parks the connecting coroutine indefinitely590 * while it holds a claimed pool slot (the pop() timeout protects waiters,591 * never the holder).592 *593 * The query ceiling is deliberately NOT here. It cannot ride594 * MYSQL_ATTR_INIT_COMMAND: the session variable's name is flavor-specific,595 * and an init command naming an unknown one fails inside the PDO596 * constructor, so every pooled and warm-up connection would throw. It is597 * applied after connect instead — see {@see applyQueryTimeout()}.598 * $queryTimeout is still accepted so both halves of the timeout contract599 * read as one call signature, and so this docblock is where the next600 * reader looks before re-adding the init command.601 *602 * Residual risk either way: pdo_mysql has no client-side READ timeout, so603 * a network black-hole mid-query is bounded only by TCP keepalive.604 *605 * @param float $queryTimeout Intentionally unused — see above.606 *607 * @return array<int, mixed>608 */609 public static function pdoOptions(float $connectTimeout, float $queryTimeout): array610 {611 $options = [612 \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,613 \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,614 \PDO::ATTR_EMULATE_PREPARES => false,615 ];616617 if ($connectTimeout > 0) {618 $options[\PDO::ATTR_TIMEOUT] = max(1, (int) ceil($connectTimeout));619 }620621 return $options;622 }623624 /**625 * Apply the per-session query ceiling to a freshly opened connection.626 *627 * NOT a MYSQL_ATTR_INIT_COMMAND: the variable's name differs by server628 * flavor, and an init command naming an unknown variable fails INSIDE the629 * PDO constructor — every pooled and warm-up connection would throw, so an630 * opt-in timeout knob would cost a MariaDB deployment all database access.631 * ATTR_SERVER_VERSION comes from the connection handshake, so choosing the632 * statement here costs no extra round trip.633 *634 * MySQL 5.7.8+ : max_execution_time, milliseconds, SELECT-only.635 * MariaDB 10.1+: max_statement_time, SECONDS (fractional allowed).636 *637 * Failure is logged and swallowed: an unusable ceiling is a degraded638 * safeguard, not a reason to refuse the connection.639 */640 public static function applyQueryTimeout(\PDO $pdo, float $queryTimeout): void641 {642 if ($queryTimeout <= 0) {643 return;644 }645646 try {647 $rawVersion = $pdo->getAttribute(\PDO::ATTR_SERVER_VERSION);648 } catch (\Throwable) {649 return;650 }651652 if (!is_scalar($rawVersion)) {653 return;654 }655656 $version = (string) $rawVersion;657658 $isMariaDb = stripos($version, 'mariadb') !== false;659660 if ($isMariaDb) {661 $statement = sprintf('SET SESSION max_statement_time=%F', $queryTimeout);662 } else {663 // MySQL below 5.7.8 has no equivalent — leave it unset rather than664 // failing the connection.665 if (version_compare(self::numericServerVersion($version), '5.7.8', '<')) {666 return;667 }668669 // Never round down to 0: MySQL reads max_execution_time=0 as670 // "no limit", so a sub-millisecond ceiling would silently mean671 // none at all. Same rule as ATTR_TIMEOUT's max(1, ceil(...)).672 $statement = sprintf('SET SESSION max_execution_time=%d', max(1, (int) round($queryTimeout * 1000)));673 }674675 try {676 $pdo->exec($statement);677 } catch (\Throwable $e) {678 StaticLoggerBridge::warning('orm', 'Could not apply the query timeout for this connection.', [679 'server_version' => $version,680 'statement' => $statement,681 'error' => $e->getMessage(),682 ]);683 }684 }685686 /** Leading dotted version from a server version string ("8.0.35-log" -> "8.0.35"). */687 private static function numericServerVersion(string $version): string688 {689 return preg_match('/^(\d+\.\d+\.\d+)/', $version, $m) === 1 ? $m[1] : '0.0.0';690 }691692 private function createPool(): ConnectionPoolInterface693 {694 // Ensure Swoole workers/coroutines resolve DB_* values from the project env files.695 if (class_exists(\Swoole\Coroutine::class, false)) {696 ProjectRoot::reset();697 Environment::syncEnvFromFiles();698 }699700 if ($this->config !== null) {701 $host = $this->config->cliHost && !$this->isRunningInDocker()702 ? $this->config->cliHost703 : $this->config->host;704 $port = $this->config->cliPort && !$this->isRunningInDocker()705 ? $this->config->cliPort706 : $this->config->port;707 $database = $this->config->database;708 $username = $this->config->username;709 $password = $this->config->password;710 $charset = $this->config->charset;711 $poolSize = $this->config->poolSize;712 $connectTimeout = $this->config->connectTimeout;713 $queryTimeout = $this->config->queryTimeout;714 } else {715 $host = $this->resolveDbHost();716 $port = $this->resolveDbPort();717 $database = Environment::getEnvValue('DB_DATABASE', 'semitexa');718 $username = Environment::getEnvValue('DB_USERNAME') ?? Environment::getEnvValue('DB_USER', 'root');719 $password = Environment::getEnvValue('DB_PASSWORD', '');720 $charset = Environment::getEnvValue('DB_CHARSET', 'utf8mb4');721 $poolSize = (int) Environment::getEnvValue('DB_POOL_SIZE', '10');722 // Via ConnectionConfig's parser: a present-but-empty723 // DB_CONNECT_TIMEOUT returns '' (so ?? never fires) and casts to724 // 0.0, which PDO reads as "wait forever" — silently restoring the725 // hung-server failure the timeout exists to prevent.726 $connectTimeout = ConnectionConfig::parseTimeoutValue(Environment::getEnvValue('DB_CONNECT_TIMEOUT'), 5.0);727 $queryTimeout = ConnectionConfig::parseTimeoutValue(Environment::getEnvValue('DB_QUERY_TIMEOUT'), 0.0);728 }729730 $dsn = "mysql:host={$host};port={$port};dbname={$database};charset={$charset}";731732 $options = self::pdoOptions($connectTimeout, $queryTimeout);733 $factory = static function () use ($dsn, $username, $password, $options, $queryTimeout): \PDO {734 $pdo = new \PDO($dsn, $username, $password, $options);735 self::applyQueryTimeout($pdo, $queryTimeout);736737 return $pdo;738 };739740 if ($this->shouldUseCoroutinePool()) {741 return new ConnectionPool($poolSize, $factory);742 }743744 return new SingleConnectionPool($factory);745 }746747 /**748 * Whether the coroutine-safe ConnectionPool should back this manager.749 *750 * True under a running Swoole server, where PDO sockets are coroutine-hooked751 * (enableCoroutine(SWOOLE_HOOK_ALL) normally runs once in the master before752 * workers fork, so the flag is inherited at WorkerStart). getHookFlags() !== 0753 * is the causally-exact "server present" signal and is independent of the754 * current coroutine id. getCid() >= 0 is the in-coroutine fast-path. True CLI755 * (no server, hooks off, getCid() === -1) falls through to the single shared756 * connection, which is correct there.757 *758 * This is evaluated both at pool construction AND on every subsequent759 * getPool()/getAdapter() (see ensureCoroutineSafePool) so a SingleConnectionPool760 * cached before the runtime came up can still be upgraded once it is live.761 */762 private function shouldUseCoroutinePool(): bool763 {764 return class_exists(\Swoole\Coroutine::class, false)765 && (766 \Swoole\Coroutine::getCid() >= 0767 || (class_exists(\Swoole\Runtime::class, false)768 && \Swoole\Runtime::getHookFlags() !== 0)769 );770 }771772 /**773 * Self-heal a stale pool SELECTION.774 *775 * createPool() may cache the non-coroutine SingleConnectionPool if the very776 * first getPool()/getAdapter() ran before SWOOLE_HOOK_ALL was applied — e.g.777 * master-side warmup before fork, which then inherits the wrong pool into778 * every worker. That choice is otherwise frozen for the worker's life, and779 * SingleConnectionPool gives no true pooling under load. Once the coroutine780 * runtime is live, swap in the real ConnectionPool and drop every memoized781 * service that captured the old pool (directly or via the old adapter) so the782 * next access rebuilds against it.783 *784 * Never runs mid-transaction — yanking the pool out from under an open785 * transaction would orphan its connection.786 */787 private function ensureCoroutineSafePool(): void788 {789 if (! $this->pool instanceof SingleConnectionPool) {790 return;791 }792793 if (! $this->shouldUseCoroutinePool()) {794 return;795 }796797 // TransactionManager::isActive() is COROUTINE-LOCAL: it answers for798 // the coroutine asking for the swap, not for the worker. Coroutine A799 // can be mid-query on the old pool while coroutine B reaches this800 // getter, sees no transaction of its own, and closes A's connection801 // out from under it. The pool itself is the only worker-wide witness,802 // so ask it whether anyone still holds the connection; the swap is803 // simply deferred to whoever asks next once the pool is quiescent.804 if ($this->transactionManager !== null && $this->transactionManager->isActive()) {805 return;806 }807808 if ($this->pool->hasOutstandingBorrow()) {809 return;810 }811812 $this->pool->close();813 $this->pool = $this->createPool();814815 // Every field below captured the old pool, directly or via the old adapter.816 $this->adapter = null;817 $this->schemaComparator = null;818 $this->syncEngine = null;819 $this->transactionManager = null;820 $this->seedRunner = null;821 $this->resourceModelRelationLoader = null;822 $this->aggregateWriteEngine = null;823 }824825 /** When running on host (CLI), use DB_CLI_* so GUI/CLI connect to host port; inside Docker use DB_HOST/DB_PORT. */826 private function resolveDbHost(): string827 {828 if (!$this->isRunningInDocker()) {829 $cliHost = Environment::getEnvValue('DB_CLI_HOST');830 if ($cliHost !== null && $cliHost !== '') {831 return $cliHost;832 }833 }834 return Environment::getEnvValue('DB_HOST', '127.0.0.1');835 }836837 private function resolveDbPort(): string838 {839 if (!$this->isRunningInDocker()) {840 $cliPort = Environment::getEnvValue('DB_CLI_PORT');841 if ($cliPort !== null && $cliPort !== '') {842 return $cliPort;843 }844 }845 return Environment::getEnvValue('DB_PORT', '3306');846 }847848 private function isRunningInDocker(): bool849 {850 return file_exists('/.dockerenv');851 }852853 /**854 * Resolve the database driver from environment configuration.855 * Defaults to 'mysql' for backward compatibility.856 */857 /** The resolved database driver for this connection ('mysql' or 'sqlite'). */858 public function getDriver(): string859 {860 return $this->resolveDriver();861 }862863 private function resolveDriver(): string864 {865 $driverSource = $this->config !== null866 ? $this->config->driver867 : (Environment::getEnvValue('DB_DRIVER', 'mysql') ?? 'mysql');868 $driver = strtolower($driverSource);869870 return match ($driver) {871 'mysql', 'sqlite' => $driver,872 default => throw new \InvalidArgumentException(873 "Unsupported DB driver '{$driver}'. Expected 'mysql' or 'sqlite'.",874 ),875 };876 }877878 /**879 * Create a SQLite adapter based on environment configuration.880 *881 * Supports:882 * - DB_SQLITE_PATH: absolute or relative path to SQLite file883 * - DB_SQLITE_MEMORY: if set to "1" or "true", use in-memory database884 */885 private function createSqliteAdapter(): SqliteAdapter886 {887 if ($this->config !== null) {888 if ($this->config->sqliteMemory) {889 return new SqliteAdapter('sqlite::memory:');890 }891892 $path = $this->config->sqlitePath;893 if ($path === null || $path === '') {894 $path = ProjectRoot::get() . '/var/database/semitexa.sqlite';895 }896 } else {897 $memory = Environment::getEnvValue('DB_SQLITE_MEMORY');898 if (in_array(strtolower((string) $memory), ['1', 'true', 'yes'], true)) {899 return new SqliteAdapter('sqlite::memory:');900 }901902 $path = Environment::getEnvValue('DB_SQLITE_PATH');903 if ($path === null || $path === '') {904 $path = ProjectRoot::get() . '/var/database/semitexa.sqlite';905 }906 }907908 // Ensure directory exists909 $dir = dirname($path);910 if (!is_dir($dir)) {911 mkdir($dir, 0755, true);912 }913914 return new SqliteAdapter("sqlite:{$path}");915 }916}917
Destructive Safety
Why column drops are intentionally slow
A missing column does not become an immediate DROP; the first pass only marks it deprecated.
Real destructive operations are separated in the execution plan and require explicit opt-in with --allow-destructive.
The executed plan is logged as both structured JSON and plain SQL for review, audit, and DevOps handoff.
If code and database already match, there is nothing to write and nothing to execute.
How it works
orm:sync collects the code schema, compares it with the live database, builds an execution plan, and separates safe changes from destructive ones. Drops use a two-phase flow: first mark deprecated, later drop only with explicit approval.
Why it matters
This reduces busywork, makes destructive intent visible, and still gives teams exact SQL artifacts when they need them for review or deployment.
Key concepts
- orm:sync
- Command that computes and optionally executes the schema synchronization plan.
- Two-phase drop
- Column or table removal is delayed: first deprecate, then drop on a later explicit destructive pass.
- AuditLogger
- Writes executed sync operations as both JSON and SQL files under var/migrations/history.