Creating Custom ToString Methods

Posted: March 11, 2013 in PowerShell

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.

Comments
  1. […] We have a new Powershell blogger on the block named Rohn with a great tip on creating ToString methods on custom objects […]

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s