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\Attribute\InjectAsReadonly;9use Semitexa\Core\Console\BaseCommand;10use Semitexa\Orm\Application\Service\Connection\ConnectionRegistry;11use Semitexa\Orm\Application\Service\Sync\SchemaSyncLock;12use Semitexa\Orm\OrmManager;13use Symfony\Component\Console\Command\Command;14use Symfony\Component\Console\Input\InputInterface;15use Symfony\Component\Console\Input\InputOption;16use Symfony\Component\Console\Output\OutputInterface;17use Symfony\Component\Console\Style\SymfonyStyle;1819#[AsCommand(name: 'orm:sync', description: 'Synchronize ORM schema with the database')]20class OrmSyncCommand extends BaseCommand21{22 #[InjectAsReadonly]23 protected ConnectionRegistry $connections;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-4. Compare, plan and apply — under the schema lock, so nodes81 // deploying together do not run the same DDL twice. A dry run82 // changes nothing and takes no lock.83 $cycle = fn (): int => $this->compareAndApply($orm, $codeSchema, $io, $output, $dryRun, $allowDestructive, $outputFile);84 $result = $dryRun ? $cycle() : (new SchemaSyncLock($orm))->run($cycle);8586 $orm->shutdown();87 return $result;88 } catch (\Throwable $e) {89 $io->error('Sync failed: ' . $e->getMessage());90 if ($output->isVerbose()) {91 $io->text($e->getTraceAsString());92 }93 return Command::FAILURE;94 }95 }9697 /**98 * @param array<string, \Semitexa\Orm\Domain\Model\TableDefinition> $codeSchema99 */100 private function compareAndApply(101 OrmManager $orm,102 array $codeSchema,103 SymfonyStyle $io,104 OutputInterface $output,105 bool $dryRun,106 bool $allowDestructive,107 ?string $outputFile,108 ): int {109 // 2. Compare with DB110 $io->section('Comparing with database...');111 $comparator = $orm->getSchemaComparator();112 $diff = $comparator->compare($codeSchema);113114 if ($diff->isEmpty()) {115 $io->success('Database is up to date. No changes needed.');116117 return Command::SUCCESS;118 }119120 // 3. Build execution plan121 $syncEngine = $orm->getSyncEngine();122 $plan = $syncEngine->buildPlan($diff);123124 $io->text($plan->getSummary());125 $io->newLine();126127 // Show plan details128 $safeOps = $plan->getSafeOperations();129 $destructiveOps = $plan->getDestructiveOperations();130131 if ($safeOps !== []) {132 $io->section('Safe operations:');133 foreach ($safeOps as $op) {134 $io->text(" <info>[{$op->type->value}]</info> {$op->description}");135 if ($output->isVerbose()) {136 $io->text(" SQL: {$op->sql}");137 }138 }139 }140141 if ($destructiveOps !== []) {142 $io->section('Destructive operations:');143 foreach ($destructiveOps as $op) {144 $io->text(" <fg=red>[{$op->type->value}]</> {$op->description}");145 if ($output->isVerbose()) {146 $io->text(" SQL: {$op->sql}");147 }148 }149150 if (!$allowDestructive) {151 $io->warning('Destructive operations will be skipped. Use --allow-destructive to include them.');152 }153 }154155 // Save to file if requested156 if ($outputFile !== null) {157 $statements = $plan->toSqlStatements($allowDestructive);158 $content = implode(";\n", $statements) . ";\n";159 file_put_contents($outputFile, $content);160 $io->text("SQL plan saved to: {$outputFile}");161 }162163 // Execute if not dry-run164 if ($dryRun) {165 $io->note('Dry run mode — no changes applied.');166167 return Command::SUCCESS;168 }169170 $executed = $syncEngine->execute($plan, $allowDestructive);171 $io->success(sprintf('Executed %d operation(s).', count($executed)));172173 return Command::SUCCESS;174 }175}176
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\Persistence\ReplicatedWriteGuard;14use Semitexa\Orm\Application\Service\Transaction\SingleConnectionAdapter;15use Semitexa\Orm\Adapter\MySqlType;16use Semitexa\Orm\Adapter\SqlIdentifier;17use Semitexa\Orm\Adapter\SqliteType;18use Semitexa\Orm\Domain\Model\ColumnDefinition;19use Semitexa\Orm\Domain\Model\DbColumnState;20use Semitexa\Orm\Domain\Model\ForeignKeyDefinition;21use Semitexa\Orm\Domain\Model\SchemaDiff;22use Semitexa\Orm\Domain\Model\TableDefinition;2324class SyncEngine25{26 public const DEPRECATED_COMMENT = 'SEMITEXA_DEPRECATED';2728 public function __construct(29 private readonly DatabaseAdapterInterface $adapter,30 private readonly ?AuditLogger $auditLogger = null,31 private readonly ?ConnectionPoolInterface $pool = null,32 private readonly string $connectionName = ReplicatedWriteGuard::DEFAULT_CONNECTION,33 ) {}3435 public function buildPlan(SchemaDiff $diff): ExecutionPlan36 {37 $plan = new ExecutionPlan();38 foreach ($diff->getRestoreTables() as $tableName) { // first: a later failing step must not keep the mark39 $plan->addOperation(new DdlOperation(sql: 'ALTER TABLE ' . SqlIdentifier::quote($tableName) . " COMMENT ''", type: DdlOperationType::AlterColumn, tableName: $tableName, isDestructive: false, description: "Clear the deprecation mark on '{$tableName}': it is back in code"));40 }4142 // 1. CREATE TABLEs first (sorted by dependencies)43 $sortedTables = $this->topologicalSort($diff->getCreateTables());44 foreach ($sortedTables as $table) {45 $plan->addOperation(new DdlOperation(46 sql: $this->generateCreateTable($table),47 type: DdlOperationType::CreateTable,48 tableName: $table->name,49 isDestructive: false,50 description: "Create table '{$table->name}'",51 ));5253 if ($this->isSqlite()) {54 foreach ($table->getIndexes() as $index) {55 $name = $index->name ?? $this->generateIndexName($table->name, $index->columns, $index->unique);56 $plan->addOperation(new DdlOperation(57 sql: $this->generateAddIndex($table->name, $index, $name),58 type: DdlOperationType::AddIndex,59 tableName: $table->name,60 isDestructive: false,61 description: "Add index '{$name}' on '{$table->name}'",62 ));63 }64 }65 }6667 // 2. ADD COLUMNs (safe)68 foreach ($diff->getAddColumns() as $tableName => $columns) {69 foreach ($columns as $column) {70 $plan->addOperation(new DdlOperation(71 sql: $this->generateAddColumn($tableName, $column),72 type: DdlOperationType::AddColumn,73 tableName: $tableName,74 isDestructive: false,75 description: "Add column '{$column->name}' to '{$tableName}'",76 ));77 }78 }7980 // 3. ALTER COLUMNs (may be safe or destructive)81 foreach ($diff->getAlterColumns() as $tableName => $alterations) {82 foreach ($alterations as $alteration) {83 $column = $alteration['column'];84 $changes = $alteration['changes'];85 $isDestructive = $this->isAlterDestructive($changes);8687 $plan->addOperation(new DdlOperation(88 sql: $this->generateAlterColumn($tableName, $column),89 type: DdlOperationType::AlterColumn,90 tableName: $tableName,91 isDestructive: $isDestructive,92 description: "Alter column '{$column->name}' in '{$tableName}': " . implode(', ', $changes),93 ));94 }95 }9697 // 4. ADD FOREIGN KEYs (safe — all tables already exist at this point)98 foreach ($diff->getAddForeignKeys() as $fk) {99 $plan->addOperation(new DdlOperation(100 sql: $this->generateAddForeignKey($fk),101 type: DdlOperationType::AddForeignKey,102 tableName: $fk->table,103 isDestructive: false,104 description: "Add FK constraint '{$fk->constraintName()}' on '{$fk->table}'.{$fk->column} → '{$fk->referencedTable}'.{$fk->referencedColumn}",105 ));106 }107108 // 5–6. INDEX changes: DROP + ADD109 //110 // When an index is being recreated (same name appears in both drop and add111 // lists for the same table), MySQL may refuse to drop it if a FK constraint112 // depends on it (error 1553). Combining DROP INDEX + ADD INDEX into a single113 // ALTER TABLE statement lets MySQL atomically swap the index definition114 // without ever leaving the FK unsupported.115 $dropIndexes = $diff->getDropIndexes();116 $addIndexes = $diff->getAddIndexes();117118 // Build a lookup of add-indexes keyed by table.name for pairing119 $addByTableAndName = [];120 foreach ($addIndexes as $tableName => $indexes) {121 foreach ($indexes as $entry) {122 $addByTableAndName[$tableName . '.' . $entry['name']] = $entry;123 }124 }125126 // Track which add-indexes have been paired with a drop (emitted as combined DDL)127 $pairedAdds = [];128129 // 5. DROP INDEXes (destructive — must run before ADD to avoid duplicate key names)130 foreach ($dropIndexes as $tableName => $indexNames) {131 foreach ($indexNames as $indexName) {132 $key = $tableName . '.' . $indexName;133 $q = $this->quoteChar();134135 if (isset($addByTableAndName[$key])) {136 // Same index name is being dropped and re-added → combine into one statement137 $entry = $addByTableAndName[$key];138 $index = $entry['index'];139140 if ($this->isSqlite()) {141 // SQLite: separate DROP and CREATE142 $plan->addOperation(new DdlOperation(143 sql: 'DROP INDEX ' . SqlIdentifier::quote($indexName, $q),144 type: DdlOperationType::DropIndex,145 tableName: $tableName,146 isDestructive: true,147 description: "Drop index '{$indexName}' from '{$tableName}'",148 ));149 $plan->addOperation(new DdlOperation(150 sql: $this->generateAddIndex($tableName, $index, $indexName),151 type: DdlOperationType::AddIndex,152 tableName: $tableName,153 isDestructive: false,154 description: "Recreate index '{$indexName}' on '{$tableName}'",155 ));156 } else {157 $cols = implode(', ', SqlIdentifier::quoteAll($index->columns));158 $type = $index->unique ? 'UNIQUE INDEX' : 'INDEX';159 $quotedTable = SqlIdentifier::quote($tableName);160 $quotedIndex = SqlIdentifier::quote($indexName);161 $sql = "ALTER TABLE {$quotedTable} DROP INDEX {$quotedIndex}, ADD {$type} {$quotedIndex} ({$cols})";162163 $plan->addOperation(new DdlOperation(164 sql: $sql,165 type: DdlOperationType::DropIndex,166 tableName: $tableName,167 isDestructive: true,168 description: "Recreate index '{$indexName}' on '{$tableName}'",169 ));170 }171 $pairedAdds[$key] = true;172 } else {173 if ($this->isSqlite()) {174 $plan->addOperation(new DdlOperation(175 sql: 'DROP INDEX ' . SqlIdentifier::quote($indexName, $q),176 type: DdlOperationType::DropIndex,177 tableName: $tableName,178 isDestructive: true,179 description: "Drop index '{$indexName}' from '{$tableName}'",180 ));181 } else {182 $plan->addOperation(new DdlOperation(183 sql: 'ALTER TABLE ' . SqlIdentifier::quote($tableName)184 . ' DROP INDEX ' . SqlIdentifier::quote($indexName),185 type: DdlOperationType::DropIndex,186 tableName: $tableName,187 isDestructive: true,188 description: "Drop index '{$indexName}' from '{$tableName}'",189 ));190 }191 }192 }193 }194195 // 6. ADD INDEXes (safe) — skip any that were already emitted as part of a combined statement196 foreach ($addIndexes as $tableName => $indexes) {197 foreach ($indexes as $entry) {198 $key = $tableName . '.' . $entry['name'];199 if (isset($pairedAdds[$key])) {200 continue;201 }202203 $index = $entry['index'];204 $name = $entry['name'];205 $plan->addOperation(new DdlOperation(206 sql: $this->generateAddIndex($tableName, $index, $name),207 type: DdlOperationType::AddIndex,208 tableName: $tableName,209 isDestructive: false,210 description: "Add index '{$name}' on '{$tableName}'",211 ));212 }213 }214215 // 7. DROP COLUMNs — two-phase logic (destructive)216 foreach ($diff->getDropColumns() as $tableName => $columns) {217 foreach ($columns as $colInfo) {218 $columnName = $colInfo['name'];219 $comment = $colInfo['comment'];220 $dbState = $colInfo['dbState'];221222 if ($comment !== self::DEPRECATED_COMMENT) {223 // Column was not previously marked as deprecated → block drop, add deprecation comment instead.224 // MODIFY COLUMN requires the full column definition — reconstruct it from DbColumnState.225 $plan->addOperation(new DdlOperation(226 sql: $this->generateDeprecationDdl($tableName, $dbState),227 type: DdlOperationType::AlterColumn,228 tableName: $tableName,229 isDestructive: false,230 description: "Mark column '{$columnName}' in '{$tableName}' as deprecated (two-phase drop, phase 1)",231 ));232 } else {233 // Column was already deprecated → safe to drop234 $q = $this->quoteChar();235 $plan->addOperation(new DdlOperation(236 sql: 'ALTER TABLE ' . SqlIdentifier::quote($tableName, $q)237 . ' DROP COLUMN ' . SqlIdentifier::quote($columnName, $q),238 type: DdlOperationType::DropColumn,239 tableName: $tableName,240 isDestructive: true,241 description: "Drop deprecated column '{$columnName}' from '{$tableName}' (two-phase drop, phase 2)",242 ));243 }244 }245 }246247 // 8. DROP FOREIGN KEYs (destructive — must happen before DROP TABLE/COLUMN)248 foreach ($diff->getDropForeignKeys() as $entry) {249 if ($this->isSqlite()) {250 // SQLite: FK constraints cannot be dropped separately.251 // Table recreation would be needed — skip for now.252 continue;253 }254 $plan->addOperation(new DdlOperation(255 sql: 'ALTER TABLE ' . SqlIdentifier::quote($entry['table'])256 . ' DROP FOREIGN KEY ' . SqlIdentifier::quote($entry['constraintName']),257 type: DdlOperationType::DropForeignKey,258 tableName: $entry['table'],259 isDestructive: true,260 description: "Drop FK constraint '{$entry['constraintName']}' from '{$entry['table']}'",261 ));262 }263264 // 9. DROP TABLEs — two-phase logic (destructive)265 foreach ($diff->getDropTables() as $dbTable) {266 $tableName = $dbTable->name;267 $q = $this->quoteChar();268 if ($dbTable->tableComment !== self::DEPRECATED_COMMENT) {269 if ($this->isSqlite()) {270 // SQLite lacks table comments, so do not drop the table implicitly.271 // Force an explicit/manual follow-up instead of risking silent data loss.272 $plan->addOperation(new DdlOperation(273 sql: "-- SQLITE_DROP_TABLE_REQUIRES_MANUAL_REVIEW:{$tableName}",274 type: DdlOperationType::AlterColumn,275 tableName: $tableName,276 isDestructive: false,277 description: "Manual review required before dropping SQLite table '{$tableName}'",278 ));279 } else {280 // Table was not previously marked as deprecated → block drop, add deprecation comment instead.281 $plan->addOperation(new DdlOperation(282 sql: 'ALTER TABLE ' . SqlIdentifier::quote($tableName)283 . " COMMENT '" . self::DEPRECATED_COMMENT . "'",284 type: DdlOperationType::AlterColumn,285 tableName: $tableName,286 isDestructive: false,287 description: "Mark table '{$tableName}' as deprecated (two-phase drop, phase 1)",288 ));289 }290 } else {291 // Table was already deprecated → safe to drop292 $plan->addOperation(new DdlOperation(293 sql: 'DROP TABLE ' . SqlIdentifier::quote($tableName),294 type: DdlOperationType::DropTable,295 tableName: $tableName,296 isDestructive: true,297 description: "Drop deprecated table '{$tableName}' (two-phase drop, phase 2)",298 ));299 }300 }301302 return $plan;303 }304305 /**306 * Execute a plan against the database.307 *308 * On SQLite the whole plan runs inside one transaction, so a mid-plan309 * failure rolls back cleanly. On MySQL/MariaDB it does NOT: every DDL310 * statement carries an implicit commit, so operations are applied one by311 * one and a mid-plan failure leaves the earlier operations in place. Plans312 * are ordered (tables, columns, foreign keys, indexes) so that a partial313 * application is re-runnable — the next sync diffs from the real schema314 * and continues where this one stopped.315 *316 * With a pool present the plan still pins ONE connection for its whole317 * lifetime: statements spread across pooled connections would race with318 * concurrent request traffic and (before this) hand back a connection319 * carrying an untracked transaction.320 *321 * @return DdlOperation[] Executed operations322 */323 public function execute(ExecutionPlan $plan, bool $allowDestructive = false): array324 {325 $operations = array_filter(326 $plan->getOperations(),327 fn(DdlOperation $op) => $allowDestructive || !$op->isDestructive,328 );329330 if ($operations === []) {331 return [];332 }333334 $isSqlite = $this->isSqlite();335336 // Only SQLite gets a transaction around the plan, and the reason is a337 // server fact rather than a preference: MySQL performs an IMPLICIT338 // COMMIT before and after every DDL statement, so a plan wrapped in339 // START TRANSACTION/COMMIT was never atomic there — the first CREATE340 // TABLE already committed everything, and the closing COMMIT ran with341 // no transaction left to commit. (ServerCapability::AtomicDdl is about342 // MySQL 8.0 making a SINGLE DDL statement crash-safe; it does not make343 // DDL transactional.) Keeping the fiction also meant a pooled344 // connection could carry an untracked open transaction back into the345 // pool between the BEGIN and the first DDL. SQLite genuinely does run346 // DDL inside transactions, so it keeps the wrapper and its rollback.347 $useTransaction = $isSqlite && $this->adapter->supports(\Semitexa\Orm\Adapter\ServerCapability::AtomicDdl);348349 // The whole plan must run on ONE connection. Through a pooled adapter,350 // every query()/execute() pops a DIFFERENT connection: BEGIN, each DDL,351 // and COMMIT would land on unrelated connections — the "transaction" is352 // a fiction, and worse, the connection that received BEGIN goes back to353 // the pool with an OPEN transaction, so an unrelated coroutine inherits354 // it and its writes get committed/rolled back by whoever holds that355 // connection next. With a pool present, pin a single dedicated356 // connection for the plan's whole lifetime.357 if ($this->pool !== null && !$isSqlite) {358 // Resolve the version BEFORE holding a connection (a cold adapter359 // runs a detection query that borrows its own connection — holding360 // one while waiting for a second is the pool-deadlock shape).361 $serverVersion = $this->adapter->getServerVersion();362 $pdo = $this->pool->pop();363 try {364 return $this->executeOperationsOn(365 new SingleConnectionAdapter($pdo, $serverVersion, $this->connectionName),366 $operations,367 $useTransaction,368 $isSqlite,369 // Drive the transaction through PDO, not a raw370 // `START TRANSACTION` query: PDO::inTransaction() only371 // tracks beginTransaction(), so a raw statement would make372 // BOTH the finally guard below and ConnectionPool::push()'s373 // hygiene blind — and this connection goes back to the pool.374 $pdo,375 );376 } finally {377 // Never return a connection to the pool mid-transaction: this378 // is the last line of defense if the body threw between BEGIN379 // and its own ROLLBACK (e.g. the ROLLBACK itself failed on a380 // dead connection).381 //382 // inTransaction() is inside the try as well — on a severed383 // connection the status check itself throws, and an unguarded384 // one here would both mask the original DDL error and skip the385 // push below, leaking the connection. The push therefore lives386 // in its own finally so it runs no matter what cleanup does.387 try {388 if ($pdo->inTransaction()) {389 $pdo->rollBack();390 }391 } catch (\Throwable) {392 // A dead connection cannot be cleaned — the pool's push()393 // hygiene discards it and frees its slot.394 } finally {395 $this->pool->push($pdo);396 }397 }398 }399400 return $this->executeOperationsOn($this->adapter, $operations, $useTransaction, $isSqlite);401 }402403 /**404 * @param DdlOperation[] $operations405 * @return DdlOperation[] Executed operations406 */407 private function executeOperationsOn(408 DatabaseAdapterInterface $adapter,409 array $operations,410 bool $useTransaction,411 bool $isSqlite,412 ?\PDO $txConnection = null,413 ): array {414 $executed = [];415 try {416 if ($useTransaction) {417 if ($txConnection !== null) {418 $txConnection->beginTransaction();419 } else {420 $adapter->query($isSqlite ? 'BEGIN' : 'START TRANSACTION');421 }422 }423424 foreach ($operations as $operation) {425 if ($isSqlite && $this->isSqlitePlaceholder($operation->sql)) {426 throw new \RuntimeException(427 "SQLite sync requires table recreation for unsupported operation: {$operation->description}",428 );429 }430431 $adapter->execute($operation->sql);432 $executed[] = $operation;433 }434435 if ($useTransaction) {436 if ($txConnection !== null) {437 $txConnection->commit();438 } else {439 $adapter->query('COMMIT');440 }441 }442 } catch (\Throwable $e) {443 if ($useTransaction) {444 try {445 if ($txConnection !== null) {446 if ($txConnection->inTransaction()) {447 $txConnection->rollBack();448 }449 } else {450 $adapter->query('ROLLBACK');451 }452 } catch (\Throwable) {453 // A failed ROLLBACK (dead connection) must not mask the454 // original failure — that is the exception worth seeing.455 }456 }457 throw $e;458 }459460 $this->auditLogger?->log($executed);461462 return $executed;463 }464465 /**466 * Check if SQL is a SQLite placeholder for unsupported operations.467 */468 private function isSqlitePlaceholder(string $sql): bool469 {470 return str_starts_with($sql, '-- SQLITE_');471 }472473 private function generateCreateTable(TableDefinition $table): string474 {475 $isSqlite = $this->isSqlite();476 $lines = [];477 $pk = null;478 $inlineFks = [];479480 foreach ($table->getColumns() as $col) {481 $line = ' ' . $this->generateColumnDdl($col);482 if ($col->isPrimaryKey) {483 $pk = $col;484 }485 $lines[] = $line;486 }487488 if ($pk !== null) {489 if ($isSqlite && $pk->pkStrategy === 'auto' && $this->isSqliteAutoIncrementPrimaryKey($pk)) {490 // SQLite: INTEGER PRIMARY KEY implies AUTOINCREMENT behavior491 // Already handled in generateColumnDdl492 } else {493 $q = $isSqlite ? SqlIdentifier::DOUBLE_QUOTE : SqlIdentifier::BACKTICK;494 $lines[] = ' PRIMARY KEY (' . SqlIdentifier::quote($pk->name, $q) . ')';495 }496 }497498 foreach ($table->getIndexes() as $index) {499 $name = $index->name ?? $this->generateIndexName($table->name, $index->columns, $index->unique);500 $q = $isSqlite ? SqlIdentifier::DOUBLE_QUOTE : SqlIdentifier::BACKTICK;501 $cols = implode(', ', SqlIdentifier::quoteAll($index->columns, $q));502 if ($isSqlite) {503 // SQLite: indexes are created separately, not inline in CREATE TABLE504 // We'll handle them after table creation505 } else {506 $prefix = $index->unique ? 'UNIQUE KEY' : 'KEY';507 $lines[] = " {$prefix} " . SqlIdentifier::quote($name) . " ({$cols})";508 }509 }510511 // Add inline FK constraints for SQLite (must be in CREATE TABLE)512 if ($isSqlite) {513 foreach ($table->getForeignKeys() as $fk) {514 $d = SqlIdentifier::DOUBLE_QUOTE;515 $lines[] = ' FOREIGN KEY (' . SqlIdentifier::quote($fk->column, $d) . ') REFERENCES '516 . SqlIdentifier::quote($fk->referencedTable, $d)517 . '(' . SqlIdentifier::quote($fk->referencedColumn, $d) . ')'518 . " ON DELETE {$fk->onDelete->value} ON UPDATE {$fk->onUpdate->value}";519 }520 }521522 $body = implode(",\n", $lines);523524 if ($isSqlite) {525 $quoted = SqlIdentifier::quote($table->name, SqlIdentifier::DOUBLE_QUOTE);526527 return "CREATE TABLE {$quoted} (\n{$body}\n)";528 }529530 $quoted = SqlIdentifier::quote($table->name);531532 return "CREATE TABLE {$quoted} (\n{$body}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";533 }534535 private function generateColumnDdl(ColumnDefinition $col): string536 {537 $isSqlite = $this->isSqlite();538 $q = $this->quoteChar();539 $type = $this->sqlType($col);540 $null = $col->nullable ? 'NULL' : 'NOT NULL';541542 // Auto-increment handling543 $auto = '';544 if ($col->isPrimaryKey && $col->pkStrategy === 'auto') {545 if ($isSqlite && $this->isSqliteAutoIncrementPrimaryKey($col)) {546 // SQLite: INTEGER PRIMARY KEY auto-increments implicitly547 // Type is already INTEGER from sqlType()548 $auto = ' PRIMARY KEY AUTOINCREMENT';549 // In SQLite, we skip the separate PRIMARY KEY clause550 // and the NULL clause for autoincrement PKs551 $deprecated = $col->isDeprecated ? " -- " . self::DEPRECATED_COMMENT : '';552 return SqlIdentifier::quote($col->name, $q) . " {$type}{$auto}{$deprecated}";553 } elseif ($col->type instanceof MySqlType && in_array($col->type, [MySqlType::Int, MySqlType::Bigint], true)) {554 $auto = ' AUTO_INCREMENT';555 }556 }557558 $default = $this->defaultClause($col);559 $deprecated = !$isSqlite && $col->isDeprecated ? " COMMENT '" . self::DEPRECATED_COMMENT . "'" : '';560561 return SqlIdentifier::quote($col->name, $q) . " {$type} {$null}{$auto}{$default}{$deprecated}";562 }563564 private function sqlType(ColumnDefinition $col): string565 {566 // Delegate to the type's own toSql() method567 return $col->type->toSql($col->length, $col->precision, $col->scale);568 }569570 private function isSqliteAutoIncrementPrimaryKey(ColumnDefinition $col): bool571 {572 if ($col->type instanceof SqliteType) {573 return in_array($col->type, [SqliteType::Int, SqliteType::Bigint], true);574 }575576 if ($col->type instanceof MySqlType) {577 return in_array($col->type, [MySqlType::Int, MySqlType::Bigint], true);578 }579580 return false;581 }582583 private function defaultClause(ColumnDefinition $col): string584 {585 if ($col->default === null && !$col->nullable) {586 return '';587 }588589 if ($col->default === null) {590 return ' DEFAULT NULL';591 }592593 if (is_bool($col->default)) {594 return ' DEFAULT ' . ($col->default ? '1' : '0');595 }596597 if (is_int($col->default) || is_float($col->default)) {598 return ' DEFAULT ' . $col->default;599 }600601 return " DEFAULT '" . str_replace("'", "''", (string) $col->default) . "'";602 }603604 private function generateAddColumn(string $tableName, ColumnDefinition $col): string605 {606 $q = $this->quoteChar();607 $ddl = $this->generateColumnDdl($col);608609 return 'ALTER TABLE ' . SqlIdentifier::quote($tableName, $q) . " ADD COLUMN {$ddl}";610 }611612 private function generateAlterColumn(string $tableName, ColumnDefinition $col): string613 {614 if ($this->isSqlite()) {615 // SQLite has very limited ALTER TABLE support.616 // For column alterations, we need to recreate the table.617 // This is handled specially in the execution phase.618 return "-- SQLITE_ALTER_COLUMN:{$tableName}:{$col->name}";619 }620621 $ddl = $this->generateColumnDdl($col);622623 return 'ALTER TABLE ' . SqlIdentifier::quote($tableName) . " MODIFY COLUMN {$ddl}";624 }625626 /**627 * Generate MODIFY COLUMN DDL that marks a live DB column as deprecated.628 *629 * MySQL MODIFY COLUMN requires the complete column definition — omitting630 * the type causes MySQL to silently reset it to a default. We reconstruct631 * the full definition from DbColumnState (the live DB snapshot read via632 * INFORMATION_SCHEMA) and append the deprecation comment.633 */634 private function generateDeprecationDdl(string $tableName, DbColumnState $col): string635 {636 if ($this->isSqlite()) {637 // SQLite doesn't support column comments.638 // We skip deprecation marking for SQLite.639 return "-- SQLITE_DEPRECATION_NOT_SUPPORTED";640 }641642 // columnType from INFORMATION_SCHEMA is the authoritative full type string643 // (e.g. "varchar(255)", "decimal(10,2)", "tinyint(1)") — use it verbatim.644 $null = $col->nullable ? 'NULL' : 'NOT NULL';645 $auto = $col->isAutoIncrement ? ' AUTO_INCREMENT' : '';646 $default = '';647648 if ($col->defaultValue !== null) {649 $default = " DEFAULT '" . str_replace("'", "''", $col->defaultValue) . "'";650 } elseif ($col->nullable) {651 $default = ' DEFAULT NULL';652 }653654 $comment = " COMMENT '" . self::DEPRECATED_COMMENT . "'";655656 $ddl = SqlIdentifier::quote($col->name) . " {$col->columnType} {$null}{$auto}{$default}{$comment}";657658 return 'ALTER TABLE ' . SqlIdentifier::quote($tableName) . " MODIFY COLUMN {$ddl}";659 }660661 /**662 * @param \Semitexa\Orm\Domain\Model\IndexDefinition $index663 */664 private function generateAddIndex(string $tableName, $index, string $name): string665 {666 $q = $this->quoteChar();667 $cols = implode(', ', SqlIdentifier::quoteAll($index->columns, $q));668 $type = $index->unique ? 'UNIQUE INDEX' : 'INDEX';669670 if ($this->isSqlite()) {671 return "CREATE {$type} " . SqlIdentifier::quote($name, $q)672 . ' ON ' . SqlIdentifier::quote($tableName, $q) . " ({$cols})";673 }674675 return 'ALTER TABLE ' . SqlIdentifier::quote($tableName)676 . " ADD {$type} " . SqlIdentifier::quote($name) . " ({$cols})";677 }678679 private function generateAddForeignKey(ForeignKeyDefinition $fk): string680 {681 $q = $this->quoteChar();682683 if ($this->isSqlite()) {684 // SQLite: FK constraints must be added during table creation.685 // For existing tables, we need to recreate the table.686 return "-- SQLITE_ADD_FK:{$fk->table}:{$fk->column}:{$fk->referencedTable}:{$fk->referencedColumn}";687 }688689 $name = $fk->constraintName();690 return sprintf(691 'ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s(%s) ON DELETE %s ON UPDATE %s',692 SqlIdentifier::quote($fk->table),693 SqlIdentifier::quote($name),694 SqlIdentifier::quote($fk->column),695 SqlIdentifier::quote($fk->referencedTable),696 SqlIdentifier::quote($fk->referencedColumn),697 $fk->onDelete->value,698 $fk->onUpdate->value,699 );700 }701702 /**703 * Topological sort of tables by FK dependencies (BelongsTo relations).704 * Tables without dependencies come first.705 *706 * @param TableDefinition[] $tables707 * @return TableDefinition[]708 */709 private function topologicalSort(array $tables): array710 {711 $tableMap = [];712 foreach ($tables as $table) {713 $tableMap[$table->name] = $table;714 }715716 // Build a reverse map: FQCN resource class → table name.717 // Relations store target as a FQCN (e.g. App\Resource\UserResource), but718 // $tableMap is keyed by table name (e.g. 'users'). Without this mapping719 // the dependency lookup always misses, leaving tables in arbitrary order.720 $classToTable = [];721 foreach ($tables as $table) {722 foreach ($table->getRelations() as $relation) {723 $targetClass = $relation->target;724 if (isset($classToTable[$targetClass])) {725 continue;726 }727 try {728 $meta = \Semitexa\Orm\Domain\Model\ResourceMetadata::for($targetClass);729 $classToTable[$targetClass] = $meta->getTableName();730 } catch (\Throwable) {731 // Target class not available in this context — skip gracefully732 }733 }734 }735736 // Build dependency graph using table names throughout737 $deps = [];738 foreach ($tables as $table) {739 $deps[$table->name] = [];740 foreach ($table->getRelations() as $relation) {741 if ($relation->type === 'belongs_to') {742 $targetTable = $classToTable[$relation->target] ?? null;743 if ($targetTable !== null && isset($tableMap[$targetTable])) {744 $deps[$table->name][] = $targetTable;745 }746 }747 }748 }749750 $sorted = [];751 $visited = [];752 $visiting = [];753754 $visit = function (string $name) use (&$visit, &$sorted, &$visited, &$visiting, $tableMap, $deps): void {755 if (isset($visited[$name])) {756 return;757 }758 if (isset($visiting[$name])) {759 // Circular dependency — just add it; CREATE TABLE handles FK separately760 return;761 }762763 $visiting[$name] = true;764765 foreach ($deps[$name] ?? [] as $dep) {766 if (isset($tableMap[$dep])) {767 $visit($dep);768 }769 }770771 unset($visiting[$name]);772 $visited[$name] = true;773774 if (isset($tableMap[$name])) {775 $sorted[] = $tableMap[$name];776 }777 };778779 foreach ($tableMap as $name => $table) {780 $visit($name);781 }782783 return $sorted;784 }785786 /**787 * Determine if column alteration is destructive.788 *789 * @param string[] $changes790 */791 private function isAlterDestructive(array $changes): bool792 {793 foreach ($changes as $change) {794 if (str_starts_with($change, 'type:')) {795 // Type change is potentially destructive — check if it's widening796 if ($this->isTypeWidening($change)) {797 continue;798 }799 return true;800 }801 }802803 return false;804 }805806 private function isTypeWidening(string $change): bool807 {808 // Extract old → new from "type: old → new"809 if (!preg_match('/type:\s*(.+?)\s*→\s*(.+)/', $change, $matches)) {810 return false;811 }812813 $old = strtolower(trim($matches[1]));814 $new = strtolower(trim($matches[2]));815816 // VARCHAR(N) → VARCHAR(M) where M >= N817 if (preg_match('/^varchar\((\d+)\)$/', $old, $oldM) && preg_match('/^varchar\((\d+)\)$/', $new, $newM)) {818 return (int) $newM[1] >= (int) $oldM[1];819 }820821 // VARCHAR(any) → TEXT/MEDIUMTEXT/LONGTEXT — always wider822 if (str_starts_with($old, 'varchar(') && in_array($new, ['text', 'mediumtext', 'longtext'], true)) {823 return true;824 }825826 // TEXT → MEDIUMTEXT → LONGTEXT827 $textOrder = ['text' => 0, 'mediumtext' => 1, 'longtext' => 2];828 if (isset($textOrder[$old], $textOrder[$new])) {829 return $textOrder[$new] >= $textOrder[$old];830 }831832 // Integer widening order: TINYINT → SMALLINT → INT → BIGINT833 $intOrder = ['tinyint' => 0, 'smallint' => 1, 'int' => 2, 'bigint' => 3];834 $oldBase = preg_replace('/\(\d+\)/', '', $old); // strip (1) from tinyint(1)835 if (isset($intOrder[$oldBase], $intOrder[$new])) {836 return $intOrder[$new] >= $intOrder[$oldBase];837 }838839 // Float widening: FLOAT → DOUBLE840 if ($old === 'float' && $new === 'double') {841 return true;842 }843844 // CHAR(N) → CHAR(M) where M >= N845 if (preg_match('/^char\((\d+)\)$/', $old, $oldM) && preg_match('/^char\((\d+)\)$/', $new, $newM)) {846 return (int) $newM[1] >= (int) $oldM[1];847 }848849 // CHAR(any) → VARCHAR(any) — always wider (fixed → variable)850 if (str_starts_with($old, 'char(') && str_starts_with($new, 'varchar(')) {851 return true;852 }853854 return false;855 }856857 /**858 * @param string[] $columns859 */860 private function generateIndexName(string $tableName, array $columns, bool $unique): string861 {862 $prefix = $unique ? 'uniq' : 'idx';863 return $prefix . '_' . $tableName . '_' . implode('_', $columns);864 }865866 /**867 * Check if the current adapter is SQLite.868 */869 private function isSqlite(): bool870 {871 return $this->adapter instanceof \Semitexa\Orm\Adapter\SqliteAdapter;872 }873874 /**875 * Get the quote character for the current database.876 */877 private function quoteChar(): string878 {879 return $this->isSqlite() ? '"' : '`';880 }881}882
1<?php23declare(strict_types=1);45namespace Semitexa\Orm\Application\Service\Sync;67use Semitexa\Orm\Domain\Model\DdlOperation;89class AuditLogger10{11 public function __construct(12 private readonly string $historyDir,13 ) {}1415 /**16 * @param list<DdlOperation> $operations17 */18 public function log(array $operations): void19 {20 if ($operations === []) {21 return;22 }2324 if (!is_dir($this->historyDir)) {25 mkdir($this->historyDir, 0755, true);26 }2728 $now = \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true)));29 $timestamp = $now !== false ? $now->format('Y-m-d_H-i-s.v') : date('Y-m-d_H-i-s') . '.' . substr((string) microtime(), 2, 3);30 $filename = $this->historyDir . '/' . $timestamp . '_sync.json';3132 $entries = [];33 foreach ($operations as $op) {34 $entries[] = [35 'type' => $op->type->value,36 'table' => $op->tableName,37 'destructive' => $op->isDestructive,38 'description' => $op->description,39 'sql' => $op->sql,40 ];41 }4243 $data = [44 'timestamp' => date('c'),45 'operations_count' => count($operations),46 'operations' => $entries,47 ];4849 file_put_contents($filename, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));5051 // Also write a .sql file for DevOps convenience52 $sqlFilename = $this->historyDir . '/' . $timestamp . '_sync.sql';53 $sqlLines = [54 '-- Semitexa ORM Sync — ' . date('c'),55 '-- Operations: ' . count($operations),56 '',57 ];5859 foreach ($operations as $op) {60 $sqlLines[] = '-- ' . $op->description;61 $sqlLines[] = $op->sql . ';';62 $sqlLines[] = '';63 }6465 file_put_contents($sqlFilename, implode("\n", $sqlLines));66 }67}68
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\ConnectCircuitBreaker;14use Semitexa\Orm\Adapter\ConnectionPool;15use Semitexa\Orm\Adapter\ConnectionPoolInterface;16use Semitexa\Orm\Domain\Model\ConnectionConfig;17use Semitexa\Orm\Adapter\DatabaseAdapterInterface;18use Semitexa\Orm\Adapter\MysqlAdapter;19use Semitexa\Orm\Adapter\QueryRecorder;20use Semitexa\Orm\Adapter\NullConnectionPool;21use Semitexa\Orm\Adapter\SingleConnectionPool;22use Semitexa\Orm\Adapter\SqliteAdapter;23use Semitexa\Orm\Application\Service\Schema\SqliteSchemaComparator;24use Semitexa\Orm\Domain\Model\OrmBootstrapReport;25use Semitexa\Orm\Application\Service\OrmBootstrapValidator;26use Semitexa\Orm\Application\Service\Hydration\ResourceModelHydrator;27use Semitexa\Orm\Application\Service\Hydration\ResourceModelRelationLoader;28use Semitexa\Orm\Application\Service\Mapping\MapperRegistry;29use Semitexa\Orm\Metadata\ResourceModelMetadataRegistry;30use Semitexa\Orm\Application\Service\Persistence\AggregateWriteEngine;31use Semitexa\Orm\Query\ResourceModelQuery;32use Semitexa\Orm\Repository\DomainRepository;33use Semitexa\Orm\Application\Service\Schema\SchemaCollector;34use Semitexa\Orm\Application\Service\Schema\SchemaComparator;35use Semitexa\Orm\Domain\Contract\SchemaComparatorInterface;36use Semitexa\Orm\Application\Service\Sync\AuditLogger;37use Semitexa\Orm\Application\Service\Sync\SeedRunner;38use Semitexa\Orm\Application\Service\Sync\SyncEngine;39use Semitexa\Orm\Application\Service\Transaction\TransactionAwareAdapter;40use Semitexa\Orm\Application\Service\Transaction\TransactionManager;4142class OrmManager43{44 private ClassDiscovery $classDiscovery;45 private ?ConnectionPoolInterface $pool = null;4647 private ?ConnectCircuitBreaker $connectBreaker = null;48 private ?DatabaseAdapterInterface $adapter = null;49 private ?SchemaCollector $schemaCollector = null;50 private ?SchemaComparatorInterface $schemaComparator = null;51 private ?SyncEngine $syncEngine = null;52 private ?TransactionManager $transactionManager = null;53 private ?TransactionAwareAdapter $transactionAwareAdapter = null;54 private ?SeedRunner $seedRunner = null;55 private ?MapperRegistry $mapperRegistry = null;56 private ?ResourceModelMetadataRegistry $resourceModelMetadataRegistry = null;57 private ?ResourceModelHydrator $resourceModelHydrator = null;58 private ?ResourceModelRelationLoader $resourceModelRelationLoader = null;59 private ?AggregateWriteEngine $aggregateWriteEngine = null;60 private ?OrmBootstrapValidator $bootstrapValidator = null;6162 /**63 * Lazy resolver for the default EventDispatcher, set once per worker at bootstrap64 * by the framework container (see ContainerFactory). It is invoked lazily — at65 * first write-engine construction — because the dispatcher is a discovered service66 * that only exists after the container is built, whereas the default OrmManager is67 * constructed during bootstrap (before build). This makes EVERY default OrmManager68 * carry the dispatcher: the explicit ConnectionRegistry::manager() instance AND any69 * bare `new OrmManager()` repository fallback — without a compile-time coupling from70 * orm to the core container.71 *72 * @var (\Closure(): ?EventDispatcherInterface)|null73 */74 private static ?\Closure $defaultEventDispatcherResolver = null;7576 public function __construct(77 ?ClassDiscovery $classDiscovery = null,78 private readonly ?ConnectionConfig $config = null,79 private readonly string $connectionName = 'default',80 private readonly ?EventDispatcherInterface $events = null,81 ) {82 $this->classDiscovery = $classDiscovery ?? new ClassDiscovery();83 }8485 public function getClassDiscovery(): ClassDiscovery86 {87 return $this->classDiscovery;88 }8990 public function getAdapter(): DatabaseAdapterInterface91 {92 // A request-time getAdapter() may hold an adapter built at bootstrap over a93 // stale SingleConnectionPool. Re-check before handing it out: the upgrade94 // nulls $this->adapter so it rebuilds below over the coroutine-safe pool.95 if ($this->adapter !== null && $this->pool !== null) {96 $this->ensureCoroutineSafePool();97 }9899 if ($this->adapter === null) {100 $driver = $this->resolveDriver();101102 if ($driver === 'sqlite') {103 $this->adapter = $this->createSqliteAdapter();104 } else {105 $this->adapter = new MysqlAdapter($this->getPool(), $this->connectionName);106 }107 }108109 return $this->adapter;110 }111112 /**113 * Start recording executed queries, for development tooling.114 *115 * Off by default and never enabled by the framework itself. The recorded log116 * is an unbounded in-memory array, so this is only safe for the span of one117 * request a developer is deliberately inspecting — {@see drainQueryLog()}118 * both reads and clears it.119 *120 * IMPORTANT: an OrmManager lives for the whole worker, and so does this flag.121 * Under concurrent load a trace would therefore also capture queries issued by122 * other coroutines on the same worker. That is acceptable for the one-developer123 * one-request case this exists for, and wrong for anything else — which is why124 * nothing in the framework turns it on.125 */126 public function enableQueryLog(): void127 {128 QueryRecorder::start();129 }130131 /**132 * Read and clear the recorded queries. Returns an empty list when recording133 * was never started.134 *135 * @return list<array{sql: string, params: array<mixed>, timeMs: float}>136 */137 public function drainQueryLog(): array138 {139 return QueryRecorder::drain();140 }141142 /**143 * Stop recording and drop the wrapper, so the unbounded log cannot keep144 * growing once the request that asked for it has finished.145 */146 public function disableQueryLog(): void147 {148 QueryRecorder::stop();149 }150151 public function getPool(): ConnectionPoolInterface152 {153 if ($this->pool === null) {154 $driver = $this->resolveDriver();155156 // SQLite doesn't need connection pooling157 if ($driver === 'sqlite') {158 throw new \LogicException('getPool() is not applicable for SQLite adapter. Use getAdapter() directly.');159 }160161 return $this->pool = $this->createPool();162 }163164 $this->ensureCoroutineSafePool();165166 return $this->pool ?? throw new \LogicException('The connection pool was dropped while it was being healed.');167 }168169 public function getSchemaCollector(): SchemaCollector170 {171 if ($this->schemaCollector === null) {172 $this->schemaCollector = new SchemaCollector(173 $this->classDiscovery,174 $this->resolveDriver(),175 $this->connectionName,176 );177 }178179 return $this->schemaCollector;180 }181182 public function getSchemaComparator(): SchemaComparatorInterface183 {184 // Like getTransactionManager(): a comparator memoized at bootstrap185 // captured an adapter over a possibly-stale SingleConnectionPool, and186 // the sync/migration path reaches it without ever passing through187 // getPool()/getAdapter() — so re-check here or the self-heal never188 // fires for it. The swap nulls $this->schemaComparator, and the block189 // below rebuilds it over the healed adapter.190 if ($this->schemaComparator !== null && $this->pool !== null) {191 $this->ensureCoroutineSafePool();192 }193194 if ($this->schemaComparator === null) {195 if ($this->resolveDriver() === 'sqlite') {196 $this->schemaComparator = new SqliteSchemaComparator(197 $this->getAdapter(),198 $this->resolveIgnoreTables(),199 );200 } else {201 $this->schemaComparator = new SchemaComparator(202 $this->getAdapter(),203 $this->getDatabaseName(),204 $this->resolveIgnoreTables(),205 );206 }207 }208209 return $this->schemaComparator;210 }211212 public function getSyncEngine(): SyncEngine213 {214 // Same self-heal re-check as getTransactionManager(): the sync flows215 // (orm:sync, the update-system migration gateway) reach this engine216 // via getSchemaCollector()/getSchemaComparator() and never touch217 // getPool()/getAdapter() after memoization, so an engine built during218 // bootstrap over a stale SingleConnectionPool would be served into219 // coroutine context unhealed. ensureCoroutineSafePool() nulls220 // $this->syncEngine on a swap; the block below rebuilds it over the221 // healed pool + adapter.222 if ($this->syncEngine !== null && $this->pool !== null) {223 $this->ensureCoroutineSafePool();224 }225226 if ($this->syncEngine === null) {227 $historyDir = ProjectRoot::get() . '/var/migrations/history';228 // The pool lets execute() pin ONE dedicated connection for a whole229 // DDL plan (BEGIN/DDL/COMMIT must share a connection; a pooled230 // adapter would spread them and return an open-tx connection to the231 // pool). SQLite has no pool — its adapter already owns a single PDO.232 $this->syncEngine = new SyncEngine(233 $this->getAdapter(), new AuditLogger($historyDir),234 $this->resolveDriver() === 'sqlite' ? null : $this->getPool(),235 $this->connectionName,236 );237 }238239 return $this->syncEngine;240 }241242 /**243 * The adapter for the repository READ path: routes queries to the current244 * coroutine's transaction connection when one is active, so reads inside245 * TransactionManager::run() see their own uncommitted writes and never246 * borrow a second pooled connection while holding the first. Resolves the247 * real adapter/manager lazily per call, so pool self-heal is honored.248 * NOT used for getAdapter() consumers — they dialect-branch on the249 * concrete adapter class (see TransactionAwareAdapter docblock).250 */251 public function getQueryAdapter(): TransactionAwareAdapter252 {253 return $this->transactionAwareAdapter ??= new TransactionAwareAdapter(254 fn (): DatabaseAdapterInterface => $this->getAdapter(),255 fn (): TransactionManager => $this->getTransactionManager(),256 );257 }258259 public function getTransactionManager(): TransactionManager260 {261 // A memoized TransactionManager may wrap an adapter/pool built at bootstrap262 // over a stale SingleConnectionPool (before Swoole hooks were enabled). Like263 // getPool()/getAdapter(), re-check before handing it out so a request that264 // enters through the transaction path also self-heals onto the265 // coroutine-safe pool. ensureCoroutineSafePool() preserves the266 // active-transaction guard and nulls $this->transactionManager on a swap,267 // so the block below rebuilds it over the fresh pool + adapter.268 if ($this->transactionManager !== null) {269 $this->ensureCoroutineSafePool();270 }271272 if ($this->transactionManager === null) {273 $driver = $this->resolveDriver();274275 // SQLite: TransactionManager takes a dedicated SQLite code path276 // and does not actually consult the pool — supply a named277 // NullConnectionPool so any accidental pop() throws loudly.278 $pool = $driver === 'sqlite'279 ? new NullConnectionPool()280 : $this->getPool();281282 $this->transactionManager = new TransactionManager(283 $pool,284 $this->getAdapter(),285 connectionName: $this->connectionName,286 );287 }288289 return $this->transactionManager;290 }291292 public function getSeedRunner(): SeedRunner293 {294 if ($this->seedRunner === null) {295 $this->seedRunner = new SeedRunner($this->getAdapter(), $this->classDiscovery);296 }297298 return $this->seedRunner;299 }300301 public function getMapperRegistry(): MapperRegistry302 {303 if ($this->mapperRegistry === null) {304 // Build FIRST, memoize LAST. build() walks the classmap through305 // ClassDiscovery, whose autoloads are file IO — a coroutine306 // SUSPENSION point under SWOOLE_HOOK_ALL. Memoizing the empty307 // registry before build() (the old order) let a concurrent308 // coroutine on the same manager observe a half-built registry and309 // die with MissingMapperException — reproduced as intermittent310 // 500s on the first concurrent burst after a worker boot. Losing311 // the ??= race is fine: both registries are complete, the first312 // one wins, the duplicate is GC'd.313 $registry = new MapperRegistry($this->classDiscovery);314 $registry->build();315 $this->mapperRegistry ??= $registry;316 }317318 return $this->mapperRegistry;319 }320321 public function getResourceModelMetadataRegistry(): ResourceModelMetadataRegistry322 {323 if ($this->resourceModelMetadataRegistry === null) {324 $this->resourceModelMetadataRegistry = new ResourceModelMetadataRegistry();325 }326327 return $this->resourceModelMetadataRegistry;328 }329330 public function getResourceModelHydrator(): ResourceModelHydrator331 {332 if ($this->resourceModelHydrator === null) {333 $this->resourceModelHydrator = new ResourceModelHydrator(334 metadataRegistry: $this->getResourceModelMetadataRegistry(),335 );336 }337338 return $this->resourceModelHydrator;339 }340341 public function getResourceModelRelationLoader(): ResourceModelRelationLoader342 {343 if ($this->resourceModelRelationLoader === null) {344 $this->resourceModelRelationLoader = new ResourceModelRelationLoader(345 $this->getQueryAdapter(),346 $this->getResourceModelHydrator(),347 $this->getResourceModelMetadataRegistry(),348 );349 }350351 return $this->resourceModelRelationLoader;352 }353354 public function getAggregateWriteEngine(): AggregateWriteEngine355 {356 if ($this->aggregateWriteEngine === null) {357 $this->aggregateWriteEngine = new AggregateWriteEngine(358 $this->getAdapter(),359 $this->getResourceModelHydrator(),360 $this->getResourceModelMetadataRegistry(),361 // Lazy on purpose: this engine is memoized, and a dispatcher362 // captured here freezes whatever was resolvable at FIRST write —363 // in CLI workers that is before any bootstrap registered the364 // resolver, silently killing auto-publish for the whole process.365 fn (): ?EventDispatcherInterface => $this->getEventDispatcher(),366 // Lazy for the same reason: getTransactionManager() self-heals367 // onto a fresh pool; a manager captured at first write would368 // pin the stale one.369 fn (): TransactionManager => $this->getTransactionManager(),370 );371 }372373 return $this->aggregateWriteEngine;374 }375376 /**377 * Register the lazy default EventDispatcher resolver (framework bootstrap only).378 * Invoked once per worker by ContainerFactory once the container can resolve379 * EventDispatcherInterface. Pass null to clear (tests).380 *381 * @param (\Closure(): ?EventDispatcherInterface)|null $resolver382 */383 public static function setDefaultEventDispatcherResolver(?\Closure $resolver): void384 {385 self::$defaultEventDispatcherResolver = $resolver;386 }387388 /**389 * Resolve the EventDispatcher this manager dispatches resource-changed events390 * through: an explicitly injected one wins (P2's ctor param / direct tests),391 * otherwise the framework's lazy default resolver (the bootstrap-wired one),392 * otherwise null (no container bootstrapped → dispatch stays a silent no-op,393 * exactly as before this brick).394 */395 public function getEventDispatcher(): ?EventDispatcherInterface396 {397 if ($this->events !== null) {398 return $this->events;399 }400401 if (self::$defaultEventDispatcherResolver !== null) {402 return (self::$defaultEventDispatcherResolver)();403 }404405 return null;406 }407408 public function getBootstrapValidator(): OrmBootstrapValidator409 {410 if ($this->bootstrapValidator === null) {411 $this->bootstrapValidator = new OrmBootstrapValidator(412 classDiscovery: $this->classDiscovery,413 metadataRegistry: $this->getResourceModelMetadataRegistry(),414 mapperRegistry: $this->getMapperRegistry(),415 );416 }417418 return $this->bootstrapValidator;419 }420421 public function validateBootstrap(): OrmBootstrapReport422 {423 return $this->getBootstrapValidator()->validate();424 }425426 /**427 * A query over a resource model alone (a read that projects rows itself, no428 * domain model); state the tenant scope for a tenant-scoped model.429 *430 * @param class-string $resourceModelClass431 */432 public function query(string $resourceModelClass): ResourceModelQuery433 {434 return new ResourceModelQuery(435 $resourceModelClass,436 $this->getQueryAdapter(),437 $this->getResourceModelHydrator(),438 $this->getResourceModelRelationLoader(),439 $this->getResourceModelMetadataRegistry(),440 mapperRegistry: $this->getMapperRegistry(),441 );442 }443444 /**445 * @param class-string $resourceModelClass446 * @param class-string $domainModelClass447 *448 * @api449 */450 public function repository(string $resourceModelClass, string $domainModelClass): DomainRepository451 {452 return new DomainRepository(453 resourceModelClass: $resourceModelClass,454 domainModelClass: $domainModelClass,455 adapter: $this->getQueryAdapter(),456 mapperRegistry: $this->getMapperRegistry(),457 hydrator: $this->getResourceModelHydrator(),458 relationLoader: $this->getResourceModelRelationLoader(),459 metadataRegistry: $this->getResourceModelMetadataRegistry(),460 writeEngine: $this->getAggregateWriteEngine(),461 );462 }463464 public function getDatabaseName(): string465 {466 if ($this->config !== null) {467 if ($this->config->driver === 'sqlite') {468 return $this->config->sqliteMemory469 ? ':memory:'470 : ($this->config->sqlitePath ?? 'sqlite');471 }472473 return $this->config->database;474 }475476 if ($this->resolveDriver() === 'sqlite') {477 $memory = Environment::getEnvValue('DB_SQLITE_MEMORY');478 if (in_array(strtolower((string) $memory), ['1', 'true', 'yes'], true)) {479 return ':memory:';480 }481482 return Environment::getEnvValue('DB_SQLITE_PATH', ProjectRoot::get() . '/var/database/semitexa.sqlite')483 ?? ProjectRoot::get() . '/var/database/semitexa.sqlite';484 }485486 return Environment::getEnvValue('DB_DATABASE', 'semitexa') ?? 'semitexa';487 }488489 public function shutdown(): void490 {491 $this->pool?->close();492 $this->pool = null;493 $this->adapter = null;494 // An explicit teardown starts over: the next getPool() makes a fresh495 // connect attempt instead of inheriting an open circuit.496 $this->connectBreaker = null;497 }498499 /**500 * Destructors run wherever GC happens to fire — mid-container-build, in501 * another coroutine burst's world, or with no coroutine at all — and a502 * Swoole Channel touched from the wrong context raises a C-level503 * "must call constructor first" fatal that BYPASSES try/catch (and,504 * inside a destructor, is uncatchable by any frame). So the destructor505 * only DROPS references: releasing the Channel lets refcounting free the506 * queued PDO connections exactly as a drain would, minus the fatal.507 * Deliberate teardown at a known-safe point stays {@see shutdown()}.508 */509 public function __destruct()510 {511 $this->pool = null;512 $this->adapter = null;513 }514515 /**516 * Run a callback with a managed OrmManager instance.517 * shutdown() is guaranteed via finally — even if the callback throws.518 *519 * @template T520 * @param callable(OrmManager): T $callback521 * @return T522 */523 public static function run(callable $callback): mixed524 {525 $orm = new self();526 try {527 return $callback($orm);528 } finally {529 $orm->shutdown();530 }531 }532533 /**534 * Tables sync must not consider for dropping.535 *536 * Two sources, merged. `ORM_IGNORE_TABLES` is the operator-level escape537 * hatch for tables belonging to something outside Semitexa. `#[SelfManagedTable]`538 * is the package-level statement of ownership, for tables a Semitexa package539 * creates and migrates itself and which therefore have no `#[FromTable]`540 * resource to claim them.541 *542 * Without the second source, such a table is indistinguishable from one543 * abandoned by a deleted resource, so every sync marks it deprecated and the544 * next destructive run drops it — silently, since the marker is only a545 * comment.546 *547 * @return list<string>548 */549 private function resolveIgnoreTables(): array550 {551 $tables = [];552553 $raw = Environment::getEnvValue('ORM_IGNORE_TABLES', '');554 if ($raw !== '') {555 // Filter on emptiness explicitly: a bare array_filter() would also556 // drop a table literally named "0".557 $tables = array_filter(558 array_map('trim', explode(',', $raw)),559 static fn (string $table): bool => $table !== '',560 );561 }562563 foreach ($this->discoverSelfManagedTables() as $table) {564 $tables[] = $table;565 }566567 return array_values(array_unique(array_filter(568 $tables,569 static fn (string $table): bool => $table !== '',570 )));571 }572573 /** @return list<string> */574 private function discoverSelfManagedTables(): array575 {576 $tables = [];577578 try {579 // The configured instance, not a fresh default: a caller with custom580 // discovery roots finds its #[FromTable] resources through this one,581 // and ownership declarations must be found the same way or the two582 // views of the codebase disagree — silently re-exposing the bug this583 // attribute exists to fix.584 $classes = $this->classDiscovery->findClassesWithAttribute(SelfManagedTable::class);585 } catch (\Throwable) {586 // Discovery is a convenience here, not a correctness requirement: if587 // it cannot run, fall back to the env list rather than failing the588 // whole sync. The cost is the pre-existing behaviour, not worse.589 return [];590 }591592 foreach ($classes as $class) {593 // class_exists() also narrows string to class-string for static594 // analysis, and skips anything discovery listed that cannot load.595 if (!class_exists($class)) {596 continue;597 }598599 foreach ((new \ReflectionClass($class))->getAttributes(SelfManagedTable::class) as $attribute) {600 $table = trim($attribute->newInstance()->table);601 if ($table !== '') {602 $tables[] = $table;603 }604 }605 }606607 return $tables;608 }609610 /**611 * PDO options for pooled MySQL connections.612 *613 * ATTR_TIMEOUT is the CONNECT timeout for pdo_mysql: without it a614 * hung/unreachable server parks the connecting coroutine indefinitely615 * while it holds a claimed pool slot (the pop() timeout protects waiters,616 * never the holder).617 *618 * The query ceiling is deliberately NOT here. It cannot ride619 * MYSQL_ATTR_INIT_COMMAND: the session variable's name is flavor-specific,620 * and an init command naming an unknown one fails inside the PDO621 * constructor, so every pooled and warm-up connection would throw. It is622 * applied after connect instead — see {@see applyQueryTimeout()}.623 * $queryTimeout is still accepted so both halves of the timeout contract624 * read as one call signature, and so this docblock is where the next625 * reader looks before re-adding the init command.626 *627 * Residual risk either way: pdo_mysql has no client-side READ timeout, so628 * a network black-hole mid-query is bounded only by TCP keepalive.629 *630 * @param float $queryTimeout Intentionally unused — see above.631 *632 * @return array<int, mixed>633 */634 public static function pdoOptions(float $connectTimeout, float $queryTimeout): array635 {636 $options = [637 \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,638 \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,639 \PDO::ATTR_EMULATE_PREPARES => false,640 ];641642 if ($connectTimeout > 0) {643 $options[\PDO::ATTR_TIMEOUT] = max(1, (int) ceil($connectTimeout));644 }645646 return $options;647 }648649 /**650 * Apply the per-session query ceiling to a freshly opened connection.651 *652 * NOT a MYSQL_ATTR_INIT_COMMAND: the variable's name differs by server653 * flavor, and an init command naming an unknown variable fails INSIDE the654 * PDO constructor — every pooled and warm-up connection would throw, so an655 * opt-in timeout knob would cost a MariaDB deployment all database access.656 * ATTR_SERVER_VERSION comes from the connection handshake, so choosing the657 * statement here costs no extra round trip.658 *659 * MySQL 5.7.8+ : max_execution_time, milliseconds, SELECT-only.660 * MariaDB 10.1+: max_statement_time, SECONDS (fractional allowed).661 *662 * Failure is logged and swallowed: an unusable ceiling is a degraded663 * safeguard, not a reason to refuse the connection.664 */665 public static function applyQueryTimeout(\PDO $pdo, float $queryTimeout): void666 {667 if ($queryTimeout <= 0) {668 return;669 }670671 try {672 $rawVersion = $pdo->getAttribute(\PDO::ATTR_SERVER_VERSION);673 } catch (\Throwable) {674 return;675 }676677 if (!is_scalar($rawVersion)) {678 return;679 }680681 $version = (string) $rawVersion;682683 $isMariaDb = stripos($version, 'mariadb') !== false;684685 if ($isMariaDb) {686 $statement = sprintf('SET SESSION max_statement_time=%F', $queryTimeout);687 } else {688 // MySQL below 5.7.8 has no equivalent — leave it unset rather than689 // failing the connection.690 if (version_compare(self::numericServerVersion($version), '5.7.8', '<')) {691 return;692 }693694 // Never round down to 0: MySQL reads max_execution_time=0 as695 // "no limit", so a sub-millisecond ceiling would silently mean696 // none at all. Same rule as ATTR_TIMEOUT's max(1, ceil(...)).697 $statement = sprintf('SET SESSION max_execution_time=%d', max(1, (int) round($queryTimeout * 1000)));698 }699700 try {701 $pdo->exec($statement);702 } catch (\Throwable $e) {703 StaticLoggerBridge::warning('orm', 'Could not apply the query timeout for this connection.', [704 'server_version' => $version,705 'statement' => $statement,706 'error' => $e->getMessage(),707 ]);708 }709 }710711 /** Leading dotted version from a server version string ("8.0.35-log" -> "8.0.35"). */712 private static function numericServerVersion(string $version): string713 {714 return preg_match('/^(\d+\.\d+\.\d+)/', $version, $m) === 1 ? $m[1] : '0.0.0';715 }716717 private function createPool(): ConnectionPoolInterface718 {719 // Ensure Swoole workers/coroutines resolve DB_* values from the project env files.720 if (class_exists(\Swoole\Coroutine::class, false)) {721 ProjectRoot::reset();722 Environment::syncEnvFromFiles();723 }724725 if ($this->config !== null) {726 $host = $this->config->cliHost && !$this->isRunningInDocker()727 ? $this->config->cliHost728 : $this->config->host;729 $port = $this->config->cliPort && !$this->isRunningInDocker()730 ? $this->config->cliPort731 : $this->config->port;732 $database = $this->config->database;733 $username = $this->config->username;734 $password = $this->config->password;735 $charset = $this->config->charset;736 $poolSize = $this->config->poolSize;737 $connectTimeout = $this->config->connectTimeout;738 $queryTimeout = $this->config->queryTimeout;739 $connectFailureCooldown = $this->config->connectFailureCooldown;740 } else {741 $host = $this->resolveDbHost();742 $port = $this->resolveDbPort();743 $database = Environment::getEnvValue('DB_DATABASE', 'semitexa');744 $username = Environment::getEnvValue('DB_USERNAME') ?? Environment::getEnvValue('DB_USER', 'root');745 $password = Environment::getEnvValue('DB_PASSWORD', '');746 $charset = Environment::getEnvValue('DB_CHARSET', 'utf8mb4');747 $poolSize = (int) Environment::getEnvValue('DB_POOL_SIZE', '10');748 // Via ConnectionConfig's parser: a present-but-empty749 // DB_CONNECT_TIMEOUT returns '' (so ?? never fires) and casts to750 // 0.0, which PDO reads as "wait forever" — silently restoring the751 // hung-server failure the timeout exists to prevent.752 $connectTimeout = ConnectionConfig::parseTimeoutValue(Environment::getEnvValue('DB_CONNECT_TIMEOUT'), 5.0);753 $queryTimeout = ConnectionConfig::parseTimeoutValue(Environment::getEnvValue('DB_QUERY_TIMEOUT'), 0.0);754 $connectFailureCooldown = ConnectionConfig::parseTimeoutValue(Environment::getEnvValue('DB_CONNECT_FAILURE_COOLDOWN'), 2.0);755 }756757 $dsn = "mysql:host={$host};port={$port};dbname={$database};charset={$charset}";758759 $options = self::pdoOptions($connectTimeout, $queryTimeout);760 $factory = static function () use ($dsn, $username, $password, $options, $queryTimeout): \PDO {761 $pdo = \Semitexa\Orm\Adapter\MySqlSessionTimeZone::applyTo(new \PDO($dsn, $username, $password, $options));762 self::applyQueryTimeout($pdo, $queryTimeout);763764 return $pdo;765 };766767 // Fail fast while the database is down instead of paying a full768 // connect (up to DB_CONNECT_TIMEOUT) on every request. Kept on the769 // manager so a pool swap (ensureCoroutineSafePool) keeps its state.770 $this->connectBreaker ??= new ConnectCircuitBreaker(771 $connectFailureCooldown,772 max($connectTimeout, 1.0) + $connectFailureCooldown,773 );774 $factory = $this->connectBreaker->wrap($factory);775776 if ($this->shouldUseCoroutinePool()) {777 return new ConnectionPool($poolSize, $factory, $this->connectionName);778 }779780 return new SingleConnectionPool($factory, $this->connectionName);781 }782783 /**784 * Whether the coroutine-safe ConnectionPool should back this manager.785 *786 * True under a running Swoole server, where PDO sockets are coroutine-hooked787 * (enableCoroutine(SWOOLE_HOOK_ALL) normally runs once in the master before788 * workers fork, so the flag is inherited at WorkerStart). getHookFlags() !== 0789 * is the causally-exact "server present" signal and is independent of the790 * current coroutine id. getCid() >= 0 is the in-coroutine fast-path. True CLI791 * (no server, hooks off, getCid() === -1) falls through to the single shared792 * connection, which is correct there.793 *794 * This is evaluated both at pool construction AND on every subsequent795 * getPool()/getAdapter() (see ensureCoroutineSafePool) so a SingleConnectionPool796 * cached before the runtime came up can still be upgraded once it is live.797 */798 private function shouldUseCoroutinePool(): bool799 {800 return class_exists(\Swoole\Coroutine::class, false)801 && (802 \Swoole\Coroutine::getCid() >= 0803 || (class_exists(\Swoole\Runtime::class, false)804 && \Swoole\Runtime::getHookFlags() !== 0)805 );806 }807808 /**809 * Self-heal a stale pool SELECTION.810 *811 * createPool() may cache the non-coroutine SingleConnectionPool if the very812 * first getPool()/getAdapter() ran before SWOOLE_HOOK_ALL was applied — e.g.813 * master-side warmup before fork, which then inherits the wrong pool into814 * every worker. That choice is otherwise frozen for the worker's life, and815 * SingleConnectionPool gives no true pooling under load. Once the coroutine816 * runtime is live, swap in the real ConnectionPool and drop every memoized817 * service that captured the old pool (directly or via the old adapter) so the818 * next access rebuilds against it.819 *820 * Never runs mid-transaction — yanking the pool out from under an open821 * transaction would orphan its connection.822 */823 private function ensureCoroutineSafePool(): void824 {825 if (! $this->pool instanceof SingleConnectionPool) {826 return;827 }828829 if (! $this->shouldUseCoroutinePool()) {830 return;831 }832833 // TransactionManager::isActive() is COROUTINE-LOCAL: it answers for834 // the coroutine asking for the swap, not for the worker. Coroutine A835 // can be mid-query on the old pool while coroutine B reaches this836 // getter, sees no transaction of its own, and closes A's connection837 // out from under it. The pool itself is the only worker-wide witness,838 // so ask it whether anyone still holds the connection; the swap is839 // simply deferred to whoever asks next once the pool is quiescent.840 if ($this->transactionManager !== null && $this->transactionManager->isActive()) {841 return;842 }843844 if ($this->pool->hasOutstandingBorrow()) {845 return;846 }847848 $this->pool->close();849 $this->pool = $this->createPool();850851 // Every field below captured the old pool, directly or via the old adapter.852 $this->adapter = null;853 $this->schemaComparator = null;854 $this->syncEngine = null;855 $this->transactionManager = null;856 $this->seedRunner = null;857 $this->resourceModelRelationLoader = null;858 $this->aggregateWriteEngine = null;859 }860861 /** When running on host (CLI), use DB_CLI_* so GUI/CLI connect to host port; inside Docker use DB_HOST/DB_PORT. */862 private function resolveDbHost(): string863 {864 if (!$this->isRunningInDocker()) {865 $cliHost = Environment::getEnvValue('DB_CLI_HOST');866 if ($cliHost !== null && $cliHost !== '') {867 return $cliHost;868 }869 }870 return Environment::getEnvValue('DB_HOST', '127.0.0.1');871 }872873 private function resolveDbPort(): string874 {875 if (!$this->isRunningInDocker()) {876 $cliPort = Environment::getEnvValue('DB_CLI_PORT');877 if ($cliPort !== null && $cliPort !== '') {878 return $cliPort;879 }880 }881 return Environment::getEnvValue('DB_PORT', '3306');882 }883884 private function isRunningInDocker(): bool885 {886 return file_exists('/.dockerenv');887 }888889 /**890 * Resolve the database driver from environment configuration.891 * Defaults to 'mysql' for backward compatibility.892 */893 /** The resolved database driver for this connection ('mysql' or 'sqlite'). */894 public function getDriver(): string895 {896 return $this->resolveDriver();897 }898899 private function resolveDriver(): string900 {901 $driverSource = $this->config !== null902 ? $this->config->driver903 : (Environment::getEnvValue('DB_DRIVER', 'mysql') ?? 'mysql');904 $driver = strtolower($driverSource);905906 return match ($driver) {907 'mysql', 'sqlite' => $driver,908 default => throw new \InvalidArgumentException(909 "Unsupported DB driver '{$driver}'. Expected 'mysql' or 'sqlite'.",910 ),911 };912 }913914 /**915 * Create a SQLite adapter based on environment configuration.916 *917 * Supports:918 * - DB_SQLITE_PATH: absolute or relative path to SQLite file919 * - DB_SQLITE_MEMORY: if set to "1" or "true", use in-memory database920 */921 private function createSqliteAdapter(): SqliteAdapter922 {923 if ($this->config !== null) {924 if ($this->config->sqliteMemory) {925 return new SqliteAdapter('sqlite::memory:', connectionName: $this->connectionName);926 }927928 $path = $this->config->sqlitePath;929 if ($path === null || $path === '') {930 $path = ProjectRoot::get() . '/var/database/semitexa.sqlite';931 }932 } else {933 $memory = Environment::getEnvValue('DB_SQLITE_MEMORY');934 if (in_array(strtolower((string) $memory), ['1', 'true', 'yes'], true)) {935 return new SqliteAdapter('sqlite::memory:', connectionName: $this->connectionName);936 }937938 $path = Environment::getEnvValue('DB_SQLITE_PATH');939 if ($path === null || $path === '') {940 $path = ProjectRoot::get() . '/var/database/semitexa.sqlite';941 }942 }943944 // Ensure directory exists945 $dir = dirname($path);946 if (!is_dir($dir)) {947 mkdir($dir, 0755, true);948 }949950 return new SqliteAdapter("sqlite:{$path}", connectionName: $this->connectionName);951 }952}953
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.