CakePHP 5 CLI z ORM

W poprzedniej części pokazaliśmy komendę, która przetwarzała CSV i symulowała import. Teraz podpinamy do tego ORM, żeby rekordy trafiały do tabeli users. W CakePHP 5 tabela jest zwykle pobierana przez TableLocator, a zapis pojedynczej encji najlepiej robić przez saveOrFail(), jeśli chcesz, żeby błąd był jawny i nie przeszedł niezauważony.

Co zbudujemy?

Komenda:
  • przyjmuje plik CSV jako argument,
  • obsługuje --dry-run,
  • pokazuje kolorowe statusy,
  • renderuje progress bar,
  • zapisuje rekordy do bazy przez ORM,
  • i raportuje błędy walidacji lub zapisu.

Model danych

Załóżmy prostą tabelę users z polami:
id
name
email
created
modified
W CakePHP ORM działa przez encje i tabelę. W komendzie najwygodniej pobrać tabelę tak:
$usersTable = $this->fetchTable('Users');
Dokumentacja CakePHP wskazuje też TableLocator jako standardowy mechanizm dostępu do tabel w innych kontekstach niż kontrolery, w tym w komendach CLI.

Komenda

Poniżej pełna komenda:
<?php
declare(strict_types=1);

namespace App\Command;

use Cake\Command\Command;
use Cake\Console\Arguments;
use Cake\Console\ConsoleIo;
use Cake\Console\ConsoleOptionParser;
use Cake\ORM\Exception\PersistenceFailedException;
use RuntimeException;

final class ImportUsersCommand extends Command
{
    public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
    {
        $parser
            ->addArgument('file', [
                'help' => 'Ścieżka do pliku CSV z użytkownikami.',
                'required' => true,
            ])
            ->addOption('dry-run', [
                'short' => 'd',
                'help' => 'Symuluj import bez zapisu do bazy.',
                'boolean' => true,
            ]);

        return $parser;
    }

    public function execute(Arguments $args, ConsoleIo $io): int
    {
        $file = $args->getArgument('file');
        $dryRun = (bool)$args->getOption('dry-run');

        if (!is_string($file) || $file === '') {
            $io->err('<error>Brak pliku wejściowego.</error>');
            return static::CODE_ERROR;
        }

        if (!is_readable($file)) {
            $io->err('<error>Plik nie istnieje lub nie jest czytelny.</error>');
            return static::CODE_ERROR;
        }

        $rows = $this->readCsv($file);
        $usersTable = $this->fetchTable('Users');

        $io->out('<info>Start importu użytkowników</info>');

        if ($dryRun) {
            $io->out('<warning>Tryb dry-run aktywny - dane nie zostaną zapisane.</warning>');
        }

        $progress = $io->helper('Progress');
        $progress->init([
            'total' => count($rows),
            'width' => 40,
        ]);

        $saved = 0;
        $failed = 0;
        $skipped = 0;

        foreach ($rows as $row) {
            $progress->increment();
            $progress->draw();

            if (!$this->isValidRow($row)) {
                $skipped++;
                $io->warning(sprintf(
                    'Pomijam niepoprawny rekord: %s',
                    json_encode($row, JSON_UNESCAPED_UNICODE)
                ));
                continue;
            }

            if ($dryRun) {
                $io->out(sprintf(
                    '<warning>[DRY-RUN]</warning> Zostałby zapisany: %s <%s>',
                    $row['name'],
                    $row['email']
                ));
                continue;
            }

            try {
                $entity = $usersTable->newEntity([
                    'name' => $row['name'],
                    'email' => $row['email'],
                ]);

                if ($entity->getErrors()) {
                    $failed++;
                    $io->error(sprintf(
                        'Błędy walidacji dla %s: %s',
                        $row['email'],
                        json_encode($entity->getErrors(), JSON_UNESCAPED_UNICODE)
                    ));
                    continue;
                }

                $usersTable->saveOrFail($entity);
                $saved++;

                $io->success(sprintf(
                    'Zapisano: %s <%s>',
                    $row['name'],
                    $row['email']
                ));
            } catch (PersistenceFailedException $e) {
                $failed++;
                $io->error(sprintf(
                    'Nie udało się zapisać %s: %s',
                    $row['email'],
                    $e->getMessage()
                ));
            }
        }

        $io->out();
        $io->out(sprintf(
            '<info>Gotowe. Zapisano: %d, błędów: %d, pominięto: %d.</info>',
            $saved,
            $failed,
            $skipped
        ));

        return $failed > 0 ? static::CODE_ERROR : static::CODE_SUCCESS;
    }

