addOption('dry-run', null, InputOption::VALUE_NONE, 'List missing user ids without recreating them'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $dryRun = (bool) $input->getOption('dry-run'); // 1. Discover every (table, column) that references "user" via a foreign key. $references = $this->connection->fetchAllAssociative(<<<'SQL' SELECT t.relname AS child_table, a.attname AS child_col FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid JOIN pg_class rt ON rt.oid = c.confrelid JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) ON true JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum WHERE c.contype = 'f' AND rt.relname = 'user' ORDER BY t.relname, a.attname SQL); // 2. Collect distinct orphan ids across all those columns. $missingIds = []; foreach ($references as $ref) { $table = $ref['child_table']; $col = $ref['child_col']; $ids = $this->connection->fetchFirstColumn(sprintf( 'SELECT DISTINCT %1$s FROM %2$s WHERE %1$s IS NOT NULL AND %1$s NOT IN (SELECT id FROM "user")', $this->connection->quoteIdentifier($col), $this->connection->quoteIdentifier($table), )); foreach ($ids as $id) { $missingIds[(int) $id] = true; } } $missingIds = array_keys($missingIds); sort($missingIds); $io->section(sprintf('%d foreign-key column(s) scanned', count($references))); if ([] === $missingIds) { $io->success('No missing users referenced. Nothing to restore.'); return Command::SUCCESS; } $io->writeln(sprintf('Missing user id(s): %s', implode(', ', $missingIds))); if ($dryRun) { $io->note('Dry run — no user recreated.'); return Command::SUCCESS; } // 3. Recreate each missing user as an archived placeholder, preserving its id. $hash = $this->passwordHasher->hashPassword(new User(), bin2hex(random_bytes(16))); $created = 0; foreach ($missingIds as $id) { $inserted = $this->connection->executeStatement( <<<'SQL' INSERT INTO "user" (id, username, first_name, last_name, roles, password, created_at, is_employee, work_time_ratio, annual_leave_days, reference_period_start, initial_leave_balance, archived) VALUES (:id, :username, :firstName, :lastName, :roles, :password, NOW(), false, 1.0, 25.0, '06-01', 0.0, true) ON CONFLICT (id) DO NOTHING SQL, [ 'id' => $id, 'username' => sprintf('deleted-user-%d', $id), 'firstName' => 'Compte', 'lastName' => sprintf('supprimé #%d', $id), 'roles' => json_encode(['ROLE_USER'], JSON_THROW_ON_ERROR), 'password' => $hash, ], ); // ON CONFLICT may have skipped an already-present row — only count real inserts. if ($inserted > 0) { ++$created; $io->writeln(sprintf(' ✓ user #%d recreated (archived)', $id)); } else { $io->writeln(sprintf(' • user #%d already present — skipped', $id)); } } $io->success(sprintf('%d user(s) restored as archived. References are valid again — no data lost.', $created)); return Command::SUCCESS; } }