|
- <?php
-
- declare(strict_types=1);
-
- /**
- * Migrates territory data from database/myAccessFile.accdb into database/app.sqlite.
- *
- * Platform support
- * Windows — PDO ODBC via the Microsoft Access Database Engine driver.
- * Requires: pdo_odbc extension + Microsoft Access Database Engine
- * Linux/macOS — mdbtools (`mdb-export` CLI).
- * Requires: sudo apt install mdbtools / brew install mdbtools
- *
- * Usage:
- * php database/migrate_access_to_sqlite.php # auto-detect driver
- * php database/migrate_access_to_sqlite.php --dry-run # show counts, no writes
- * php database/migrate_access_to_sqlite.php --driver=odbc
- * php database/migrate_access_to_sqlite.php --driver=mdbtools
- */
-
- // ── CLI args ───────────────────────────────────────────────────────────────
- $args = $argv ?? [];
- $dryRun = in_array('--dry-run', $args, true);
-
- $driverFlag = null;
- foreach ($args as $arg) {
- if (str_starts_with($arg, '--driver=')) {
- $driverFlag = substr($arg, strlen('--driver='));
- }
- }
-
- // ── Paths ──────────────────────────────────────────────────────────────────
- $accessPath = realpath(__DIR__ . '/myAccessFile.accdb');
- $sqlitePath = realpath(__DIR__ . '/app.sqlite');
-
- if (!$accessPath) {
- fwrite(STDERR, "ERROR: myAccessFile.accdb not found in " . __DIR__ . "\n");
- exit(1);
- }
-
- if (!$sqlitePath) {
- fwrite(STDERR, "ERROR: app.sqlite not found — run the migrations first.\n");
- exit(1);
- }
-
- // ── Access reader interface ────────────────────────────────────────────────
-
- interface AccessReader
- {
- /** Total number of rows in the given Access table. */
- public function count(string $table): int;
-
- /** Yield each row as an associative array (string keys, string|null values). */
- public function rows(string $table): iterable;
- }
-
- // ── ODBC reader (Windows) ──────────────────────────────────────────────────
-
- final class OdbcAccessReader implements AccessReader
- {
- private PDO $pdo;
-
- public function __construct(string $path)
- {
- $dsn = "odbc:Driver={Microsoft Access Driver (*.mdb, *.accdb)};Dbq={$path};";
- try {
- $this->pdo = new PDO($dsn, '', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
- } catch (PDOException $e) {
- fwrite(STDERR, "ERROR: Cannot open Access database via ODBC.\n{$e->getMessage()}\n");
- fwrite(STDERR, "Ensure pdo_odbc is enabled and the Microsoft Access Database Engine is installed.\n");
- exit(1);
- }
- }
-
- public function count(string $table): int
- {
- return (int) $this->pdo->query("SELECT COUNT(*) FROM [{$table}]")->fetchColumn();
- }
-
- public function rows(string $table): iterable
- {
- yield from $this->pdo->query("SELECT * FROM [{$table}]");
- }
- }
-
- // ── mdbtools reader (Linux / macOS) ───────────────────────────────────────
-
- final class MdbToolsAccessReader implements AccessReader
- {
- public function __construct(private readonly string $path)
- {
- if (!$this->which('mdb-export')) {
- fwrite(STDERR, "ERROR: mdb-export not found.\n");
- fwrite(STDERR, "Install mdbtools: sudo apt install mdbtools (Debian/Ubuntu)\n");
- fwrite(STDERR, " brew install mdbtools (macOS)\n");
- exit(1);
- }
- }
-
- public function count(string $table): int
- {
- // Row count = line count of mdb-export output minus the header line.
- $cmd = 'mdb-export ' . escapeshellarg($this->path) . ' ' . escapeshellarg($table) . ' | wc -l';
- $output = shell_exec($cmd);
- return max(0, (int) trim((string) $output) - 1);
- }
-
- public function rows(string $table): iterable
- {
- $cmd = 'mdb-export ' . escapeshellarg($this->path) . ' ' . escapeshellarg($table);
- $handle = popen($cmd, 'r');
-
- if ($handle === false) {
- throw new RuntimeException("Failed to run mdb-export for table '{$table}'.");
- }
-
- $headers = null;
-
- while (!feof($handle)) {
- $line = fgets($handle);
- if ($line === false) {
- break;
- }
- $line = rtrim($line, "\r\n");
- if ($line === '') {
- continue;
- }
-
- $cols = str_getcsv($line);
-
- if ($headers === null) {
- $headers = $cols;
- continue;
- }
-
- // Pad short rows (trailing empty columns may be omitted by mdbtools).
- while (count($cols) < count($headers)) {
- $cols[] = '';
- }
-
- yield array_combine($headers, $cols);
- }
-
- pclose($handle);
- }
-
- private function which(string $bin): bool
- {
- return !empty(shell_exec('which ' . escapeshellarg($bin) . ' 2>/dev/null'));
- }
- }
-
- // ── Select driver ──────────────────────────────────────────────────────────
-
- $driver = $driverFlag ?? (PHP_OS_FAMILY === 'Windows' ? 'odbc' : 'mdbtools');
-
- echo "Access file: {$accessPath}\n";
- echo "Driver: {$driver}\n";
-
- $reader = match ($driver) {
- 'odbc' => new OdbcAccessReader($accessPath),
- 'mdbtools' => new MdbToolsAccessReader($accessPath),
- default => throw new InvalidArgumentException("Unknown driver '{$driver}'. Use 'odbc' or 'mdbtools'."),
- };
-
- // ── SQLite connection ──────────────────────────────────────────────────────
-
- echo "SQLite file: {$sqlitePath}\n";
-
- $sqlite = new PDO("sqlite:{$sqlitePath}", null, null, [
- PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
- PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
- ]);
- $sqlite->exec('PRAGMA foreign_keys = OFF');
- $sqlite->exec('PRAGMA journal_mode = WAL');
- $sqlite->exec('PRAGMA synchronous = NORMAL');
- $sqlite->exec('PRAGMA cache_size = -16000');
-
- if ($dryRun) {
- echo "\n-- DRY RUN: no data will be written --\n";
- }
-
- $now = date('Y-m-d H:i:s');
-
- // ── Normalisation helpers ──────────────────────────────────────────────────
-
- function normaliseString(mixed $v): ?string
- {
- return ($v === null || $v === '') ? null : (string) $v;
- }
-
- function normaliseInt(mixed $v): ?int
- {
- return ($v === null || $v === '') ? null : (int) $v;
- }
-
- function normaliseFloat(mixed $v): ?float
- {
- return ($v === null || $v === '') ? null : (float) $v;
- }
-
- function normaliseDate(mixed $v, string $format = 'Y-m-d'): ?string
- {
- if ($v === null || $v === '') {
- return null;
- }
- $ts = strtotime((string) $v);
- return $ts !== false ? date($format, $ts) : null;
- }
-
- function normaliseDateTime(mixed $v): ?string
- {
- return normaliseDate($v, 'Y-m-d H:i:s');
- }
-
- // ── Migration runner ───────────────────────────────────────────────────────
-
- function migrateTable(
- AccessReader $src,
- PDO $dst,
- string $srcTable,
- string $dstTable,
- string $insertSql,
- callable $mapRow,
- bool $dryRun,
- int $batchSize = 500
- ): void {
- $total = $src->count($srcTable);
- echo " {$srcTable} → {$dstTable}: {$total} rows";
-
- if ($dryRun) {
- echo " (skipped — dry run)\n";
- return;
- }
-
- echo "\n";
- $dst->exec("DELETE FROM [{$dstTable}]");
-
- $stmt = $dst->prepare($insertSql);
- $dst->beginTransaction();
- $n = 0;
-
- foreach ($src->rows($srcTable) as $row) {
- $stmt->execute($mapRow($row));
- $n++;
-
- if ($n % $batchSize === 0) {
- $dst->commit();
- $dst->beginTransaction();
- printf(" %d / %d (%.0f%%)\n", $n, $total, $total > 0 ? ($n / $total) * 100 : 0);
- }
- }
-
- if ($dst->inTransaction()) {
- $dst->commit();
- }
-
- printf(" Done: %d rows inserted.\n", $n);
- }
-
- // ── 1. Territories ─────────────────────────────────────────────────────────
- echo "\n[1/3] Territories\n";
- migrateTable(
- $reader,
- $sqlite,
- 'Territories',
- 'territories',
- 'INSERT INTO territories (id, name, description, coordinates, created_at, updated_at)
- VALUES (:id, :name, :description, :coordinates, :created_at, :updated_at)',
- function (array $row) use ($now): array {
- return [
- ':id' => (int) $row['Id'],
- ':name' => normaliseString($row['Name']),
- ':description' => normaliseString($row['Description']),
- ':coordinates' => normaliseString($row['Coordinates']),
- ':created_at' => $now,
- ':updated_at' => $now,
- ];
- },
- $dryRun
- );
-
- // ── 2. Households ──────────────────────────────────────────────────────────
- echo "\n[2/3] Households\n";
- migrateTable(
- $reader,
- $sqlite,
- 'Households',
- 'households',
- 'INSERT INTO households
- (id, territory_id, address, street_number, street_name,
- latitude, longitude, is_business, do_not_call,
- do_not_call_date, do_not_call_notes, do_not_call_private_notes,
- created_at, updated_at)
- VALUES
- (:id, :territory_id, :address, :street_number, :street_name,
- :latitude, :longitude, :is_business, :do_not_call,
- :do_not_call_date, :do_not_call_notes, :do_not_call_private_notes,
- :created_at, :updated_at)',
- function (array $row) use ($now): array {
- return [
- ':id' => (int) $row['Id'],
- ':territory_id' => (int) $row['TerritoryId'],
- ':address' => normaliseString($row['Address']),
- ':street_number' => normaliseInt($row['StreetNumber']),
- ':street_name' => normaliseString($row['StreetName']),
- ':latitude' => normaliseFloat($row['Latitude']),
- ':longitude' => normaliseFloat($row['Longitude']),
- ':is_business' => (int) ($row['IsBusiness'] ?? 0),
- ':do_not_call' => (int) ($row['DoNotCall'] ?? 0),
- ':do_not_call_date' => normaliseDate($row['DoNotCallDate']),
- ':do_not_call_notes' => normaliseString($row['DoNotCallNotes']),
- ':do_not_call_private_notes' => normaliseString($row['DoNotCallPrivateNotes']),
- ':created_at' => $now,
- ':updated_at' => $now,
- ];
- },
- $dryRun
- );
-
- // ── 3. HouseholderNames ────────────────────────────────────────────────────
- echo "\n[3/3] HouseholderNames\n";
- migrateTable(
- $reader,
- $sqlite,
- 'HouseholderNames',
- 'householder_names',
- 'INSERT INTO householder_names
- (id, household_id, name, letter_returned, return_date, created_at, updated_at)
- VALUES
- (:id, :household_id, :name, :letter_returned, :return_date, :created_at, :updated_at)',
- function (array $row) use ($now): array {
- return [
- ':id' => (int) $row['Id'],
- ':household_id' => (int) $row['HouseholdId'],
- ':name' => normaliseString($row['Name']),
- ':letter_returned' => (int) ($row['LetterReturned'] ?? 0),
- ':return_date' => normaliseDateTime($row['ReturnDate']),
- ':created_at' => normaliseDateTime($row['Created']) ?? $now,
- ':updated_at' => $now,
- ];
- },
- $dryRun
- );
-
- // ── Finalise ───────────────────────────────────────────────────────────────
- $sqlite->exec('PRAGMA foreign_keys = ON');
-
- echo "\n";
- if ($dryRun) {
- echo "Dry run complete — no data was written.\n";
- } else {
- echo "Migration complete. SQLite row counts:\n";
- foreach (['territories', 'households', 'householder_names'] as $t) {
- $n = $sqlite->query("SELECT COUNT(*) FROM [{$t}]")->fetchColumn();
- printf(" %-25s %d\n", $t, $n);
- }
- }
|