    private function readCsv(string $file): array
    {
        $handle = fopen($file, 'r');

        if ($handle === false) {
            throw new RuntimeException('Nie można otworzyć pliku CSV.');
        }

        try {
            $header = fgetcsv($handle);

            if ($header === false) {
                return [];
            }

            $rows = [];

            while (($data = fgetcsv($handle)) !== false) {
                $rows[] = array_combine($header, $data);
            }

            return $rows;
        } finally {
            fclose($handle);
        }
    }

    private function isValidRow(array $row): bool
    {
        return isset($row['name'], $row['email'])
            && is_string($row['name'])
            && is_string($row['email'])
            && trim($row['name']) !== ''
            && filter_var($row['email'], FILTER_VALIDATE_EMAIL) !== false;
    }
}

Dlaczego saveOrFail()

CakePHP dokumentuje saveOrFail() jako bezpieczny sposób zapisu encji, który rzuca wyjątek, jeśli zapis się nie powiedzie. To wygodne w CLI, bo możesz natychmiast zauważyć problem zamiast sprawdzać ręcznie false po save().

Jak działa dry-run z ORM

W trybie --dry-run wykonujesz:
  • odczyt pliku,
  • walidację danych,
  • przygotowanie encji,
  • i wypisanie informacji, co byłoby zapisane.
Ale nie wywołujesz saveOrFail() ani save(). Dzięki temu możesz przejść przez cały przepływ importu i zobaczyć, które rekordy przeszłyby walidację, a które zostałyby odrzucone. To ważne, bo dry-run powinien być możliwie zbliżony do prawdziwego uruchomienia.

Encje i walidacja

Tworzenie encji przez newEntity() to naturalny etap przed zapisem. CakePHP ORM wykona reguły walidacji zdefiniowane w UsersTable, a błędy będą dostępne na encji przez getErrors(). Przykład prostego UsersTable:
<?php
declare(strict_types=1);

namespace App\Model\Table;

use Cake\ORM\Table;
use Cake\Validation\Validator;

final class UsersTable extends Table
{
    public function validationDefault(Validator $validator): Validator
    {
        $validator
            ->scalar('name')
            ->notEmptyString('name');

        $validator
            ->email('email')
            ->notEmptyString('email');

        return $validator;
    }
}
To dobra praktyka, bo walidacja pozostaje w modelu, a komenda CLI tylko ją wykorzystuje.

Przykładowy CSV

name,email
Alicja,[email protected]
Bartosz,[email protected]
Zły rekord,niepoprawny-email
W trybie normalnym: Alicja i Bartosz zostaną zapisani, trzeci rekord zakończy się błędem walidacji. W --dry-run wszystkie poprawne rekordy zostaną tylko wypisane, nic nie trafi do bazy.

Co z transakcjami?

Jeśli import ma być atomowy, możesz opakować cały batch w transakcję. Jeśli zależy Ci na tym, żeby poprawne rekordy zapisywały się nawet wtedy, gdy jeden nie przejdzie, zostaw zapis rekord po rekordzie. CakePHP ORM wspiera też bardziej zbiorcze operacje, takie jak saveManyOrFail(), ale przy klasycznym CLI importowym często lepiej trzymać kontrolę nad każdym rekordem osobno.

Kiedy używać transakcji?

