Getting unique elements from a column returns one value

Viewed 132

I have the following function defined in a ps1 file, using the DLL from the latest taglib release. I downloaded the nuget package and ran expand-archive on it and copied the DLL to the correct place.


[System.Reflection.Assembly]::LoadFrom((Resolve-Path ($PSScriptRoot + "\TagLibSharp.dll")))
function Get-Image {
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipelineByPropertyName)] $Name
    )

    process {
        $fullPath = (Resolve-Path $Name).Path
        $image = [taglib.file]::create($fullpath)

        return $image
    }
}

function Get-ImmediatePhotos {
    Get-ChildItem | Where-Object  {$_.Extension -eq ".jpg" -or $_.Extension -eq ".png"} | Get-Image
}

When I run this command to extract the years from the EXIF data in photos in a given directory I get a table like this:

> $years = Get-ImmediatePhotos | select-object {$_.Tag.DateTime.Year}
> $years
$_.Tag.DateTime.Year
--------------------
                2020
                2020
                2020
                2020
                2020
                2020
                2020
                2020
                2020
                2020
                2021
                2021
                2021
                2021

If I then try to extract unique years with sort-object I only get one year!

> $years | sort-object -unique

$_.Tag.DateTime.Year
--------------------
                2020

If I try to group years with group-object I get this error:

$years | group-object
Group-Object: Cannot compare "@{$_.Tag.DateTime.Year=2020}" to
"@{$_.Tag.DateTime.Year=2020}" because the objects are not the same type or the object 
"@{$_.Tag.DateTime.Year=2020}" does not implement "IComparable".

This seems to be telling me that the type of values in the column are some sort of anonymous thing which can't be compared.

How do I use the results of select as values, such as strings? My end goal is to automatically sort and categorize photos into directories in /year/month format.

2 Answers

Give your calculated property a proper name so that you can reference it later when using Sort-Object or Group-Object:

$years = Get-ImmediatePhotos | select-object @{Name='YearTaken';Expression={$_.Tag.DateTime.Year}}

$years |Sort-Object YearTaken -Unique
# or 
$years |Group-Object YearTaken

Altenatively, use ForEach-Object instead of Select-Object - ForEach-Object will spit out the raw Year values (as opposed to an object having the value stored in a nested property, which is what Select-Object gives you):

$years = Get-ImmediatePhotos | ForEach-Object {$_.Tag.DateTime.Year}

# `$years` is just an array of raw Year values now, no need to specify a key property
$years |Sort-Object -Unique
# or 
$years |Group-Object 

To complement Mathias R. Jessen's helpful answer with why what you tried didn't work:

The immediate problem is that Sort-Object-Unique considers all [pscustomobject] instances to be equal, even if they have different property values, and even if they have different sets of properties.

  • Note that your Select-Object call, due to use of the (positionally implied) -Property parameter, does not extract just the years from its input objects, but creates a [pscustomobject] wrapper object with a property containing the year.

  • Normally, -ExpandProperty is used to extract just the values of a single property, but with a calculated property, such as in your case, this doesn't work. However, passing the exact same script block to ForEach-Object does work.

Therefore, to make your original code work, you need to pass the name of the properties whose values should be sorted and used as the basis for the uniqueness determination:

$years | sort-object -unique -property '$_.Tag.DateTime.Year'

Note the strange property name, which results from your Select-Object call having used just a script block ({ ... }) as a calculated property, in which case the script block's literal source code becomes the property name (the result of calling .ToString() on the script block).

Typically, a hashtable is used to define a calculated property, which allows naming the property, as shown in Mathias' answer.


As an aside: If you want to merely sort by year while passing the original image objects through, you can use your script block as-is directly with Sort-Object's (positionally implied) -Property parameter:

# Sort by year, but output the image objects.
Get-ImmediatePhotos | Sort-Object {$_.Tag.DateTime.Year}

Note that in this use of a calculated property, direct use of a script block is appropriate and usually sufficient, as the property is purely used for sorting, and doesn't require a name.

However, Sort-Object too supports hashtable-based calculated properties, but such hashtables do not support a name / label entry (because it would be meaningless), but you can use either ascending or descending as a Boolean entry to control the sort order on a per-property basis.

That is, the following is the verbose equivalent of the command above:

Get-ImmediatePhotos | Sort-Object @{ ascending=$true; expression={$_.Tag.DateTime.Year} }
Related