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
}