mdb-tools.psm1

#-----------------------------------------------------------------------------------------
# MDB TOOLS
# Author: Mukunda Johnson
# License: MIT
#
# Handy scripts for inspecting MDB database files and differences.
#-----------------------------------------------------------------------------------------
Function Compare-MDBDatabase {
   <#
      .SYNOPSIS
         Compares two MS Access database files (MDB) and prints differences.
 
      .EXAMPLE
         Compare-MDBDatabase .\database1.mdb .\database2.mdb
 
         Compares the two database files given and prints differences to the output.
   #>

   Param(
      [string]$Path1,
      [string]$Path2
   )

   # Initialize DB Engine
   $dbe = New-Object -comobject DAO.DBEngine.120

   # Open databases. Note these need absolute paths.
   # Params are path, "options", and "read-only"
   # I want to try and get around any locks on the file, but I'm not sure if this gets
   # past an exclusive lock.
   # "Options" controls exclusive mode.
   # https://docs.microsoft.com/en-us/office/client-developer/access/desktop-database-reference/dbengine-opendatabase-method-dao
   $db1 = $dbe.OpenDatabase( (Resolve-Path $Path1), $false, $true )
   $db2 = $dbe.OpenDatabase( (Resolve-Path $Path2), $false, $true )

   #--------------------------------------------------------------------------------------
   # Outputs any difference in the table listing.
   Function Get-Tables-Diff {
      Param( $db1, $db2 )

      $tables1 = $db1.TableDefs | %{ $_.Name }
      $tables2 = $db2.TableDefs | %{ $_.Name }

      $tables1 | %{
         if( $tables2 -notcontains $_ ) {
               Write-Output "(Schema) DB1 has additional table $_"
         }
      }

      $tables2 | %{
         if( $tables1 -notcontains $_ ) {
               Write-Output "(Schema) DB2 has additional table $_"
         }
      }
   }

   #--------------------------------------------------------------------------------------
   # Intersects the table list and returns a list of names that are present in both
   # tables.
   function Select-Common-Tables {
      Param( $db1, $db2 )
      $tables1 = $db1.TableDefs | %{ $_.Name }
      $tables2 = $db2.TableDefs | %{ $_.Name }
      
      $tables1 | Where-Object {$tables2 -contains $_}
   }

   Get-Tables-Diff $db1 $db2

   #--------------------------------------------------------------------------------------
   # Checks field list in both tables and outputs any differences.
   function Get-Table-Fields-Diff {
      Param( $name, $db1, $db2 )
      $fields1 = $db1.TableDefs | Where-Object {$_.Name -eq $name}
      $fields2 = $db2.TableDefs | Where-Object {$_.Name -eq $name}

      $not_found = [System.Collections.ArrayList]@(($fields2 | %{ $_.Name }))

      foreach( $ca in $fields1 ) {
         $fieldname = $ca.Name
         $cb = $fields2 | Where-Object {$_.Name -eq $fieldname}
         if( -not $cb ) {
            Write-Output "(Schema) DB1 has additional field in ${name}: $fieldname"
            continue
         }

         $not_found.Remove( $fieldname )

         $comparisons = ("Type", "Size", "DefaultValue", "Required", "AllowZeroLength")

         $comparisons | %{
            if( $ca.$_ -ne $cb.$_ ) {
               Write-Output "$name.$fieldname - `"$_`" differs: $($ca.$_) - $($cb.$_)"
            }
         }
      }
      
      foreach( $a in $not_found ) {
         Write-Output "(Schema) DB2 has additional field in ${name}: $a"
      }
   }

   #--------------------------------------------------------------------------------------
   # Returns a list of field names that are present in both tables.
   function Select-Common-Fields {
      Param( $table, $db1, $db2 )
      $fields1 = @(($db1.TableDefs | Where-Object {$_.Name -eq $table}).Fields | %{$_.Name})
      $fields2 = @(($db2.TableDefs | Where-Object {$_.Name -eq $table}).Fields | %{$_.Name})

      $fields1 | Where-Object {$fields2 -contains $_}
   }

   #--------------------------------------------------------------------------------------
   # Bulk of the scan.
   # # Exclude any system tables.
   Select-Common-Tables $db1 $db2 | Where-Object {-not $_.StartsWith("MSys")} | %{
      $table = $_
      Get-Table-Fields-Diff $table $db1 $db2

      $fields = Select-Common-Fields $table $db1 $db2

      # Potentially unsafe table injection method. Is there a better way?
      $fieldsquery = ($fields | %{"[$_]"}) -join ","
      $rs1 = $db1.OpenRecordset( "select $fieldsquery FROM $_" )
      $rs2 = $db2.OpenRecordset( "select $fieldsquery FROM $_" )

      $differing = New-Object System.Collections.ArrayList
      $stop_threshold = 3
      $stopping = $false

      $row = 0

      while( $true ) {
         if( $rs1.EOF -or $rs2.EOF ) {
            if( -not $rs.EOF -or -not $rs2.EOF ) {
               # We can't just check "RecordCount" because those do not contain the
               # actual row count until you iterate/fetch all of the data. (I think?)
               Write-Output "(Data) Table $_ has differing record counts."
            }
            break
         }

         $row += 1
         # GetRows returns a 2 dimensional array. We'll just do one row at a time.
         $set1 = $rs1.GetRows(1)
         $set2 = $rs2.GetRows(1)

         for( $i = 0; $i -lt $fields.count; $i++ ) {
            if( $set1[$i,0] -ne $set1[$i,0] ) {
               if( $differing.Count -ge $stop_threshold ) {
                  $stopping = $true
                  break
               } else {
                  $ht = @{ Row = "$row(1)" }
                  for( $j = 0; $j -lt $fields.count; $j++ ) {
                     $a = $set1[$j, 0]
                     $ht.Add( $fields[$j], $a )
                  }
                  $differing.Add( $ht )
                  $ht = @{ Row = "$row(2)" }
                  for( $j = 0; $j -lt $fields.count; $j++ ) {
                     $a = $set2[$j, 0]
                     $ht.Add( $fields[$j], $a )
                  }
                  $differing.Add( $ht )
               }
            }
         }
         if( $stopping ) { break }
      }

      if( $differing.Count -gt 0 ) {
         $differing | Format-Table
      }

      if( $stopping ) {
         Write-Output "More than 3 differences; skipping to next table."
      }

      $rs1.Close()
      $rs2.Close()
   }

   $db1.Close()
   $db2.Close()
}

#-----------------------------------------------------------------------------------------
class Match {
   [string]$MatchType = "Table Name"
}

#-----------------------------------------------------------------------------------------
class TableNameMatch : Match {
   [string]$Table

   TableNameMatch( [string]$Table ) {
      $this.MatchType = "Table Name"
      $this.Table = $Table
   }
}

#-----------------------------------------------------------------------------------------
class FieldNameMatch : Match {
   [string]$Table
   [string]$Field

   FieldNameMatch( [string]$Table, [string]$Field ) {
      $this.MatchType = "Field Name"
      $this.Table = $Table
      $this.Field = $Field
   }
}

#-----------------------------------------------------------------------------------------
class FieldValueMatch : Match {
   [string]$Table
   [string]$Field
   [int]$Row
   [string]$Value
   
   FieldValueMatch( [string]$Table, [string]$Field, [int]$Row, [string]$Value ) {
      $this.MatchType = "Field Value"
      $this.Table = $Table
      $this.Field = $Field
      $this.Row   = $Row
      $this.Value = $Value
   }
}

#-----------------------------------------------------------------------------------------
Function Search-MDBDatabase {
   <#
      .SYNOPSIS
         Searches for a regex string in an MDB file.
 
      .PARAMETER Path
         Path to the MDB file to inspect.
 
      .PARAMETER SearchPattern
         Regex pattern to search against.
 
      .PARAMETER SearchInTableNames
         Default true; search table name strings for matches.
 
      .PARAMETER SearchInFieldNames
         Default true; search field name strings for matches.
 
      .PARAMETER SearchInFieldValues
         Default true; search ALL field/cell data values for matches.
 
      .EXAMPLE
         Search-MDBDatabase .\database1.mdb "test"
 
         Searches the database for the string "test". Will search in table names,
   #>

   Param(
      [Parameter(Mandatory=$true)]
      [string]$Path,
      [Parameter(Mandatory=$true)]
      [string]$SearchPattern,
      [boolean]$SearchInTableNames  = $true,
      [boolean]$SearchInFieldNames  = $true,
      [boolean]$SearchInFieldValues = $true
   )

   $dbe = New-Object -comobject DAO.DBEngine.120
   $db = $dbe.OpenDatabase( (Resolve-Path $Path), $false, $true )

   foreach( $table in $db.TableDefs ) {
      # Skip internal tables.
      if( $table.Name.StartsWith("MSys") ) { continue }

      if( $SearchInTableNames -and ($table.Name -match $SearchPattern) ) {
         Write-Output( [TableNameMatch]::new($table.Name) )
      }
      
      $r = $db.OpenRecordset( "SELECT * FROM [$($table.Name)]" )
      
      if( $SearchInFieldNames ) {
         foreach( $field in $r.Fields ) {
            if( $field.Name -match $SearchPattern ) {
               Write-Output( [FieldNameMatch]::new($table.Name, $field.Name) )
            }
         }
      }
      
      if( $SearchInFieldValues ) {
         $rowCount = 0
         while( !$r.EOF ) {
            $rowCount++
            $rowData = $r.GetRows(1)
            for( $i = 0; $i -lt $r.Fields.Count; $i++ ) {
               if( [string]$rowData[$i,0] -match $SearchPattern ) {
                  $cellvalue = $rowData[$i,0]
                  Write-Output( [FieldValueMatch]::new(
                     $table.Name, $r.Fields[$i].Name, $rowCount, $cellvalue
                  ))
               }
            }
         }
      }

      $r.Close()
   }

   $db.Close()
}
#/////////////////////////////////////////////////////////////////////////////////////////