Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

359 linhas
13KB

  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * Migrates territory data from database/myAccessFile.accdb into database/app.sqlite.
  5. *
  6. * Platform support
  7. * Windows — PDO ODBC via the Microsoft Access Database Engine driver.
  8. * Requires: pdo_odbc extension + Microsoft Access Database Engine
  9. * Linux/macOS — mdbtools (`mdb-export` CLI).
  10. * Requires: sudo apt install mdbtools / brew install mdbtools
  11. *
  12. * Usage:
  13. * php database/migrate_access_to_sqlite.php # auto-detect driver
  14. * php database/migrate_access_to_sqlite.php --dry-run # show counts, no writes
  15. * php database/migrate_access_to_sqlite.php --driver=odbc
  16. * php database/migrate_access_to_sqlite.php --driver=mdbtools
  17. */
  18. // ── CLI args ───────────────────────────────────────────────────────────────
  19. $args = $argv ?? [];
  20. $dryRun = in_array('--dry-run', $args, true);
  21. $driverFlag = null;
  22. foreach ($args as $arg) {
  23. if (str_starts_with($arg, '--driver=')) {
  24. $driverFlag = substr($arg, strlen('--driver='));
  25. }
  26. }
  27. // ── Paths ──────────────────────────────────────────────────────────────────
  28. $accessPath = realpath(__DIR__ . '/myAccessFile.accdb');
  29. $sqlitePath = realpath(__DIR__ . '/app.sqlite');
  30. if (!$accessPath) {
  31. fwrite(STDERR, "ERROR: myAccessFile.accdb not found in " . __DIR__ . "\n");
  32. exit(1);
  33. }
  34. if (!$sqlitePath) {
  35. fwrite(STDERR, "ERROR: app.sqlite not found — run the migrations first.\n");
  36. exit(1);
  37. }
  38. // ── Access reader interface ────────────────────────────────────────────────
  39. interface AccessReader
  40. {
  41. /** Total number of rows in the given Access table. */
  42. public function count(string $table): int;
  43. /** Yield each row as an associative array (string keys, string|null values). */
  44. public function rows(string $table): iterable;
  45. }
  46. // ── ODBC reader (Windows) ──────────────────────────────────────────────────
  47. final class OdbcAccessReader implements AccessReader
  48. {
  49. private PDO $pdo;
  50. public function __construct(string $path)
  51. {
  52. $dsn = "odbc:Driver={Microsoft Access Driver (*.mdb, *.accdb)};Dbq={$path};";
  53. try {
  54. $this->pdo = new PDO($dsn, '', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
  55. } catch (PDOException $e) {
  56. fwrite(STDERR, "ERROR: Cannot open Access database via ODBC.\n{$e->getMessage()}\n");
  57. fwrite(STDERR, "Ensure pdo_odbc is enabled and the Microsoft Access Database Engine is installed.\n");
  58. exit(1);
  59. }
  60. }
  61. public function count(string $table): int
  62. {
  63. return (int) $this->pdo->query("SELECT COUNT(*) FROM [{$table}]")->fetchColumn();
  64. }
  65. public function rows(string $table): iterable
  66. {
  67. yield from $this->pdo->query("SELECT * FROM [{$table}]");
  68. }
  69. }
  70. // ── mdbtools reader (Linux / macOS) ───────────────────────────────────────
  71. final class MdbToolsAccessReader implements AccessReader
  72. {
  73. public function __construct(private readonly string $path)
  74. {
  75. if (!$this->which('mdb-export')) {
  76. fwrite(STDERR, "ERROR: mdb-export not found.\n");
  77. fwrite(STDERR, "Install mdbtools: sudo apt install mdbtools (Debian/Ubuntu)\n");
  78. fwrite(STDERR, " brew install mdbtools (macOS)\n");
  79. exit(1);
  80. }
  81. }
  82. public function count(string $table): int
  83. {
  84. // Row count = line count of mdb-export output minus the header line.
  85. $cmd = 'mdb-export ' . escapeshellarg($this->path) . ' ' . escapeshellarg($table) . ' | wc -l';
  86. $output = shell_exec($cmd);
  87. return max(0, (int) trim((string) $output) - 1);
  88. }
  89. public function rows(string $table): iterable
  90. {
  91. $cmd = 'mdb-export ' . escapeshellarg($this->path) . ' ' . escapeshellarg($table);
  92. $handle = popen($cmd, 'r');
  93. if ($handle === false) {
  94. throw new RuntimeException("Failed to run mdb-export for table '{$table}'.");
  95. }
  96. $headers = null;
  97. while (!feof($handle)) {
  98. $line = fgets($handle);
  99. if ($line === false) {
  100. break;
  101. }
  102. $line = rtrim($line, "\r\n");
  103. if ($line === '') {
  104. continue;
  105. }
  106. $cols = str_getcsv($line);
  107. if ($headers === null) {
  108. $headers = $cols;
  109. continue;
  110. }
  111. // Pad short rows (trailing empty columns may be omitted by mdbtools).
  112. while (count($cols) < count($headers)) {
  113. $cols[] = '';
  114. }
  115. yield array_combine($headers, $cols);
  116. }
  117. pclose($handle);
  118. }
  119. private function which(string $bin): bool
  120. {
  121. return !empty(shell_exec('which ' . escapeshellarg($bin) . ' 2>/dev/null'));
  122. }
  123. }
  124. // ── Select driver ──────────────────────────────────────────────────────────
  125. $driver = $driverFlag ?? (PHP_OS_FAMILY === 'Windows' ? 'odbc' : 'mdbtools');
  126. echo "Access file: {$accessPath}\n";
  127. echo "Driver: {$driver}\n";
  128. $reader = match ($driver) {
  129. 'odbc' => new OdbcAccessReader($accessPath),
  130. 'mdbtools' => new MdbToolsAccessReader($accessPath),
  131. default => throw new InvalidArgumentException("Unknown driver '{$driver}'. Use 'odbc' or 'mdbtools'."),
  132. };
  133. // ── SQLite connection ──────────────────────────────────────────────────────
  134. echo "SQLite file: {$sqlitePath}\n";
  135. $sqlite = new PDO("sqlite:{$sqlitePath}", null, null, [
  136. PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  137. PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  138. ]);
  139. $sqlite->exec('PRAGMA foreign_keys = OFF');
  140. $sqlite->exec('PRAGMA journal_mode = WAL');
  141. $sqlite->exec('PRAGMA synchronous = NORMAL');
  142. $sqlite->exec('PRAGMA cache_size = -16000');
  143. if ($dryRun) {
  144. echo "\n-- DRY RUN: no data will be written --\n";
  145. }
  146. $now = date('Y-m-d H:i:s');
  147. // ── Normalisation helpers ──────────────────────────────────────────────────
  148. function normaliseString(mixed $v): ?string
  149. {
  150. return ($v === null || $v === '') ? null : (string) $v;
  151. }
  152. function normaliseInt(mixed $v): ?int
  153. {
  154. return ($v === null || $v === '') ? null : (int) $v;
  155. }
  156. function normaliseFloat(mixed $v): ?float
  157. {
  158. return ($v === null || $v === '') ? null : (float) $v;
  159. }
  160. function normaliseDate(mixed $v, string $format = 'Y-m-d'): ?string
  161. {
  162. if ($v === null || $v === '') {
  163. return null;
  164. }
  165. $ts = strtotime((string) $v);
  166. return $ts !== false ? date($format, $ts) : null;
  167. }
  168. function normaliseDateTime(mixed $v): ?string
  169. {
  170. return normaliseDate($v, 'Y-m-d H:i:s');
  171. }
  172. // ── Migration runner ───────────────────────────────────────────────────────
  173. function migrateTable(
  174. AccessReader $src,
  175. PDO $dst,
  176. string $srcTable,
  177. string $dstTable,
  178. string $insertSql,
  179. callable $mapRow,
  180. bool $dryRun,
  181. int $batchSize = 500
  182. ): void {
  183. $total = $src->count($srcTable);
  184. echo " {$srcTable} → {$dstTable}: {$total} rows";
  185. if ($dryRun) {
  186. echo " (skipped — dry run)\n";
  187. return;
  188. }
  189. echo "\n";
  190. $dst->exec("DELETE FROM [{$dstTable}]");
  191. $stmt = $dst->prepare($insertSql);
  192. $dst->beginTransaction();
  193. $n = 0;
  194. foreach ($src->rows($srcTable) as $row) {
  195. $stmt->execute($mapRow($row));
  196. $n++;
  197. if ($n % $batchSize === 0) {
  198. $dst->commit();
  199. $dst->beginTransaction();
  200. printf(" %d / %d (%.0f%%)\n", $n, $total, $total > 0 ? ($n / $total) * 100 : 0);
  201. }
  202. }
  203. if ($dst->inTransaction()) {
  204. $dst->commit();
  205. }
  206. printf(" Done: %d rows inserted.\n", $n);
  207. }
  208. // ── 1. Territories ─────────────────────────────────────────────────────────
  209. echo "\n[1/3] Territories\n";
  210. migrateTable(
  211. $reader,
  212. $sqlite,
  213. 'Territories',
  214. 'territories',
  215. 'INSERT INTO territories (id, name, description, coordinates, created_at, updated_at)
  216. VALUES (:id, :name, :description, :coordinates, :created_at, :updated_at)',
  217. function (array $row) use ($now): array {
  218. return [
  219. ':id' => (int) $row['Id'],
  220. ':name' => normaliseString($row['Name']),
  221. ':description' => normaliseString($row['Description']),
  222. ':coordinates' => normaliseString($row['Coordinates']),
  223. ':created_at' => $now,
  224. ':updated_at' => $now,
  225. ];
  226. },
  227. $dryRun
  228. );
  229. // ── 2. Households ──────────────────────────────────────────────────────────
  230. echo "\n[2/3] Households\n";
  231. migrateTable(
  232. $reader,
  233. $sqlite,
  234. 'Households',
  235. 'households',
  236. 'INSERT INTO households
  237. (id, territory_id, address, street_number, street_name,
  238. latitude, longitude, is_business, do_not_call,
  239. do_not_call_date, do_not_call_notes, do_not_call_private_notes,
  240. created_at, updated_at)
  241. VALUES
  242. (:id, :territory_id, :address, :street_number, :street_name,
  243. :latitude, :longitude, :is_business, :do_not_call,
  244. :do_not_call_date, :do_not_call_notes, :do_not_call_private_notes,
  245. :created_at, :updated_at)',
  246. function (array $row) use ($now): array {
  247. return [
  248. ':id' => (int) $row['Id'],
  249. ':territory_id' => (int) $row['TerritoryId'],
  250. ':address' => normaliseString($row['Address']),
  251. ':street_number' => normaliseInt($row['StreetNumber']),
  252. ':street_name' => normaliseString($row['StreetName']),
  253. ':latitude' => normaliseFloat($row['Latitude']),
  254. ':longitude' => normaliseFloat($row['Longitude']),
  255. ':is_business' => (int) ($row['IsBusiness'] ?? 0),
  256. ':do_not_call' => (int) ($row['DoNotCall'] ?? 0),
  257. ':do_not_call_date' => normaliseDate($row['DoNotCallDate']),
  258. ':do_not_call_notes' => normaliseString($row['DoNotCallNotes']),
  259. ':do_not_call_private_notes' => normaliseString($row['DoNotCallPrivateNotes']),
  260. ':created_at' => $now,
  261. ':updated_at' => $now,
  262. ];
  263. },
  264. $dryRun
  265. );
  266. // ── 3. HouseholderNames ────────────────────────────────────────────────────
  267. echo "\n[3/3] HouseholderNames\n";
  268. migrateTable(
  269. $reader,
  270. $sqlite,
  271. 'HouseholderNames',
  272. 'householder_names',
  273. 'INSERT INTO householder_names
  274. (id, household_id, name, letter_returned, return_date, created_at, updated_at)
  275. VALUES
  276. (:id, :household_id, :name, :letter_returned, :return_date, :created_at, :updated_at)',
  277. function (array $row) use ($now): array {
  278. return [
  279. ':id' => (int) $row['Id'],
  280. ':household_id' => (int) $row['HouseholdId'],
  281. ':name' => normaliseString($row['Name']),
  282. ':letter_returned' => (int) ($row['LetterReturned'] ?? 0),
  283. ':return_date' => normaliseDateTime($row['ReturnDate']),
  284. ':created_at' => normaliseDateTime($row['Created']) ?? $now,
  285. ':updated_at' => $now,
  286. ];
  287. },
  288. $dryRun
  289. );
  290. // ── Finalise ───────────────────────────────────────────────────────────────
  291. $sqlite->exec('PRAGMA foreign_keys = ON');
  292. echo "\n";
  293. if ($dryRun) {
  294. echo "Dry run complete — no data was written.\n";
  295. } else {
  296. echo "Migration complete. SQLite row counts:\n";
  297. foreach (['territories', 'households', 'householder_names'] as $t) {
  298. $n = $sqlite->query("SELECT COUNT(*) FROM [{$t}]")->fetchColumn();
  299. printf(" %-25s %d\n", $t, $n);
  300. }
  301. }

Powered by TurnKey Linux.