Sort-Topological.ps1
<#PSScriptInfo
.Version 0.1.2 .Guid 19631007-48a4-4acc-b0bc-c1a23796eb24 .Author Ronald Bode (iRon) .CompanyName PowerSnippets.com .Copyright Ronald Bode (iRon) .Tags Sort Topological Dependency Vertex Edge .LicenseUri https://github.com/iRon7/Sort-Topological/LICENSE .ProjectUri https://github.com/iRon7/Sort-Topological .IconUri https://raw.githubusercontent.com/iRon7/Sort-Topological/master/Sort-Topological.png .ExternalModuleDependencies .RequiredScripts .ExternalScriptDependencies .ReleaseNotes .PrivateData #> <# .SYNOPSIS Sort-Topological .DESCRIPTION Orders objects (vertices) such that for every directed dependency (edge u-v), the object dependent on (vertex u) comes before the object that is dependent on it (vertex v) in the ordering. .INPUTS PSCustomObject[] .OUTPUTS PSCustomObject[] .EXAMPLE # Custom list with dependencies Sorting the following list of objects with dependencies: $List = [PSCustomObject]@{ Id = 101; Dependency = 103 }, [PSCustomObject]@{ Id = 102; Dependency = 104, 105 }, [PSCustomObject]@{ Id = 103; Dependency = $Null }, [PSCustomObject]@{ Id = 104; Dependency = 105, 103, 101 }, [PSCustomObject]@{ Id = 105; Dependency = 101 } $List | Sort-Topological -Dependency Dependency -Name Id Results in: Id Dependency -- ---------- 103 101 103 105 101 104 {105, 103, 101} 102 {104, 105} .EXAMPLE # Class extension dependencies Consider a list of classes where some of the classes are extended (dependent) on other classes. $Script = @' Class Class1 : Class3 { } Class Class2 : Class4, Class5 { } Class Class3 { } Class Class4 : Class5, Class3, Class1 { } Class Class5 : Class1 { } '@ $Ast = [System.Management.Automation.Language.Parser]::ParseInput($Script, [ref]$null, [ref]$null) $Classes = $Ast.EndBlock.Statements $Sorted = $Classes | Sort-Topological -IdName Name -DependencyName { $_.BaseTypes.TypeName.Name } $Sorted.Name Class3 Class1 Class5 Class4 Class2 .EXAMPLE # Topological sort of services Order the service list based on the `ServicesDependedOn` property. $Services = Get-Service $Ordered = $Services | Sort-Topological -Dependency ServicesDependedOn .PARAMETER InputObject A list of objects to be topologically sorted. .PARAMETER EdgeName The name (or path) of the property that contains the dependency list. If the EdgeName is a script block, the script block is executed for each vertex to retrieve the dependency list. > [!IMPORTANT] > To prevent code injection, the script block should only contain safe paths in the form of > `$_.<verbatim path>` or `$PSItem.<verbatim path>`, e.g.: `$_.BaseTypes.TypeName.Name`. > Any other type is converted to a `[String]` type. There are two ways a dependency list might be setup: 1. By property (id) 2. By object The Sort-Topological cmdlet automatically recognizes each way. #### By property (id) Each dependency in the list is linked to a vertex (object node) by an id or a name. For instance a class extension which is depended on a base class that is defined by the base class *name*. #### By object Each dependency in the list directly refers to an other (vertex) object in the `$InputObject` list. For instance the [`DependentServices` property][1] of a [`ServiceController` object][2] retrieved from [Get-Service] cmdlet that contains a (recursive) list of other service *objects* that are directly linked by their reference. Such dependencies might only be linked during run time like: $ByObject = 101..105 | Foreach-Object { [PSCustomObject]@{ Id = $_; Name = "Function$_"; Link = $Null } } $ByObject[0].Link = @($ByObject[2]) $ByObject[1].Link = @($ByObject[3], $ByObject[4]) $ByObject[2].Link = @() $ByObject[3].Link = @($ByObject[0], $ByObject[2], $ByObject[4]) $ByObject[4].Link = @($ByObject[0]) .PARAMETER IdName The name of the property that contains the property name used to identify the object in the `InputObject` list The `IdName` parameter is required when the dependencies are linked **by property (id)**. > [!TIP] > The `IdName` is not required in case the dependencies are linked **by object**. > Yet, supplying the `IdName` might help to easier identify an object in a (circular) sort error message. .LINK [1]: https://learn.microsoft.com/en-us/dotnet/api/system.serviceprocess.servicecontroller.dependentservices "ServiceController" [2]: https://learn.microsoft.com/dotnet/api/system.serviceprocess.servicecontroller "DependentServices" #> Using Namespace System.Management.Automation Using Namespace System.Management.Automation.Language Using Namespace System.Collections Using Namespace System.Collections.Generic [Diagnostics.CodeAnalysis.SuppressMessage('PSUseApprovedVerbs', '', Scope = 'function', Target = '' )] [CmdletBinding()]Param( [Parameter(ValueFromPipeline = $True, Mandatory = $True)]$InputObject, [Parameter(Position = 0, Mandatory = $True)][Alias('DependencyName')]$EdgeName, [Parameter(Position = 1)][Alias('NameId')][String]$IdName ) function Throw-Error($ErrorRecord) { $PSCmdlet.ThrowTerminatingError($ErrorRecord) } if ($EdgeName -is [ScriptBlock]) { # Prevent code injection $Ast = [System.Management.Automation.Language.Parser]::ParseInput($EdgeName, [ref]$null, [ref]$null) $Expression = $Ast.EndBlock.Statements.PipelineElements.Expression While ($Expression -is [MemberExpressionAst] -and $Expression.Member -is [StringConstantExpressionAst]) { $Expression = $Expression.Expression } if ($Expression -isnot [VariableExpressionAst] -or $Expression.VariablePath.UserPath -notin '_', 'PSItem') { $Message = "The { $Expression } expression should contain safe path." Throw-Error ([ErrorRecord]::new($Message, 'InvalidIdExpression', 'InvalidArgument', $Expression)) } } elseif ($Null -ne $IdName -and $IdName -isnot [String]) { $IdName = "$IdName" } Function FormatId ($Vertex) { if ($Vertex -is [ValueType] -or $Vertex -is [String]) { $Value = $Vertex } elseif (@($_.PSObject.Properties.Name).Contains($IdName)) { $Value = $_.PSObject.Properties[$IdName].Value } else { return "[$(@($List).IndexOf($Vertex))]" } if ($Value -is [String]) { """$Value""" } else { $Value } } $ById = $Null $Sorted = [List[Object]]::new() if ($Input) { $List = $Input } else { $List = $InputObject } if ($List -isnot [iEnumerable]) { return $List } $EdgeCount = 0 while ($Sorted.get_Count() -lt $List.get_Count()) { $Stack = [Stack]::new() $Enumerator = $List.GetEnumerator() while ($Enumerator.MoveNext()) { $Vertex = $Enumerator.Current if($Sorted.Contains($Vertex)) { continue } $Edges = [List[Object]]::new() if ($EdgeName -is [ScriptBlock]) { $Edges = $Vertex.foreach($EdgeName) } else { $Edges = $Vertex.PSObject.Properties[$EdgeName].Value } if ($Edges -isnot [iList]) { $Edges = @($Edges) } if ($Null -eq $ById -and $Edges.Count -gt 0) { if ($Edges[0] -is [ValueType] -or $Edges[0] -is [String]) { if (-not $IdName) { $Message = 'Dependencies by id require the IdName parameter.' Throw-Error ([ErrorRecord]::new($Message, 'MissingIdName', 'InvalidArgument', $Vertex)) } $ById = @{} foreach ($Item in $List) { $ById[$Item.PSObject.Properties[$IdName].Value] = $Item } } else { $ById = $False } } if ($ById) { $Ids = $Edges; $Edges = [List[Object]]::new() foreach ($Id in $Ids) { if ($Null -eq $Id) { } elseif ($ById.contains($Id)) { $Edges.Add($ById[$Id]) } else { $Message = "Unknown vertex id: $(FormatId $Id)." Write-Error ([ErrorRecord]::new($Message, 'UnknownVertex', 'InvalidArgument', $Vertex)) } } } if ($Stack.Count -gt 0 -or $Edges.Count -eq $EdgeCount) { $At = if ($Stack.Count -gt 0) { @($Stack.Current).IndexOf($Vertex) + 1 } $Stack.Push($Enumerator) if ($At -gt 0) { $Message = "Circular dependency: $((@($Stack)[0..$At].Current).foreach{ FormatId $_ } -Join ', ')." Throw-Error ([ErrorRecord]::new($Message, 'CircularDependency', 'InvalidArgument', $Vertex)) } $Enumerator = $Edges.GetEnumerator() } } if ($Stack.Count -gt 0) { $Enumerator = $Stack.Pop() $Vertex = $Enumerator.Current if ($Vertex -is [ValueType] -or $Vertex -is [String]) { $Vertex = $ById[$Vertex] } if (-not $Sorted.Contains($Vertex)) { $Sorted.Add($Vertex) } } else { $EdgeCount++ } } $Sorted |