<?php
|
|
declare(strict_types=1);
|
|
namespace App\LegacyImport;
|
|
use App\Entity\AbstractBook;
|
use App\Entity\Book;
|
use App\Entity\OustedBook;
|
use App\Enum\LabelType;
|
use Doctrine\DBAL\Connection;
|
use Doctrine\ORM\EntityManagerInterface;
|
|
/**
|
* Copies the old "ksiazki", "pozycz" and "pozycz_historia" tables into the current schema.
|
*
|
* The source is never written to. The import is one transaction, so a failure leaves the
|
* target untouched.
|
*
|
* The import can be repeated: a legacy book whose ID is already taken in the target, as a book or
|
* as an ousted book, is left as it is along with its loans, so edits made since are kept and only
|
* new books are added.
|
*/
|
class LegacyImporter
|
{
|
private const int ISBN10_LENGTH = 10;
|
private const int ISSN_LENGTH = 8;
|
|
public function __construct(
|
private readonly Connection $legacyConnection,
|
private readonly EntityManagerInterface $entityManager,
|
) {
|
}
|
|
public function import(bool $dryRun = false): ImportReport
|
{
|
$existingIds = $this->existingIds();
|
|
// Everything is read up front: the source and the target may be the same SQLite file,
|
// which could not be written to during an open read.
|
$bookRows = $this->fetchRows('ksiazki', ['id']);
|
$borrowRows = $this->fetchRows('pozycz', ['id', 'od']);
|
$historyRows = $this->fetchRows('pozycz_historia', ['id', 'od']);
|
|
$connection = $this->entityManager->getConnection();
|
$connection->beginTransaction();
|
|
try {
|
$report = new ImportReport();
|
[$books, $oustedBooks] = $this->importBooks($bookRows, $existingIds, $report);
|
$this->importBorrows($borrowRows, $books, $oustedBooks, $existingIds, $report);
|
$this->importHistory($historyRows, $books, $oustedBooks, $existingIds, $report);
|
$this->entityManager->flush();
|
|
$dryRun ? $connection->rollBack() : $connection->commit();
|
} catch (\Throwable $exception) {
|
if ($connection->isTransactionActive()) {
|
$connection->rollBack();
|
}
|
|
throw $exception;
|
}
|
|
$this->entityManager->clear();
|
|
return $report;
|
}
|
|
/**
|
* @return array<int, true> the IDs of the books and ousted books already in the target
|
*/
|
private function existingIds(): array
|
{
|
$ids = [];
|
|
foreach ([Book::class, OustedBook::class] as $entity) {
|
$rows = $this->entityManager->createQueryBuilder()
|
->select('b.id')
|
->from($entity, 'b')
|
->getQuery()
|
->getSingleColumnResult();
|
|
foreach ($rows as $id) {
|
$ids[(int) $id] = true;
|
}
|
}
|
|
return $ids;
|
}
|
|
/**
|
* @param list<string> $orderBy
|
*
|
* @return list<array<string, mixed>> rows with lower-case keys, whatever case the source reports
|
*/
|
private function fetchRows(string $table, array $orderBy): array
|
{
|
$rows = $this->legacyConnection->createQueryBuilder()
|
->select('*')
|
->from($this->legacyConnection->quoteSingleIdentifier($table))
|
->orderBy(implode(', ', $orderBy))
|
->executeQuery()
|
->fetchAllAssociative();
|
|
return array_map(static fn (array $row): array => array_change_key_case($row, \CASE_LOWER), $rows);
|
}
|
|
/**
|
* @param list<array<string, mixed>> $rows
|
* @param array<int, true> $existingIds
|
*
|
* @return array{array<int, Book>, array<int, OustedBook>}
|
*/
|
private function importBooks(array $rows, array $existingIds, ImportReport $report): array
|
{
|
$books = [];
|
$oustedBooks = [];
|
|
foreach ($rows as $row) {
|
$id = (int) $row['id'];
|
|
if (isset($existingIds[$id])) {
|
++$report->alreadyPresent;
|
continue;
|
}
|
|
if ((string) $row['wycofana'] === '1') {
|
$book = new OustedBook((string) $row['powod']);
|
$oustedBooks[$id] = $book;
|
++$report->oustedBooks;
|
} else {
|
$book = new Book();
|
$books[$id] = $book;
|
++$report->books;
|
}
|
|
$this->fillBook($book, $row, $report);
|
$this->entityManager->persist($book);
|
}
|
|
return [$books, $oustedBooks];
|
}
|
|
/**
|
* @param array<string, mixed> $row
|
*/
|
private function fillBook(AbstractBook $book, array $row, ImportReport $report): void
|
{
|
$year = (int) $row['rok'];
|
|
$book
|
->setId((int) $row['id'])
|
->setTitle((string) $row['tytul'])
|
->setAuthor((string) $row['autor'])
|
->setLanguage(self::text($row['jezyk']))
|
->setEdition(self::text($row['wydanie']))
|
->setPublisher(self::text($row['wydawnictwo']))
|
->setPublicationLoc(self::text($row['miejsce']))
|
->setPublicationYear($year === 0 ? null : $year)
|
->setIssn(self::standardNumber($row['issn'], self::ISSN_LENGTH))
|
->setPlacement(self::placement($row['regal'], $row['polka'], $row['rzad']));
|
|
$isbn = self::standardNumber($row['isbn'], self::ISBN10_LENGTH);
|
|
if ($isbn === null) {
|
return;
|
}
|
|
if (LabelType::ISBN->isValid(LabelType::ISBN->normalize($isbn))) {
|
$book->setIsbn($isbn);
|
} else {
|
$book->addLabel($isbn);
|
$report->invalidIsbns[] = \sprintf('book %d: %s', $row['id'], $isbn);
|
}
|
}
|
|
/**
|
* @param list<array<string, mixed>> $rows
|
* @param array<int, Book> $books
|
* @param array<int, OustedBook> $oustedBooks
|
* @param array<int, true> $existingIds books kept as they are, whose loans are not imported
|
*/
|
private function importBorrows(array $rows, array $books, array $oustedBooks, array $existingIds, ImportReport $report): void
|
{
|
// The old key was (id, od), so a book could be lent twice at once; only one loan fits
|
// now, and the rows are sorted, so reversing keeps the most recent.
|
foreach (array_reverse($rows) as $row) {
|
$id = (int) $row['id'];
|
|
if (isset($existingIds[$id])) {
|
continue;
|
}
|
|
$book = $books[$id] ?? null;
|
|
if ($book === null) {
|
$report->skipped[] = isset($oustedBooks[$id])
|
? \sprintf('pozycz: book %d is on loan but ousted', $id)
|
: \sprintf('pozycz: book %d does not exist', $id);
|
continue;
|
}
|
|
if ($book->isBorrowed()) {
|
$report->skipped[] = \sprintf('pozycz: book %d has more than one open loan, kept the latest', $id);
|
continue;
|
}
|
|
$book->lendTo((string) $row['kto'], self::dateTime($row['od']));
|
++$report->borrows;
|
}
|
}
|
|
/**
|
* @param list<array<string, mixed>> $rows
|
* @param array<int, Book> $books
|
* @param array<int, OustedBook> $oustedBooks
|
* @param array<int, true> $existingIds books kept as they are, whose loans are not imported
|
*/
|
private function importHistory(array $rows, array $books, array $oustedBooks, array $existingIds, ImportReport $report): void
|
{
|
foreach ($rows as $row) {
|
$id = (int) $row['id'];
|
|
if (isset($existingIds[$id])) {
|
continue;
|
}
|
|
$book = $books[$id] ?? $oustedBooks[$id] ?? null;
|
|
if ($book === null) {
|
$report->skipped[] = \sprintf('pozycz_historia: book %d does not exist', $id);
|
continue;
|
}
|
|
if ($row['do'] === null) {
|
$report->skipped[] = \sprintf('pozycz_historia: loan of book %d from %s has no end date', $id, self::dateTime($row['od'])->format('Y-m-d'));
|
continue;
|
}
|
|
$book->addBorrowHistory((string) $row['kto'], self::dateTime($row['od']), self::dateTime($row['do']));
|
$book instanceof Book ? ++$report->borrowHistory : ++$report->oustedBorrowHistory;
|
}
|
}
|
|
private static function text(mixed $value): ?string
|
{
|
return $value === null || (string) $value === '' ? null : (string) $value;
|
}
|
|
/**
|
* Numbers were stored as DECIMAL, which dropped leading zeros.
|
*/
|
private static function standardNumber(mixed $value, int $shortLength): ?string
|
{
|
$number = (string) $value;
|
|
return $number === '' || $number === '0' ? null : str_pad($number, $shortLength, '0', \STR_PAD_LEFT);
|
}
|
|
/**
|
* Joins bookcase, shelf and row as "HOL/4/1", without trailing separators for missing parts.
|
*/
|
private static function placement(mixed $bookcase, mixed $shelf, mixed $row): ?string
|
{
|
if ($bookcase === null) {
|
return null;
|
}
|
|
return rtrim(implode('/', [(string) $bookcase, (string) $shelf, (string) $row]), '/');
|
}
|
|
private static function dateTime(mixed $timestamp): \DateTimeImmutable
|
{
|
// A "@" date is in UTC, but Doctrine stores the wall clock of the object it is given.
|
return new \DateTimeImmutable('@'.(int) $timestamp)
|
->setTimezone(new \DateTimeZone(date_default_timezone_get()));
|
}
|
}
|