modules/Devolutions.CIEM.Graph/Public/Get-CIEMGraphPath.ps1

function Get-CIEMGraphPath {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory)]
        [string]$FromKind,

        [Parameter(Mandatory)]
        [string]$ToKind,

        [Parameter()]
        [int]$MaxDepth = 5,

        [Parameter()]
        [string]$EdgeKind,

        [Parameter()]
        [int]$MaxPaths = 100
    )

    $ErrorActionPreference = 'Stop'

    # Find start nodes
    $startNodes = @(Get-CIEMGraphNode -Kind $FromKind)
    if ($startNodes.Count -eq 0) { return @() }

    $results = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($start in $startNodes) {
        $startObj = [PSCustomObject]@{ Id = $start.Id; Kind = $start.Kind; DisplayName = $start.DisplayName; Properties = $start.Properties }

        # BFS queue: each entry is a hashtable with NodeId, PathNodes, PathEdges, Depth
        $queue = [System.Collections.Generic.Queue[hashtable]]::new()
        $queue.Enqueue(@{
            NodeId    = $start.Id
            PathNodes = @($startObj)
            PathEdges = @()
            Depth     = 0
        })
        $visited = @{ $start.Id = $true }

        while ($queue.Count -gt 0) {
            if ($results.Count -ge $MaxPaths) { break }
            $current = $queue.Dequeue()
            if ($current.Depth -ge $MaxDepth) { continue }

            # Build SQL for outbound edges
            $edgeCondition = "e.source_id = @nodeId"
            $params = @{ nodeId = $current.NodeId }
            if ($EdgeKind) {
                $edgeCondition += " AND e.kind = @edgeKind"
                $params['edgeKind'] = $EdgeKind
            }

            $sql = @"
SELECT e.target_id, e.kind AS edge_kind, e.properties AS edge_properties,
       n.id, n.kind, n.display_name, n.properties
FROM graph_edges e
JOIN graph_nodes n ON n.id = e.target_id
WHERE $edgeCondition
"@

            $neighbors = @(Invoke-CIEMQuery -Query $sql -Parameters $params)

            foreach ($neighbor in $neighbors) {
                if ($visited.ContainsKey($neighbor.id)) { continue }

                $neighborNode = [PSCustomObject]@{
                    Id          = $neighbor.id
                    Kind        = $neighbor.kind
                    DisplayName = $neighbor.display_name
                    Properties  = $neighbor.properties
                }
                $edge = [PSCustomObject]@{
                    Kind       = $neighbor.edge_kind
                    Properties = $neighbor.edge_properties
                }

                $newPathNodes = @($current.PathNodes) + @($neighborNode)
                $newPathEdges = @($current.PathEdges) + @($edge)

                if ($neighbor.kind -eq $ToKind) {
                    $results.Add([PSCustomObject]@{
                        FromNode = $startObj
                        ToNode   = $neighborNode
                        Path     = $newPathNodes
                        Edges    = $newPathEdges
                        Depth    = $current.Depth + 1
                    })
                    # Mark destination nodes as visited and do NOT enqueue them —
                    # destinations are terminal to prevent BFS path explosion
                    $visited[$neighbor.id] = $true
                } else {
                    # Mark intermediate nodes as visited to prevent cycles
                    $visited[$neighbor.id] = $true
                    $queue.Enqueue(@{
                        NodeId    = $neighbor.id
                        PathNodes = $newPathNodes
                        PathEdges = $newPathEdges
                        Depth     = $current.Depth + 1
                    })
                }
            }
        }
    }

    @($results)
}