Skip to content

Instantly share code, notes, and snippets.

@swbbl
Last active January 10, 2023 07:43
Show Gist options
  • Save swbbl/ad832a75c71fd57242e9a0e90d9ec4b6 to your computer and use it in GitHub Desktop.
Save swbbl/ad832a75c71fd57242e9a0e90d9ec4b6 to your computer and use it in GitHub Desktop.
Join-Object combines two collections based on property names or scriptblocks like a SQL-variant of Group-Object.
using namespace System.Collections.Generic
function Join-Object {
<#
.SYNOPSIS
Join-Object combines two collections based on property names or scriptblocks like a SQL-variant of Group-Object.
.DESCRIPTION
Join-Object combines two collections based on property names or scriptblocks like a SQL-variant of Group-Object.
.EXAMPLE
$leftObjects = Get-ChildItem -LiteralPath 'C:\Windows'
$rightObjects = Get-ChildItem -LiteralPath 'C:\Windows.old\Windows'
Join-Object -LeftObject $leftObjects -RightObject $rightObjects -LeftProperty 'Name'
.EXAMPLE
$leftObjects = Get-ChildItem -LiteralPath 'C:\Windows' -Recurse -Force
$rightObjects = Get-ChildItem -LiteralPath 'C:\Windows.old\Windows' -Recurse -Force
$test = Join-Object -LeftObject $leftObjects -RightObject $rightObjects -On { $_.FullName -replace '^C:\\Windows(\.old\\Windows)?' }
#>
[CmdletBinding()]
param(
# Collection A.
[Parameter(Mandatory, ValueFromPipeline)]
[psobject[]]
$LeftObject,
# String or ScriptBlock.
[Parameter(Mandatory)]
[Alias('Property', 'On')]
[ValidateNotNullOrEmpty()]
[psobject]
$LeftProperty,
# Collection B.
[Parameter()]
[psobject[]]
$RightObject,
# String or ScriptBlock (like: { $_.PropertyName -replace '[0-9]' } ).
# If empty, LeftProperty will be used.
[Parameter()]
[ValidateNotNullOrEmpty()]
[psobject]
$RightProperty,
# SQL Join-Type. Default: FullJoin
# NoJoin will only return objects where the relevant property (Right/LeftProperty) is $null.
[Parameter()]
[ValidateSet('LeftJoin', 'RightJoin', 'InnerJoin', 'FullJoin', 'NoJoin')]
[string]
$JoinType = 'FullJoin',
# Case-sensitive join.
[Parameter()]
[switch]
$CaseSensitive,
# Returns an overview and keeps left/right objects still separated (i.e. properties are not merged).
[Parameter()]
[switch]
$NoMerge
)
begin {
$_joinTypes = if ($JoinType -ne 'FullJoin') {
$JoinType
if ($JoinType -in 'LeftJoin', 'RightJoin') {
'InnerJoin'
}
}
if ([string]::IsNullOrEmpty($RightProperty)) {
$RightProperty = $LeftProperty
}
$rightObjectSet = $RightObject
if (-not $PSCmdlet.MyInvocation.ExpectingInput) {
$leftObjectSet = $LeftObject
} else {
$leftObjectSet = [System.Collections.Generic.List[psobject]]::new()
}
$joinSet = if ($CaseSensitive) {
[Dictionary[string, Dictionary[string, List[psobject]]]]::new()
} else {
[Dictionary[string, Dictionary[string, List[psobject]]]]::new([System.StringComparer]::OrdinalIgnoreCase)
}
}
process {
if ($PSCmdlet.MyInvocation.ExpectingInput) {
$leftObjectSet.Add($LeftObject)
}
}
end {
foreach ($item in $leftObjectSet) {
# we use ForEach-method to allow Strings and ScriptBlocks as Left/RightProperty
$itemProperty = [string] $item.ForEach($LeftProperty)
if (-not $joinSet.ContainsKey($itemProperty)) {
$joinSet[$itemProperty] = [Dictionary[string, psobject]]::new()
}
if (-not $joinSet[$itemProperty].ContainsKey('Left')) {
$joinSet[$itemProperty]['Left'] = [System.Collections.Generic.List[psobject]]::new()
}
$joinSet[$itemProperty]['Left'].Add($item)
}
foreach ($item in $rightObjectSet) {
# we use ForEach-method to allow Strings and ScriptBlocks as Left/RightProperty
$itemProperty = [string] $item.ForEach($RightProperty)
if (-not $joinSet.ContainsKey($itemProperty)) {
$joinSet[$itemProperty] = [Dictionary[string, psobject]]::new()
}
if (-not $joinSet[$itemProperty].ContainsKey('Right')) {
$joinSet[$itemProperty]['Right'] = [System.Collections.Generic.List[psobject]]::new()
}
$joinSet[$itemProperty]['Right'].Add($item)
}
foreach ($entry in $joinSet.GetEnumerator()) {
$joinObjectKey = $entry.Key
$joinObjectLeft = $entry.Value['Left']
$joinObjectRight = $entry.Value['Right']
$joinObjectType = if ([string]::IsNullOrEmpty($joinObjectKey)) {
'NoJoin'
} elseif ($joinObjectLeft.Count -and -not $joinObjectRight.Count) {
'LeftJoin'
} elseif ($joinObjectRight.Count -and -not $joinObjectLeft.Count) {
'RightJoin'
} else {
'InnerJoin'
}
if ($null -eq $_joinTypes -or $joinObjectType -in $_joinTypes) {
if ($NoMerge) {
[PSCustomObject]@{
PSTypeName = 'JoinObject'
Key = $joinObjectKey
Type = $joinObjectType
Count = $joinObjectLeft.Count + $joinObjectRight.Count
Left = $joinObjectLeft
Right = $joinObjectRight
}
} else {
$mergedObjectProps = [ordered]@{}
$propIsList = [HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($object in @($joinObjectLeft; $joinObjectRight)) {
foreach ($prop in $object.psobject.properties) {
$propName = $prop.Name
$propValue = $prop.Value
if ($mergedObjectProps.Contains($propName)) {
if ($mergedObjectProps[$propName] -notcontains $propValue) {
if (-not $propIsList.Contains($propName)) {
$currentValue = $mergedObjectProps[$propName]
# we use queue<T> rather than list<T>, so it's possible (in most cases) to validate which value comes from left and which from right (FIFO)
$mergedObjectProps[$propName] = [Queue[psobject]]::new()
$mergedObjectProps[$propName].Enqueue($currentValue)
[void] $propIsList.Add($propName)
}
$mergedObjectProps[$propName].Enqueue($propValue)
}
} else {
$mergedObjectProps[$propName] = $propValue
}
}
}
foreach ($propName in $propIsList) {
# convert queue<T> to array (e.g. to allow indexing). Order (FIFO) remains.
$mergedObjectProps[$propName] = $mergedObjectProps[$propName].ToArray()
# adding the hint that the property values are merged.
# Can be validated via: "if ($<object>.<property>.pstypenames.Contains('Merged')){ <doIt> }"
$mergedObjectProps[$propName].pstypenames.Insert(0, 'Merged')
}
[pscustomobject] $mergedObjectProps
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment