|
- <#
- .SYNOPSIS
- Migrates territory data from database\myAccessFile.accdb to database\app.sqlite.
-
- .DESCRIPTION
- Reads the Territories, Households, and HouseholderNames tables from the Access
- database and inserts them into the matching SQLite tables.
-
- Prerequisites:
- - 64-bit Microsoft Access Database Engine (ACE OLEDB 12.0):
- https://www.microsoft.com/en-us/download/details.aspx?id=54920
- If you have 32-bit Office installed, run from 32-bit PowerShell instead:
- %SystemRoot%\SysWOW64\WindowsPowerShell\v1.0\powershell.exe
- - sqlite3.exe on PATH, in the project root, or in database\ .
- Download: https://www.sqlite.org/download.html (sqlite-tools-win-x64-*.zip)
- The script will attempt to download it automatically if not found.
-
- .PARAMETER AccessFile
- Path to the .accdb file. Relative paths resolve from the project root.
- Default: database\myAccessFile.accdb
-
- .PARAMETER SQLiteFile
- Path to the SQLite database. Relative paths resolve from the project root.
- Default: database\app.sqlite
-
- .PARAMETER DryRun
- Print Access row counts without writing any data to SQLite.
-
- .PARAMETER Sqlite3Path
- Explicit path to sqlite3.exe (overrides automatic search).
-
- .EXAMPLE
- .\database\Migrate-AccessToSQLite.ps1
- .\database\Migrate-AccessToSQLite.ps1 -DryRun
- .\database\Migrate-AccessToSQLite.ps1 -Sqlite3Path C:\sqlite\sqlite3.exe
- #>
-
- [CmdletBinding()]
- param(
- [string]$AccessFile = "database\myAccessFile.accdb",
- [string]$SQLiteFile = "database\app.sqlite",
- [switch]$DryRun,
- [string]$Sqlite3Path = ""
- )
-
- Set-StrictMode -Version Latest
- $ErrorActionPreference = 'Stop'
-
- # ── Resolve project root ───────────────────────────────────────────────────────
- $projectRoot = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path }
- if ($projectRoot -match '[/\\]database$') {
- $projectRoot = Split-Path $projectRoot -Parent
- }
-
- function Resolve-ProjectPath([string]$p) {
- if ([System.IO.Path]::IsPathRooted($p)) { return $p }
- return Join-Path $projectRoot $p
- }
-
- $accessPath = Resolve-ProjectPath $AccessFile
- $sqlitePath = Resolve-ProjectPath $SQLiteFile
-
- if (-not (Test-Path $accessPath)) {
- Write-Error "Access file not found: $accessPath"
- exit 1
- }
- if (-not (Test-Path $sqlitePath)) {
- Write-Error "SQLite file not found: $sqlitePath`nRun the PHP migrations first to create the schema."
- exit 1
- }
-
- Write-Host "Access file : $accessPath"
- Write-Host "SQLite file : $sqlitePath"
-
- # ── Locate sqlite3.exe ─────────────────────────────────────────────────────────
- function Find-Sqlite3([string]$hint) {
- $candidates = @(
- $hint,
- (Get-Command sqlite3.exe -ErrorAction SilentlyContinue |
- Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue),
- (Join-Path $projectRoot "sqlite3.exe"),
- (Join-Path $projectRoot "database\sqlite3.exe"),
- "C:\sqlite\sqlite3.exe",
- "C:\tools\sqlite3.exe",
- "C:\ProgramData\chocolatey\bin\sqlite3.exe"
- ) | Where-Object { $_ -and (Test-Path $_ -ErrorAction SilentlyContinue) }
- return $candidates | Select-Object -First 1
- }
-
- $sqlite3 = Find-Sqlite3 $Sqlite3Path
-
- if (-not $sqlite3 -and -not $DryRun) {
- Write-Host ""
- Write-Host "sqlite3.exe not found — attempting automatic download ..."
- $destExe = Join-Path $projectRoot "sqlite3.exe"
- try {
- $dlPage = Invoke-WebRequest -Uri "https://www.sqlite.org/download.html" -UseBasicParsing
- $zipName = ($dlPage.Content |
- Select-String -Pattern 'sqlite-tools-win-x64-[\d]+\.zip' -AllMatches
- ).Matches[0].Value
- if (-not $zipName) { throw "Could not parse download filename from sqlite.org" }
-
- $zipUrl = "https://www.sqlite.org/$zipName"
- $zipPath = Join-Path $env:TEMP $zipName
- Write-Host " Downloading $zipUrl ..."
- Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing
-
- $extractDir = Join-Path $env:TEMP "sqlite_extract_$(Get-Random)"
- Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force
- $extracted = Get-ChildItem -Path $extractDir -Filter sqlite3.exe -Recurse |
- Select-Object -First 1
- if ($extracted) {
- Copy-Item $extracted.FullName $destExe -Force
- $sqlite3 = $destExe
- Write-Host " sqlite3.exe saved to: $destExe"
- } else {
- throw "sqlite3.exe not found inside downloaded archive"
- }
- } catch {
- Write-Host ""
- Write-Host " Automatic download failed: $_"
- Write-Host ""
- Write-Host " Please download sqlite3.exe manually:"
- Write-Host " 1. Visit https://www.sqlite.org/download.html"
- Write-Host " 2. Download 'sqlite-tools-win-x64-*.zip'"
- Write-Host " 3. Extract sqlite3.exe to: $projectRoot"
- Write-Host " Then re-run this script."
- exit 1
- }
- }
-
- if ($sqlite3) {
- Write-Host "sqlite3.exe : $sqlite3"
- }
-
- # ── Open Access database via OleDb ────────────────────────────────────────────
- Add-Type -AssemblyName System.Data
-
- $script:oleConn = [System.Data.OleDb.OleDbConnection]::new(
- "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=$accessPath;Mode=Read;"
- )
- try {
- $script:oleConn.Open()
- } catch {
- Write-Error @"
- Cannot open Access database. Error: $($_.Exception.Message)
-
- Ensure the 64-bit Microsoft Access Database Engine is installed:
- https://www.microsoft.com/en-us/download/details.aspx?id=54920
-
- If you have 32-bit Office, run this script from 32-bit PowerShell:
- %SystemRoot%\SysWOW64\WindowsPowerShell\v1.0\powershell.exe .\database\Migrate-AccessToSQLite.ps1
- "@
- exit 1
- }
-
- # Reads all rows from an Access table and returns a List[hashtable].
- # Using a DataReader avoids DataTable/pipeline-unwrap quirks in PowerShell.
- function Read-AccessTable([string]$TableName) {
- $rows = [System.Collections.Generic.List[hashtable]]::new()
- $cmd = [System.Data.OleDb.OleDbCommand]::new("SELECT * FROM [$TableName]", $script:oleConn)
- $rdr = $cmd.ExecuteReader()
-
- $n = $rdr.FieldCount
- $cols = [string[]]::new($n)
- for ($i = 0; $i -lt $n; $i++) { $cols[$i] = $rdr.GetName($i) }
-
- while ($rdr.Read()) {
- $row = @{}
- for ($i = 0; $i -lt $n; $i++) {
- $v = $rdr.GetValue($i)
- $row[$cols[$i]] = if ($v -is [System.DBNull]) { $null } else { $v }
- }
- $rows.Add($row)
- }
-
- $rdr.Dispose()
- $cmd.Dispose()
- return $rows
- }
-
- # ── SQL value helpers ─────────────────────────────────────────────────────────
- function Esc-Str([object]$v) {
- if ($null -eq $v) { return 'NULL' }
- $s = [string]$v
- if ($s -eq '') { return 'NULL' }
- return "'" + $s.Replace("'", "''") + "'"
- }
-
- # For NOT NULL VARCHAR columns: fall back to empty string rather than NULL.
- function EscStrNN([object]$v) {
- if ($null -eq $v) { return "''" }
- return "'" + ([string]$v).Replace("'", "''") + "'"
- }
-
- function Esc-Int([object]$v) {
- if ($null -eq $v) { return 'NULL' }
- $i = 0
- if ([int]::TryParse([string]$v, [ref]$i)) { return [string]$i }
- return 'NULL'
- }
-
- function Esc-Float([object]$v) {
- if ($null -eq $v) { return 'NULL' }
- $f = 0.0
- $style = [System.Globalization.NumberStyles]::Float
- $culture = [System.Globalization.CultureInfo]::InvariantCulture
- if ([double]::TryParse([string]$v, $style, $culture, [ref]$f)) {
- return $f.ToString($culture)
- }
- return 'NULL'
- }
-
- function Esc-Bool([object]$v) {
- if ($null -eq $v) { return '0' }
- $s = ([string]$v).ToLower().Trim()
- if ($s -in @('true', '1', 'yes', '-1', 'on')) { return '1' }
- return '0'
- }
-
- function Esc-Date([object]$v, [string]$fmt = 'yyyy-MM-dd') {
- if ($null -eq $v) { return 'NULL' }
- if ($v -is [datetime]) { return "'" + ([datetime]$v).ToString($fmt) + "'" }
- $dt = [datetime]::MinValue
- if ([datetime]::TryParse([string]$v, [ref]$dt)) {
- return "'" + $dt.ToString($fmt) + "'"
- }
- return 'NULL'
- }
-
- function Esc-DateTime([object]$v) { return Esc-Date $v 'yyyy-MM-dd HH:mm:ss' }
-
- # ── Read all three tables ─────────────────────────────────────────────────────
- Write-Host ""
- Write-Host "[1/3] Reading Territories ..."
- $tTerritories = Read-AccessTable 'Territories'
- Write-Host " $($tTerritories.Count) row(s)"
-
- Write-Host "[2/3] Reading Households ..."
- $tHouseholds = Read-AccessTable 'Households'
- Write-Host " $($tHouseholds.Count) row(s)"
-
- Write-Host "[3/3] Reading HouseholderNames ..."
- $tHouseholderNames = Read-AccessTable 'HouseholderNames'
- Write-Host " $($tHouseholderNames.Count) row(s)"
-
- $script:oleConn.Close()
-
- if ($DryRun) {
- Write-Host ""
- Write-Host "-- DRY RUN: no data written --"
- Write-Host (" {0,-25} {1}" -f "territories", $tTerritories.Count)
- Write-Host (" {0,-25} {1}" -f "households", $tHouseholds.Count)
- Write-Host (" {0,-25} {1}" -f "householder_names", $tHouseholderNames.Count)
- exit 0
- }
-
- # ── Build SQL ─────────────────────────────────────────────────────────────────
- $now = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
- $sql = [System.Text.StringBuilder]::new()
-
- [void]$sql.AppendLine("PRAGMA foreign_keys = OFF;")
- [void]$sql.AppendLine("PRAGMA journal_mode = WAL;")
- [void]$sql.AppendLine("BEGIN TRANSACTION;")
- [void]$sql.AppendLine("")
- [void]$sql.AppendLine("DELETE FROM householder_names;")
- [void]$sql.AppendLine("DELETE FROM households;")
- [void]$sql.AppendLine("DELETE FROM territories;")
- [void]$sql.AppendLine("")
-
- # Territories
- foreach ($row in $tTerritories) {
- $id = [int]$row['Id']
- $name = EscStrNN $row['Name']
- $desc = Esc-Str $row['Description']
- $coord = Esc-Str $row['Coordinates']
- [void]$sql.AppendLine(
- "INSERT INTO territories (id, name, description, coordinates, created_at, updated_at) " +
- "VALUES ($id, $name, $desc, $coord, '$now', '$now');"
- )
- }
- [void]$sql.AppendLine("")
-
- # Households
- foreach ($row in $tHouseholds) {
- $id = [int]$row['Id']
- $terrId = [int]$row['TerritoryId']
- $addr = EscStrNN $row['Address']
- $sNum = Esc-Int $row['StreetNumber']
- $sName = Esc-Str $row['StreetName']
- $lat = Esc-Float $row['Latitude']
- $lon = Esc-Float $row['Longitude']
- $isBiz = Esc-Bool $row['IsBusiness']
- $dnc = Esc-Bool $row['DoNotCall']
- $dncDate = Esc-Date $row['DoNotCallDate']
- $dncNotes = Esc-Str $row['DoNotCallNotes']
- $dncPriv = Esc-Str $row['DoNotCallPrivateNotes']
- [void]$sql.AppendLine(
- "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, $terrId, $addr, $sNum, $sName, $lat, $lon, $isBiz, $dnc, " +
- "$dncDate, $dncNotes, $dncPriv, '$now', '$now');"
- )
- }
- [void]$sql.AppendLine("")
-
- # HouseholderNames
- foreach ($row in $tHouseholderNames) {
- $id = [int]$row['Id']
- $hhId = [int]$row['HouseholdId']
- $name = EscStrNN $row['Name']
- $letRet = Esc-Bool $row['LetterReturned']
- $retDate = Esc-DateTime $row['ReturnDate']
- $creAt = Esc-DateTime $row['Created']
- if ($creAt -eq 'NULL') { $creAt = "'$now'" }
- [void]$sql.AppendLine(
- "INSERT INTO householder_names (id, household_id, name, letter_returned, " +
- "return_date, created_at, updated_at) " +
- "VALUES ($id, $hhId, $name, $letRet, $retDate, $creAt, '$now');"
- )
- }
-
- [void]$sql.AppendLine("")
- [void]$sql.AppendLine("COMMIT;")
- [void]$sql.AppendLine("PRAGMA foreign_keys = ON;")
-
- # ── Execute SQL via sqlite3 stdin ─────────────────────────────────────────────
- Write-Host ""
- Write-Host "Writing to SQLite ..."
-
- $output = $sql.ToString() | & $sqlite3 $sqlitePath 2>&1
-
- if ($LASTEXITCODE -ne 0) {
- Write-Host "sqlite3 output:"
- $output | ForEach-Object { Write-Host " $_" }
- Write-Error "sqlite3 exited with code $LASTEXITCODE"
- exit 1
- }
-
- # ── Verify ────────────────────────────────────────────────────────────────────
- Write-Host ""
- Write-Host "Migration complete. SQLite row counts:"
- $countSql = @(
- "SELECT 'territories', COUNT(*) FROM territories;"
- "SELECT 'households', COUNT(*) FROM households;"
- "SELECT 'householder_names', COUNT(*) FROM householder_names;"
- ) -join "`n"
-
- $counts = ($countSql | & $sqlite3 $sqlitePath) 2>&1
- foreach ($line in $counts) {
- $parts = [string]$line -split '\|'
- Write-Host (" {0,-25} {1}" -f $parts[0].Trim(), $parts[1].Trim())
- }
|