src/Public/Join-Object.ps1
|
function Join-Object { <# .SYNOPSIS Dynamically merges objects from the pipeline with data from another cmdlet. .DESCRIPTION Joins objects from the pipeline with data from another cmdlet. It takes an input object, identifies a common identity property (like UPN or GUID), calls a second cmdlet with that identity, and merges the properties of both results into a single object. Also available via the alias 'Join'. .PARAMETER Cmdlet The cmdlet to call for the enrichment. Accepts either: - The name of a cmdlet (e.g., 'Get-ADUser', 'Get-MailboxStatistics'). The identity parameter is discovered automatically and the source identity value is splatted in. - A script block (e.g., { Get-Mailbox -Identity $_.PrimarySmtpAddress }). The full input object is exposed as $_ (and $PSItem), giving you complete control over the call. No identity discovery or auto-splatting happens in this mode. .PARAMETER InputObject The object coming from the pipeline. .PARAMETER IdentityProperty Optional: Explicitly define which property of the InputObject should be used as the identity. .PARAMETER TargetParameter Optional: Explicitly name the parameter of the target cmdlet that receives the identity value, instead of relying on automatic discovery. Use this when the discovery picks the wrong parameter (e.g. Get-Process resolves to -Id, but you want -Name). Not applicable in script-block mode. .PARAMETER Options A hashtable of additional parameters to pass to the target cmdlet (e.g., @{Properties = '*'}). Aliases: Args, With, Splat, Parameters .PARAMETER Force If set, properties from the second cmdlet will overwrite existing properties of the InputObject. .EXAMPLE Get-Mailbox | Join-Object Get-MailboxStatistics Simple merge: Adds mailbox statistics to your mailbox objects. .EXAMPLE Get-Service | Join Get-Process -IdentityProperty Name -TargetParameter Name Joins services with their corresponding processes by name (using the 'Join' alias). -TargetParameter is needed here because automatic discovery would pick Get-Process -Id. .EXAMPLE Get-ADUser -Filter "Name -like 'John*'" | Join-Object Get-Mailbox -Options @{ErrorAction = 'SilentlyContinue'} Joins AD users with their Exchange mailboxes, suppressing errors if a user has no mailbox. .EXAMPLE $Data | Join-Object Get-ADUser -With @{Properties = "Department", "Office"} -Force Merges AD attributes and overwrites any existing 'Department' or 'Office' fields in the source data. .EXAMPLE Get-RemoteMailbox | Join-Object { Get-Mailbox -Identity $_.PrimarySmtpAddress -ErrorAction SilentlyContinue } Script-block form: the input object is exposed as $_, so you control exactly how the target is called. #> [CmdletBinding()] [Alias('Join')] param( [Parameter(Mandatory, Position=0)] [ValidateNotNull()] # Either a cmdlet name ([string]) or a [scriptblock] for full control over the target call. [object] $Cmdlet, [Parameter(ValueFromPipeline)] $InputObject, [string] $IdentityProperty, [string] $TargetParameter, [Alias("Args", "With", "Splat", "Parameters")] [hashtable] $Options, [switch] $Force ) begin { # A script block gives the caller full control over the target call: no cmdlet to resolve, # no identity parameter to discover. The input object is exposed as $_ in process{}. $isScriptBlock = $Cmdlet -is [scriptblock] if (-not $isScriptBlock -and $Cmdlet -isnot [string]) { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.ArgumentException]::new("The Cmdlet parameter must be a cmdlet name ([string]) or a [scriptblock], but got [$($Cmdlet.GetType().Name)]."), 'InvalidCmdletArgument', [System.Management.Automation.ErrorCategory]::InvalidArgument, $Cmdlet ) $PSCmdlet.ThrowTerminatingError($errorRecord) } if (-not $isScriptBlock) { # Resolve the target cmdlet. A missing cmdlet surfaces as a clean terminating error. $cmdInfo = Get-Command $Cmdlet -ErrorAction Stop $targetParams = @($cmdInfo.Parameters.Keys) if ($TargetParameter) { # Explicit override: skip discovery, but fail fast if the parameter doesn't exist. if ($targetParams -notcontains $TargetParameter) { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.ArgumentException]::new("Cmdlet '$Cmdlet' has no parameter '$TargetParameter'."), 'TargetParameterNotFound', [System.Management.Automation.ErrorCategory]::InvalidArgument, $TargetParameter ) $PSCmdlet.ThrowTerminatingError($errorRecord) } $idParam = $TargetParameter } else { # Common identity parameter names used across many modules $preferredIdParams = @("Identity","UserId","Mailbox","PrimarySmtpAddress","Guid","Id","Name") $idParam = $preferredIdParams | Where-Object { $targetParams -contains $_ } | Select-Object -First 1 if (-not $idParam) { # Terminate up front so process{} never runs with an undefined identity parameter. $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.ArgumentException]::new("Could not determine an identity parameter for cmdlet '$Cmdlet'. Expected one of: $($preferredIdParams -join ', '). Use -TargetParameter to name it explicitly."), 'IdentityParameterNotFound', [System.Management.Automation.ErrorCategory]::InvalidArgument, $Cmdlet ) $PSCmdlet.ThrowTerminatingError($errorRecord) } } } } process { if ($null -eq $InputObject) { return } $inputProps = $InputObject.PSObject.Properties if ($isScriptBlock) { # Script-block form: expose the whole input object as $_ and let the caller drive the call. # Identity discovery, IdentityProperty and Options do not apply here. $obj2 = try { $InputObject | ForEach-Object $Cmdlet } catch { # Failed enrichment falls back to passthrough; surface the reason on the # verbose stream so failures are distinguishable from "no match". Write-Verbose "Enrichment script block failed: $($_.Exception.Message). Passing the input object through unchanged." $null } } else { # 1. Locate the source property for identity if ($IdentityProperty) { $srcProp = $inputProps | Where-Object Name -eq $IdentityProperty } else { # Heuristic search for identity-related fields in the source object. Iterate the # preferred list (not the object's properties) so the list order decides the # priority - matching how the target parameter is discovered in begin{}. $preferredProps = @("ExternalDirectoryObjectId","PrimarySmtpAddress","UserPrincipalName","Identity","Alias","Guid","Id","Name") $srcProp = $preferredProps | ForEach-Object { $inputProps[$_] } | Where-Object { $_ } | Select-Object -First 1 } if (-not $srcProp) { if ($IdentityProperty) { Write-Warning "Input object has no '$IdentityProperty' property. Passing it through unchanged." } else { Write-Warning "No identity property found on the input object. Passing it through unchanged." } return $InputObject } # 2. Prepare Splatting for the target cmdlet $splat = @{ $idParam = $srcProp.Value } if ($Options) { foreach ($key in $Options.Keys) { # Add user options if they don't conflict with the dynamic identity if (-not $splat.ContainsKey($key)) { $splat[$key] = $Options[$key] } } } # Default to a terminating ErrorAction so failures reach the catch below, # but let an ErrorAction supplied via -Options win instead of overriding it. if (-not $splat.ContainsKey('ErrorAction')) { $splat['ErrorAction'] = 'Stop' } # 3. Execute secondary cmdlet $obj2 = try { & $Cmdlet @splat } catch { # Failed enrichment falls back to passthrough; surface the reason on the # verbose stream so failures are distinguishable from "no match". Write-Verbose "Call to '$Cmdlet' with $idParam '$($srcProp.Value)' failed: $($_.Exception.Message). Passing the input object through unchanged." $null } } # If the target returns multiple objects, enrich from the first match - but say so, # instead of silently dropping the rest. if ($obj2 -is [System.Collections.IEnumerable] -and $obj2 -isnot [string]) { $matches2 = @($obj2) if ($matches2.Count -gt 1) { Write-Warning "The enrichment returned $($matches2.Count) objects; merging only the first one." } $obj2 = $matches2 | Select-Object -First 1 } # If no match is found, pass through the original object if ($null -eq $obj2) { return $InputObject } # 4. Merge data into an ordered dictionary to preserve the property order $mergedResult = [ordered]@{} # Load properties from source object foreach ($p in $inputProps) { $mergedResult[$p.Name] = $p.Value } # Add or overwrite properties from the second object foreach ($p in $obj2.PSObject.Properties) { # Use key existence (not value) so source properties with a $null value still collide. if ($mergedResult.Contains($p.Name)) { if ($Force) { $mergedResult[$p.Name] = $p.Value } else { # Avoid property collisions by adding an incrementing suffix (_2, _3, ...) so # repeated collisions - e.g. from chaining several Join-Object calls - don't # silently overwrite each other under the same fixed name. $suffix = 2 while ($mergedResult.Contains("$($p.Name)_$suffix")) { $suffix++ } $mergedResult["$($p.Name)_$suffix"] = $p.Value } } else { $mergedResult[$p.Name] = $p.Value } } # Return as a clean PSObject [pscustomobject]$mergedResult } } |