Transakcja dla całego importu ma sens, gdy:
  • wszystkie rekordy muszą wejść razem albo wcale.
  • import nie może zakończyć się stanem częściowym.
  • dane są ze sobą powiązane.
  • chcesz uprościć logikę "sukces/porażka".
s Jeśli natomiast akceptujesz częściowy sukces, lepszy może być zapis rekord po rekordzie bez jednej wspólnej transakcji.

Główna idea

Zamiast zapisywać każdy rekord od razu, robisz tak:
  1. czytasz CSV,
  2. walidujesz dane,
  3. uruchamiasz transactional(),
  4. zapisujesz wszystkie rekordy wewnątrz jednej transakcji,
  5. jeśli cokolwiek się wysypie, wszystko wraca do stanu sprzed importu.

Komenda z transakcją

Poniżej pełny przykład. Zachowuje --dry-run, kolory i progress bar, ale przy prawdziwym uruchomieniu wykonuje zapis w jednej transakcji.
<?php
declare(strict_types=1);

namespace App\Command;

use Cake\Command\Command;
use Cake\Console\Arguments;
use Cake\Console\ConsoleIo;
use Cake\Console\ConsoleOptionParser;
use Cake\Datasource\ConnectionManager;
use Cake\ORM\Exception\PersistenceFailedException;
use RuntimeException;
use Throwable;

final class ImportUsersCommand extends Command
{
    public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
    {
        $parser
            ->addArgument('file', [
                'help' => 'Ścieżka do pliku CSV z użytkownikami.',
                'required' => true,
            ])
            ->addOption('dry-run', [
                'short' => 'd',
                'help' => 'Symuluj import bez zapisu do bazy.',
                'boolean' => true,
            ]);

        return $parser;
    }

    public function execute(Arguments $args, ConsoleIo $io): int
    {
        $file = $args->getArgument('file');
        $dryRun = (bool)$args->getOption('dry-run');

        if (!is_string($file) || $file === '') {
            $io->err('<error>Brak pliku wejściowego.</error>');
            return static::CODE_ERROR;
        }

        if (!is_readable($file)) {
            $io->err('<error>Plik nie istnieje lub nie jest czytelny.</error>');
            return static::CODE_ERROR;
        }

        $rows = $this->readCsv($file);

        $io->out('<info>Start importu użytkowników</info>');

        if ($dryRun) {
            $io->out('<warning>Tryb dry-run aktywny - dane nie zostaną zapisane.</warning>');
        }

        $progress = $io->helper('Progress');
        $progress->init([
            'total' => count($rows),
            'width' => 40,
        ]);

        $saved = 0;
        $skipped = 0;

        if ($dryRun) {
            foreach ($rows as $row) {
                $progress->increment();
                $progress->draw();

                if (!$this->isValidRow($row)) {
                    $skipped++;
                    $io->warning(sprintf(
                        'Pomijam niepoprawny rekord: %s',
                        json_encode($row, JSON_UNESCAPED_UNICODE)
                    ));
                    continue;
                }

                $io->out(sprintf(
                    '<warning>[DRY-RUN]</warning> Zostałby zapisany: %s <%s>',
                    $row['name'],
                    $row['email']
                ));
            }

            $io->out();
            $io->out(sprintf(
                '<info>Gotowe. Zapisano: 0, pominięto: %d.</info>',
                $skipped
            ));

            return static::CODE_SUCCESS;
        }

        $usersTable = $this->fetchTable('Users');
        $connection = ConnectionManager::get('default');

        try {
            $connection->transactional(function () use ($rows, $usersTable, $io, $progress, &$saved, &$skipped): void {
                foreach ($rows as $row) {
                    $progress->increment();
                    $progress->draw();

                    if (!$this->isValidRow($row)) {
                        $skipped++;
                        $io->warning(sprintf(
                            'Pomijam niepoprawny rekord: %s',
                            json_encode($row, JSON_UNESCAPED_UNICODE)
                        ));
                        continue;
                    }

                    $entity = $usersTable->newEntity([
                        'name' => $row['name'],
                        'email' => $row['email'],
                    ]);

                    if ($entity->getErrors()) {
                        throw new PersistenceFailedException(
                            $entity,
                            'Błąd walidacji danych użytkownika.'
                        );
                    }

                    $usersTable->saveOrFail($entity);
                    $saved++;

                    $io->success(sprintf(
                        'Zapisano: %s <%s>',
                        $row['name'],
                        $row['email']
                    ));
                }
            });
        } catch (Throwable $e) {
            $io->out();
            $io->err(sprintf(
                '<error>Import przerwany, wykonano rollback: %s</error>',
                $e->getMessage()
            ));

            return static::CODE_ERROR;
        }

        $io->out();
        $io->out(sprintf(
            '<info>Gotowe. Zapisano: %d, pominięto: %d.</info>',
            $saved,
            $skipped
        ));

        return static::CODE_SUCCESS;
    }

