There are a lot of times when I want to display a collection of objects, and I want to change the way that some of the properties are displayed. As a quick example, I’m going to create a collection of items that each has three properties: a name, a size, and a date. If you want, you can imagine that each item represents a file. We’ll use this data throughout the rest of the post, so make sure you run this section if you want to follow along on your computer:
$SourceData = @" Name,Size,Date Name1,890013842,7/7/2001 Name2,328080899,6/10/2001 Name3,567628652,6/22/2008 Name4,558459174,1/15/2003 Name5,433485744,10/26/2011 Name6,409636452,10/23/2002 Name7,265550001,3/6/2008 Name8,300272180,10/12/2010 Name9,2935701,1/16/2003 Name10,659420106,9/10/2001 "@ | ConvertFrom-Csv $Data = $SourceData | ForEach-Object { $Properties = @{ Name = $_.Name Size = [int] $_.Size Date = [datetime] $_.Date } New-Object -TypeName PSObject -Property $Properties } | select Name, Size, Date
This is taking data that I randomly generated contained in a here string, then converting it from a CSV to an object. That object is then passed to the ForEachObject cmdlet, where a new custom PSObject gets created for each line in the original CSV (the Size and Date columns from the CSV are cast into int and datetime objects). All of the newly created objects are piped to the select statement to control the order of the properties. Hashtables don’t have a guaranteed order, and I used a hashtable to add the properties to the new object. PowerShell v3 fixed this by allowing you to create ordered hashtables via the [ordered] type accelerator, but I’m not using that just in case anyone with PowerShell v2 tries to follow along (the [ordered] type accelerator is really cool, though, and I’m glad it was added to v3).
Now, let’s let PowerShell list the contents of the collection using the default formatting:
PS > $Data
Name Size Date
---- ---- ----
Name1 890013842 7/7/2001 12:00:00 AM
Name2 328080899 6/10/2001 12:00:00 AM
Name3 567628652 6/22/2008 12:00:00 AM
Name4 558459174 1/15/2003 12:00:00 AM
Name5 433485744 10/26/2011 12:00:00 AM
Name6 409636452 10/23/2002 12:00:00 AM
Name7 265550001 3/6/2008 12:00:00 AM
Name8 300272180 10/12/2010 12:00:00 AM
Name9 2935701 1/16/2003 12:00:00 AM
Name10 659420106 9/10/2001 12:00:00 AM
Personally, I don’t like the way that the Size and Date properties are being displayed. The Size (let’s assume that it’s in bytes) is tough to read since there are no separators and the numbers are very large. You can tell that the source of the Date information didn’t contain a time (so all of the DateTime objects are showing 12:00:00 AM). I would like to format the Size to show how big each item is in MB, and I would also like to format the Date to only show the date portion.
There’s more than one way to accomplish this. One way would be to make two new properties on each object that contain the custom formatting. This is what that would look like if we defined $Data with two extra properties:
$Data = $SourceData | ForEach-Object { $Properties = @{ Name = $_.Name Size = [int] $_.Size FormattedSize = ("{0:n2} MB" -f ($_.Size / 1mb)) Date = [datetime] $_.Date FormattedDate = ([datetime] $_.Date).ToShortDateString() } New-Object -TypeName PSObject -Property $Properties } | select Name, Size, FormattedSize, Date, FormattedDate
PS > $Data | format-table
Name Size FormattedSize Date FormattedDate
---- ---- ------------- ---- -------------
Name1 890013842 848.78 MB 7/7/2001 12:00:00 AM 7/7/2001
Name2 328080899 312.88 MB 6/10/2001 12:00:00 AM 6/10/2001
Name3 567628652 541.33 MB 6/22/2008 12:00:00 AM 6/22/2008
Name4 558459174 532.59 MB 1/15/2003 12:00:00 AM 1/15/2003
Name5 433485744 413.40 MB 10/26/2011 12:00:00 AM 10/26/2011
Name6 409636452 390.66 MB 10/23/2002 12:00:00 AM 10/23/2002
Name7 265550001 253.25 MB 3/6/2008 12:00:00 AM 3/6/2008
Name8 300272180 286.36 MB 10/12/2010 12:00:00 AM 10/12/2010
Name9 2935701 2.80 MB 1/16/2003 12:00:00 AM 1/16/2003
Name10 659420106 628.87 MB 9/10/2001 12:00:00 AM 9/10/2001
Now you’ve got five columns instead of the original three. You can fix that by using Select-Object to only show the formatted columns:
PS > $Data | select Name, Formatted*
Name FormattedSize FormattedDate
---- ------------- -------------
Name1 848.78 MB 7/7/2001
Name2 312.88 MB 6/10/2001
Name3 541.33 MB 6/22/2008
Name4 532.59 MB 1/15/2003
Name5 413.40 MB 10/26/2011
Name6 390.66 MB 10/23/2002
Name7 253.25 MB 3/6/2008
Name8 286.36 MB 10/12/2010
Name9 2.80 MB 1/16/2003
Name10 628.87 MB 9/10/2001
There’s a big problem with this solution, though: you can’t operate on these fields the way you would expect. Look at what happens when you try to sort or use the Where-Object cmdlet on them:
PS > $Data | sort FormattedDate | select Name, Formatted*
Name FormattedSize FormattedDate
---- ------------- -------------
Name4 532.59 MB 1/15/2003
Name9 2.80 MB 1/16/2003
Name8 286.36 MB 10/12/2010
Name6 390.66 MB 10/23/2002
Name5 413.40 MB 10/26/2011
Name7 253.25 MB 3/6/2008
Name2 312.88 MB 6/10/2001
Name3 541.33 MB 6/22/2008
Name1 848.78 MB 7/7/2001
Name10 628.87 MB 9/10/2001
PS > $Data | where { $_.FormattedSize -gt 1gb } | select Name, Formatted*
Name FormattedSize FormattedDate
---- ------------- -------------
Name1 848.78 MB 7/7/2001
Name2 312.88 MB 6/10/2001
Name3 541.33 MB 6/22/2008
Name4 532.59 MB 1/15/2003
Name5 413.40 MB 10/26/2011
Name6 390.66 MB 10/23/2002
Name7 253.25 MB 3/6/2008
Name8 286.36 MB 10/12/2010
Name9 2.80 MB 1/16/2003
Name10 628.87 MB 9/10/2001
Sort-Object is sorting the strings, so the dates aren’t in the right order. When trying to search for files greater than 1GB, all files are returned, even though none of them are greater than that size (PowerShell is actually converting 1gb into a string and comparing the strings. If I had put 1gb first in the comparison, I would have gotten an error because the FormattedSize objects can’t be converted to numbers).
There’s a better way to handle the displaying problem: replace the ToString() method on each of the objects. In the .NET Framework (which PowerShell is based on), all objects have a ToString() method. When our collection gets displayed to the screen, PowerShell is calling the ToString() method of each of the objects it displays. To define our own version of the method, we’ll use the Add-Member cmdlet. The cool part about this is that the original object is preserved. It will still behave the same as if we didn’t change the way it is being displayed. Take a look:
$Data = $SourceData | ForEach-Object { $Properties = @{ Name = $_.Name Size = [int] $_.Size Date = [datetime] $_.Date } $NewObject = New-Object -TypeName PSObject -Property $Properties $NewObject.Size = $NewObject.Size | Add-Member -MemberType ScriptMethod -Name ToString -Value { "{0:n2} MB" -f ($this / 1mb) } -Force -PassThru $NewObject.Date = $NewObject.Date | Add-Member -MemberType ScriptMethod -Name ToString -Value { $this.ToShortDateString() } -Force -PassThru $NewObject # Emit the new object } | select Name, Size, Date
This time around, we save each new PSObject to a variable, $NewObject, and then we use Add-Member to create a new Size and Date object. We have to pass the -Force parameter because the ToString method already exists, and we’re overwriting it. Note that the scriptblock passed to the -Value parameter uses the $this automatic variable to refer to the object. The last thing to note is the use of the -PassThru variable. Here’s another area where PowerShell v3 offers a simplified way to do this, but we’re keeping it in the v2 format for backwards compatibility. The -PassThru parameter is making Add-Member output an object instead of just operating on the object passed to it.
Now let’s take a look at what we can do with the new version of $Data:
PS > $Data | sort Date
Name Size Date
---- ---- ----
Name2 312.88 MB 6/10/2001
Name1 848.78 MB 7/7/2001
Name10 628.87 MB 9/10/2001
Name6 390.66 MB 10/23/2002
Name4 532.59 MB 1/15/2003
Name9 2.80 MB 1/16/2003
Name7 253.25 MB 3/6/2008
Name3 541.33 MB 6/22/2008
Name8 286.36 MB 10/12/2010
Name5 413.40 MB 10/26/2011
PS > $Data | where { $_.Size -gt 500mb } | sort size
Name Size Date
---- ---- ----
Name4 532.59 MB 1/15/2003
Name3 541.33 MB 6/22/2008
Name10 628.87 MB 9/10/2001
Name1 848.78 MB 7/7/2001
As you can see, we’re still able to sort on the objects, and we can still use Where-Object as if nothing has changed. You can also do the same types of math operations you would be able to do with the original, unmodified objects:
PS > $Data[0].Size.GetType()
IsPublic IsSerial Name BaseType
-------- -------- ---- --------
True True Int32 System.ValueType
PS > $Data[0].Size
848.78 MB
PS > [int] $Data[0].Size
890013842
PS > $Data[0].Size + 1
890013843
PS > $Data[0].Date.GetType()
IsPublic IsSerial Name BaseType
-------- -------- ---- --------
True True DateTime System.ValueType
PS > $Data[0].Date
Saturday, July 7, 2001 12:00:00 AM
PS > $Data[0].Date.ToString()
7/7/2001
PS > (Get-Date) - $Data[0].Date
Days : 4264
Hours : 15
Minutes : 11
Seconds : 35
Milliseconds : 294
Ticks : 3684642952940613
TotalDays : 4264.63304738497
TotalHours : 102351.193137239
TotalMinutes : 6141071.58823435
TotalSeconds : 368464295.294061
TotalMilliseconds : 368464295294.061
We’re still not done yet, though. We have introduced a new problem: the original ToString() methods allowed you to do some pretty sophisticated formatting. Take a look at the different arguments that a DateTime object’s ToString() method can take compared to our new ToString() method:
PS > (Get-Date).ToString.OverloadDefinitions
string ToString()
string ToString(string format)
string ToString(System.IFormatProvider provider)
string ToString(string format, System.IFormatProvider provider)
string IFormattable.ToString(string format, System.IFormatProvider formatProvider)
string IConvertible.ToString(System.IFormatProvider provider)
PS > $Data[0].Date.ToString.OverloadDefinitions
System.Object ToString();
Sad, isn’t it? Don’t worry, we can still fix this. We’ll just do our custom formatting if no arguments are supplied to our version of ToString(), and we’ll call the original ToString() when arguments are supplied. Now, you might be wondering how in the world we can call the original ToString() if we’ve overwritten it. That’s actually a very good question. It turns out that PowerShell allows you to get to the original object through a special member called PsBase. That means we can still access the original ToString() method by referencing the PsBase.ToString() method. Here’s what our updated ToString() methods look like:
$Data = $SourceData | ForEach-Object { $Properties = @{ Name = $_.Name Size = [int] $_.Size Date = [datetime] $_.Date } $NewObject = New-Object -TypeName PSObject -Property $Properties $NewObject.Size = $NewObject.Size | Add-Member -MemberType ScriptMethod -Name ToString -Value { if ($args.Count -eq 0) { "{0:n2} MB" -f ($this / 1mb) } else { $this.PsBase.ToString($args) } } -Force -PassThru $NewObject.Date = $NewObject.Date | Add-Member -MemberType ScriptMethod -Name ToString -Value { if ($args.Count -eq 0) { if ((Get-Culture).Name -eq "en-US") { $ShortDatePattern = "MM/dd/yyyy" } else { $ShortDatePattern = (Get-Culture).DateTimeFormat.ShortDatePattern } # No arguments were passed, so convert date to the ShortDatePattern # If I wasn't changing the 'en-US' culture to have a custom ShortDatePattern, # the previous if/else statements wouldn't be needed, and the next line could # use the .ToShortDateString() method instead $this.PsBase.ToString($ShortDatePattern) } else { $this.PsBase.ToString($args) } } -Force -PassThru $NewObject # Emit the new object } | select Name, Size, Date
I made one other change to how the date is being displayed for the US culture: I made the dates always have two digits for the month and day. Let’s see how it looks:
PS > $Data
Name Size Date
---- ---- ----
Name1 848.78 MB 07/07/2001
Name2 312.88 MB 06/10/2001
Name3 541.33 MB 06/22/2008
Name4 532.59 MB 01/15/2003
Name5 413.40 MB 10/26/2011
Name6 390.66 MB 10/23/2002
Name7 253.25 MB 03/06/2008
Name8 286.36 MB 10/12/2010
Name9 2.80 MB 01/16/2003
Name10 628.87 MB 09/10/2001
It looks the same as before (except that months and dates are always two digits now), as it should. We should now be able to still get the old ToString() functionality out of the objects now:
PS > $Data[0].Size.ToString()
848.78 MB
PS > $Data[0].Size.ToString("n0")
890,013,842
PS > $Data[0].Date.ToString()
07/07/2001
PS > $Data[0].Date.ToString("yyyy-MM-dd")
2001-07-07
This might seem like a lot of work just to change the way that data is displayed. It is incredibly powerful when you’re creating custom functions that will return data to the user, though. This post is already much longer than I anticipated, so I’ll have to save how to have PowerShell take care of adding custom members (including our custom ToString() method) automatically via the extended type system for another day.
[…] We have a new Powershell blogger on the block named Rohn with a great tip on creating ToString methods on custom objects […]