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); } }