    private function readCsv(string $file): array
    {
        $handle = fopen($file, 'r');

        if ($handle === false) {
            throw new RuntimeException('Nie można otworzyć pliku CSV.');
        }

        try {
            $header = fgetcsv($handle);

            if ($header === false) {
                return [];
            }

            $rows = [];

            while (($data = fgetcsv($handle)) !== false) {
                $rows[] = array_combine($header, $data);
            }

            return $rows;
        } finally {
            fclose($handle);
        }
    }

    private function isValidRow(array $row): bool
    {
        return isset($row['name'], $row['email'])
            && is_string($row['name'])
            && is_string($row['email'])
            && trim($row['name']) !== ''
            && filter_var($row['email'], FILTER_VALIDATE_EMAIL) !== false;
    }
}

Jak działa transactional()?

CakePHP opisuje transakcję jako blok, który: - zaczyna transakcję, - wykonuje przekazany callback, - robi commit, jeśli wszystko się uda, - albo rollback, jeśli callback rzuci wyjątek. To znaczy, że nie musisz ręcznie wywoływać begin(), commit() i rollback() w prostych przypadkach. Framework zajmie się tym za Ciebie.

Co się dzieje przy błędzie?

Jeśli w trakcie importu:
  • walidacja zwróci błąd,
  • saveOrFail() rzuci wyjątek,
  • albo pojawi się inny problem techniczny,
to transakcja zostanie cofnięta i żadna część importu nie zostanie utrwalona. To jest najważniejsza różnica względem wersji "rekord po rekordzie".

Uwaga do --dry-run

W trybie --dry-run transakcji nie otwieramy w ogóle, bo nic nie zapisujemy. To ważne rozdzielenie:
  • dry-run = symulacja bez skutków ubocznych,
  • normal run = jedna transakcja dla całego importu.
Dzięki temu oba tryby są czytelne i bezpieczne.

Kiedy ten wariant jest lepszy?

Transakcja dla całego importu jest bardzo dobra, jeśli:
  • dane muszą być spójne,
  • import jest krytyczny biznesowo,
  • nie chcesz manualnie sprzątać po częściowym błędzie,
  • rekordy zależą od siebie logicznie.
Jeśli jednak importujesz tysiące rekordów i chcesz zachować to, co się udało, lepszy będzie model częściowy z raportem błędów.

Lepsza organizacja logiki

W większym projekcie warto wydzielić serwis importu:
final class UserImportService
{
    public function import(array $rows, bool $dryRun): array
    {
        // logika biznesowa
    }
}
Komenda wtedy:
  • czyta argumenty,
  • wywołuje serwis,
  • obsługuje output.
Serwis:
  • waliduje,
  • decyduje o zapisie,
  • zwraca wynik.
To sprawia, że kod jest łatwiejszy w testach i mniej podatny na rozrost.

Uruchomienie

bin/cake import_users data/users.csv --dry-run
bin/cake import_users data/users.csv
Pierwsze polecenie symuluje import, drugie zapisuje dane do bazy.