Skip to content

Commit

Permalink
feat(graph): detect cycle in graph
Browse files Browse the repository at this point in the history
  • Loading branch information
Mendes Hugo authored and Mendes Hugo committed Aug 5, 2023
1 parent 590204d commit 6bf5f3b
Show file tree
Hide file tree
Showing 6 changed files with 178 additions and 16 deletions.
50 changes: 37 additions & 13 deletions apps/backend/src/app/graph/arc/graph-arc.service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { EventArgs, EventSubscriber } from "@mikro-orm/core";
import { EntityName } from "@mikro-orm/nestjs";
import { Injectable, MethodNotAllowedException } from "@nestjs/common";
import { graphHasCycle } from "~/lib/common/app/graph/algorithms";
import { GraphArcCreateDto } from "~/lib/common/app/graph/dtos/arc";
import { getAdjacencyList } from "~/lib/common/app/graph/transformations";
import { EntityId } from "~/lib/common/dtos/entity";

import { GraphArcDifferentGraphException } from "./exceptions";
import { GraphArc } from "./graph-arc.entity";
import { GraphArcRepository } from "./graph-arc.repository";
import { EntityService } from "../../_lib/entity";
import { GraphCyclicException } from "../exceptions";
import { GraphNodeService } from "../node/graph-node.service";

/**
Expand Down Expand Up @@ -42,22 +45,43 @@ export class GraphArcService
entity: { __from, __to }
} = event;

const { data: nodes } = await this.graphNodeService.findAndCount(
{ $or: [{ inputs: { _id: __to } }, { outputs: { _id: __from } }] },
{ limit: 2 }
);
const {
data: [nodeA],
pagination: { total: totalA }
} = await this.graphNodeService.findAndCount({ inputs: { _id: __to } }, { limit: 1 });
const {
data: [nodeB],
pagination: { total: totalB }
} = await this.graphNodeService.findAndCount({ outputs: { _id: __from } }, { limit: 1 });

switch ((totalA + totalB) as 0 | 1 | 2) {
case 0:
case 1: // One was not found
// Let the FK error be triggered
return;
case 2:
if (nodeA.__graph !== nodeB.__graph) {
throw new GraphArcDifferentGraphException(__from, __to);
}

// No need to test if:
// - length === 0 -> Error will be thrown due to unknown id (FK constraint)
// - length === 1 -> Same node, cyclic graph is tested bellow
if (nodes.length === 2) {
const [nodeA, nodeB] = nodes;
if (nodeA.__graph !== nodeB.__graph) {
throw new GraphArcDifferentGraphException(__from, __to);
}
break;
}

// TODO: test if it creates a cycle in the graph
// Load graph content
const { __graph } = nodeA;
const { data: arcs } = await this.findAndCount({
$or: [{ from: { graphNode: { __graph } } }, { to: { graphNode: { __graph } } }]
});
const { data: nodes } = await this.graphNodeService.findAndCount({ __graph, $or: [] });

const adjacencyList = getAdjacencyList({
arcs: [{ __from, __to }, ...arcs],
nodes: nodes.map(node => node.toJSON())
});

if (graphHasCycle(adjacencyList)) {
throw new GraphCyclicException();
}
}

/**
Expand Down
55 changes: 55 additions & 0 deletions libs/common/src/app/graph/algorithms/graph.has-cycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,45 @@ describe("graphHasCycle", () => {
{ inputs: [{ _id: 20 }, { _id: 21 }], outputs: [{ _id: 22 }] },
{ inputs: [{ _id: 30 }, { _id: 31 }], outputs: [] }
]
},
{
arcs: [
{ __from: 1, __to: 2 },

{ __from: 11, __to: 20 }
],
nodes: [
{ inputs: [], outputs: [{ _id: 1 }] },
{ inputs: [{ _id: 2 }], outputs: [] },

// Not connected to the previous nodes
{ inputs: [], outputs: [{ _id: 11 }] },
{ inputs: [{ _id: 20 }], outputs: [] }
]
},

{
arcs: [
{ __from: 10, __to: 10 },
{ __from: 20, __to: 20 }
],
nodes: [
{ inputs: [], outputs: [{ _id: 10 }] },
{ inputs: [], outputs: [{ _id: 20 }] },
{ inputs: [{ _id: 10 }, { _id: 20 }], outputs: [] }
]
},
{
arcs: [
{ __from: 10, __to: 21 },
{ __from: 10, __to: 10 },
{ __from: 20, __to: 20 }
],
nodes: [
{ inputs: [], outputs: [{ _id: 10 }] },
{ inputs: [{ _id: 10 }], outputs: [{ _id: 20 }] },
{ inputs: [{ _id: 20 }, { _id: 21 }], outputs: [] }
]
}
];

Expand Down Expand Up @@ -77,6 +116,22 @@ describe("graphHasCycle", () => {
{ inputs: [{ _id: 20 }], outputs: [{ _id: 22 }] },
{ inputs: [{ _id: 30 }, { _id: 31 }], outputs: [{ _id: 32 }] }
]
},
{
arcs: [
{ __from: 1, __to: 2 },

{ __from: 11, __to: 20 },
{ __from: 21, __to: 10 }
],
nodes: [
{ inputs: [], outputs: [{ _id: 1 }] },
{ inputs: [{ _id: 2 }], outputs: [] },

// Not connected to the previous nodes
{ inputs: [{ _id: 10 }], outputs: [{ _id: 11 }] },
{ inputs: [{ _id: 20 }], outputs: [{ _id: 21 }] }
]
}
];

Expand Down
69 changes: 67 additions & 2 deletions libs/common/src/app/graph/algorithms/graph.has-cycle.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,78 @@
import { AdjacencyList } from "../transformations";
import { depth } from "treeverse";

import { AdjacencyList, AdjacencyListNode } from "../transformations";

/**
* To easily stop the algorithm once a cycle is detected
*
* @internal
*/
class CycleException extends Error {}

