Comment migrer du type array vers JSON avec Doctrine
Doctrine a déprécié les types array et object en version 3. Il est temps de migrer vers un type plus interopérable, et moins sensibles au refactoring ! Vous l’aurez compris, il faut maintenant utiliser…

Doctrine a déprécié les types array
et object
en version 3. Il est temps de migrer vers un type plus interopérable, et moins sensibles au refactoring ! Vous l’aurez compris, il faut maintenant utiliser du JSON.
Dans cet article, nous verrons comment migrer ces colonnes facilement
Étape 1 : Un type doctrine hybride
La première étape consiste à ajouter un support de compatibilité avec les deux types de colonnes. Nous allons ajouter un nouveau type Doctrine qui sait lire les deux types de colonnes. Il va d'abord tester de décoder la colonne au format JSON, et si ça échoue, il va essayer de décoder la colonne au format PHP.
php
namespace App\Doctrine\Type;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ArrayType;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\JsonType;
class MigrateJsonNullable extends JsonType
{
private ArrayType $arrayType;
public function getSQLDeclaration(array $column, AbstractPlatform $platform)
{
$arrayType = $this->getArrayType();
$column['type'] = $arrayType;
return $arrayType->getSQLDeclaration($column, $platform);
}
public function convertToPHPValue($value, AbstractPlatform $platform)
{
try {
return parent::convertToPHPValue($value, $platform);
} catch (ConversionException) {
return $this->getArrayType()->convertToPHPValue($value, $platform);
}
}
public function getName(): string
{
return $this->getArrayType()->getName();
}
public function requiresSQLCommentHint(AbstractPlatform $platform)
{
return $this->getArrayType()->requiresSQLCommentHint($platform);
}
private function getArrayType(): ArrayType
{
return $this->arrayType ??= new ArrayType();
}
}
Il faut ensuite ajouter ce nouveau type dans le fichier de configuration de doctrine :
doctrine:
dbal:
types:
migrate_json_nullable: App\Doctrine\Type\MigrateJsonNullable
Et enfin, il faut changer le type de colonne :
- #[ORM\Column(type: 'array', nullable: true)]
+ #[ORM\Column(type: 'migrate_json_nullable', nullable: true)]
private array $foo;
Dès cette étape, il est techniquement possible de déployer, mais cela ne servira à rien, à part ralentir la production. Il faut donc passer à l’étape suivante.
Étape 2 : Migrer les données
Ici, nous avons plus d'options pour migrer les données. L'avantage, c'est que nous pouvons migrer ces données sans bloquer le déploiement. Suivant le nombre d'enregistrements, nous pouvons choisir entre :
- Faire une commande qui va migrer les données en arrière plan
- Faire une commande qui va migrer les données en plusieurs fois, en utilisant un système de batch, et un système de queue, afin de migrer les données en parallèle.
Voici un exemple de commande
namespace App\Command\Tmp;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\DependencyInjection\ServicesResetter;
#[AsCommand(
name: 'app:tmp:migrate-to-json',
description: 'Migrate all tables (that needs to be converted) to json',
)]
class MigrateToJsonCommand extends Command
{
private const array TABLES_TO_MIGRATE = [
'table_name_1' => ['id', ['column_name_1', 'column_name_2']],
'table_name_2' => ['uuid', ['column_name_1', 'column_name_2']],
];
public function __construct(
private readonly Connection $connection,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
#[Autowire(service: 'services_resetter')]
private readonly ServicesResetter $servicesResetter,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limit the number of records to migrate')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
gc_disable();
$limit = $input->getOption('limit');
ProgressBar::setFormatDefinition('custom', ' %current%/%max% [%bar%] %percent:3s%% ; %elapsed:6s%/%estimated:-6s% ; %memory:6s% ; %speed% msg/sec ; %error% error(s)');
foreach (self::TABLES_TO_MIGRATE as $tableName => [$idColumn, $columnNames]) {
$this->migrate($io, $tableName, $idColumn, $columnNames, $limit);
}
return Command::SUCCESS;
}
private function migrate(SymfonyStyle $io, string $tableName, string $idColumn, array $columnNames, ?int $limit): void
{
$io->title('Migration of ' . $tableName . '.' . implode(', ', $columnNames));
$tableName = $this->connection->quoteIdentifier($tableName);
$idColumn = $this->connection->quoteIdentifier($idColumn);
$limitSql = null;
if ($limit) {
$limitSql = 'LIMIT ' . $limit;
}
$columns = [];
$conditions = [];
foreach ($columnNames as $columnName) {
$columns[] = $this->connection->quoteIdentifier($columnName);
$conditions[] = \sprintf('NOT JSON_VALID(%s)', $this->connection->quoteIdentifier($columnName));
}
$columnNamesAsString = implode(', ', $columns);
$conditionsAsString = implode(' AND ', $conditions);
$records = $this->connection->fetchAllAssociative(<<<SQL
SELECT {$idColumn} as id, {$columnNamesAsString}
FROM {$tableName}
WHERE {$conditionsAsString}
{$limitSql}
SQL);
if (!$records) {
$io->success('No records to migrate');
return;
}
$paramsUpdateAsString = implode(' = ?, ', $columns) . ' = ?';
$stmt = $this->connection->prepare(\sprintf(
'UPDATE %s SET %s WHERE %s = ?',
$tableName,
$paramsUpdateAsString,
$idColumn,
));
$bar = $io->createProgressBar(\count($records));
$bar->setFormat('custom');
$bar->setMessage('0', 'speed');
$bar->setMessage('0', 'error');
$errors = [];
$prevErrorHandler = set_error_handler(function ($type, $msg, $file, $line, $context = []) use (&$prevErrorHandler) {
if (__FILE__ === $file && !\in_array($type, [\E_DEPRECATED, \E_USER_DEPRECATED], true)) {
throw new \ErrorException($msg, 0, $type, $file, $line);
}
return $prevErrorHandler ? $prevErrorHandler($type, $msg, $file, $line, $context) : false;
});
$startedAt = microtime(true);
try {
foreach ($records as $i => $record) {
$oldValues = [];
foreach ($columnNames as $columnName) {
try {
$oldValues[$columnName] = unserialize($record[$columnName]);
} catch (\ErrorException) {
$errors[$record['id']] = true;
$oldValues[$columnName] = [];
}
}
try {
$newValues = array_map(fn ($oldValue) => json_encode($oldValue, \JSON_THROW_ON_ERROR), $oldValues);
} catch (\ErrorException) {
$errors[$record['id']] = true;
continue;
}
$stmt->executeStatement([
...array_values($newValues),
$record['id'],
]);
$bar->advance();
if (0 === $i % 100) {
$bar->setMessage((string) round(100 / (microtime(true) - $startedAt), 2), 'speed');
$bar->setMessage((string) \count($errors), 'error');
$startedAt = microtime(true);
if (0 === $i % 10_000) {
$this->servicesResetter->reset();
gc_collect_cycles();
}
}
}
} finally {
restore_error_handler();
}
$bar->finish();
$io->newLine();
$io->newLine();
$io->success('Migration done');
$errors = array_keys($errors);
if ($errors) {
$io->error(\sprintf('Some records (%d) could not be migrated', \count($errors)));
$io->listing($errors);
file_put_contents($this->projectDir . '/var/migrate_to_json.txt', implode("\n", $errors), \FILE_APPEND);
file_put_contents($this->projectDir . '/var/migrate_to_json.txt', "\n", \FILE_APPEND);
}
}
}
Étape 2.5 : Le cas des colonnes not null
Il y a un cas particulier à prendre en compte : les colonnes non-nullable.
Il était possible, pour une colonne de type array
ou object
, de contenir null
, même si elle était non-nullable ! En effet, doctrine va convertir un null
en N;
(valeur de retour de serialize(null)
). Donc la colonne n'est pas vide.