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 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 $orderBy * * @return list> 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> $rows * @param array $existingIds * * @return array{array, array} */ 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 $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> $rows * @param array $books * @param array $oustedBooks * @param array $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> $rows * @param array $books * @param array $oustedBooks * @param array $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())); } }