/**
* Detects if the given graph has a cycle.
*
* The cycle is determined by the explored nodes.
*
* @param graph the graph by its adjacency list
* @throws an exception when the given data is incoherent
* @returns true if the graphs contains at least one cycle
*/
export function graphHasCycle(graph: AdjacencyList): boolean {
throw new Error("TODO");
if (graph.size === 0) {
return false;
}

// The roots to visit (nodes starting a traversal).
// It will be decreased once a node is visited.
const roots = new Set(graph.keys());

try {
for (const root of roots) {
// The current visited nodes, used to detect a cycle
const path = new Set<AdjacencyListNode>();

depth({
// No need to process a node that is no longer in the root list
filter: node => roots.has(node),
getChildren: node => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Will thrown an error if the data is invalid
const children = graph.get(node)!.adjacentTo.map(({ to: { node } }) => node);

for (const child of children) {
// The node has already been visited, there's a cycle
if (path.has(child)) {
// "Kill" the traversal
throw new CycleException();
}
}

// The algorithm filters the already visited nodes
return children;
},
leave: (node: AdjacencyListNode) => {
// No longer on the path
path.delete(node);
return node;
},
tree: root,
visit: node => {
// There no need to start the traversal from this node
roots.delete(node);
// The node is being visited in the current traversal
path.add(node);

return node;
}
});
}
} catch (exception: unknown) {
if (exception instanceof CycleException) {
return true;
}

throw exception;
}

return false;
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export function getAdjacencyList<Arc extends AdjacencyListArc, Node extends Adja

if (!from || !to) {
throw new AdjacencyListUnlinkedArcException(
`The arc [${__from} -> ${__to}] can not the 2 nodes`
`The arc [${__from} -> ${__to}] can not link the 2 nodes`
);
}

Expand Down
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"qs": "^6.11.0",
"reflect-metadata": "^0.1.13",
"rxjs": "~7.8.1",
"treeverse": "^3.0.0",
"tslib": "^2.3.0",
"zone.js": "~0.13.0"
},
Expand Down Expand Up @@ -78,6 +79,7 @@
"@types/dockerode": "^3.3.14",
"@types/jest": "29.5.1",
"@types/node": "18.13.0",
"@types/treeverse": "^3.0.0",
"@types/webpack-node-externals": "^3.0.0",
"@typescript-eslint/eslint-plugin": "5.62.0",
"@typescript-eslint/parser": "5.62.0",
Expand Down

0 comments on commit 6bf5f3b

Please sign in to comment.