StableTablePS.psm1
function New-StableTable { [CmdletBinding()] param ( # Where to store the table $Path, [switch]$SkipIndexing # InMemoryIndex ) $Path = Resolve-Path_Force $Path $table = [StableTable]@{ Path = $Path } if (-not $PSBoundParameters.SkipIndexing -and (Test-Path $Path)) { $table.InitIndex() } $table } function New-StableTableIndex { [CmdletBinding()] param ( $Path ) } class StableTable { $Path [System.Text.Encoding]$Encoding = [System.Text.Encoding]::UTF8 [hashtable]$Index = @{} $IndexPath $JsonDepth = 5 StableTable() {} StableTable([switch]$NoIndex) {} StableTable([string]$IndexPath) {} InitIndex() { $regex = "^(?<partitionKey>[^=]*)=(?<key>[^=]*)=" [int]$position = 0 # Indexing is read-only, so there is no point to locking the file [System.IO.FileStream]$fs = [System.IO.File]::Open($this.Path, "Open", "Read", "ReadWrite") $sr = [System.IO.StreamReader]::New($fs, $this.Encoding) #region BOM # Check to see if the file starts with a BOM and if it does, increase position by the BOM amount # Works for up to 6 bytes of BOM $items = 1..4 | & { process { $sr.Read() } } $sr.BaseStream.Seek(0, "Begin") $sr.DiscardBufferedData() $buffer = [byte[]]::new(10) $null = $sr.BaseStream.Read($buffer, 0, $buffer.Length) # Turn both arrays into space seperated lists that regex can use for comparison and cleanup # Then convert back to array to see how many bytes are for BOM $BomLength = ([string]$buffer -replace " ?$items.*" -split " ").Count $position = $BomLength # Need to reset back to character 1 $sr.BaseStream.Seek($BomLength, "Begin") $sr.DiscardBufferedData() #endregion BOM #region CRLF # Determine whether the file has CRLF or just LF line endings $line = $sr.ReadLine() $sr.BaseStream.Seek($line.Length, "Begin") $sr.DiscardBufferedData() $curr = '' do { $prev = $curr $curr = $sr.read() } until ($curr -eq 10) if ($prev -eq 13) { $newline = 2 } else { $newline = 1 } $position -= $newline $sr.BaseStream.Seek($BomLength, "Begin") $sr.DiscardBufferedData() #endregion CRLF do { $line = $sr.ReadLine() # Validate that this is a good line and also extract the keys from the line via capture groups if ($line -match $regex) { try { $this.Index[[string]$matches.partitionKey][[string]$matches.key] = $position + $matches[0].length + $newline } catch { $this.Index[[string]$matches.partitionKey] = [hashtable]@{} $this.Index[[string]$matches.partitionKey][[string]$matches.key] = $position + $matches[0].length + $newline } $position += $this.Encoding.GetByteCount($line) + $newline } else { throw "line does not contain mappings!" } } until ($sr.EndOfStream) $sr.Dispose() } [object] Get([string[]]$Keys) { return $this.Get('', $Keys) } [object] Get([string]$PartitionKey, [string[]]$Keys) { if ($this.Index.Keys) { [System.IO.FileStream]$fs = [System.IO.File]::Open($this.Path, "Open", "Read", "ReadWrite") $sr = [System.IO.StreamReader]::New($fs, $this.Encoding) $results = [ordered]@{} foreach ($item in $Keys) { $this.Index[$PartitionKey][$item] $results[$item] = $this.Get([bigint]$this.Index[$PartitionKey][$item], $sr) } $sr.Dispose() if ($results.keys.count -eq 1) { return $results.values[0] } else { return $results } } else { Write-Warning "No index found. Reverting to file scan" return $this.Get($partitionKey, $Keys, $true) } } [object] Get([bigint]$Position, $sr) { try { $sr.BaseStream.Seek($Position, "Begin") | Out-Null $sr.DiscardBufferedData() $line = $sr.ReadLine() try { return $line | ConvertFrom-Json } catch { Write-Warning "Converting from JSON failed for this string: $($Matches[0])" return $line } } catch { throw $_ } } [object] Get([string]$PartitionKey, [string]$Key, [switch]$NonIndexed) { $out = $null $regex = "^$PartitionKey=$Key=" $out = [System.IO.File]::ReadAllLines($this.Path) | & { begin { $res = $null } process { if ($_ -match $regex) { $res = $_ -replace $regex } } end { $res } } if ($null -eq $out) { return $out } else { return ($out | ConvertFrom-Json) } } Set([string]$Name, $Value) { $this.set($Name, $Value, '') } Set([string]$Name, $Value, [string]$PartitionKey) { if ($partitionKey -match "=") { throw "PartitionKey must not contain '=' (You used PartitionKey:'$partitionKey')" } if ($Name -match "=") { throw "Name must not contain '=' (You used name:'$Name')" } $sw = [System.IO.StreamWriter]::new($this.Path, $true, $this.Encoding) try { $null = $_.Value | ConvertFrom-Json -ea stop $sw.WriteLine("$partitionKey=$Name=$Value") } catch { $sw.WriteLine("$partitionKey=$Name=$($Value|ConvertTo-Json -Compress -Depth $this.JsonDepth)") } $sw.Dispose() # Add to index if present. # Add to index file if present? } BulkSet([hashtable]$Hashtable) { $this.BulkSet($Hashtable, '') } BulkSet([hashtable]$Hashtable, [string]$PartitionKey) { # Sample: # @{ # "key1" = "value2" # "key2" = "value2" # } $this.BulkSet(@{$PartitionKey = $Hashtable }, $true) } BulkSet([hashtable]$Hashtable, [switch]$PartitionedHashtable) { # Sample: # @{ # "parititonKey1" = @{ # "key1" = "value" # "key2" = "value" # } # "parititonKey2" = @{ # "key1" = "value2" # "key2" = "value2" # } # } $sw = [System.IO.StreamWriter]::new($this.Path, $true, $this.Encoding) $Hashtable.GetEnumerator() | & { process { $partitionKey = $_.name if ($partitionKey -match "=") { throw "partitionKey must not contain '=' (You used partitionKey:'$partitionKey')" } $_.value.GetEnumerator() | & { process { if ($_.Name -match "=") { throw "Name must not contain '=' (You used name:'$($_.Name)')" } $name = $_.Name $value = $_.Value try { $null = $_.Value | ConvertFrom-Json -ea stop $sw.WriteLine("$partitionKey=$name=$value") } catch { $sw.WriteLine("$partitionKey=$name=$($value|ConvertTo-Json -Compress -Depth $this.JsonDepth)") } } } } } $sw.Dispose() # Add to index if present. # Add to index file if present? } } function Resolve-Path_Force { <# .SYNOPSIS Calls Resolve-Path but works for files that don't exist. .REMARKS From http://devhawk.net/blog/2010/1/22/fixing-powershells-busted-resolve-path-cmdlet #> param ( [string] $FileName ) $FileName = Resolve-Path $FileName -ErrorAction SilentlyContinue -ErrorVariable _frperror if (-not($FileName)) { $FileName = $_frperror[0].TargetObject } return $FileName } |