Skip to content

Instantly share code, notes, and snippets.

@jhoneill
Last active May 1, 2024 17:05
Show Gist options
  • Save jhoneill/f93ab7dbc421a55058989f76ae099b05 to your computer and use it in GitHub Desktop.
Save jhoneill/f93ab7dbc421a55058989f76ae099b05 to your computer and use it in GitHub Desktop.
class SmartBuilder { # A string builder with a progress bar that automatically fluses periodically.
[System.Text.StringBuilder] hidden $Builder # The builder itself - its a sealed class otherwise the smartbuilder would be based on it.
#region supporting properties
[string]$RepeatingHeader = '' # If set, the repeating header is re-added as the first line after each flush
[scriptblock]$OnFlush = {$this.ToString()} # What to do when we flush the data (besides clearing the builder and re-adding any header) - should return a string or nothing.
[bool] hidden $_ShowProgress = $true # If false, don't show the progress bar
[string] hidden $_Activity = 'Building' # Displayed on the progress bar - accessed via $ProgressStatus
[string] hidden $_Status = 'Items so far' # Displayed on the progress bar with ": <ItemCount>" - accessed via $ProgressStatus
[bool] hidden $ProgressShown = $false # Have we shown a progress bar
[int]$ProgressTotalToDO = 0 # If set, the progres bar will display items process as a#54 perecentage of this number
[int]$ProgressEvery = 100 # How many additions between updates to the progress bar. (Frequent updates hurt performance)
[int]$FlushEvery = 1000 # How many additions before we flush the builder. If < 1 it will only flush manually
[int]$MaxSize = 1GB # Perform an additional flush if headers + added text + linebreaks becomes too great
[int]$AddsDone = 0 # Counter of additions (whether we add the line break after them or not)
#endregion
#region constructors - # create the builder, initialize progress, add any header - 4 permuations of capacity/header
SmartBuilder() {$this.init(1mb, '', $false)}
SmartBuilder( [int]$Capacity ) {$this.init($Capacity, '', $false)}
SmartBuilder( [string]$RepeatingHeader ) {$this.init(1mb, $RepeatingHeader, $false)}
SmartBuilder( [int]$Capacity, [string]$RepeatingHeader ) {$this.init($Capacity, $RepeatingHeader, $false)}
SmartBuilder( [int]$Capacity, [string]$RepeatingHeader, [bool]$HideProgress ) {$this.init($Capacity, $RepeatingHeader, $HideProgress)}
[void] hidden init([int]$Capacity, [string]$RepeatingHeader , [bool]$HideProgress) {
$this.Builder = New-Object -TypeName System.Text.StringBuilder -ArgumentList $Capacity
if ($RepeatingHeader) {
$this.RepeatingHeader = $RepeatingHeader
$null = $this.Builder.AppendLine($RepeatingHeader)
}
if ($HideProgress) {
$this._ShowProgress = $false
}
else {$this.UpdateProgress()}
}
#endregion
[string] ToString() { # Our ToString is the builder's .ToString() - but remove any trailing linebreaks
return ($this.Builder.ToString() -replace "[\r\n]+\z")
}
[string] Flush() { # Do whatever we need to with the contents of the builder, clear down and add any repeating header
$s = & $this.OnFlush
$null = $this.Builder.clear()
if ($this.RepeatingHeader) {
$null = $this.Builder.AppendLine($this.RepeatingHeader)
}
return $s
}
[string] Finish() { # Flush data if there is any, clear progress indicator
if ($this.ShowProgress -and $this.ProgressShown) {
Write-Progress -Activity $this.ProgressActivity -Completed
}
if ($this.Builder.Length -gt ( $this.RepeatingHeader.length + 2) ) {
return $this.flush()
}
else { return $null}
}
[void] hidden UpdateProgress() { # Update the progess bar, if there is one
if ($this.ShowProgress -and $this._Activity -and $this._Status) {
$params = @{Activity = $this.ProgressActivity
Status = "$($this.ProgressStatus): $($this.AddsDone)"
}
if ($this.ProgressTotalToDO -gt $this.AddsDone) {
$params['PercentComplete'] = 100 * $this.AddsDone / $this.ProgressTotalToDO
}
Write-Progress @params
$this.ProgressShown = $true
}
}
#region wrap the builders append/appendline we need the option to return flushed data but deal-with-blanks (the string method) OR send with no return (the two voids)
[string] Add( [string]$Line, [bool]$NewLine) { # Overload to append the line with/without line break; if necessary, update progress and/or flush
if ($NewLine) {$null = $this.Builder.AppendLine($line)}
else {$null = $this.Builder.Append($line) }
$this.AddsDone ++
if ( $this.ShowProgress -and ($this.AddsDone % $this.ProgressEvery) -eq 0 ) {
$this.UpdateProgress()
}
if ( ($this.FlushEvery -gt 0 -and ($this.AddsDone % $this.FlushEvery) -eq 0) -or
($this.MaxSize -gt 0 -and ($this.maxsize -lt $this.Builder.length) ) ){
return $this.Flush() }
else { return $null }
}
[void] Add( [string]$Line) { # Overload to append the line and leave line breaks to the caller
[void] $this.Add($line, $false)
}
[void] AddLine([string]$Line) { # Method to append a line adding a trailing line break
[void] $this.Add($line, $true)
}
#endregion
}
#region we can't have update property events in a PowerShell class but we can bolt on script properties to do things as an update
$extraMembers =@{
ProgressStatus = @{
Value = {$this._Status}
SecondValue = {
$this._Status = $Args[0] ; $this.UpdateProgress()
}
}
ProgressActivity = @{
Value = {$this._Activity}
SecondValue = {
if (-not $this.$ProgressShown) {$this._Activity = $args[0]}
else { # if we have shown progress and changing the 'Activity' label, remove the old
Write-Progress -Activity $this._Activity -Completed
$this._Activity = $Args[0] ; $this.UpdateProgress()
}
}
}
ShwoProgress =@{
Value = {$this._ShowProgress}
SecondValue = {
if ( $this.$ProgressShown -and -not $args[0]) {
Write-Progress -Activity $this._Activity -Completed
}
$this._ShowProgress = $Args[0] ; $this.UpdateProgress()
}
}
}
foreach ($member in $extraMembers.keys) {
$Settings = $extraMembers[$member]
if (-not (Get-TypeData -TypeName "SmartBuilder" ).Members.$member ) {
Update-TypeData -TypeName "SmartBuilder" -MemberName $member -MemberType ScriptProperty @Settings
}
}
#endregion
<#
.example
PS> $bob = New-Object smartbuilder # Builders are traditionally named "Bob"
PS> $bob.ProgressTotalToDO = 1500
PS> $newline = $true
PS> 1..1500 | ForEach-Object {if ($x = $bob.Add($_,$newline)) {$x} ; sleep -Milliseconds 2 } # so we can see the progress
PS> $bob.Finish()
Create a new builder and sends 1500 items into a it.
For 1..999 Add($_ , $true) returns an empty, at 1000 (the default) it returns the first 1000 and clears down the builder, 1001..1500 are added returning an empty string
then finish() returns lines 1001..1500
.example
PS> $bob = New-Object smartbuilder
PS> $bob.FlushEvery = 100
PS> $bob.onFlush = {$this.toString() >> $filename}
PS> $bob.AddLine("Name,Length")
PS> dir | %{$bob.AddLine(('"{0}","{1}"' -f $_.Name,$_.length))}
PS> $bob.Finish()
PS> Write-Verbose -Verbose ("Added $($bob.AddsDone) items" )
PS> notepad .\foo.txt
Instead of using Add($xx , $newline) from the first example, this uses Addline; AddLine($line) or Add with a single parameter
return nothing EVEN when a FLUSH occurs - here the flush outputs to a file so nothing is returned,
and the single parameter Add/Adline remove the need to handle empty return data. Now we flush every 100 items insead of the default 1000.
This is doing the equivalent of export-csv; there are cases where it is preferable to build a string than use
$hashtable = @{fieldName1=$fieldData1; $fieldName2=$fieldData2; } ; [pscustomObject]$HashTable piped to Export-csv
but in the example | select-object | export-CSV makes more sense !
.example
PS> $filename = "foo.txt"; del $filename -ea 0 ;
PS> dir | ForEach-Object -Begin {$bob = New-Object smartbuilder -Property @{FlushEvery=100; onFlush={$this.toString() >> $filename}
$bob.AddLine( "Name,Length")
} -Process {$bob.AddLine(('"{0}","{1}"' -f $_.Name,$_.length))
} -End {$bob.Finish() ; Write-Verbose ("Added $($bob.AddsDone) items" ) }
An alternate way of writing the previous example with the initialization and header row in the begin block of a foreach, and the finish() in the end block
.example
PS> 1..1500 | ForEach-Object -Begin { del foo.txt -ea 0 ;
$bob = New-Object smartbuilder -Property @{ProgressEvery=50; FlushEvery=800;
onFlush={Out-File -Append "foo.txt" -InputObject $this.ToString()} } ;
} -Process{ $Null = $bob.Add("$_ ") ; sleep -Milliseconds 5 # so we can see the progress
} -end { $bob.Finish() ; notepad foo.txt}
In this variation on the first example the builder is told to update and flush at different intevals, and
when it flushes the operation is to write/append its contents to foo.txt (this will add a line break after each block of 800)
This time the Add(xx) method is used insead of AddLine(xx) to build the items into long lines.
With the different intervals the first 800 are sent as one long line and the last 700 are sent by the Finish() operation
.example
# here SQL is a notional "run this on via some SQL connection" command
PS> $bob = New-Object smartbuilder -ArgumentList "INSERT INTO stuff (col1, col2, col3) VALUES "
PS> $bob.OnFlush = {SQL ($this.ToString() -replace ",\s*\z" , ";")} # run 'sql' with the insert query but replace ',' and any white space at the end with ';'
PS> foreach ($row in $someData) {
#build a line for the insert into statement ending with "," hence the replace in the script block. Send it to the builder - a batch gets sent to SQL every 1000 rows
#Use " where sql has ', that way we fix "'" and put O'Neill goes "O'Neill", then "O''Neill" and finally 'O''Neill', fix \ as well.
$ValueLine = ( '("{0}","{1}","{2}") ,' -f $row.a, $row.b, $row.c) -replace "'","''" -replace "\","\\" -replace '"',"'"
$null = $bob.AddLine($valueLine)
PS> }
PS> $bob.Finish() # send any left over to SQL
This time we use a repeating hearder to SQL INSERT queries. The smart builder is created with the "INSERT..."" as the header
Then each AddLine puts in a row with "('a','b','c'),"
The flush runs 'SQL' with the output - removing the extra "," from the last line.
#>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment