A few weeks ago, the Iron Scripter site posted an interesting challenge about writing a generic function to get the age of objects. Many things that we deal with in PowerShell have an "age" such as files, processes or even AD groups and users. I think this is an especially useful exercise because it forces you to understand the PowerShell paradigm of "objects in the pipeline."
ManageEngine ADManager Plus - Download Free Trial
Exclusive offer on ADManager Plus for US and UK regions. Claim now!
Methodology
I always find it helpful to try and visualize the pipeline process. If nothing else, you need to be able to verbally describe what your command is going to do. If you can't, you will have a very hard time writing code. I knew I would have to identify at least one property for the object to use when retrieving the creation time. Because part of the challenge was to get an average age of all processed objects, I knew I had to do the bulk of the work in the End script block. Here's my verbalization:
Initialize an array to hold incoming objects. As objects are piped in to the function add each function to the temporary array. Once everything has been processed, then calculate the average age. For each object in the temporary array create a custom result with calculated time spans.
That's a decent starting point.
Outlining the Code
Sketching this out in code I come up with this.
Begin { #initialize holding array } Process { foreach ($item in $input) { #add each item to the array } } End { #calculate average age for all items in holding array $Age = #code foreach ($object in $array) { #create custom object [pscustomobject]@{ Created = $object.$CreationProperty Modified = $object.$ChangeProperty Age = $Age Average = If ($Age -gt $Avg) {"Above"} else {"Below"} } } }
I expect this to change but it gives me a good place to start. For parameters, I can take input for any type of object. But I'll have to rely on the user to know what to use for the creation property and optionally a property that indicates when the object was last changed. It is possible an object may not have a "changed" property , so I'll use the creation property by default. I also want the user to be able to specify additional parameters they'd like in the output.
Param( [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNullorEmpty()] [object[]]$InputObject, [Parameter(Mandatory, Position = 0)] [string]$CreationProperty, [Parameter(Position = 1)] [string]$ChangeProperty, [object[]]$Properties = @("Name") )
This seems manageable.
Normally, I'd simply initalize an empty array. But to make things more interesting I'm going to use a generic list object.
$all = [System.Collections.Generic.List[object]]::new()
This type of object is supposed to be better performing. As a quick test, this code
measure-command { $all = [System.Collections.Generic.List[object]]::new() dir c:\scripts -file -Recurse | foreach-object { $all.Add($_) } }
took 2.95 seconds to add almost 7900 files. Compared to the "traditional" approach.
measure-command { $all = @() dir c:\scripts -file -Recurse | foreach-object { $all+=$_ } }
Which took 5.5 seconds. I don't want to get sidetracked into a discussion of the merits of either approach, but for this solution I'll use the generic list. I'm basically going to use the same pattern: for each piped in object, add it to the list.
In the End block I can now process all objects. First, I need to build an array of the creation age timespan for each object.
$allTimeSpans = $all | ForEach-Object { Try { $X = $_ New-TimeSpan -start $_.$creationProperty -end $Now -ErrorAction Stop } Catch { Write-Warning "Failed to get $CreationProperty value. $($_.exception.message)" Write-Warning ($X | Out-String) } } #foreach
Then I can measure that collection and generate an average timespan. I'll use TotalSeconds.
$avg = New-TimeSpan -seconds ($allTimeSpans | Measure-Object -Property TotalSeconds -average).Average
I'll use this value as a comparison for the creation age of each object so I can indicate if it is above or below average.
For each file in the list, I'll create an [ordered] hashtable. I did this because I wanted at least some structure to the output.
foreach ($item in $all) { $Age = New-TimeSpan -end $Now -Start ($item.$creationProperty) $tmpHash = [ordered]@{ Created = $item.$CreationProperty Modified = $item.$ChangeProperty Age = $Age Average = If ($Age -gt $Avg) {"Above"} else {"Below"} }
Next, I need to account for the extra properties. I also decided to support a hashtable like you would use with Select-Object.
foreach ($prop in $Properties) { if ($prop.gettype().name -eq 'hashtable') { Write-Verbose "[END ] Expanding hashtable" $custom = $item | Select-object -property $prop $prop = $custom.psobject.properties.Name $val = $custom.psobject.properties.Value } else { $val = $item.$prop } #shorten the value for the verbose message if ($VerbosePreference -eq "Continue") { $valtest = ($val | Out-String).trim() if ($valtest.length -ge 19) { $valstring = "{0}.." -f $valtest.Substring(0, 19) } else { $valstring = $valtest } } Write-Verbose "[END ] Adding Property: $prop Value: $valstring" $tmpHash.Add($prop, $val) } #foreach property
The gist of this to get the property value for each object and add it as a new element in the temporary hashtable. I alsolike using Verbose messages and in my testing I decided to shorten up the value text to keep the verbose messages from wrapping. Purely cosmetic.
The last step for each object is to create the custom object.
New-Object -TypeName PSObject -property $tmpHash
Extras
I decided to have a little extra fun with this function. First, after the results are displayed, I use Write-Host to display a colorized summary of the average overall creation age.
Write-Host "`n$([char]0x1b)[1;38;5;154mAverage Creation Age Overall : $avg"
I'm even using an ANSI escape sequence to color the output with a custom color. Why Write-Host? Functions should only write one type of object to the pipeline, and mine already is. I didn't want it to also write a string. I could have added this information as a property to each object, but instead decided to simply write information to the console and not the pipeline. This escape sequence will work in Windows PowerShell and PwoerShell 7.
The other thing I wanted to try was a console-style progress indicator. I wanted to have something to indicate items were being processed and added to the list. Again, a little ANSI magic to the rescue.
In the Begin block, I define an array of characters and a counter.
$ch = @("|", "/", "-", "\", "|", "/", "-") $c = 0
As each item is processed, I use Write-Host with an ANSI sequence that makes sure each line is written at the same position.
if (-Not ($VerbosePreference -eq "Continue")) { if ($c -ge $ch.count) { $c = 0 } Write-Host "$([char]0x1b)[1EProcessing $($ch[$c])$([char]0x1b)[0m" -ForegroundColor cyan -NoNewline $c++ #adding an artificial sleep so the progress wheel looks like it is spinning Start-Sleep -Milliseconds 10 }
The end result is a little spinner. I added an artificial sleep to make it look nicer. And I only use the progress spinner when NOT using -Verbose since the list of Verbose messages would move the spinner off the screen pretty quickly.
The Complete Function
Here's the complete function. I will not display the ANSI sequences very well in the PowerShell ISE.
Function Get-ObjectAge { [cmdletbinding()] [alias('goa')] Param( [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNullorEmpty()] [object[]]$InputObject, [Parameter(Mandatory, Position = 0)] [string]$CreationProperty, [Parameter(Position = 1)] [string]$ChangeProperty, [object[]]$Properties = @("Name") ) Begin { Write-Verbose "[BEGIN ] Starting: $($MyInvocation.Mycommand)" #create a list object to hold all incoming objects. #this should perform slightly better than a traditional array $all = [System.Collections.Generic.List[object]]::new() #use the same date time for all timespan calculations $Now = Get-Date Write-Verbose "[BEGIN ] Using $NOW for timespan calculations" Write-Verbose "[BEGIN ] Using $CreationProperty for CreationProperty" #use $CreationProperty parameter value for Change property if latter is not specified if (-Not $ChangeProperty) { Write-Verbose "[BEGIN ] Using $CreationProperty for ChangeProperty" $changeProperty = $CreationProperty } else { Write-Verbose "[BEGIN ] Using $ChangeProperty for ChangeProperty" } #initialize counters and an array of characters to be using in a progress wheel $i = 0 $ch = @("|", "/", "-", "\", "|", "/", "-") $c = 0 } #begin Process { foreach ($object in $InputObject) { $i++ #display a progress wheel if NOT using -Verbose if (-Not ($VerbosePreference -eq "Continue")) { if ($c -ge $ch.count) { $c = 0 } Write-Host "$([char]0x1b)[1EProcessing $($ch[$c])$([char]0x1b)[0m" -ForegroundColor cyan -NoNewline $c++ #adding an artificial sleep so the progress wheel looks like it is spinning Start-Sleep -Milliseconds 10 } #add the incoming object to the list $all.Add($object) } } #process End { Write-Verbose "[END ] Calculating average creation age for $($all.count) objects" $allTimeSpans = $all | ForEach-Object { Try { $X = $_ New-TimeSpan -start $_.$creationProperty -end $Now -ErrorAction Stop } Catch { Write-Warning "Failed to get $CreationProperty value. $($_.exception.message)" Write-Warning ($X | Out-String) } } #foreach #get the average creation age timespan $avg = New-TimeSpan -seconds ($allTimeSpans | Measure-Object -Property TotalSeconds -average).Average #create a result object for each processed object and save to an array $results = foreach ($item in $all) { $Age = New-TimeSpan -end $Now -Start ($item.$creationProperty) $tmpHash = [ordered]@{ Created = $item.$CreationProperty Modified = $item.$ChangeProperty Age = $Age Average = If ($Age -gt $Avg) {"Above"} else {"Below"} } #add user specified properties foreach ($prop in $Properties) { if ($prop.gettype().name -eq 'hashtable') { Write-Verbose "[END ] Expanding hashtable" $custom = $item | Select-object -property $prop $prop = $custom.psobject.properties.Name $val = $custom.psobject.properties.Value } else { $val = $item.$prop } #shorten the value for the verbose message if ($VerbosePreference -eq "Continue") { $valtest = ($val | Out-String).trim() if ($valtest.length -ge 19) { $valstring = "{0}.." -f $valtest.Substring(0, 19) } else { $valstring = $valtest } } Write-Verbose "[END ] Adding Property: $prop Value: $valstring" $tmpHash.Add($prop, $val) } #foreach property #create the object New-Object -TypeName PSObject -property $tmpHash } #foreach item #display all the results at once $results #display a message about the average creation age Write-Host "`n$([char]0x1b)[1;38;5;154mAverage Creation Age Overall : $avg" Write-Verbose "[END ] Ending: $($MyInvocation.Mycommand)" } #end } #end function
Want to see it work? The function works with files.
It also works with processes. This example is using the alias I defined for the function.
Making It Better
I have a generic enough function that works with any object type, assuming it has a property that reflects when it was created. And as nice as this is I know it can be even better. But I think I've given you enough to play with for one day so I'll save my enhanced version for another day...soon.
1 thought on “Solving the PowerShell Object Age Challenge – Part 1”
Comments are closed.