From 2a22a3e027f76572014784d2068632de4ad49ecb Mon Sep 17 00:00:00 2001 From: Ryan Prather Date: Thu, 26 Mar 2026 15:14:15 -0400 Subject: [PATCH] upd: DefaultController (#25) add database transfer capability --- src/Controller/DefaultController.php | 59 ++- src/Service/DatabaseTransferService.php | 501 ++++++++++++++++++ templates/default/sidebar.html.twig | 3 + templates/default/transfer_progress.html.twig | 24 + templates/default/transfer_summary.html.twig | 29 + 5 files changed, 614 insertions(+), 2 deletions(-) create mode 100644 src/Service/DatabaseTransferService.php create mode 100644 templates/default/transfer_progress.html.twig create mode 100644 templates/default/transfer_summary.html.twig diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index 97b48f4..d46a7b8 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -7,6 +7,7 @@ use App\Entity\User; use App\Entity\Speaker; use App\Entity\Series; use App\Entity\SharedNote; +use App\Service\DatabaseTransferService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -40,6 +41,7 @@ class DefaultController extends AbstractController 'last4Notes' => $last4Notes, 'reverseNoteSort' => $openNotes, 'isAdmin' => $this->isGranted('ROLE_ADMIN'), + 'xferDB' => DatabaseTransferService::isTransferEnabled(), 'meta' => $meta, 'speakers' => $speakers, 'series' => $series, @@ -89,7 +91,7 @@ class DefaultController extends AbstractController ]); } - #[Route('/reference-editor', name: 'app_reference_editor')] + #[Route('/reference-editor', 'app_reference_editor')] public function referenceEditor(EntityManagerInterface $emi): Response { $this->denyAccessUnlessGranted('ROLE_ADMIN'); @@ -97,10 +99,63 @@ class DefaultController extends AbstractController return $this->render('editors/reference-editor.html.twig'); } - #[Route('/template-editor', name: 'app_template_editor')] + #[Route('/template-editor', 'app_template_editor')] public function templateEditor(): Response { $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); return $this->render('editors/template-editor.html.twig'); } + + #[Route('/xfer-database', name: 'app_admin_transfer_db')] + public function transfer(DatabaseTransferService $service, Request $request): Response + { + $step = $request->query->get('step', 'init'); + $session = $request->getSession(); + + if ($step === 'init') { + $service->createSchema(); + $session->remove('transfer_logs'); + return $this->redirectToRoute('app_admin_transfer_db', ['step' => 0]); + } + + if ($step === 'summary') { + // Finalize + $service->finalizeEnvSwap(); + + return $this->render('default/transfer_summary.html.twig', [ + 'logs' => $session->get('transfer_logs', []) + ]); + } + + $classes = $service->getEntityClasses(); + $totalClasses = count($classes); + + if (isset($classes[$step])) { + $class = explode("\\", $classes[$step]); + $className = end($class); + $func = "transfer{$className}Table"; + + if (method_exists($service, $func)) { + $skippedCount = $service->{$func}(); + + if ($skippedCount > 0) { + $logs = $session->get('transfer_logs', []); + $logs[] = "Skipped $skippedCount orphaned records in the $className table."; + $session->set('transfer_logs', $logs); + } + + $progress = round((($step+1) / $totalClasses) * 100); + $nextStep = ($step + 1 < $totalClasses) ? ($step + 1) : 'summary'; + + return $this->render('default/transfer_progress.html.twig', [ + 'current' => $className, + 'progress' => $progress, + 'next_step' => $nextStep, + ]); + } + } + + // Step 7: Logout and redirect + return $this->redirectToRoute('app_logout'); + } } diff --git a/src/Service/DatabaseTransferService.php b/src/Service/DatabaseTransferService.php new file mode 100644 index 0000000..0a7bbc1 --- /dev/null +++ b/src/Service/DatabaseTransferService.php @@ -0,0 +1,501 @@ +srcDB = new PDO('sqlite:'.$dbFile); + break; + case 'pdo-mysql'; + case 'pdo-pgsql'; + $dsn = $this->convertDBURLString($_ENV['DATABASE_URL']); + $this->srcDB = new PDO($dsn['dsn'], $dsn['username'], $dsn['password']); + } + + // connect the destination database + switch(explode(':', $_ENV['XFER_DATABASE_URL'])[0]) { + case 'pdo-sqlite': + list($driverName, $dbFile) = explode(":/", $_ENV['XFER_DATABASE']); + $this->destDB = new PDO('sqlite:'.$dbFile); + break; + case 'pdo-mysql': + case 'pdo-pgsql': + $dsn = $this->convertDBURLString($_ENV['XFER_DATABASE_URL']); + $this->destDB = new PDO($dsn['dsn'], $dsn['username'], $dsn['password']); + } + } + + public function createSchema(): void + { + $destEm = $this->doctrine->getManager('transfer'); + $metadata = $destEm->getMetadataFactory()->getAllMetadata(); + $tool = new SchemaTool($destEm); + + $tool->dropSchema($metadata); + $tool->createSchema($metadata); + } + + public function getEntityClasses(): array + { + return [ + User::class, + Bible::class, + Reference::class, + Template::class, + Series::class, + Speaker::class, + Note::class + ]; + } + + public function transferUserTable(): int + { + $sql = "SELECT * FROM user"; + $stmt = $this->srcDB->prepare($sql); + $stmt->execute(); + $insQuery = "INSERT INTO user (id, email, roles, password, name, meta_data, home_church_rss) ". + "VALUES ". + "(:id, :email, :roles, :password, :name, :meta_data, :home_church)"; + $destStmt = $this->destDB->prepare($insQuery); + $skippedCount = 0; + + $this->destDB->beginTransaction(); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $params = [ + 'id' => $row['id'], + 'email' => $row['email'], + 'roles' => $row['roles'], + 'password' => $row['password'], + 'name' => $row['name'], + 'meta_data' => $row['meta_data'], + 'home_church' => $row['home_church_rss'], + ]; + + $this->destDB->exec("SAVEPOINT row_insert"); + + try { + $destStmt->execute($params); + + $this->destDB->exec("RELEASE SAVEPOINT row_insert"); + } catch (PDOException $e) { + $skipCodes = [ + '23000', + '23503', + '23505', + ]; + if (in_array($e->getCode(), $skipCodes, true)) { + $this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert"); + $skippedCount++; // 4. Increment the counter + continue; + } + + $this->destDB->rollBack(); + print_r($params); + die("Critical Error: ".$e->getMessage()); + } + } + + $this->destDB->commit(); + + return $skippedCount; + } + + public function transferBibleTable(): int + { + $sql = "SELECT * FROM bible"; + $stmt = $this->srcDB->prepare($sql); + $stmt->execute(); + $insQuery = "INSERT INTO bible (id, book, chapter, verse, content, book_index, label) ". + "VALUES ". + "(:id, :book, :chapter, :verse, :content, :book_index, :label)"; + $destStmt = $this->destDB->prepare($insQuery); + $skippedCount = 0; + + $this->destDB->beginTransaction(); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $uuid = Uuid::fromString($row['id']); + + $params = [ + 'id' => $uuid->toBinary(), + 'book' => $row['book'], + 'chapter' => $row['chapter'], + 'verse' => $row['verse'], + 'content' => $row['content'], + 'book_index' => $row['book_index'], + 'label' => $row['label'], + ]; + + $this->destDB->exec("SAVEPOINT row_insert"); + + try { + $destStmt->execute($params); + + $this->destDB->exec("RELEASE SAVEPOINT row_insert"); + } catch (PDOException $e) { + $skipCodes = [ + '23000', + '23503', + '23505', + ]; + if (in_array($e->getCode(), $skipCodes, true)) { + $this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert"); + $skippedCount++; // 4. Increment the counter + continue; + } + + $this->destDB->rollBack(); + print_r($params); + die("Critical Error: ".$e->getMessage()); + } + } + $this->destDB->commit(); + + return $skippedCount; + } + + public function transferReferenceTable(): int + { + $sql = "SELECT * FROM reference"; + $stmt = $this->srcDB->prepare($sql); + $stmt->execute(); + $insQuery = "INSERT INTO reference (id, type, name, label, ndx, content) ". + "VALUES ". + "(:id, :type, :name, :label, :ndx, :content)"; + $destStmt = $this->destDB->prepare($insQuery); + $skippedCount = 0; + + $this->destDB->beginTransaction(); + while($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $params = [ + 'id' => $row['id'], + 'type' => $row['type'], + 'name' => $row['name'], + 'label' => $row['label'], + 'ndx' => $row['ndx'], + 'content' => $row['content'], + ]; + + $this->destDB->exec("SAVEPOINT row_insert"); + + try { + $destStmt->execute($params); + + $this->destDB->exec("RELEASE SAVEPOINT row_insert"); + } catch (PDOException $e) { + $skipCodes = [ + '23000', + '23503', + '23505', + ]; + if (in_array($e->getCode(), $skipCodes, true)) { + $this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert"); + $skippedCount++; // 4. Increment the counter + continue; + } + + $this->destDB->rollBack(); + print_r($params); + die("Critical Error: ".$e->getMessage()); + } + } + + $this->destDB->commit(); + + return $skippedCount; + } + + public function transferTemplateTable(): int + { + $sql = "SELECT * FROM template"; + $stmt = $this->srcDB->prepare($sql); + $stmt->execute(); + $insQuery = "INSERT INTO template (id, user_id, name, content) ". + "VALUES ". + "(:id, :user_id, :name, :content)"; + $destStmt = $this->destDB->prepare($insQuery); + $skippedCount = 0; + + $this->destDB->beginTransaction(); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $params = [ + 'id' => $row['id'], + 'user_id' => $row['user_id'], + 'name' => $row['name'], + 'content' => $row['content'], + ]; + + $this->destDB->exec("SAVEPOINT row_insert"); + + try { + $destStmt->execute($params); + + $this->destDB->exec("RELEASE SAVEPOINT row_insert"); + } catch (PDOException $e) { + $skipCodes = [ + '23000', + '23503', + '23505', + ]; + if (in_array($e->getCode(), $skipCodes, true)) { + $this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert"); + $skippedCount++; // 4. Increment the counter + continue; + } + + $this->destDB->rollBack(); + print_r($params); + die("Critical Error: ".$e->getMessage()); + } + } + + $this->destDB->commit(); + + return $skippedCount; + } + + public function transferSpeakerTable(): int + { + $sql = "SELECT * FROM speaker"; + $stmt = $this->srcDB->prepare($sql); + $stmt->execute(); + $insQuery = "INSERT INTO speaker (id, name, user_id) ". + "VALUES ". + "(:id, :name, :user_id)"; + $destStmt = $this->destDB->prepare($insQuery); + $skippedCount = 0; + + $this->destDB->beginTransaction(); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $params = [ + 'id' => $row['id'], + 'name' => $row['name'], + 'user_id' => $row['user_id'], + ]; + + $this->destDB->exec("SAVEPOINT row_insert"); + + try { + $destStmt->execute($params); + + $this->destDB->exec("RELEASE SAVEPOINT row_insert"); + } catch (PDOException $e) { + $skipCodes = [ + '23000', + '23503', + '23505', + ]; + if (in_array($e->getCode(), $skipCodes, true)) { + $this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert"); + $skippedCount++; // 4. Increment the counter + continue; + } + + $this->destDB->rollBack(); + print_r($params); + die("Critical Error: ".$e->getMessage()); + } + } + + $this->destDB->commit(); + + return $skippedCount; + } + + public function transferSeriesTable(): int + { + $sql = "SELECT * FROM series"; + $stmt = $this->srcDB->prepare($sql); + $stmt->execute(); + $insQuery = "INSERT INTO series (id, name, user_id, template_id) ". + "VALUES ". + "(:id, :name, :user_id, :template_id)"; + $destStmt = $this->destDB->prepare($insQuery); + $skippedCount = 0; + + $this->destDB->beginTransaction(); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $params = [ + 'id' => $row['id'], + 'name' => $row['name'], + 'user_id' => $row['user_id'], + 'template_id' => $row['template_id'], + ]; + + $this->destDB->exec("SAVEPOINT row_insert"); + + try { + $destStmt->execute($params); + + $this->destDB->exec("RELEASE SAVEPOINT row_insert"); + } catch (PDOException $e) { + $skipCodes = [ + '23000', + '23503', + '23505', + ]; + if (in_array($e->getCode(), $skipCodes, true)) { + $this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert"); + $skippedCount++; // 4. Increment the counter + continue; + } + + $this->destDB->rollBack(); + print_r($params); + die("Critical Error: ".$e->getMessage()); + } + } + + $this->destDB->commit(); + + return $skippedCount; + } + + public function transferNoteTable(): int + { + $sql = "SELECT * FROM note"; + $stmt = $this->srcDB->prepare($sql); + $stmt->execute(); + $insQuery = "INSERT INTO note (id, title, date, passage, refs, text, speaker_id, series_id, user_id, recording) ". + "VALUES ". + "(:id, :title, :date, :passage, :refs, :text, :speaker_id, :series_id, :user_id, :recording)"; + $destStmt = $this->destDB->prepare($insQuery); + $skippedCount = 0; + + $this->destDB->beginTransaction(); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $params = [ + 'id' => $row['id'], + 'speaker_id' => $row['speaker_id'], + 'series_id' => $row['series_id'], + 'user_id' => $row['user_id'], + 'title' => $row['title'], + 'date' => $row['date'], + 'passage' => $row['passage'], + 'refs' => $row['refs'], + 'text' => $row['text'], + 'recording' => $row['recording'], + ]; + + $this->destDB->exec("SAVEPOINT row_insert"); + + try { + $destStmt->execute($params); + + $this->destDB->exec("RELEASE SAVEPOINT row_insert"); + } catch (PDOException $e) { + $skipCodes = [ + '23000', + '23503', + '23505', + ]; + if (in_array($e->getCode(), $skipCodes, true)) { + $this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert"); + $skippedCount++; // 4. Increment the counter + continue; + } + + $this->destDB->rollBack(); + print_r($params); + die("Critical Error: ".$e->getMessage()); + } + } + + $this->destDB->commit(); + + return $skippedCount; + } + + public function finalizeEnvSwap(): void + { + $envPath = $this->projectDir . '/.env'; + + if (!file_exists($envPath)) { + throw new \Exception(".env file not found at $envPath"); + } + + $lines = file($envPath); + $newLines = []; + $xferUrlValue = null; + + // 1. First pass: Find the value of XFER_DATABASE_URL + foreach ($lines as $line) { + if (preg_match('/^XFER_DATABASE_URL=(.*)/', trim($line), $matches)) { + $xferUrlValue = $matches[1]; + break; + } + } + + if (!$xferUrlValue) { + throw new \Exception("XFER_DATABASE_URL not found in .env file."); + } + + // 2. Second pass: Perform the swap + foreach ($lines as $line) { + $trimmed = trim($line); + + // Prepend #OLD_ to the existing DATABASE_URL + if (preg_match('/^DATABASE_URL=/', $trimmed)) { + $newLines[] = "# OLD_" . $line; + continue; + } + + // Rename XFER_DATABASE_URL to DATABASE_URL + if (preg_match('/^XFER_DATABASE_URL=/', $trimmed)) { + $newLines[] = "DATABASE_URL=" . $xferUrlValue . PHP_EOL; + continue; + } + + $newLines[] = $line; + } + + file_put_contents($envPath, implode('', $newLines)); + } + + public static function isTransferEnabled(): bool + { + return (isset($_ENV['XFER_DATABASE_URL']) && !empty($_ENV['XFER_DATABASE_URL'])); + } + + private function convertDBURLString(string $DBURL): array + { + $dsn = preg_split("/[:|\/|\@]+/", $DBURL); + if (count($dsn) > 6) { + die('You cannot have special characters in your password for the database entry during this process. Change the database user password to just use alpha-numeric characters, then run again. You can change it back to more secure after this transfer is complete'); + } + + $res = [ + 'dsn' => substr($dsn[0], 4).':'. + 'host='.$dsn[3].';'. + 'port='.$dsn[4].';'. + 'dbname='.$dsn[5], + 'username' => $dsn[1], + 'password' => $dsn[2] + ]; + + return $res; + } +} \ No newline at end of file diff --git a/templates/default/sidebar.html.twig b/templates/default/sidebar.html.twig index ff70c98..21a4b4e 100644 --- a/templates/default/sidebar.html.twig +++ b/templates/default/sidebar.html.twig @@ -25,6 +25,9 @@
  • Save Note
  • {% if isAdmin is defined and isAdmin %}
  • Reference Editor
  • + {% if xferDB is defined and xferDB %} +
  • Transfer Database
  • + {% endif %} {% endif %}
  • Open Reference
  • Template Editor
  • diff --git a/templates/default/transfer_progress.html.twig b/templates/default/transfer_progress.html.twig new file mode 100644 index 0000000..412079f --- /dev/null +++ b/templates/default/transfer_progress.html.twig @@ -0,0 +1,24 @@ +{% extends 'base.html.twig' %} + +{% block body %} +
    +

    Transferring Database...

    +

    Processing: {{ current }}

    + +
    +
    + {{ progress }}% +
    +
    + +

    Please do not close this window.

    +
    + + +{% endblock %} \ No newline at end of file diff --git a/templates/default/transfer_summary.html.twig b/templates/default/transfer_summary.html.twig new file mode 100644 index 0000000..1e4a777 --- /dev/null +++ b/templates/default/transfer_summary.html.twig @@ -0,0 +1,29 @@ +{% extends 'base.html.twig' %} + +{% block body %} +
    +

    Database Transfer Complete!

    +

    Your data has been successfully moved to the new database, and the environment variables have been updated.

    + + {% if logs|length > 0 %} +
    +
    Data Clean-up Notice
    +

    During the transfer, the following orphaned records were safely ignored to maintain database integrity:

    +
      + {% for log in logs %} +
    • {{ log }}
    • + {% endfor %} +
    +
    + {% else %} +
    + Perfect transfer! No orphaned records were found. +
    + {% endif %} + +
    +

    You must log in again to establish a connection with the new database.

    + Acknowledge & Relogin +
    +
    +{% endblock %} \ No newline at end of file