How can I remove $null and whitespace key/value pairs in a [System.Collections.Specialized.OrderedDictionary] using PowerShell 7?

134 views Asked by At

There are plenty of examples of removing null and empty/whitespace keys from arrays, hashtables, and PSCustomObjects. I've adapted and merged several functions I found on this site to come up with this:

Function Remove-NullAndEmptyProperties {
    [CmdletBinding()]
    Param(
        # Object from which to remove the null values.
        [Parameter(ValueFromPipeline,Mandatory,Position=0)]
        $InputObject,
        # Instead of also removing values that are empty strings, include them
        # in the output.
        [Switch]$LeaveEmptyStrings,
        # Additional entries to remove, which are either present in the
        # properties list as an object or as a string representation of the
        # object.
        # I.e. $item.ToString().
        [Object[]]$AlsoRemove = @()
    )

    begin {
        $IsNonRefType = {
            param (
                [AllowNull()]
                $Val
            )
            return (($null -ne $Val) -and (-not($Val.GetType().IsValueType)))
        }
    }

    Process {

        try  {

            $IsValidType = (& $IsNonRefType -Val $InputObject)
            if(-not$IsValidType){
                Write-Error "Input object is an invalid type."
                return
            }

            if($InputObject.GetType().name -eq 'Hashtable'){
                $TmpString = $InputObject | ConvertTo-Json -Depth 15
                $TmpString = $TmpString -replace '"\w+?"\s*:\s*null,?'
                if(!$LeaveEmptyStrings){
                    $TmpString = $TmpString -replace '"\w+?"\s*:\s*"\s+",?'
                }
                $NewHash = $TmpString | ConvertFrom-Json
                $NewHash
            } else {
                # Iterate InputObject in case input was passed as an array
                ForEach ($obj in $InputObject) {
                    $obj | Select-Object -Property (
                        $obj.PSObject.Properties.Name | Where-Object {
                            -not (
                                # If prop is null, remove it
                                $null -eq $obj.$_ -or
                                # If -LeaveEmptyStrings is not specified and the property
                                # is an empty string, remove it
                                (-not $LeaveEmptyStrings.IsPresent -and
                                    [string]::IsNullOrWhiteSpace($obj.$_)) -or
                                # If AlsoRemove contains the property, remove it
                                $AlsoRemove.Contains($obj.$_) -or
                                # If AlsoRemove contains the string representation of
                                # the property, remove it
                                $AlsoRemove.Contains($obj.$_.ToString())
                            )
                        }
                    )
                }
            }
        }
        catch {
            Write-Output "A terminating error occured: $($PSItem.ToString())"
            $PSCmdlet.ThrowTerminatingError($PSItem)
        }
    }
}

These examples work fine:

$Hash = @{
    "Key1" = 'Keyboard'
    "Key2" = '  '
    "Key3" = 'Mouse'
    "Key4" = $null
    "Key5" = 'Computer'
}

$Hash | Remove-NullAndEmptyProperties

enter image description here

$PSC = [PSCustomObject]@{
    KeyA = "   "
    KeyB = "Windows"
    KeyC = "Applications"
    KeyD = $null
    KeyE = ""
    KeyF = "Documents"
}

$PSC | Remove-NullAndEmptyProperties

enter image description here

But if I try this:

$OHash = [ordered]@{
    "Key1" = 'Alpha'
    "Key2" = '  '
    "Key3" = 'Beta'
    "Key4" = $null
    "Key5" = 'Omega'
}

$OHash | Remove-NullAndEmptyProperties

I'm greeted with this:

enter image description here

How can I iterate over an Ordered Dictionary and remove the $null and whitespace keys/values?

I'd like this function to be as robust as possible.

Any solutions are greatly welcomed!

2

There are 2 answers

0
Santiago Squarzon On BEST ANSWER

The way I would handle null or whitespace values in an OrderedDictionary is by enumerating the key / value pairs and using [string]::IsNullOrWhiteSpace to skip them:

$OHash = [ordered]@{
    'Key1' = 'Alpha'
    'Key2' = ''
    'Key3' = 'Beta'
    'Key4' = $null
    'Key5' = 'Omega'
}

# toggle for testing
[Switch] $LeaveEmptyStrings = $true

# can use `-is [System.Collections.IDictionary]` here
# to target any type implementing the interface (i.e.: hashtable)
if ($OHash -is [System.Collections.Specialized.OrderedDictionary]) {
    $newDict = [ordered]@{}
    foreach ($pair in $OHash.GetEnumerator()) {
        if (-not $LeaveEmptyStrings.IsPresent -and [string]::IsNullOrWhiteSpace($pair.Value)) {
            continue
        }
        elseif ($null -eq $pair.Value) {
            continue
        }

        $newDict[$pair.Key] = $pair.Value
    }
    $newDict
}

The logic above attempts to create a copy of the input dictionary, it does not attempt to mutate it, however if the keys could have reference values that need to be dereferenced then you will need to serialize it before making the copy. ConvertTo-Json and ConvertFrom-Json is a good option.

As aside, this logic can also be re-used in the PSCustomObject code path with very small variations in the code, you can use an OrderedDictionary to construct the new object then cast the [pscustomobject] accelerator:

$PSC = [PSCustomObject]@{
    KeyA = '   '
    KeyB = 'Windows'
    KeyC = 'Applications'
    KeyD = $null
    KeyE = ''
    KeyF = 'Documents'
}

[switch] $LeaveEmptyStrings = $true

if ($PSC -is [System.Management.Automation.PSCustomObject]) {
    $newObject = [ordered]@{}
    foreach ($property in $PSC.PSObject.Properties) {
        if (-not $LeaveEmptyStrings.IsPresent -and [string]::IsNullOrWhiteSpace($property.Value)) {
            continue
        }
        elseif ($null -eq $property.Value) {
            continue
        }

        $newObject[$property.Name] = $property.Value
    }
    [pscustomobject] $newObject
}
3
Abraham Zinala On

The issue comes from only checking for type names of HashTable, when you should also be checking for OrderedDictionary. On another note, this can be simplified to passing it a conditional statement to know what it should remove:

if ($InputObject -is [System.Collections.IDictionary])
{
    $InputObject.GetEnumerator().Where({ 
        if (-not ($LeaveEmptyStrings.IsPresent -and $_.Value -eq [string]::Empty)) 
        { 
            [string]::IsNullOrWhiteSpace($_.Value)
        }
    }) | ForEach-Object -Process { $InputObject.Remove($_.Key) } -End { $InputObject }
}

As a friendly reminder, by default, you cannot modify a collection while it is enumerating so piping to Where-Object would render this ineffective. The work around to this is calling on the .Where() instrinsic method provided by the PSObject wrapper.

This is possible using the .Where() method since it immediately filters the collection and produces a separate set of results. Because this filtering happens all at once and outside of the pipeline, the collection is not being actively enumerated when subsequent actions (like modifications) are performed. This separation allows for the original collection to be safely modified after the filtering step without encountering enumeration errors.