From e2ce84a3969797d5b0a7f0b61ccd91dc8fa5531e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 16:09:24 +0000 Subject: [PATCH 01/20] feat: convert AbstractLogEntry::$data from array to json type for DBAL 4 compatibility Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/c9d7bf14-7e2d-40b1-92b7-c9e9394a79d8 Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- doc/loggable.md | 61 ++++++++++++++++++- .../MappedSuperclass/AbstractLogEntry.php | 6 +- tests/Gedmo/Loggable/LoggableEntityTest.php | 8 --- 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/doc/loggable.md b/doc/loggable.md index 3f0b9dc55e..d95e344b74 100644 --- a/doc/loggable.md +++ b/doc/loggable.md @@ -3,7 +3,7 @@ The **Loggable** behavior adds support for logging changes to and restoring prior versions of your Doctrine objects. > [!NOTE] -> The Loggable extension is NOT compatible with `doctrine/dbal` 4.0 or later +> The `data` column in the log entry table was changed from PHP's `serialize()` format to JSON in order to be compatible with `doctrine/dbal` 4.0 or later. If you are upgrading from an older version, see [Migrating existing serialized data to JSON](#migrating-existing-serialized-data-to-json) below. ## Index @@ -13,6 +13,7 @@ The **Loggable** behavior adds support for logging changes to and restoring prio - [Object Repositories](#object-repositories) - [Fetching a Model's Log Entries](#fetching-a-models-log-entries) - [Revert a Model to a Previous Version](#revert-a-model-to-a-previous-version) +- [Migrating Existing Serialized Data to JSON](#migrating-existing-serialized-data-to-json) ## Getting Started @@ -247,3 +248,61 @@ $repo = $em->getRepository(LogEntry::class); // We are now able to revert to an older version $repo->revert($article, 2); ``` + +## Migrating Existing Serialized Data to JSON + +If you have an existing `ext_log_entries` table (or a custom log entry table) with data stored in the old +PHP `serialize()` format, you need to migrate it to JSON before using `doctrine/dbal` 4.0 or later. + +The project provides a standalone migration script at `bin/migrate-loggable-data-to-json.php`. The script: + +1. Renames the existing `data` column to `data_serialized` (so no existing data is lost). +2. Adds a new `data` column with a JSON-compatible type. +3. Reads each row, calls `unserialize()` on the old value and `json_encode()` on the result, and writes it into the new column. +4. After verifying the migration, you can manually drop the `data_serialized` column. + +### Usage + +```bash +php bin/migrate-loggable-data-to-json.php \ + --dsn="mysql://user:password@localhost/mydb" \ + --table="ext_log_entries" +``` + +> [!NOTE] +> Run this script **before** updating the entity mapping to use the `json` type and before upgrading to DBAL 4. +> Make a database backup before running the migration. + +### Manual migration approach + +If you prefer to handle the migration yourself, the steps are: + +1. **Rename** the `data` column to `data_serialized`: + ```sql + ALTER TABLE ext_log_entries RENAME COLUMN data TO data_serialized; + ``` + +2. **Add** a new `data` column (use `LONGTEXT` for MySQL/MariaDB or `TEXT` for SQLite/PostgreSQL): + ```sql + ALTER TABLE ext_log_entries ADD COLUMN data LONGTEXT DEFAULT NULL; + ``` + +3. **Convert** each row using PHP (the `unserialize()` → `json_encode()` round-trip): + + ```php + $rows = $connection->fetchAllAssociative('SELECT id, data_serialized FROM ext_log_entries WHERE data_serialized IS NOT NULL'); + foreach ($rows as $row) { + $deserialized = unserialize($row['data_serialized'], ['allowed_classes' => false]); + $json = json_encode($deserialized, JSON_THROW_ON_ERROR); + $connection->executeStatement( + 'UPDATE ext_log_entries SET data = ? WHERE id = ?', + [$json, $row['id']] + ); + } + ``` + +4. **Drop** the legacy column once you have verified the migration: + ```sql + ALTER TABLE ext_log_entries DROP COLUMN data_serialized; + ``` + diff --git a/src/Loggable/Entity/MappedSuperclass/AbstractLogEntry.php b/src/Loggable/Entity/MappedSuperclass/AbstractLogEntry.php index d0965547a4..730c068044 100644 --- a/src/Loggable/Entity/MappedSuperclass/AbstractLogEntry.php +++ b/src/Loggable/Entity/MappedSuperclass/AbstractLogEntry.php @@ -83,11 +83,9 @@ abstract class AbstractLogEntry implements LogEntryInterface /** * @var array|null * - * @ORM\Column(type="array", nullable=true) - * - * NOTE: The attribute uses the "array" name directly instead of the constant since it was removed in DBAL 4.0. + * @ORM\Column(type="json", nullable=true) */ - #[ORM\Column(type: 'array', nullable: true)] + #[ORM\Column(type: Types::JSON, nullable: true)] protected $data; /** diff --git a/tests/Gedmo/Loggable/LoggableEntityTest.php b/tests/Gedmo/Loggable/LoggableEntityTest.php index 21312c45a1..2b10982b91 100644 --- a/tests/Gedmo/Loggable/LoggableEntityTest.php +++ b/tests/Gedmo/Loggable/LoggableEntityTest.php @@ -11,7 +11,6 @@ namespace Gedmo\Tests\Loggable; -use Doctrine\DBAL\Types\ArrayType; use Gedmo\Loggable\Entity\LogEntry; use Gedmo\Loggable\Entity\Repository\LogEntryRepository; use Gedmo\Loggable\Loggable; @@ -40,13 +39,6 @@ abstract class LoggableEntityTest extends BaseTestCaseORM */ protected LoggableListener $listener; - public static function setUpBeforeClass(): void - { - if (!class_exists(ArrayType::class)) { - static::markTestSkipped('The loggable extension is not compatible with doctrine/dbal:>=4.0'); - } - } - public function testShouldHandleClonedEntity(): void { $art0 = new Article(); From 337ef495a5d2b92b63241ac2cf9c8b22b59f43e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 16:11:27 +0000 Subject: [PATCH 02/20] feat: add migration script and update documentation for json data field Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/c9d7bf14-7e2d-40b1-92b7-c9e9394a79d8 Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- doc/loggable.md | 4 +- tools/migrate-loggable-data-to-json.php | 310 ++++++++++++++++++++++++ 2 files changed, 312 insertions(+), 2 deletions(-) create mode 100755 tools/migrate-loggable-data-to-json.php diff --git a/doc/loggable.md b/doc/loggable.md index d95e344b74..00ee4d20b1 100644 --- a/doc/loggable.md +++ b/doc/loggable.md @@ -254,7 +254,7 @@ $repo->revert($article, 2); If you have an existing `ext_log_entries` table (or a custom log entry table) with data stored in the old PHP `serialize()` format, you need to migrate it to JSON before using `doctrine/dbal` 4.0 or later. -The project provides a standalone migration script at `bin/migrate-loggable-data-to-json.php`. The script: +The project provides a standalone migration script at `tools/migrate-loggable-data-to-json.php`. The script: 1. Renames the existing `data` column to `data_serialized` (so no existing data is lost). 2. Adds a new `data` column with a JSON-compatible type. @@ -264,7 +264,7 @@ The project provides a standalone migration script at `bin/migrate-loggable-data ### Usage ```bash -php bin/migrate-loggable-data-to-json.php \ +php tools/migrate-loggable-data-to-json.php \ --dsn="mysql://user:password@localhost/mydb" \ --table="ext_log_entries" ``` diff --git a/tools/migrate-loggable-data-to-json.php b/tools/migrate-loggable-data-to-json.php new file mode 100755 index 0000000000..55fd373294 --- /dev/null +++ b/tools/migrate-loggable-data-to-json.php @@ -0,0 +1,310 @@ +#!/usr/bin/env php + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/** + * Migration script: convert Loggable `data` column from PHP serialize() to JSON. + * + * This script is needed when upgrading from doctrine/dbal <4 to >=4, because the + * "array" Doctrine type (which used PHP serialize/unserialize) was removed in DBAL 4. + * The AbstractLogEntry::$data field now uses the "json" type instead. + * + * Steps performed by this script: + * 1. Rename the existing `data` column to `data_serialized` (preserves all original data). + * 2. Add a new `data` column of a JSON-compatible text type. + * 3. Read every row with a non-NULL `data_serialized` value, unserialize it, JSON-encode + * it, and write the result into the new `data` column. + * + * After verifying the migration you can drop the `data_serialized` column manually: + * ALTER TABLE DROP COLUMN data_serialized; + * + * Usage: + * php tools/migrate-loggable-data-to-json.php --dsn="mysql://user:pass@host/db" [--table="ext_log_entries"] [--batch-size=500] [--drop-legacy] + * + * Options: + * --dsn DBAL-compatible DSN string (required). + * --table Name of the log entry table (default: ext_log_entries). + * --batch-size Number of rows to process per database round-trip (default: 500). + * --drop-legacy Drop the data_serialized column after a successful migration. + */ + +// --------------------------------------------------------------------------- +// Autoloader – try to find the Composer autoloader from common locations. +// --------------------------------------------------------------------------- +$autoloadCandidates = [ + __DIR__.'/../vendor/autoload.php', + __DIR__.'/../../vendor/autoload.php', + __DIR__.'/../../../vendor/autoload.php', +]; +$autoloaderFound = false; +foreach ($autoloadCandidates as $candidate) { + if (file_exists($candidate)) { + require_once $candidate; + $autoloaderFound = true; + break; + } +} +if (!$autoloaderFound) { + fwrite(STDERR, "Could not find the Composer autoloader. Run 'composer install' first.\n"); + exit(1); +} + +// --------------------------------------------------------------------------- +// Parse CLI arguments. +// --------------------------------------------------------------------------- +$options = getopt('', ['dsn:', 'table::', 'batch-size::', 'drop-legacy']); + +$dsn = $options['dsn'] ?? null; +if (null === $dsn) { + fwrite(STDERR, "Error: --dsn is required.\n\nUsage:\n php tools/migrate-loggable-data-to-json.php --dsn=\"mysql://user:pass@host/db\" [--table=ext_log_entries] [--batch-size=500] [--drop-legacy]\n"); + exit(1); +} + +$table = $options['table'] ?? 'ext_log_entries'; +$batchSize = (int) ($options['batch-size'] ?? 500); +$dropLegacy = array_key_exists('drop-legacy', $options); + +// --------------------------------------------------------------------------- +// Build a DBAL connection from the DSN. +// --------------------------------------------------------------------------- +use Doctrine\DBAL\DriverManager; + +$connectionParams = ['url' => $dsn]; + +try { + $connection = DriverManager::getConnection($connectionParams); + $connection->connect(); +} catch (\Throwable $e) { + fwrite(STDERR, sprintf("Could not connect to the database: %s\n", $e->getMessage())); + exit(1); +} + +$platform = $connection->getDatabasePlatform(); +$schemaManager = method_exists($connection, 'createSchemaManager') + ? $connection->createSchemaManager() + : $connection->getSchemaManager(); // DBAL 3 compat + +// --------------------------------------------------------------------------- +// Detect the existing columns on the table. +// --------------------------------------------------------------------------- +$columns = $schemaManager->listTableColumns($table); +$columnNames = array_keys($columns); + +$hasData = in_array('data', $columnNames, true); +$hasDataSerialized = in_array('data_serialized', $columnNames, true); + +if (!$hasData && !$hasDataSerialized) { + fwrite(STDERR, sprintf("Neither 'data' nor 'data_serialized' column found in table '%s'. Nothing to migrate.\n", $table)); + exit(1); +} + +// --------------------------------------------------------------------------- +// Step 1 – Rename data → data_serialized (skip if already done). +// --------------------------------------------------------------------------- +if ($hasData && !$hasDataSerialized) { + echo "Step 1: Renaming column 'data' to 'data_serialized' in table '{$table}'...\n"; + + $renameSql = $platform->getAlterTableSQL( + (new \Doctrine\DBAL\Schema\TableDiff($table)) + ); + + // Use raw SQL for maximum compatibility across drivers. + $driver = $connection->getDriver(); + $driverName = strtolower((string) $driver->getDatabasePlatform($connection->getServerVersion())->getName()); + + if (str_contains($driverName, 'sqlite')) { + // SQLite does not support RENAME COLUMN before version 3.25.0; use recreate workaround. + migrateViaSqliteRecreate($connection, $table); + // After this helper the table already has data_serialized + data (JSON) columns. + // Jump straight to step 3 (data already migrated inside helper). + finalize($connection, $table, $dropLegacy); + exit(0); + } + + // MySQL / MariaDB / PostgreSQL + $connection->executeStatement(sprintf('ALTER TABLE %s RENAME COLUMN data TO data_serialized', $table)); + echo " Done.\n"; +} elseif ($hasDataSerialized && !$hasData) { + echo "Step 1: Column 'data_serialized' already exists; skipping rename.\n"; +} else { + // Both columns exist: the rename was done, but the data migration may not be complete. + echo "Step 1: Both 'data' and 'data_serialized' columns exist; assuming rename was already performed.\n"; +} + +// --------------------------------------------------------------------------- +// Step 2 – Add the new JSON `data` column (skip if already present). +// --------------------------------------------------------------------------- +$columnsAfterRename = array_keys($schemaManager->listTableColumns($table)); +if (!in_array('data', $columnsAfterRename, true)) { + echo "Step 2: Adding new 'data' column (JSON/LONGTEXT) to table '{$table}'...\n"; + $connection->executeStatement(sprintf('ALTER TABLE %s ADD COLUMN data LONGTEXT DEFAULT NULL', $table)); + echo " Done.\n"; +} else { + echo "Step 2: Column 'data' already exists; skipping ADD COLUMN.\n"; +} + +// --------------------------------------------------------------------------- +// Step 3 – Convert rows: unserialize → json_encode. +// --------------------------------------------------------------------------- +echo "Step 3: Converting serialized data to JSON...\n"; +$converted = convertRows($connection, $table, $batchSize); +echo sprintf(" Converted %d row(s).\n", $converted); + +// --------------------------------------------------------------------------- +// Finalize (optionally drop legacy column). +// --------------------------------------------------------------------------- +finalize($connection, $table, $dropLegacy); +exit(0); + +// --------------------------------------------------------------------------- +// Helper functions +// --------------------------------------------------------------------------- + +/** + * Iterate over all rows with non-NULL data_serialized, convert them, and + * write the JSON representation into the `data` column. + * + * @return int Number of rows converted. + */ +function convertRows(\Doctrine\DBAL\Connection $connection, string $table, int $batchSize): int +{ + $converted = 0; + $offset = 0; + + while (true) { + $rows = $connection->fetchAllAssociative( + sprintf('SELECT id, data_serialized FROM %s WHERE data_serialized IS NOT NULL AND data IS NULL LIMIT %d OFFSET %d', $table, $batchSize, $offset) + ); + + if ([] === $rows) { + break; + } + + foreach ($rows as $row) { + $serialized = $row['data_serialized']; + if (null === $serialized) { + continue; + } + + // Suppress warnings; handle errors explicitly. + $deserialized = @unserialize($serialized, ['allowed_classes' => false]); + + if (false === $deserialized && 'b:0;' !== $serialized) { + fwrite(STDERR, sprintf( + " Warning: could not unserialize row id=%s – skipping.\n", + $row['id'] + )); + continue; + } + + try { + $json = json_encode($deserialized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + } catch (\JsonException $e) { + fwrite(STDERR, sprintf( + " Warning: could not JSON-encode row id=%s (%s) – skipping.\n", + $row['id'], + $e->getMessage() + )); + continue; + } + + $connection->executeStatement( + sprintf('UPDATE %s SET data = ? WHERE id = ?', $table), + [$json, $row['id']] + ); + ++$converted; + } + + $offset += $batchSize; + } + + return $converted; +} + +/** + * SQLite-specific workaround for renaming a column: recreate the table. + * This also performs the data conversion in a single pass. + */ +function migrateViaSqliteRecreate(\Doctrine\DBAL\Connection $connection, string $table): void +{ + $tmpTable = $table.'_migration_tmp'; + + // Fetch the original CREATE TABLE statement to clone the schema. + $createSql = $connection->fetchOne( + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", + [$table] + ); + + if (false === $createSql || null === $createSql) { + fwrite(STDERR, sprintf("Could not read schema for table '%s'.\n", $table)); + exit(1); + } + + // Create a temporary table with `data_serialized` instead of `data`. + $tmpCreate = str_replace( + '"'.$table.'"', + '"'.$tmpTable.'"', + str_replace($table, $tmpTable, $createSql) + ); + // Rename the data column definition to data_serialized in the DDL. + $tmpCreate = preg_replace('/\bdata\b/', 'data_serialized', $tmpCreate, 1); + + $connection->executeStatement($tmpCreate); + + // Copy all data into the temporary table. + $cols = $connection->fetchAllAssociative(sprintf('PRAGMA table_info(%s)', $table)); + $colList = implode(', ', array_map(static fn (array $c) => '"'.$c['name'].'"', $cols)); + $connection->executeStatement(sprintf('INSERT INTO "%s" SELECT %s FROM "%s"', $tmpTable, $colList, $table)); + + // Drop the original table. + $connection->executeStatement(sprintf('DROP TABLE "%s"', $table)); + + // Rename the tmp table back. + $connection->executeStatement(sprintf('ALTER TABLE "%s" RENAME TO "%s"', $tmpTable, $table)); + + // Add the new `data` (JSON) column. + $connection->executeStatement(sprintf('ALTER TABLE "%s" ADD COLUMN data TEXT DEFAULT NULL', $table)); + + // Migrate data. + $rows = $connection->fetchAllAssociative(sprintf('SELECT id, data_serialized FROM "%s" WHERE data_serialized IS NOT NULL', $table)); + foreach ($rows as $row) { + $deserialized = @unserialize($row['data_serialized'], ['allowed_classes' => false]); + if (false !== $deserialized || 'b:0;' === $row['data_serialized']) { + try { + $json = json_encode($deserialized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + $connection->executeStatement(sprintf('UPDATE "%s" SET data = ? WHERE id = ?', $table), [$json, $row['id']]); + } catch (\JsonException) { + fwrite(STDERR, sprintf(" Warning: could not JSON-encode row id=%s – skipping.\n", $row['id'])); + } + } else { + fwrite(STDERR, sprintf(" Warning: could not unserialize row id=%s – skipping.\n", $row['id'])); + } + } + + echo sprintf(" SQLite migration complete (%d row(s) converted).\n", count($rows)); +} + +/** + * Optionally drop the legacy `data_serialized` column. + */ +function finalize(\Doctrine\DBAL\Connection $connection, string $table, bool $dropLegacy): void +{ + if ($dropLegacy) { + echo "Dropping legacy column 'data_serialized'...\n"; + $connection->executeStatement(sprintf('ALTER TABLE %s DROP COLUMN data_serialized', $table)); + echo " Done.\n"; + } else { + echo "\nMigration complete.\n"; + echo "The original data is still available in the 'data_serialized' column.\n"; + echo "Once you have verified the migration, you can drop it:\n"; + echo sprintf(" ALTER TABLE %s DROP COLUMN data_serialized;\n", $table); + } +} From 77364bd4d24f9675a067b1a8d6f9c2bf472071cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 16:13:52 +0000 Subject: [PATCH 03/20] fix: address code review issues in migration script (SQL injection, platform-specific DDL, regex) Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/c9d7bf14-7e2d-40b1-92b7-c9e9394a79d8 Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- tools/migrate-loggable-data-to-json.php | 163 +++++++++++++++++------- 1 file changed, 119 insertions(+), 44 deletions(-) diff --git a/tools/migrate-loggable-data-to-json.php b/tools/migrate-loggable-data-to-json.php index 55fd373294..9f3476032c 100755 --- a/tools/migrate-loggable-data-to-json.php +++ b/tools/migrate-loggable-data-to-json.php @@ -92,6 +92,9 @@ ? $connection->createSchemaManager() : $connection->getSchemaManager(); // DBAL 3 compat +// Quoted table identifier, safe for use in raw SQL. +$quotedTable = $platform->quoteSingleIdentifier($table); + // --------------------------------------------------------------------------- // Detect the existing columns on the table. // --------------------------------------------------------------------------- @@ -106,31 +109,34 @@ exit(1); } +// --------------------------------------------------------------------------- +// Detect the database platform for platform-specific DDL. +// --------------------------------------------------------------------------- +$platformName = strtolower(get_class($platform)); +$isSqlite = str_contains($platformName, 'sqlite'); + // --------------------------------------------------------------------------- // Step 1 – Rename data → data_serialized (skip if already done). // --------------------------------------------------------------------------- if ($hasData && !$hasDataSerialized) { echo "Step 1: Renaming column 'data' to 'data_serialized' in table '{$table}'...\n"; - $renameSql = $platform->getAlterTableSQL( - (new \Doctrine\DBAL\Schema\TableDiff($table)) - ); - - // Use raw SQL for maximum compatibility across drivers. - $driver = $connection->getDriver(); - $driverName = strtolower((string) $driver->getDatabasePlatform($connection->getServerVersion())->getName()); - - if (str_contains($driverName, 'sqlite')) { + if ($isSqlite) { // SQLite does not support RENAME COLUMN before version 3.25.0; use recreate workaround. - migrateViaSqliteRecreate($connection, $table); - // After this helper the table already has data_serialized + data (JSON) columns. - // Jump straight to step 3 (data already migrated inside helper). - finalize($connection, $table, $dropLegacy); + migrateViaSqliteRecreate($connection, $platform, $table, $quotedTable); + // After this helper the table already has data_serialized + data (JSON) columns + // and data has been converted. Jump straight to finalize. + finalize($connection, $platform, $table, $quotedTable, $dropLegacy); exit(0); } - // MySQL / MariaDB / PostgreSQL - $connection->executeStatement(sprintf('ALTER TABLE %s RENAME COLUMN data TO data_serialized', $table)); + // MySQL / MariaDB / PostgreSQL support RENAME COLUMN directly. + $connection->executeStatement(sprintf( + 'ALTER TABLE %s RENAME COLUMN %s TO %s', + $quotedTable, + $platform->quoteSingleIdentifier('data'), + $platform->quoteSingleIdentifier('data_serialized') + )); echo " Done.\n"; } elseif ($hasDataSerialized && !$hasData) { echo "Step 1: Column 'data_serialized' already exists; skipping rename.\n"; @@ -144,8 +150,15 @@ // --------------------------------------------------------------------------- $columnsAfterRename = array_keys($schemaManager->listTableColumns($table)); if (!in_array('data', $columnsAfterRename, true)) { - echo "Step 2: Adding new 'data' column (JSON/LONGTEXT) to table '{$table}'...\n"; - $connection->executeStatement(sprintf('ALTER TABLE %s ADD COLUMN data LONGTEXT DEFAULT NULL', $table)); + echo "Step 2: Adding new 'data' column to table '{$table}'...\n"; + // Use the platform's CLOB declaration (TEXT/LONGTEXT/etc.) for the JSON column. + $clobType = $platform->getClobTypeDeclarationSQL([]); + $connection->executeStatement(sprintf( + 'ALTER TABLE %s ADD COLUMN %s %s DEFAULT NULL', + $quotedTable, + $platform->quoteSingleIdentifier('data'), + $clobType + )); echo " Done.\n"; } else { echo "Step 2: Column 'data' already exists; skipping ADD COLUMN.\n"; @@ -155,13 +168,13 @@ // Step 3 – Convert rows: unserialize → json_encode. // --------------------------------------------------------------------------- echo "Step 3: Converting serialized data to JSON...\n"; -$converted = convertRows($connection, $table, $batchSize); +$converted = convertRows($connection, $platform, $table, $quotedTable, $batchSize); echo sprintf(" Converted %d row(s).\n", $converted); // --------------------------------------------------------------------------- // Finalize (optionally drop legacy column). // --------------------------------------------------------------------------- -finalize($connection, $table, $dropLegacy); +finalize($connection, $platform, $table, $quotedTable, $dropLegacy); exit(0); // --------------------------------------------------------------------------- @@ -174,14 +187,32 @@ * * @return int Number of rows converted. */ -function convertRows(\Doctrine\DBAL\Connection $connection, string $table, int $batchSize): int -{ +function convertRows( + \Doctrine\DBAL\Connection $connection, + \Doctrine\DBAL\Platforms\AbstractPlatform $platform, + string $table, + string $quotedTable, + int $batchSize +): int { $converted = 0; $offset = 0; + $qData = $platform->quoteSingleIdentifier('data'); + $qDataSerialized = $platform->quoteSingleIdentifier('data_serialized'); + $qId = $platform->quoteSingleIdentifier('id'); + while (true) { $rows = $connection->fetchAllAssociative( - sprintf('SELECT id, data_serialized FROM %s WHERE data_serialized IS NOT NULL AND data IS NULL LIMIT %d OFFSET %d', $table, $batchSize, $offset) + sprintf( + 'SELECT %s, %s FROM %s WHERE %s IS NOT NULL AND %s IS NULL LIMIT %d OFFSET %d', + $qId, + $qDataSerialized, + $quotedTable, + $qDataSerialized, + $qData, + $batchSize, + $offset + ) ); if ([] === $rows) { @@ -217,7 +248,7 @@ function convertRows(\Doctrine\DBAL\Connection $connection, string $table, int $ } $connection->executeStatement( - sprintf('UPDATE %s SET data = ? WHERE id = ?', $table), + sprintf('UPDATE %s SET %s = ? WHERE %s = ?', $quotedTable, $qData, $qId), [$json, $row['id']] ); ++$converted; @@ -233,9 +264,14 @@ function convertRows(\Doctrine\DBAL\Connection $connection, string $table, int $ * SQLite-specific workaround for renaming a column: recreate the table. * This also performs the data conversion in a single pass. */ -function migrateViaSqliteRecreate(\Doctrine\DBAL\Connection $connection, string $table): void -{ +function migrateViaSqliteRecreate( + \Doctrine\DBAL\Connection $connection, + \Doctrine\DBAL\Platforms\AbstractPlatform $platform, + string $table, + string $quotedTable +): void { $tmpTable = $table.'_migration_tmp'; + $quotedTmp = $platform->quoteSingleIdentifier($tmpTable); // Fetch the original CREATE TABLE statement to clone the schema. $createSql = $connection->fetchOne( @@ -248,39 +284,68 @@ function migrateViaSqliteRecreate(\Doctrine\DBAL\Connection $connection, string exit(1); } - // Create a temporary table with `data_serialized` instead of `data`. - $tmpCreate = str_replace( - '"'.$table.'"', - '"'.$tmpTable.'"', - str_replace($table, $tmpTable, $createSql) + // Create a temporary table named $tmpTable. + // Replace only the table name in the CREATE TABLE header (first occurrence). + $tmpCreate = preg_replace( + '/^(CREATE\s+TABLE\s+)((?:"[^"]*"|`[^`]*`|\[[^\]]*\]|\S+))(\s*\()/i', + '$1'.$quotedTmp.'$3', + $createSql, + 1 + ); + + // Rename the `data` column definition to `data_serialized` in the DDL. + // Match only a quoted or unquoted column named exactly "data" at the start of a column definition. + $tmpCreate = preg_replace( + '/(?<=\(|,)\s*("data"|`data`|\bdata\b)(?=\s)/i', + ' "data_serialized"', + $tmpCreate, + 1 ); - // Rename the data column definition to data_serialized in the DDL. - $tmpCreate = preg_replace('/\bdata\b/', 'data_serialized', $tmpCreate, 1); $connection->executeStatement($tmpCreate); // Copy all data into the temporary table. - $cols = $connection->fetchAllAssociative(sprintf('PRAGMA table_info(%s)', $table)); - $colList = implode(', ', array_map(static fn (array $c) => '"'.$c['name'].'"', $cols)); - $connection->executeStatement(sprintf('INSERT INTO "%s" SELECT %s FROM "%s"', $tmpTable, $colList, $table)); + $cols = $connection->fetchAllAssociative(sprintf('PRAGMA table_info(%s)', $quotedTable)); + $colList = implode(', ', array_map( + static fn (array $c) => $platform->quoteSingleIdentifier($c['name']), + $cols + )); + $connection->executeStatement(sprintf('INSERT INTO %s SELECT %s FROM %s', $quotedTmp, $colList, $quotedTable)); // Drop the original table. - $connection->executeStatement(sprintf('DROP TABLE "%s"', $table)); + $connection->executeStatement(sprintf('DROP TABLE %s', $quotedTable)); // Rename the tmp table back. - $connection->executeStatement(sprintf('ALTER TABLE "%s" RENAME TO "%s"', $tmpTable, $table)); + $connection->executeStatement(sprintf('ALTER TABLE %s RENAME TO %s', $quotedTmp, $quotedTable)); // Add the new `data` (JSON) column. - $connection->executeStatement(sprintf('ALTER TABLE "%s" ADD COLUMN data TEXT DEFAULT NULL', $table)); + $clobType = $platform->getClobTypeDeclarationSQL([]); + $connection->executeStatement(sprintf( + 'ALTER TABLE %s ADD COLUMN %s %s DEFAULT NULL', + $quotedTable, + $platform->quoteSingleIdentifier('data'), + $clobType + )); // Migrate data. - $rows = $connection->fetchAllAssociative(sprintf('SELECT id, data_serialized FROM "%s" WHERE data_serialized IS NOT NULL', $table)); + $qData = $platform->quoteSingleIdentifier('data'); + $qDataSerialized = $platform->quoteSingleIdentifier('data_serialized'); + $qId = $platform->quoteSingleIdentifier('id'); + + $rows = $connection->fetchAllAssociative( + sprintf('SELECT %s, %s FROM %s WHERE %s IS NOT NULL', $qId, $qDataSerialized, $quotedTable, $qDataSerialized) + ); + $converted = 0; foreach ($rows as $row) { $deserialized = @unserialize($row['data_serialized'], ['allowed_classes' => false]); if (false !== $deserialized || 'b:0;' === $row['data_serialized']) { try { $json = json_encode($deserialized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); - $connection->executeStatement(sprintf('UPDATE "%s" SET data = ? WHERE id = ?', $table), [$json, $row['id']]); + $connection->executeStatement( + sprintf('UPDATE %s SET %s = ? WHERE %s = ?', $quotedTable, $qData, $qId), + [$json, $row['id']] + ); + ++$converted; } catch (\JsonException) { fwrite(STDERR, sprintf(" Warning: could not JSON-encode row id=%s – skipping.\n", $row['id'])); } @@ -289,17 +354,26 @@ function migrateViaSqliteRecreate(\Doctrine\DBAL\Connection $connection, string } } - echo sprintf(" SQLite migration complete (%d row(s) converted).\n", count($rows)); + echo sprintf(" SQLite migration complete (%d row(s) converted).\n", $converted); } /** * Optionally drop the legacy `data_serialized` column. */ -function finalize(\Doctrine\DBAL\Connection $connection, string $table, bool $dropLegacy): void -{ +function finalize( + \Doctrine\DBAL\Connection $connection, + \Doctrine\DBAL\Platforms\AbstractPlatform $platform, + string $table, + string $quotedTable, + bool $dropLegacy +): void { if ($dropLegacy) { echo "Dropping legacy column 'data_serialized'...\n"; - $connection->executeStatement(sprintf('ALTER TABLE %s DROP COLUMN data_serialized', $table)); + $connection->executeStatement(sprintf( + 'ALTER TABLE %s DROP COLUMN %s', + $quotedTable, + $platform->quoteSingleIdentifier('data_serialized') + )); echo " Done.\n"; } else { echo "\nMigration complete.\n"; @@ -308,3 +382,4 @@ function finalize(\Doctrine\DBAL\Connection $connection, string $table, bool $dr echo sprintf(" ALTER TABLE %s DROP COLUMN data_serialized;\n", $table); } } + From 69b076e33bffc01e557808ae1c21616b66cf4e33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 16:15:06 +0000 Subject: [PATCH 04/20] fix: further improve migration script based on code review (regex, PRAGMA, unserialize) Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/c9d7bf14-7e2d-40b1-92b7-c9e9394a79d8 Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- tools/migrate-loggable-data-to-json.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tools/migrate-loggable-data-to-json.php b/tools/migrate-loggable-data-to-json.php index 9f3476032c..86c9c8fe97 100755 --- a/tools/migrate-loggable-data-to-json.php +++ b/tools/migrate-loggable-data-to-json.php @@ -225,8 +225,8 @@ function convertRows( continue; } - // Suppress warnings; handle errors explicitly. - $deserialized = @unserialize($serialized, ['allowed_classes' => false]); + // Attempt to unserialize; if it fails the value is not valid PHP serialization. + $deserialized = unserialize($serialized, ['allowed_classes' => false]); if (false === $deserialized && 'b:0;' !== $serialized) { fwrite(STDERR, sprintf( @@ -286,17 +286,19 @@ function migrateViaSqliteRecreate( // Create a temporary table named $tmpTable. // Replace only the table name in the CREATE TABLE header (first occurrence). + // The pattern restricts unquoted identifiers to valid SQLite identifier characters. $tmpCreate = preg_replace( - '/^(CREATE\s+TABLE\s+)((?:"[^"]*"|`[^`]*`|\[[^\]]*\]|\S+))(\s*\()/i', + '/^(CREATE\s+TABLE\s+)((?:"[^"]*"|`[^`]*`|\[[^\]]*\]|[a-zA-Z_][a-zA-Z0-9_]*))(\s*\()/i', '$1'.$quotedTmp.'$3', $createSql, 1 ); // Rename the `data` column definition to `data_serialized` in the DDL. - // Match only a quoted or unquoted column named exactly "data" at the start of a column definition. + // Match the column name exactly – either quoted or as a standalone identifier + // not followed by alphanumeric characters (to avoid matching e.g. "data_type"). $tmpCreate = preg_replace( - '/(?<=\(|,)\s*("data"|`data`|\bdata\b)(?=\s)/i', + '/(?<=\(|,)\s*("data"|`data`|\[data\]|(?executeStatement($tmpCreate); // Copy all data into the temporary table. - $cols = $connection->fetchAllAssociative(sprintf('PRAGMA table_info(%s)', $quotedTable)); + // Note: SQLite's PRAGMA table_info requires an unquoted table name. + $cols = $connection->fetchAllAssociative(sprintf('PRAGMA table_info(%s)', $table)); $colList = implode(', ', array_map( static fn (array $c) => $platform->quoteSingleIdentifier($c['name']), $cols @@ -337,7 +340,7 @@ function migrateViaSqliteRecreate( ); $converted = 0; foreach ($rows as $row) { - $deserialized = @unserialize($row['data_serialized'], ['allowed_classes' => false]); + $deserialized = unserialize($row['data_serialized'], ['allowed_classes' => false]); if (false !== $deserialized || 'b:0;' === $row['data_serialized']) { try { $json = json_encode($deserialized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); From d63f682a254733244d9089b8f3be999b6274d399 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 16:17:23 +0000 Subject: [PATCH 05/20] chore: remove bin from .gitignore and move migration script to bin/ Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/e44795ea-762d-486e-b81c-3d50475d8da1 Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- .gitignore | 1 - {tools => bin}/migrate-loggable-data-to-json.php | 4 ++-- doc/loggable.md | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) rename {tools => bin}/migrate-loggable-data-to-json.php (97%) diff --git a/.gitignore b/.gitignore index aafb169765..86f15b1112 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ .php-cs-fixer.cache -bin vendor composer.lock coverage.xml diff --git a/tools/migrate-loggable-data-to-json.php b/bin/migrate-loggable-data-to-json.php similarity index 97% rename from tools/migrate-loggable-data-to-json.php rename to bin/migrate-loggable-data-to-json.php index 86c9c8fe97..b68acff41e 100755 --- a/tools/migrate-loggable-data-to-json.php +++ b/bin/migrate-loggable-data-to-json.php @@ -27,7 +27,7 @@ * ALTER TABLE
DROP COLUMN data_serialized; * * Usage: - * php tools/migrate-loggable-data-to-json.php --dsn="mysql://user:pass@host/db" [--table="ext_log_entries"] [--batch-size=500] [--drop-legacy] + * php bin/migrate-loggable-data-to-json.php --dsn="mysql://user:pass@host/db" [--table="ext_log_entries"] [--batch-size=500] [--drop-legacy] * * Options: * --dsn DBAL-compatible DSN string (required). @@ -64,7 +64,7 @@ $dsn = $options['dsn'] ?? null; if (null === $dsn) { - fwrite(STDERR, "Error: --dsn is required.\n\nUsage:\n php tools/migrate-loggable-data-to-json.php --dsn=\"mysql://user:pass@host/db\" [--table=ext_log_entries] [--batch-size=500] [--drop-legacy]\n"); + fwrite(STDERR, "Error: --dsn is required.\n\nUsage:\n php bin/migrate-loggable-data-to-json.php --dsn=\"mysql://user:pass@host/db\" [--table=ext_log_entries] [--batch-size=500] [--drop-legacy]\n"); exit(1); } diff --git a/doc/loggable.md b/doc/loggable.md index 00ee4d20b1..d95e344b74 100644 --- a/doc/loggable.md +++ b/doc/loggable.md @@ -254,7 +254,7 @@ $repo->revert($article, 2); If you have an existing `ext_log_entries` table (or a custom log entry table) with data stored in the old PHP `serialize()` format, you need to migrate it to JSON before using `doctrine/dbal` 4.0 or later. -The project provides a standalone migration script at `tools/migrate-loggable-data-to-json.php`. The script: +The project provides a standalone migration script at `bin/migrate-loggable-data-to-json.php`. The script: 1. Renames the existing `data` column to `data_serialized` (so no existing data is lost). 2. Adds a new `data` column with a JSON-compatible type. @@ -264,7 +264,7 @@ The project provides a standalone migration script at `tools/migrate-loggable-da ### Usage ```bash -php tools/migrate-loggable-data-to-json.php \ +php bin/migrate-loggable-data-to-json.php \ --dsn="mysql://user:password@localhost/mydb" \ --table="ext_log_entries" ``` From 24df0e07f8f1dbf295f7e35b8e61dd1b2e281106 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 16:38:09 +0000 Subject: [PATCH 06/20] docs(loggable): document --drop-legacy option and all CLI options in migration guide Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/706eac44-2949-4e1a-b2e0-c6177d8f8fef Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- doc/loggable.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/doc/loggable.md b/doc/loggable.md index d95e344b74..5d39251bea 100644 --- a/doc/loggable.md +++ b/doc/loggable.md @@ -259,16 +259,25 @@ The project provides a standalone migration script at `bin/migrate-loggable-data 1. Renames the existing `data` column to `data_serialized` (so no existing data is lost). 2. Adds a new `data` column with a JSON-compatible type. 3. Reads each row, calls `unserialize()` on the old value and `json_encode()` on the result, and writes it into the new column. -4. After verifying the migration, you can manually drop the `data_serialized` column. +4. Optionally drops the `data_serialized` backup column automatically when `--drop-legacy` is passed. Without this flag the column is kept so you can verify the migration manually before dropping it. ### Usage ```bash php bin/migrate-loggable-data-to-json.php \ --dsn="mysql://user:password@localhost/mydb" \ - --table="ext_log_entries" + --table="ext_log_entries" \ + [--batch-size=500] \ + [--drop-legacy] ``` +| Option | Description | +|---|---| +| `--dsn` | DBAL-compatible DSN string (**required**). | +| `--table` | Name of the log entry table (default: `ext_log_entries`). | +| `--batch-size` | Number of rows to process per database round-trip (default: `500`). | +| `--drop-legacy` | Drop the `data_serialized` backup column automatically after a successful migration. Omit this flag if you want to keep the backup column for manual verification first. | + > [!NOTE] > Run this script **before** updating the entity mapping to use the `json` type and before upgrading to DBAL 4. > Make a database backup before running the migration. From 6bd19f8e6c1dafb57b92fec66410f9678caa72fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 09:56:20 +0000 Subject: [PATCH 07/20] feat(loggable): convert JSON migration script to Symfony command with DB connection fallback Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/243336ad-8bbd-4a3d-94ec-bbb282d1892c Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- bin/migrate-loggable-data-to-json.php | 366 +----------------- doc/loggable.md | 18 +- .../Command/MigrateDataToJsonCommand.php | 320 +++++++++++++++ 3 files changed, 343 insertions(+), 361 deletions(-) create mode 100644 src/Loggable/Command/MigrateDataToJsonCommand.php diff --git a/bin/migrate-loggable-data-to-json.php b/bin/migrate-loggable-data-to-json.php index b68acff41e..1089eb2594 100755 --- a/bin/migrate-loggable-data-to-json.php +++ b/bin/migrate-loggable-data-to-json.php @@ -10,40 +10,15 @@ * file that was distributed with this source code. */ -/** - * Migration script: convert Loggable `data` column from PHP serialize() to JSON. - * - * This script is needed when upgrading from doctrine/dbal <4 to >=4, because the - * "array" Doctrine type (which used PHP serialize/unserialize) was removed in DBAL 4. - * The AbstractLogEntry::$data field now uses the "json" type instead. - * - * Steps performed by this script: - * 1. Rename the existing `data` column to `data_serialized` (preserves all original data). - * 2. Add a new `data` column of a JSON-compatible text type. - * 3. Read every row with a non-NULL `data_serialized` value, unserialize it, JSON-encode - * it, and write the result into the new `data` column. - * - * After verifying the migration you can drop the `data_serialized` column manually: - * ALTER TABLE
DROP COLUMN data_serialized; - * - * Usage: - * php bin/migrate-loggable-data-to-json.php --dsn="mysql://user:pass@host/db" [--table="ext_log_entries"] [--batch-size=500] [--drop-legacy] - * - * Options: - * --dsn DBAL-compatible DSN string (required). - * --table Name of the log entry table (default: ext_log_entries). - * --batch-size Number of rows to process per database round-trip (default: 500). - * --drop-legacy Drop the data_serialized column after a successful migration. - */ +use Gedmo\Loggable\Command\MigrateDataToJsonCommand; +use Symfony\Component\Console\Application; -// --------------------------------------------------------------------------- -// Autoloader – try to find the Composer autoloader from common locations. -// --------------------------------------------------------------------------- $autoloadCandidates = [ __DIR__.'/../vendor/autoload.php', __DIR__.'/../../vendor/autoload.php', __DIR__.'/../../../vendor/autoload.php', ]; + $autoloaderFound = false; foreach ($autoloadCandidates as $candidate) { if (file_exists($candidate)) { @@ -52,337 +27,14 @@ break; } } + if (!$autoloaderFound) { fwrite(STDERR, "Could not find the Composer autoloader. Run 'composer install' first.\n"); exit(1); } -// --------------------------------------------------------------------------- -// Parse CLI arguments. -// --------------------------------------------------------------------------- -$options = getopt('', ['dsn:', 'table::', 'batch-size::', 'drop-legacy']); - -$dsn = $options['dsn'] ?? null; -if (null === $dsn) { - fwrite(STDERR, "Error: --dsn is required.\n\nUsage:\n php bin/migrate-loggable-data-to-json.php --dsn=\"mysql://user:pass@host/db\" [--table=ext_log_entries] [--batch-size=500] [--drop-legacy]\n"); - exit(1); -} - -$table = $options['table'] ?? 'ext_log_entries'; -$batchSize = (int) ($options['batch-size'] ?? 500); -$dropLegacy = array_key_exists('drop-legacy', $options); - -// --------------------------------------------------------------------------- -// Build a DBAL connection from the DSN. -// --------------------------------------------------------------------------- -use Doctrine\DBAL\DriverManager; - -$connectionParams = ['url' => $dsn]; - -try { - $connection = DriverManager::getConnection($connectionParams); - $connection->connect(); -} catch (\Throwable $e) { - fwrite(STDERR, sprintf("Could not connect to the database: %s\n", $e->getMessage())); - exit(1); -} - -$platform = $connection->getDatabasePlatform(); -$schemaManager = method_exists($connection, 'createSchemaManager') - ? $connection->createSchemaManager() - : $connection->getSchemaManager(); // DBAL 3 compat - -// Quoted table identifier, safe for use in raw SQL. -$quotedTable = $platform->quoteSingleIdentifier($table); - -// --------------------------------------------------------------------------- -// Detect the existing columns on the table. -// --------------------------------------------------------------------------- -$columns = $schemaManager->listTableColumns($table); -$columnNames = array_keys($columns); - -$hasData = in_array('data', $columnNames, true); -$hasDataSerialized = in_array('data_serialized', $columnNames, true); - -if (!$hasData && !$hasDataSerialized) { - fwrite(STDERR, sprintf("Neither 'data' nor 'data_serialized' column found in table '%s'. Nothing to migrate.\n", $table)); - exit(1); -} - -// --------------------------------------------------------------------------- -// Detect the database platform for platform-specific DDL. -// --------------------------------------------------------------------------- -$platformName = strtolower(get_class($platform)); -$isSqlite = str_contains($platformName, 'sqlite'); - -// --------------------------------------------------------------------------- -// Step 1 – Rename data → data_serialized (skip if already done). -// --------------------------------------------------------------------------- -if ($hasData && !$hasDataSerialized) { - echo "Step 1: Renaming column 'data' to 'data_serialized' in table '{$table}'...\n"; - - if ($isSqlite) { - // SQLite does not support RENAME COLUMN before version 3.25.0; use recreate workaround. - migrateViaSqliteRecreate($connection, $platform, $table, $quotedTable); - // After this helper the table already has data_serialized + data (JSON) columns - // and data has been converted. Jump straight to finalize. - finalize($connection, $platform, $table, $quotedTable, $dropLegacy); - exit(0); - } - - // MySQL / MariaDB / PostgreSQL support RENAME COLUMN directly. - $connection->executeStatement(sprintf( - 'ALTER TABLE %s RENAME COLUMN %s TO %s', - $quotedTable, - $platform->quoteSingleIdentifier('data'), - $platform->quoteSingleIdentifier('data_serialized') - )); - echo " Done.\n"; -} elseif ($hasDataSerialized && !$hasData) { - echo "Step 1: Column 'data_serialized' already exists; skipping rename.\n"; -} else { - // Both columns exist: the rename was done, but the data migration may not be complete. - echo "Step 1: Both 'data' and 'data_serialized' columns exist; assuming rename was already performed.\n"; -} - -// --------------------------------------------------------------------------- -// Step 2 – Add the new JSON `data` column (skip if already present). -// --------------------------------------------------------------------------- -$columnsAfterRename = array_keys($schemaManager->listTableColumns($table)); -if (!in_array('data', $columnsAfterRename, true)) { - echo "Step 2: Adding new 'data' column to table '{$table}'...\n"; - // Use the platform's CLOB declaration (TEXT/LONGTEXT/etc.) for the JSON column. - $clobType = $platform->getClobTypeDeclarationSQL([]); - $connection->executeStatement(sprintf( - 'ALTER TABLE %s ADD COLUMN %s %s DEFAULT NULL', - $quotedTable, - $platform->quoteSingleIdentifier('data'), - $clobType - )); - echo " Done.\n"; -} else { - echo "Step 2: Column 'data' already exists; skipping ADD COLUMN.\n"; -} - -// --------------------------------------------------------------------------- -// Step 3 – Convert rows: unserialize → json_encode. -// --------------------------------------------------------------------------- -echo "Step 3: Converting serialized data to JSON...\n"; -$converted = convertRows($connection, $platform, $table, $quotedTable, $batchSize); -echo sprintf(" Converted %d row(s).\n", $converted); - -// --------------------------------------------------------------------------- -// Finalize (optionally drop legacy column). -// --------------------------------------------------------------------------- -finalize($connection, $platform, $table, $quotedTable, $dropLegacy); -exit(0); - -// --------------------------------------------------------------------------- -// Helper functions -// --------------------------------------------------------------------------- - -/** - * Iterate over all rows with non-NULL data_serialized, convert them, and - * write the JSON representation into the `data` column. - * - * @return int Number of rows converted. - */ -function convertRows( - \Doctrine\DBAL\Connection $connection, - \Doctrine\DBAL\Platforms\AbstractPlatform $platform, - string $table, - string $quotedTable, - int $batchSize -): int { - $converted = 0; - $offset = 0; - - $qData = $platform->quoteSingleIdentifier('data'); - $qDataSerialized = $platform->quoteSingleIdentifier('data_serialized'); - $qId = $platform->quoteSingleIdentifier('id'); - - while (true) { - $rows = $connection->fetchAllAssociative( - sprintf( - 'SELECT %s, %s FROM %s WHERE %s IS NOT NULL AND %s IS NULL LIMIT %d OFFSET %d', - $qId, - $qDataSerialized, - $quotedTable, - $qDataSerialized, - $qData, - $batchSize, - $offset - ) - ); - - if ([] === $rows) { - break; - } - - foreach ($rows as $row) { - $serialized = $row['data_serialized']; - if (null === $serialized) { - continue; - } - - // Attempt to unserialize; if it fails the value is not valid PHP serialization. - $deserialized = unserialize($serialized, ['allowed_classes' => false]); - - if (false === $deserialized && 'b:0;' !== $serialized) { - fwrite(STDERR, sprintf( - " Warning: could not unserialize row id=%s – skipping.\n", - $row['id'] - )); - continue; - } - - try { - $json = json_encode($deserialized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); - } catch (\JsonException $e) { - fwrite(STDERR, sprintf( - " Warning: could not JSON-encode row id=%s (%s) – skipping.\n", - $row['id'], - $e->getMessage() - )); - continue; - } - - $connection->executeStatement( - sprintf('UPDATE %s SET %s = ? WHERE %s = ?', $quotedTable, $qData, $qId), - [$json, $row['id']] - ); - ++$converted; - } - - $offset += $batchSize; - } - - return $converted; -} - -/** - * SQLite-specific workaround for renaming a column: recreate the table. - * This also performs the data conversion in a single pass. - */ -function migrateViaSqliteRecreate( - \Doctrine\DBAL\Connection $connection, - \Doctrine\DBAL\Platforms\AbstractPlatform $platform, - string $table, - string $quotedTable -): void { - $tmpTable = $table.'_migration_tmp'; - $quotedTmp = $platform->quoteSingleIdentifier($tmpTable); - - // Fetch the original CREATE TABLE statement to clone the schema. - $createSql = $connection->fetchOne( - "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", - [$table] - ); - - if (false === $createSql || null === $createSql) { - fwrite(STDERR, sprintf("Could not read schema for table '%s'.\n", $table)); - exit(1); - } - - // Create a temporary table named $tmpTable. - // Replace only the table name in the CREATE TABLE header (first occurrence). - // The pattern restricts unquoted identifiers to valid SQLite identifier characters. - $tmpCreate = preg_replace( - '/^(CREATE\s+TABLE\s+)((?:"[^"]*"|`[^`]*`|\[[^\]]*\]|[a-zA-Z_][a-zA-Z0-9_]*))(\s*\()/i', - '$1'.$quotedTmp.'$3', - $createSql, - 1 - ); - - // Rename the `data` column definition to `data_serialized` in the DDL. - // Match the column name exactly – either quoted or as a standalone identifier - // not followed by alphanumeric characters (to avoid matching e.g. "data_type"). - $tmpCreate = preg_replace( - '/(?<=\(|,)\s*("data"|`data`|\[data\]|(?executeStatement($tmpCreate); - - // Copy all data into the temporary table. - // Note: SQLite's PRAGMA table_info requires an unquoted table name. - $cols = $connection->fetchAllAssociative(sprintf('PRAGMA table_info(%s)', $table)); - $colList = implode(', ', array_map( - static fn (array $c) => $platform->quoteSingleIdentifier($c['name']), - $cols - )); - $connection->executeStatement(sprintf('INSERT INTO %s SELECT %s FROM %s', $quotedTmp, $colList, $quotedTable)); - - // Drop the original table. - $connection->executeStatement(sprintf('DROP TABLE %s', $quotedTable)); - - // Rename the tmp table back. - $connection->executeStatement(sprintf('ALTER TABLE %s RENAME TO %s', $quotedTmp, $quotedTable)); - - // Add the new `data` (JSON) column. - $clobType = $platform->getClobTypeDeclarationSQL([]); - $connection->executeStatement(sprintf( - 'ALTER TABLE %s ADD COLUMN %s %s DEFAULT NULL', - $quotedTable, - $platform->quoteSingleIdentifier('data'), - $clobType - )); - - // Migrate data. - $qData = $platform->quoteSingleIdentifier('data'); - $qDataSerialized = $platform->quoteSingleIdentifier('data_serialized'); - $qId = $platform->quoteSingleIdentifier('id'); - - $rows = $connection->fetchAllAssociative( - sprintf('SELECT %s, %s FROM %s WHERE %s IS NOT NULL', $qId, $qDataSerialized, $quotedTable, $qDataSerialized) - ); - $converted = 0; - foreach ($rows as $row) { - $deserialized = unserialize($row['data_serialized'], ['allowed_classes' => false]); - if (false !== $deserialized || 'b:0;' === $row['data_serialized']) { - try { - $json = json_encode($deserialized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); - $connection->executeStatement( - sprintf('UPDATE %s SET %s = ? WHERE %s = ?', $quotedTable, $qData, $qId), - [$json, $row['id']] - ); - ++$converted; - } catch (\JsonException) { - fwrite(STDERR, sprintf(" Warning: could not JSON-encode row id=%s – skipping.\n", $row['id'])); - } - } else { - fwrite(STDERR, sprintf(" Warning: could not unserialize row id=%s – skipping.\n", $row['id'])); - } - } - - echo sprintf(" SQLite migration complete (%d row(s) converted).\n", $converted); -} - -/** - * Optionally drop the legacy `data_serialized` column. - */ -function finalize( - \Doctrine\DBAL\Connection $connection, - \Doctrine\DBAL\Platforms\AbstractPlatform $platform, - string $table, - string $quotedTable, - bool $dropLegacy -): void { - if ($dropLegacy) { - echo "Dropping legacy column 'data_serialized'...\n"; - $connection->executeStatement(sprintf( - 'ALTER TABLE %s DROP COLUMN %s', - $quotedTable, - $platform->quoteSingleIdentifier('data_serialized') - )); - echo " Done.\n"; - } else { - echo "\nMigration complete.\n"; - echo "The original data is still available in the 'data_serialized' column.\n"; - echo "Once you have verified the migration, you can drop it:\n"; - echo sprintf(" ALTER TABLE %s DROP COLUMN data_serialized;\n", $table); - } -} - +$application = new Application('DoctrineExtensions Loggable Migration'); +$command = new MigrateDataToJsonCommand(); +$application->add($command); +$application->setDefaultCommand((string) $command->getName(), true); +$application->run(); diff --git a/doc/loggable.md b/doc/loggable.md index 5d39251bea..a8321fa930 100644 --- a/doc/loggable.md +++ b/doc/loggable.md @@ -261,11 +261,22 @@ The project provides a standalone migration script at `bin/migrate-loggable-data 3. Reads each row, calls `unserialize()` on the old value and `json_encode()` on the result, and writes it into the new column. 4. Optionally drops the `data_serialized` backup column automatically when `--drop-legacy` is passed. Without this flag the column is kept so you can verify the migration manually before dropping it. -### Usage +### Usage (Symfony command) + +```bash +php bin/console gedmo:loggable:migrate-data-to-json \ + --table="ext_log_entries" \ + [--batch-size=500] \ + [--drop-legacy] +``` + +When this command is registered in your Symfony app, it uses the configured Doctrine DBAL connection, +so you do not need to pass credentials on the command line. + +### Standalone usage ```bash php bin/migrate-loggable-data-to-json.php \ - --dsn="mysql://user:password@localhost/mydb" \ --table="ext_log_entries" \ [--batch-size=500] \ [--drop-legacy] @@ -273,7 +284,7 @@ php bin/migrate-loggable-data-to-json.php \ | Option | Description | |---|---| -| `--dsn` | DBAL-compatible DSN string (**required**). | +| `--dsn` | DBAL-compatible DSN string. Optional when the command receives an injected Doctrine connection or when `DATABASE_URL` is set. | | `--table` | Name of the log entry table (default: `ext_log_entries`). | | `--batch-size` | Number of rows to process per database round-trip (default: `500`). | | `--drop-legacy` | Drop the `data_serialized` backup column automatically after a successful migration. Omit this flag if you want to keep the backup column for manual verification first. | @@ -314,4 +325,3 @@ If you prefer to handle the migration yourself, the steps are: ```sql ALTER TABLE ext_log_entries DROP COLUMN data_serialized; ``` - diff --git a/src/Loggable/Command/MigrateDataToJsonCommand.php b/src/Loggable/Command/MigrateDataToJsonCommand.php new file mode 100644 index 0000000000..62dd404163 --- /dev/null +++ b/src/Loggable/Command/MigrateDataToJsonCommand.php @@ -0,0 +1,320 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Loggable\Command; + +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +final class MigrateDataToJsonCommand extends Command +{ + protected static $defaultName = 'gedmo:loggable:migrate-data-to-json'; + protected static $defaultDescription = 'Migrate Loggable data column from PHP serialized values to JSON.'; + + private ?Connection $connection; + + public function __construct(?Connection $connection = null) + { + parent::__construct(); + + $this->connection = $connection; + } + + protected function configure(): void + { + $this + ->addOption('dsn', null, InputOption::VALUE_REQUIRED, 'DBAL DSN. Optional when using injected DBAL connection or DATABASE_URL env var.') + ->addOption('table', null, InputOption::VALUE_REQUIRED, 'Name of the log entry table.', 'ext_log_entries') + ->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Rows per round-trip.', '500') + ->addOption('drop-legacy', null, InputOption::VALUE_NONE, 'Drop the data_serialized column after successful migration.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $table = (string) $input->getOption('table'); + $batchSize = (int) $input->getOption('batch-size'); + $dropLegacy = (bool) $input->getOption('drop-legacy'); + + if ($batchSize < 1) { + $output->writeln('--batch-size must be greater than 0.'); + + return self::FAILURE; + } + + $connection = $this->connection; + + if (null === $connection) { + $dsn = (string) ($input->getOption('dsn') ?: (getenv('DATABASE_URL') ?: '')); + + if ('' === $dsn) { + $output->writeln('No DB connection available. Provide --dsn, set DATABASE_URL, or register this command with an injected Doctrine DBAL Connection.'); + + return self::FAILURE; + } + + try { + $connection = DriverManager::getConnection(['url' => $dsn]); + $connection->connect(); + } catch (\Throwable $e) { + $output->writeln(sprintf('Could not connect to the database: %s', $e->getMessage())); + + return self::FAILURE; + } + } + + $platform = $connection->getDatabasePlatform(); + $schemaManager = method_exists($connection, 'createSchemaManager') + ? $connection->createSchemaManager() + : $connection->getSchemaManager(); // DBAL 3 compat + + $quotedTable = $platform->quoteSingleIdentifier($table); + $columns = $schemaManager->listTableColumns($table); + $columnNames = array_keys($columns); + + $hasData = in_array('data', $columnNames, true); + $hasDataSerialized = in_array('data_serialized', $columnNames, true); + + if (!$hasData && !$hasDataSerialized) { + $output->writeln(sprintf("Neither 'data' nor 'data_serialized' column found in table '%s'. Nothing to migrate.", $table)); + + return self::FAILURE; + } + + $platformName = strtolower(get_class($platform)); + $isSqlite = str_contains($platformName, 'sqlite'); + + if ($hasData && !$hasDataSerialized) { + $output->writeln(sprintf("Step 1: Renaming column 'data' to 'data_serialized' in table '%s'...", $table)); + + if ($isSqlite) { + $this->migrateViaSqliteRecreate($connection, $platform, $table, $quotedTable, $output); + $this->finalize($connection, $platform, $table, $quotedTable, $dropLegacy, $output); + + return self::SUCCESS; + } + + $connection->executeStatement(sprintf( + 'ALTER TABLE %s RENAME COLUMN %s TO %s', + $quotedTable, + $platform->quoteSingleIdentifier('data'), + $platform->quoteSingleIdentifier('data_serialized') + )); + $output->writeln(' Done.'); + } elseif ($hasDataSerialized && !$hasData) { + $output->writeln("Step 1: Column 'data_serialized' already exists; skipping rename."); + } else { + $output->writeln("Step 1: Both 'data' and 'data_serialized' columns exist; assuming rename was already performed."); + } + + $columnsAfterRename = array_keys($schemaManager->listTableColumns($table)); + if (!in_array('data', $columnsAfterRename, true)) { + $output->writeln(sprintf("Step 2: Adding new 'data' column to table '%s'...", $table)); + $clobType = $platform->getClobTypeDeclarationSQL([]); + $connection->executeStatement(sprintf( + 'ALTER TABLE %s ADD COLUMN %s %s DEFAULT NULL', + $quotedTable, + $platform->quoteSingleIdentifier('data'), + $clobType + )); + $output->writeln(' Done.'); + } else { + $output->writeln("Step 2: Column 'data' already exists; skipping ADD COLUMN."); + } + + $output->writeln('Step 3: Converting serialized data to JSON...'); + $converted = $this->convertRows($connection, $platform, $quotedTable, $batchSize, $output); + $output->writeln(sprintf(' Converted %d row(s).', $converted)); + + $this->finalize($connection, $platform, $table, $quotedTable, $dropLegacy, $output); + + return self::SUCCESS; + } + + private function convertRows( + Connection $connection, + AbstractPlatform $platform, + string $quotedTable, + int $batchSize, + OutputInterface $output + ): int { + $converted = 0; + $offset = 0; + + $qData = $platform->quoteSingleIdentifier('data'); + $qDataSerialized = $platform->quoteSingleIdentifier('data_serialized'); + $qId = $platform->quoteSingleIdentifier('id'); + + while (true) { + $rows = $connection->fetchAllAssociative( + sprintf( + 'SELECT %s, %s FROM %s WHERE %s IS NOT NULL AND %s IS NULL LIMIT %d OFFSET %d', + $qId, + $qDataSerialized, + $quotedTable, + $qDataSerialized, + $qData, + $batchSize, + $offset + ) + ); + + if ([] === $rows) { + break; + } + + foreach ($rows as $row) { + $serialized = $row['data_serialized']; + if (null === $serialized) { + continue; + } + + $deserialized = unserialize($serialized, ['allowed_classes' => false]); + + if (false === $deserialized && 'b:0;' !== $serialized) { + $output->writeln(sprintf(' Warning: could not unserialize row id=%s – skipping.', $row['id'])); + continue; + } + + try { + $json = json_encode($deserialized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + } catch (\JsonException $e) { + $output->writeln(sprintf(' Warning: could not JSON-encode row id=%s (%s) – skipping.', $row['id'], $e->getMessage())); + continue; + } + + $connection->executeStatement( + sprintf('UPDATE %s SET %s = ? WHERE %s = ?', $quotedTable, $qData, $qId), + [$json, $row['id']] + ); + ++$converted; + } + + $offset += $batchSize; + } + + return $converted; + } + + private function migrateViaSqliteRecreate( + Connection $connection, + AbstractPlatform $platform, + string $table, + string $quotedTable, + OutputInterface $output + ): void { + $tmpTable = $table.'_migration_tmp'; + $quotedTmp = $platform->quoteSingleIdentifier($tmpTable); + + $createSql = $connection->fetchOne( + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", + [$table] + ); + + if (false === $createSql || null === $createSql) { + throw new \RuntimeException(sprintf("Could not read schema for table '%s'.", $table)); + } + + $tmpCreate = preg_replace( + '/^(CREATE\\s+TABLE\\s+)((?:"[^"]*"|`[^`]*`|\\[[^\\]]*\\]|[a-zA-Z_][a-zA-Z0-9_]*))(\\s*\\()/i', + '$1'.$quotedTmp.'$3', + $createSql, + 1 + ); + + $tmpCreate = preg_replace( + '/(?<=\\(|,)\\s*("data"|`data`|\\[data\\]|(?executeStatement($tmpCreate); + + $cols = $connection->fetchAllAssociative(sprintf('PRAGMA table_info(%s)', $table)); + $colList = implode(', ', array_map( + static fn (array $c): string => $platform->quoteSingleIdentifier($c['name']), + $cols + )); + $connection->executeStatement(sprintf('INSERT INTO %s SELECT %s FROM %s', $quotedTmp, $colList, $quotedTable)); + + $connection->executeStatement(sprintf('DROP TABLE %s', $quotedTable)); + $connection->executeStatement(sprintf('ALTER TABLE %s RENAME TO %s', $quotedTmp, $quotedTable)); + + $clobType = $platform->getClobTypeDeclarationSQL([]); + $connection->executeStatement(sprintf( + 'ALTER TABLE %s ADD COLUMN %s %s DEFAULT NULL', + $quotedTable, + $platform->quoteSingleIdentifier('data'), + $clobType + )); + + $qData = $platform->quoteSingleIdentifier('data'); + $qDataSerialized = $platform->quoteSingleIdentifier('data_serialized'); + $qId = $platform->quoteSingleIdentifier('id'); + + $rows = $connection->fetchAllAssociative( + sprintf('SELECT %s, %s FROM %s WHERE %s IS NOT NULL', $qId, $qDataSerialized, $quotedTable, $qDataSerialized) + ); + $converted = 0; + foreach ($rows as $row) { + $deserialized = unserialize($row['data_serialized'], ['allowed_classes' => false]); + if (false !== $deserialized || 'b:0;' === $row['data_serialized']) { + try { + $json = json_encode($deserialized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + $connection->executeStatement( + sprintf('UPDATE %s SET %s = ? WHERE %s = ?', $quotedTable, $qData, $qId), + [$json, $row['id']] + ); + ++$converted; + } catch (\JsonException) { + $output->writeln(sprintf(' Warning: could not JSON-encode row id=%s – skipping.', $row['id'])); + } + } else { + $output->writeln(sprintf(' Warning: could not unserialize row id=%s – skipping.', $row['id'])); + } + } + + $output->writeln(sprintf(' SQLite migration complete (%d row(s) converted).', $converted)); + } + + private function finalize( + Connection $connection, + AbstractPlatform $platform, + string $table, + string $quotedTable, + bool $dropLegacy, + OutputInterface $output + ): void { + if ($dropLegacy) { + $output->writeln("Dropping legacy column 'data_serialized'..."); + $connection->executeStatement(sprintf( + 'ALTER TABLE %s DROP COLUMN %s', + $quotedTable, + $platform->quoteSingleIdentifier('data_serialized') + )); + $output->writeln(' Done.'); + + return; + } + + $output->writeln(''); + $output->writeln('Migration complete.'); + $output->writeln("The original data is still available in the 'data_serialized' column."); + $output->writeln('Once you have verified the migration, you can drop it:'); + $output->writeln(sprintf(' ALTER TABLE %s DROP COLUMN data_serialized;', $table)); + } +} From f6196ab8d5d5fbe03e36ca2a9290d8a430dffca1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 09:57:40 +0000 Subject: [PATCH 08/20] chore(loggable): clarify DBAL fallback comment and extract SQLite regex constants Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/243336ad-8bbd-4a3d-94ec-bbb282d1892c Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- .../Command/MigrateDataToJsonCommand.php | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/Loggable/Command/MigrateDataToJsonCommand.php b/src/Loggable/Command/MigrateDataToJsonCommand.php index 62dd404163..d78b09c077 100644 --- a/src/Loggable/Command/MigrateDataToJsonCommand.php +++ b/src/Loggable/Command/MigrateDataToJsonCommand.php @@ -21,6 +21,23 @@ final class MigrateDataToJsonCommand extends Command { + /** + * Matches the CREATE TABLE header and captures the table identifier as group 2. + * + * Supported identifier formats: + * - "quoted" + * - `backticked` + * - [bracketed] + * - unquoted_identifier + */ + private const SQLITE_CREATE_TABLE_HEADER_PATTERN = '/^(CREATE\\s+TABLE\\s+)((?:"[^"]*"|`[^`]*`|\\[[^\\]]*\\]|[a-zA-Z_][a-zA-Z0-9_]*))(\\s*\\()/i'; + + /** + * Matches the `data` column identifier at a column-definition boundary. + * Prevents matching identifiers like `data_type`. + */ + private const SQLITE_DATA_COLUMN_PATTERN = '/(?<=\\(|,)\\s*("data"|`data`|\\[data\\]|(?getDatabasePlatform(); $schemaManager = method_exists($connection, 'createSchemaManager') ? $connection->createSchemaManager() - : $connection->getSchemaManager(); // DBAL 3 compat + : $connection->getSchemaManager(); // DBAL 3 fallback $quotedTable = $platform->quoteSingleIdentifier($table); $columns = $schemaManager->listTableColumns($table); @@ -229,14 +246,14 @@ private function migrateViaSqliteRecreate( } $tmpCreate = preg_replace( - '/^(CREATE\\s+TABLE\\s+)((?:"[^"]*"|`[^`]*`|\\[[^\\]]*\\]|[a-zA-Z_][a-zA-Z0-9_]*))(\\s*\\()/i', + self::SQLITE_CREATE_TABLE_HEADER_PATTERN, '$1'.$quotedTmp.'$3', $createSql, 1 ); $tmpCreate = preg_replace( - '/(?<=\\(|,)\\s*("data"|`data`|\\[data\\]|(? Date: Fri, 8 May 2026 09:58:52 +0000 Subject: [PATCH 09/20] fix(loggable): harden migration unserialize handling for malformed data Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/243336ad-8bbd-4a3d-94ec-bbb282d1892c Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- .../Command/MigrateDataToJsonCommand.php | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/Loggable/Command/MigrateDataToJsonCommand.php b/src/Loggable/Command/MigrateDataToJsonCommand.php index d78b09c077..381e482359 100644 --- a/src/Loggable/Command/MigrateDataToJsonCommand.php +++ b/src/Loggable/Command/MigrateDataToJsonCommand.php @@ -199,9 +199,8 @@ private function convertRows( continue; } - $deserialized = unserialize($serialized, ['allowed_classes' => false]); - - if (false === $deserialized && 'b:0;' !== $serialized) { + [$ok, $deserialized] = $this->safeUnserialize($serialized); + if (!$ok) { $output->writeln(sprintf(' Warning: could not unserialize row id=%s – skipping.', $row['id'])); continue; } @@ -288,8 +287,8 @@ private function migrateViaSqliteRecreate( ); $converted = 0; foreach ($rows as $row) { - $deserialized = unserialize($row['data_serialized'], ['allowed_classes' => false]); - if (false !== $deserialized || 'b:0;' === $row['data_serialized']) { + [$ok, $deserialized] = $this->safeUnserialize($row['data_serialized']); + if ($ok) { try { $json = json_encode($deserialized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); $connection->executeStatement( @@ -334,4 +333,30 @@ private function finalize( $output->writeln('Once you have verified the migration, you can drop it:'); $output->writeln(sprintf(' ALTER TABLE %s DROP COLUMN data_serialized;', $table)); } + + /** + * @return array{0: bool, 1: mixed} + */ + private function safeUnserialize(string $serialized): array + { + set_error_handler(static function (): bool { + return true; + }); + + try { + $deserialized = unserialize($serialized, ['allowed_classes' => false]); + } catch (\Throwable) { + restore_error_handler(); + + return [false, null]; + } + + restore_error_handler(); + + if (false === $deserialized && 'b:0;' !== $serialized) { + return [false, null]; + } + + return [true, $deserialized]; + } } From bbb2443269861c6599120f0569cc2f34bc9dd6d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 12:05:29 +0000 Subject: [PATCH 10/20] fix: register loggable command via #[AsCommand] and make DSN option optional Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/e2ee88ec-9bfd-48d4-81c8-eb88c446c83b Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- bin/migrate-loggable-data-to-json.php | 40 ------------------- .../Command/MigrateDataToJsonCommand.php | 10 +++-- 2 files changed, 6 insertions(+), 44 deletions(-) delete mode 100755 bin/migrate-loggable-data-to-json.php diff --git a/bin/migrate-loggable-data-to-json.php b/bin/migrate-loggable-data-to-json.php deleted file mode 100755 index 1089eb2594..0000000000 --- a/bin/migrate-loggable-data-to-json.php +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env php - http://www.gediminasm.org - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -use Gedmo\Loggable\Command\MigrateDataToJsonCommand; -use Symfony\Component\Console\Application; - -$autoloadCandidates = [ - __DIR__.'/../vendor/autoload.php', - __DIR__.'/../../vendor/autoload.php', - __DIR__.'/../../../vendor/autoload.php', -]; - -$autoloaderFound = false; -foreach ($autoloadCandidates as $candidate) { - if (file_exists($candidate)) { - require_once $candidate; - $autoloaderFound = true; - break; - } -} - -if (!$autoloaderFound) { - fwrite(STDERR, "Could not find the Composer autoloader. Run 'composer install' first.\n"); - exit(1); -} - -$application = new Application('DoctrineExtensions Loggable Migration'); -$command = new MigrateDataToJsonCommand(); -$application->add($command); -$application->setDefaultCommand((string) $command->getName(), true); -$application->run(); diff --git a/src/Loggable/Command/MigrateDataToJsonCommand.php b/src/Loggable/Command/MigrateDataToJsonCommand.php index 381e482359..2d0f0bc989 100644 --- a/src/Loggable/Command/MigrateDataToJsonCommand.php +++ b/src/Loggable/Command/MigrateDataToJsonCommand.php @@ -14,11 +14,16 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Platforms\AbstractPlatform; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +#[AsCommand( + name: 'gedmo:loggable:migrate-data-to-json', + description: 'Migrate Loggable data column from PHP serialized values to JSON.', +)] final class MigrateDataToJsonCommand extends Command { /** @@ -38,9 +43,6 @@ final class MigrateDataToJsonCommand extends Command */ private const SQLITE_DATA_COLUMN_PATTERN = '/(?<=\\(|,)\\s*("data"|`data`|\\[data\\]|(?addOption('dsn', null, InputOption::VALUE_REQUIRED, 'DBAL DSN. Optional when using injected DBAL connection or DATABASE_URL env var.') + ->addOption('dsn', null, InputOption::VALUE_OPTIONAL, 'DBAL DSN. Optional when using injected DBAL connection or DATABASE_URL env var.') ->addOption('table', null, InputOption::VALUE_REQUIRED, 'Name of the log entry table.', 'ext_log_entries') ->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Rows per round-trip.', '500') ->addOption('drop-legacy', null, InputOption::VALUE_NONE, 'Drop the data_serialized column after successful migration.') From 01ba89e6fd26842834da3b7646ba91983b54a4a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 12:23:31 +0000 Subject: [PATCH 11/20] feat: remove DSN and auto-resolve log entry tables for migration command Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/7e5ec2df-2525-4389-8d6f-589b90b1c2fb Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- doc/loggable.md | 22 ++-- .../Command/MigrateDataToJsonCommand.php | 109 ++++++++++++++---- 2 files changed, 91 insertions(+), 40 deletions(-) diff --git a/doc/loggable.md b/doc/loggable.md index a8321fa930..0312880c59 100644 --- a/doc/loggable.md +++ b/doc/loggable.md @@ -251,10 +251,10 @@ $repo->revert($article, 2); ## Migrating Existing Serialized Data to JSON -If you have an existing `ext_log_entries` table (or a custom log entry table) with data stored in the old +If you have an existing `ext_log_entries` table (or custom ORM log entry tables) with data stored in the old PHP `serialize()` format, you need to migrate it to JSON before using `doctrine/dbal` 4.0 or later. -The project provides a standalone migration script at `bin/migrate-loggable-data-to-json.php`. The script: +The project provides a Symfony console command for this migration. The command: 1. Renames the existing `data` column to `data_serialized` (so no existing data is lost). 2. Adds a new `data` column with a JSON-compatible type. @@ -265,27 +265,19 @@ The project provides a standalone migration script at `bin/migrate-loggable-data ```bash php bin/console gedmo:loggable:migrate-data-to-json \ - --table="ext_log_entries" \ [--batch-size=500] \ [--drop-legacy] ``` -When this command is registered in your Symfony app, it uses the configured Doctrine DBAL connection, -so you do not need to pass credentials on the command line. +The command uses the injected Doctrine DBAL connection and resolves the mapped ORM log entry table(s) +from Doctrine metadata, so you do not need to pass credentials or table names on the command line. -### Standalone usage - -```bash -php bin/migrate-loggable-data-to-json.php \ - --table="ext_log_entries" \ - [--batch-size=500] \ - [--drop-legacy] -``` +> [!NOTE] +> Symfony does not automatically discover command services from vendor packages by attribute alone. +> If you integrate this library directly, register the command as a service yourself or use a Symfony bundle/recipe that imports vendor services for you. | Option | Description | |---|---| -| `--dsn` | DBAL-compatible DSN string. Optional when the command receives an injected Doctrine connection or when `DATABASE_URL` is set. | -| `--table` | Name of the log entry table (default: `ext_log_entries`). | | `--batch-size` | Number of rows to process per database round-trip (default: `500`). | | `--drop-legacy` | Drop the `data_serialized` backup column automatically after a successful migration. Omit this flag if you want to keep the backup column for manual verification first. | diff --git a/src/Loggable/Command/MigrateDataToJsonCommand.php b/src/Loggable/Command/MigrateDataToJsonCommand.php index 2d0f0bc989..7a30acbe55 100644 --- a/src/Loggable/Command/MigrateDataToJsonCommand.php +++ b/src/Loggable/Command/MigrateDataToJsonCommand.php @@ -12,8 +12,9 @@ namespace Gedmo\Loggable\Command; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\Persistence\ManagerRegistry; +use Gedmo\Loggable\LogEntryInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -44,19 +45,19 @@ final class MigrateDataToJsonCommand extends Command private const SQLITE_DATA_COLUMN_PATTERN = '/(?<=\\(|,)\\s*("data"|`data`|\\[data\\]|(?connection = $connection; + $this->managerRegistry = $managerRegistry; } protected function configure(): void { $this - ->addOption('dsn', null, InputOption::VALUE_OPTIONAL, 'DBAL DSN. Optional when using injected DBAL connection or DATABASE_URL env var.') - ->addOption('table', null, InputOption::VALUE_REQUIRED, 'Name of the log entry table.', 'ext_log_entries') ->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Rows per round-trip.', '500') ->addOption('drop-legacy', null, InputOption::VALUE_NONE, 'Drop the data_serialized column after successful migration.') ; @@ -64,7 +65,6 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $table = (string) $input->getOption('table'); $batchSize = (int) $input->getOption('batch-size'); $dropLegacy = (bool) $input->getOption('drop-legacy'); @@ -77,24 +77,48 @@ protected function execute(InputInterface $input, OutputInterface $output): int $connection = $this->connection; if (null === $connection) { - $dsn = (string) ($input->getOption('dsn') ?: (getenv('DATABASE_URL') ?: '')); + $output->writeln('No DB connection available. Register this command with an injected Doctrine DBAL Connection.'); - if ('' === $dsn) { - $output->writeln('No DB connection available. Provide --dsn, set DATABASE_URL, or register this command with an injected Doctrine DBAL Connection.'); + return self::FAILURE; + } - return self::FAILURE; - } + $tables = $this->resolveLogEntryTables($connection); + + if ([] === $tables) { + $output->writeln('Could not resolve any ORM log entry table from Doctrine metadata for the injected connection.'); + + return self::FAILURE; + } - try { - $connection = DriverManager::getConnection(['url' => $dsn]); - $connection->connect(); - } catch (\Throwable $e) { - $output->writeln(sprintf('Could not connect to the database: %s', $e->getMessage())); + $output->writeln(sprintf( + 'Resolved %d log entry table(s): %s', + count($tables), + implode(', ', array_map(static fn (string $table): string => sprintf("'%s'", $table), $tables)) + )); - return self::FAILURE; + try { + foreach ($tables as $table) { + $this->migrateTable($connection, $table, $batchSize, $dropLegacy, $output); } + } catch (\Throwable $e) { + $output->writeln(sprintf('%s', $e->getMessage())); + + return self::FAILURE; } + return self::SUCCESS; + } + + private function migrateTable( + Connection $connection, + string $table, + int $batchSize, + bool $dropLegacy, + OutputInterface $output + ): void { + $output->writeln(''); + $output->writeln(sprintf("Migrating table '%s'...", $table)); + $platform = $connection->getDatabasePlatform(); $schemaManager = method_exists($connection, 'createSchemaManager') ? $connection->createSchemaManager() @@ -108,9 +132,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $hasDataSerialized = in_array('data_serialized', $columnNames, true); if (!$hasData && !$hasDataSerialized) { - $output->writeln(sprintf("Neither 'data' nor 'data_serialized' column found in table '%s'. Nothing to migrate.", $table)); - - return self::FAILURE; + throw new \RuntimeException(sprintf("Neither 'data' nor 'data_serialized' column found in table '%s'. Nothing to migrate.", $table)); } $platformName = strtolower(get_class($platform)); @@ -123,7 +145,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->migrateViaSqliteRecreate($connection, $platform, $table, $quotedTable, $output); $this->finalize($connection, $platform, $table, $quotedTable, $dropLegacy, $output); - return self::SUCCESS; + return; } $connection->executeStatement(sprintf( @@ -134,9 +156,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int )); $output->writeln(' Done.'); } elseif ($hasDataSerialized && !$hasData) { - $output->writeln("Step 1: Column 'data_serialized' already exists; skipping rename."); + $output->writeln(sprintf("Step 1: Column 'data_serialized' already exists in table '%s'; skipping rename.", $table)); } else { - $output->writeln("Step 1: Both 'data' and 'data_serialized' columns exist; assuming rename was already performed."); + $output->writeln(sprintf("Step 1: Both 'data' and 'data_serialized' columns exist in table '%s'; assuming rename was already performed.", $table)); } $columnsAfterRename = array_keys($schemaManager->listTableColumns($table)); @@ -151,16 +173,53 @@ protected function execute(InputInterface $input, OutputInterface $output): int )); $output->writeln(' Done.'); } else { - $output->writeln("Step 2: Column 'data' already exists; skipping ADD COLUMN."); + $output->writeln(sprintf("Step 2: Column 'data' already exists in table '%s'; skipping ADD COLUMN.", $table)); } - $output->writeln('Step 3: Converting serialized data to JSON...'); + $output->writeln(sprintf("Step 3: Converting serialized data to JSON in table '%s'...", $table)); $converted = $this->convertRows($connection, $platform, $quotedTable, $batchSize, $output); $output->writeln(sprintf(' Converted %d row(s).', $converted)); $this->finalize($connection, $platform, $table, $quotedTable, $dropLegacy, $output); + } - return self::SUCCESS; + /** + * @return list + */ + private function resolveLogEntryTables(Connection $connection): array + { + if (null === $this->managerRegistry) { + return []; + } + + $tables = []; + + foreach ($this->managerRegistry->getManagers() as $manager) { + if (!method_exists($manager, 'getConnection') || $manager->getConnection() !== $connection) { + continue; + } + + $allMetadata = $manager->getMetadataFactory()->getAllMetadata(); + + foreach ($allMetadata as $metadata) { + if (!method_exists($metadata, 'getTableName')) { + continue; + } + + $className = $metadata->getName(); + if (!is_a($className, LogEntryInterface::class, true)) { + continue; + } + + if (property_exists($metadata, 'isMappedSuperclass') && $metadata->isMappedSuperclass) { + continue; + } + + $tables[$metadata->getTableName()] = true; + } + } + + return array_keys($tables); } private function convertRows( From 0abcd35f52fefc0652d6cc32d61af8f79241912d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 12:24:33 +0000 Subject: [PATCH 12/20] fix: harden loggable table resolution Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/7e5ec2df-2525-4389-8d6f-589b90b1c2fb Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- src/Loggable/Command/MigrateDataToJsonCommand.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Loggable/Command/MigrateDataToJsonCommand.php b/src/Loggable/Command/MigrateDataToJsonCommand.php index 7a30acbe55..a8c2560d70 100644 --- a/src/Loggable/Command/MigrateDataToJsonCommand.php +++ b/src/Loggable/Command/MigrateDataToJsonCommand.php @@ -195,7 +195,7 @@ private function resolveLogEntryTables(Connection $connection): array $tables = []; foreach ($this->managerRegistry->getManagers() as $manager) { - if (!method_exists($manager, 'getConnection') || $manager->getConnection() !== $connection) { + if (!method_exists($manager, 'getConnection') || !$this->isSameConnection($manager->getConnection(), $connection)) { continue; } @@ -215,11 +215,16 @@ private function resolveLogEntryTables(Connection $connection): array continue; } - $tables[$metadata->getTableName()] = true; + $tables[] = $metadata->getTableName(); } } - return array_keys($tables); + return array_values(array_unique($tables)); + } + + private function isSameConnection(Connection $first, Connection $second): bool + { + return $first === $second || $first->getParams() == $second->getParams(); } private function convertRows( From 06c99593137ff785f0e170a3fa8bd9844b49400a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 12:25:14 +0000 Subject: [PATCH 13/20] fix: compare loggable connection params strictly Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/7e5ec2df-2525-4389-8d6f-589b90b1c2fb Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- src/Loggable/Command/MigrateDataToJsonCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Loggable/Command/MigrateDataToJsonCommand.php b/src/Loggable/Command/MigrateDataToJsonCommand.php index a8c2560d70..7b429d785e 100644 --- a/src/Loggable/Command/MigrateDataToJsonCommand.php +++ b/src/Loggable/Command/MigrateDataToJsonCommand.php @@ -224,7 +224,7 @@ private function resolveLogEntryTables(Connection $connection): array private function isSameConnection(Connection $first, Connection $second): bool { - return $first === $second || $first->getParams() == $second->getParams(); + return $first === $second || $first->getParams() === $second->getParams(); } private function convertRows( From da26e58fea3136151f436145964a3d9d3daa249e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 12:26:02 +0000 Subject: [PATCH 14/20] refactor: normalize loggable connection identity matching Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/7e5ec2df-2525-4389-8d6f-589b90b1c2fb Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- .../Command/MigrateDataToJsonCommand.php | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/Loggable/Command/MigrateDataToJsonCommand.php b/src/Loggable/Command/MigrateDataToJsonCommand.php index 7b429d785e..6093371161 100644 --- a/src/Loggable/Command/MigrateDataToJsonCommand.php +++ b/src/Loggable/Command/MigrateDataToJsonCommand.php @@ -215,16 +215,35 @@ private function resolveLogEntryTables(Connection $connection): array continue; } - $tables[] = $metadata->getTableName(); + $tables[$metadata->getTableName()] = true; } } - return array_values(array_unique($tables)); + return array_keys($tables); } private function isSameConnection(Connection $first, Connection $second): bool { - return $first === $second || $first->getParams() === $second->getParams(); + return $first === $second || $this->getConnectionIdentity($first) === $this->getConnectionIdentity($second); + } + + /** + * @return array + */ + private function getConnectionIdentity(Connection $connection): array + { + $params = $connection->getParams(); + $identity = []; + + foreach (['url', 'driver', 'host', 'port', 'dbname', 'path', 'memory', 'unix_socket'] as $key) { + if (array_key_exists($key, $params)) { + $identity[$key] = $params[$key]; + } + } + + ksort($identity); + + return $identity; } private function convertRows( From 44803c069fe6a15c2b07bfc9a05c4b2132df22d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 12:26:40 +0000 Subject: [PATCH 15/20] refactor: avoid duplicate loggable connection lookups Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/7e5ec2df-2525-4389-8d6f-589b90b1c2fb Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- .../Command/MigrateDataToJsonCommand.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Loggable/Command/MigrateDataToJsonCommand.php b/src/Loggable/Command/MigrateDataToJsonCommand.php index 6093371161..ca9af8345c 100644 --- a/src/Loggable/Command/MigrateDataToJsonCommand.php +++ b/src/Loggable/Command/MigrateDataToJsonCommand.php @@ -195,7 +195,12 @@ private function resolveLogEntryTables(Connection $connection): array $tables = []; foreach ($this->managerRegistry->getManagers() as $manager) { - if (!method_exists($manager, 'getConnection') || !$this->isSameConnection($manager->getConnection(), $connection)) { + if (!method_exists($manager, 'getConnection')) { + continue; + } + + $managerConnection = $manager->getConnection(); + if (!$this->isSameConnection($managerConnection, $connection)) { continue; } @@ -224,7 +229,14 @@ private function resolveLogEntryTables(Connection $connection): array private function isSameConnection(Connection $first, Connection $second): bool { - return $first === $second || $this->getConnectionIdentity($first) === $this->getConnectionIdentity($second); + if ($first === $second) { + return true; + } + + $firstIdentity = $this->getConnectionIdentity($first); + $secondIdentity = $this->getConnectionIdentity($second); + + return $firstIdentity === $secondIdentity; } /** From 3f6e51731caf03078b5aace8ba62ec697892f312 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 12:27:45 +0000 Subject: [PATCH 16/20] fix: require injected connection for loggable migration command Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/7e5ec2df-2525-4389-8d6f-589b90b1c2fb Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- doc/loggable.md | 7 ++++- .../Command/MigrateDataToJsonCommand.php | 27 ++++++++++--------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/doc/loggable.md b/doc/loggable.md index 0312880c59..dfd552ce02 100644 --- a/doc/loggable.md +++ b/doc/loggable.md @@ -274,7 +274,12 @@ from Doctrine metadata, so you do not need to pass credentials or table names on > [!NOTE] > Symfony does not automatically discover command services from vendor packages by attribute alone. -> If you integrate this library directly, register the command as a service yourself or use a Symfony bundle/recipe that imports vendor services for you. +> If you integrate this library directly, register the command as a service yourself or use a Symfony bundle/recipe that imports vendor services for you. For example: +> +> ```yaml +> services: +> Gedmo\Loggable\Command\MigrateDataToJsonCommand: ~ +> ``` | Option | Description | |---|---| diff --git a/src/Loggable/Command/MigrateDataToJsonCommand.php b/src/Loggable/Command/MigrateDataToJsonCommand.php index ca9af8345c..92813ed180 100644 --- a/src/Loggable/Command/MigrateDataToJsonCommand.php +++ b/src/Loggable/Command/MigrateDataToJsonCommand.php @@ -44,10 +44,10 @@ final class MigrateDataToJsonCommand extends Command */ private const SQLITE_DATA_COLUMN_PATTERN = '/(?<=\\(|,)\\s*("data"|`data`|\\[data\\]|(?connection; - - if (null === $connection) { - $output->writeln('No DB connection available. Register this command with an injected Doctrine DBAL Connection.'); - - return self::FAILURE; - } - - $tables = $this->resolveLogEntryTables($connection); + $tables = $this->resolveLogEntryTables($this->connection); if ([] === $tables) { $output->writeln('Could not resolve any ORM log entry table from Doctrine metadata for the injected connection.'); @@ -98,7 +90,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { foreach ($tables as $table) { - $this->migrateTable($connection, $table, $batchSize, $dropLegacy, $output); + $this->migrateTable($this->connection, $table, $batchSize, $dropLegacy, $output); } } catch (\Throwable $e) { $output->writeln(sprintf('%s', $e->getMessage())); @@ -199,7 +191,16 @@ private function resolveLogEntryTables(Connection $connection): array continue; } - $managerConnection = $manager->getConnection(); + try { + $managerConnection = $manager->getConnection(); + } catch (\Throwable) { + continue; + } + + if (!$managerConnection instanceof Connection) { + continue; + } + if (!$this->isSameConnection($managerConnection, $connection)) { continue; } From 6f4961b6562068c8992762d783d1159fc5d7af54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 12:28:25 +0000 Subject: [PATCH 17/20] refactor: tidy loggable connection identity handling Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/7e5ec2df-2525-4389-8d6f-589b90b1c2fb Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- src/Loggable/Command/MigrateDataToJsonCommand.php | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Loggable/Command/MigrateDataToJsonCommand.php b/src/Loggable/Command/MigrateDataToJsonCommand.php index 92813ed180..337b2d3189 100644 --- a/src/Loggable/Command/MigrateDataToJsonCommand.php +++ b/src/Loggable/Command/MigrateDataToJsonCommand.php @@ -43,6 +43,7 @@ final class MigrateDataToJsonCommand extends Command * Prevents matching identifiers like `data_type`. */ private const SQLITE_DATA_COLUMN_PATTERN = '/(?<=\\(|,)\\s*("data"|`data`|\\[data\\]|(?getConnection(); - } catch (\Throwable) { - continue; - } - + $managerConnection = $manager->getConnection(); if (!$managerConnection instanceof Connection) { continue; } @@ -248,7 +244,7 @@ private function getConnectionIdentity(Connection $connection): array $params = $connection->getParams(); $identity = []; - foreach (['url', 'driver', 'host', 'port', 'dbname', 'path', 'memory', 'unix_socket'] as $key) { + foreach (self::CONNECTION_IDENTITY_KEYS as $key) { if (array_key_exists($key, $params)) { $identity[$key] = $params[$key]; } From c39e75d1f11063c4556b2a467dc34908ed8ab517 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 12:29:13 +0000 Subject: [PATCH 18/20] refactor: cache loggable target connection identity Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/7e5ec2df-2525-4389-8d6f-589b90b1c2fb Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- .../Command/MigrateDataToJsonCommand.php | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/src/Loggable/Command/MigrateDataToJsonCommand.php b/src/Loggable/Command/MigrateDataToJsonCommand.php index 337b2d3189..582dc3f2e2 100644 --- a/src/Loggable/Command/MigrateDataToJsonCommand.php +++ b/src/Loggable/Command/MigrateDataToJsonCommand.php @@ -185,7 +185,8 @@ private function resolveLogEntryTables(Connection $connection): array return []; } - $tables = []; + $connectionIdentity = $this->getConnectionIdentity($connection); + $tablesByName = []; foreach ($this->managerRegistry->getManagers() as $manager) { if (!method_exists($manager, 'getConnection')) { @@ -197,7 +198,7 @@ private function resolveLogEntryTables(Connection $connection): array continue; } - if (!$this->isSameConnection($managerConnection, $connection)) { + if ($managerConnection !== $connection && $this->getConnectionIdentity($managerConnection) !== $connectionIdentity) { continue; } @@ -217,23 +218,11 @@ private function resolveLogEntryTables(Connection $connection): array continue; } - $tables[$metadata->getTableName()] = true; + $tablesByName[$metadata->getTableName()] = true; } } - return array_keys($tables); - } - - private function isSameConnection(Connection $first, Connection $second): bool - { - if ($first === $second) { - return true; - } - - $firstIdentity = $this->getConnectionIdentity($first); - $secondIdentity = $this->getConnectionIdentity($second); - - return $firstIdentity === $secondIdentity; + return array_keys($tablesByName); } /** From a6d07baf625c692ed3a736fdc148c05a943a1a81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 12:29:58 +0000 Subject: [PATCH 19/20] refactor: extract loggable connection match helper Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/7e5ec2df-2525-4389-8d6f-589b90b1c2fb Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- src/Loggable/Command/MigrateDataToJsonCommand.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Loggable/Command/MigrateDataToJsonCommand.php b/src/Loggable/Command/MigrateDataToJsonCommand.php index 582dc3f2e2..82c1055f66 100644 --- a/src/Loggable/Command/MigrateDataToJsonCommand.php +++ b/src/Loggable/Command/MigrateDataToJsonCommand.php @@ -198,7 +198,7 @@ private function resolveLogEntryTables(Connection $connection): array continue; } - if ($managerConnection !== $connection && $this->getConnectionIdentity($managerConnection) !== $connectionIdentity) { + if (!$this->isSameConnection($managerConnection, $connection, $connectionIdentity)) { continue; } @@ -225,6 +225,14 @@ private function resolveLogEntryTables(Connection $connection): array return array_keys($tablesByName); } + /** + * @param array $connectionIdentity + */ + private function isSameConnection(Connection $first, Connection $second, array $connectionIdentity): bool + { + return $first === $second || $this->getConnectionIdentity($first) === $connectionIdentity; + } + /** * @return array */ From 95bc7e1899aca2eb5b26ad7d471105c219ab57dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 12:30:48 +0000 Subject: [PATCH 20/20] docs: clarify AsCommand vendor registration note Agent-Logs-Url: https://github.com/QurPlus/DoctrineExtensions/sessions/7e5ec2df-2525-4389-8d6f-589b90b1c2fb Co-authored-by: stephanvierkant <601833+stephanvierkant@users.noreply.github.com> --- doc/loggable.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/loggable.md b/doc/loggable.md index dfd552ce02..d3e8eaca4e 100644 --- a/doc/loggable.md +++ b/doc/loggable.md @@ -273,7 +273,7 @@ The command uses the injected Doctrine DBAL connection and resolves the mapped O from Doctrine metadata, so you do not need to pass credentials or table names on the command line. > [!NOTE] -> Symfony does not automatically discover command services from vendor packages by attribute alone. +> Symfony does not automatically discover command services from vendor packages by the `#[AsCommand]` attribute alone. > If you integrate this library directly, register the command as a service yourself or use a Symfony bundle/recipe that imports vendor services for you. For example: > > ```yaml