平面BOM结构到层次结构,检测树/图中的周期

I've got many<>many table which stores Bill Of Material hierarchy data like:

id | childId

a | b

a | c

c | d

d | a

I need to find closed loops, like in this case d points back to a, which breaks the system since some recursive functions would never stop.

My idea was to transform this data structure from flat to hierarchical, then iterate over hierarchical structure using recursion.

I am looking up some knowledge on Web and most of the solutions of transforming flat to nested structure, cover the case where there is:

id | parentId

structure, where top level items has parentId set to root value (e.g. zero).

I am a self-taught systems developer for many years, but this is a moment where I am lacking the education many of you had during studies :)

EDIT: Graphical examples

Valid trees:
a)
      A              
     / \             
    B   C               
         \
          D
         / \
        E   F

b)

      K -> E
     / \
    G   B
   /|\ 
  H I J


Invalid tree (closed loop)
      A             
     / \ 
    B   C<----\
         \     |
          D   /
         / \ /
        E   F
             \
              G
             /|\
            H I J

After bit of a trial and error, here is what I've came up with.

Given the flat list of relations, it builds the lookup, which is then used in a recursive traversal function. Traversal function "remembers" the original part that was requested ($initialPart) and throws an Exception when cycle is detected. E.g. exception: Loop detected: f=>c=>d=>f!

Script:

$rows = [
    ['assemblyId' => 'a', 'partId' => 'b'],
    ['assemblyId' => 'a', 'partId' => 'c'],
    ['assemblyId' => 'c', 'partId' => 'd'],
    ['assemblyId' => 'd', 'partId' => 'e'],
    ['assemblyId' => 'd', 'partId' => 'f'],
    ['assemblyId' => 'f', 'partId' => 'c'],
    ['assemblyId' => 'f', 'partId' => 'g'],
    ['assemblyId' => 'g', 'partId' => 'h'],
    ['assemblyId' => 'g', 'partId' => 'i'],
    ['assemblyId' => 'g', 'partId' => 'j'],
];

$assemblyLookup = [];
foreach ($rows as $row) {
    $assemblyLookup[$row['assemblyId']][$row['partId']] = $row;
}

function getTree(&$part, $initialPart = null, $path = '', $level = 0, $maxLevel = 100) {
    global $assemblyLookup;

    if ($initialPart === null) {
        $initialPart = $part;
    }

    if ($level >= $maxLevel) {
        return;
    }

    $path .= '=>' . $part['partId'];
    if (is_array($assemblyLookup[$part['partId']])) {
        $lookup = $assemblyLookup[$part['partId']];
        foreach ($lookup as $child) {

            if ($child['partId'] === $initialPart['partId']) {
                $circularPath = substr($path, 2) . '=>' . $child['partId'] . '!';
                throw new Exception("Loop detected: " . $circularPath);
                return;
            }

            $part['children'][$child['partId']] = $child;
            getTree($part['children'][$child['partId']], $initialPart, $path, ++$level, $maxLevel);
        }
    } else {
        $path .= ';';
        $part['path'] = substr($path, 2); // Strip arrow from left side
    }

    return $part;

}

$part = ['partId' => 'f'];

$tree = getTree($part);