Skip to content

Commit 720de9b

Browse files
committed
feat: graph repository
1 parent ec914ec commit 720de9b

File tree

6 files changed

+379
-0
lines changed

6 files changed

+379
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
"rxjs": "^7.8.2",
8383
"short-uuid": "^5.2.0",
8484
"swagger-ui-express": "^5.0.1",
85+
"typescript-graph": "^0.3.0",
8586
"winston": "^3.17.0",
8687
"winston-loki": "^6.1.3"
8788
},

src/data/base-graph.repository.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { TPrismaTx } from '../domain/entities';
2+
import {
3+
DirectedGraphHierarchy,
4+
TEdge,
5+
TPath,
6+
} from '../domain/entities/graph.entity';
7+
import { ExtendedBadRequestException } from '../frameworks/shared/exceptions/common.exception';
8+
import { CyclicException } from '../frameworks/shared/exceptions/graph.exception';
9+
import { camelize } from '../frameworks/shared/utils/string.util';
10+
import { GraphHelper } from './helpers';
11+
12+
export class BaseGraphRepository<
13+
Entity extends Record<string, any>,
14+
Include extends Record<string, any>,
15+
Where extends Record<string, any>,
16+
> {
17+
protected _entity: string;
18+
protected _tableName: string;
19+
20+
public defaultInclude: Include;
21+
public defaultWhere: Where;
22+
23+
constructor(entity: new () => Entity) {
24+
this._entity = camelize(entity.name.substring(5));
25+
this._tableName = `_${entity.name.substring(5)}Groups`;
26+
}
27+
28+
/**
29+
* ## buildGraph
30+
* This function builds the graph hierarchy of this entity which are defined from generic **Entity**.
31+
* there's no filter entities which will be generate a paths then into hierarchy.
32+
* **Carefully to used this function it may affect performance!. Because generate all path it same as looping through all data.**
33+
* @param tx TPrismaTx
34+
* @param include Include
35+
* @returns Promise<DirectedGraphHierarchy<Entity>>
36+
*/
37+
public async buildGraph(
38+
tx: TPrismaTx,
39+
include?: Include,
40+
): Promise<DirectedGraphHierarchy<Entity>> {
41+
const graph = new DirectedGraphHierarchy<Entity>((n: Entity) => n.id);
42+
43+
const edges: TEdge[] = await GraphHelper.findEdges(tx, this._tableName);
44+
45+
const nodes: Entity[] = await tx[this._entity].findMany({
46+
include,
47+
});
48+
49+
nodes.forEach((node) => {
50+
graph.insert(node);
51+
});
52+
53+
edges.forEach((edge) => {
54+
graph.addEdge(edge.A, edge.B);
55+
});
56+
57+
return graph;
58+
}
59+
60+
/**
61+
* ## buildGraphHierarchyFromId
62+
* This function builds the graph hierarchy of this entity which are defined from generic **Entity**.
63+
* fromId is required to filter only specific path that we want to generate paths then into hierarchy
64+
* @param tx TPrismaTx
65+
* @param fromId string
66+
* @param include Include
67+
* @returns Promise<DirectedGraphHierarchy<Entity>>
68+
*/
69+
public async buildGraphHierarchyFromId(
70+
tx: TPrismaTx,
71+
fromId: string,
72+
include?: Include,
73+
): Promise<DirectedGraphHierarchy<Entity>> {
74+
const graph = await this.buildGraph(tx, include);
75+
76+
const path: TPath[] = await GraphHelper.findPathByFromId(
77+
tx,
78+
this._tableName,
79+
fromId,
80+
);
81+
82+
const collectionOfNodeIdentity = path.map((path) => path.path);
83+
graph.createHierarchy(collectionOfNodeIdentity);
84+
return graph;
85+
}
86+
87+
/**
88+
* ## createNewBodyForCreate<T>
89+
* We need to transform body request into prisma format
90+
* @param body any
91+
* @param groupsFieldName string
92+
* @param entriesFieldName string
93+
* @returns T
94+
*/
95+
public createNewBodyForCreate<T>(
96+
body: any,
97+
groupsFieldName: string,
98+
entriesFieldName: string,
99+
): T {
100+
body[groupsFieldName] = {
101+
connect: body[groupsFieldName].map((groupId: string) => ({
102+
id: groupId,
103+
})),
104+
};
105+
106+
body[entriesFieldName] = {
107+
createMany: {
108+
data: body[entriesFieldName].map((roleId: string) => ({
109+
roleId: roleId,
110+
})),
111+
},
112+
};
113+
114+
return body;
115+
}
116+
117+
/**
118+
* ## createNewBodyForUpdate<T>
119+
* We need to transform body request into prisma format
120+
* @param tx TPrismaTx
121+
* @param body any
122+
* @param id string
123+
* @param groupsFieldName string
124+
* @param entriesFieldName string
125+
* @returns Promise<T>
126+
*/
127+
public async createNewBodyForUpdate<T>(
128+
tx: TPrismaTx,
129+
body: any,
130+
id: string,
131+
groupsFieldName: string,
132+
entriesFieldName: string,
133+
include?: Include,
134+
): Promise<T> {
135+
// Build the graph first
136+
const graph = await this.buildGraphHierarchyFromId(tx, id, include);
137+
138+
if (body[groupsFieldName]) {
139+
/**
140+
* Check and return the existing groups
141+
*/
142+
const existingGroups = this.checkCyclicForUpdateOperation(
143+
id,
144+
body[groupsFieldName],
145+
graph,
146+
);
147+
148+
body[groupsFieldName] = {
149+
// Remove all existing groups
150+
disconnect: existingGroups.map((groupId) => ({
151+
id: groupId,
152+
})),
153+
// Connect a new groups
154+
connect: body[groupsFieldName].map((groupId) => ({
155+
id: groupId,
156+
})),
157+
};
158+
}
159+
160+
if (body[entriesFieldName]) {
161+
body[entriesFieldName] = {
162+
deleteMany: {},
163+
createMany: {
164+
data: body[entriesFieldName].map((roleId) => ({
165+
roleId: roleId,
166+
})),
167+
},
168+
};
169+
}
170+
171+
return body;
172+
}
173+
174+
/**
175+
* ## checkCyclicForUpdateOperation
176+
* This method is to check if the candidate groups will contain cyclic or not.
177+
* @param id string
178+
* @param groupIds string[] | undefined
179+
* @param graph DirectedGraphHierarchy<Entity>
180+
* @returns string[]
181+
*/
182+
public checkCyclicForUpdateOperation(
183+
id: string,
184+
groupIds: string[] | undefined,
185+
graph: DirectedGraphHierarchy<Entity>,
186+
): string[] {
187+
const parentNode = graph.getNode(id);
188+
189+
if (!parentNode) {
190+
throw new ExtendedBadRequestException({
191+
message: `id with ${id} does not exist!`,
192+
});
193+
}
194+
195+
/**
196+
* Subtract parentGroupIds - body.groupIds.
197+
* Purpose is to create candidate id
198+
*/
199+
const substractGroupIds = groupIds?.filter(
200+
(value) => !parentNode.groups.map((group) => group.id).includes(value),
201+
);
202+
203+
/**
204+
* Add the list of groupsId that it will be groups or children.
205+
* We need to put candidate groups into the edge, so we can check whether this id contain cyclic!
206+
*/
207+
substractGroupIds?.forEach((groupId) => {
208+
graph.addEdge(id, groupId);
209+
});
210+
211+
/**
212+
* Check whether is data cyclic!. because cyclic can cause circular references!
213+
*/
214+
if (!graph.isAcyclic()) {
215+
throw new CyclicException({
216+
message: 'the candidate groups contain cyclic!',
217+
});
218+
}
219+
220+
/**
221+
* Return existing groups
222+
*/
223+
return parentNode.groups.map((group) => group.id);
224+
}
225+
}

src/data/helpers/graph.helper.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { TPrismaTx } from '../../domain/entities';
2+
import { TEdge, TPath } from '../../domain/entities/graph.entity';
3+
4+
export class GraphHelper {
5+
static async findNodes<Entity extends Record<string, any>, Include>(
6+
tx: TPrismaTx,
7+
entityName: string,
8+
include?: Include,
9+
): Promise<Entity[]> {
10+
return await tx[entityName].findMany({
11+
include,
12+
});
13+
}
14+
15+
/**
16+
* ## findEdges
17+
* This function is to used query many to many self relation tables.
18+
* this will result two column A and B.
19+
* This is edges is usefull later to create hierarchical graph data structure.
20+
* @param tx TPrismaTx
21+
* @param tableName string
22+
* @returns <TEdge[]>
23+
*/
24+
static async findEdges(tx: TPrismaTx, tableName: string) {
25+
return await tx.$queryRawUnsafe<TEdge[]>(`select * from "${tableName}"`);
26+
}
27+
28+
/**
29+
* ## findPath
30+
* * This function is to find the path from many to many self relation or we can now is graph data structure.
31+
* https://www.geeksforgeeks.org/graph-data-structure-and-algorithms/.
32+
* * In order to generate the path we need to queries using CTE recursive functions. I have great articles about this query.
33+
* https://blog.whiteprompt.com/implementing-graph-queries-in-a-relational-database-7842b8075ca8. This function will create 3 table
34+
* ***(A, B, path)***. This is path is usefull later to create hierarchical graph data structure.
35+
* @param tx TPrismaTx
36+
* @param tableName string
37+
* @returns <TPath[]>
38+
*/
39+
static async findPath(tx: TPrismaTx, tableName: string) {
40+
return await tx.$queryRawUnsafe<TPath[]>(
41+
`
42+
WITH RECURSIVE distance_graph ("A", "B", path) AS (
43+
SELECT gg."A"::varchar, gg."B"::varchar, ARRAY[gg."A", gg."B"]::varchar[] as path
44+
FROM "${tableName}" as gg --non-recursive
45+
UNION ALL
46+
SELECT g."A"::varchar, gg."B"::varchar, g.path || ARRAY[gg."B"]::varchar[]
47+
FROM "${tableName}" as gg
48+
JOIN distance_graph as g
49+
ON gg."A" = g."B"
50+
and gg."B" != ALL(g.path)
51+
)
52+
select * from distance_graph;
53+
`,
54+
);
55+
}
56+
57+
/**
58+
* ## findPathByFromId
59+
* * This function is to find the path from many to many self relation or we can now is graph data structure.
60+
* https://www.geeksforgeeks.org/graph-data-structure-and-algorithms/.
61+
* * In order to generate the path we need to queries using CTE recursive functions. I have great articles about this query.
62+
* https://blog.whiteprompt.com/implementing-graph-queries-in-a-relational-database-7842b8075ca8. This function will create 3 table
63+
* ***(A, B, path)***. This is path is usefull later to create hierarchical graph data structure.
64+
* @param tx TPrismaTx
65+
* @param tableName string
66+
* @param fromId string
67+
* @returns <TPath[]>
68+
*/
69+
static async findPathByFromId(
70+
tx: TPrismaTx,
71+
tableName: string,
72+
fromId: string,
73+
) {
74+
return await tx.$queryRawUnsafe<TPath[]>(
75+
`
76+
WITH RECURSIVE distance_graph ("A", "B", path) AS (
77+
SELECT gg."A"::varchar, gg."B"::varchar, ARRAY[gg."A", gg."B"]::varchar[] as path
78+
FROM "${tableName}" as gg --non-recursive
79+
UNION ALL
80+
SELECT g."A"::varchar, gg."B"::varchar, g.path || ARRAY[gg."B"]::varchar[]
81+
FROM "${tableName}" as gg
82+
JOIN distance_graph as g
83+
ON gg."A" = g."B"
84+
and gg."B" != ALL(g.path)
85+
)
86+
select * from distance_graph where "A" = '${fromId}';
87+
`,
88+
);
89+
}
90+
}

src/data/helpers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './write.helper';
22
export * from './delete.helper';
33
export * from './read.helper';
44
export * from './tree.helper';
5+
export * from './graph.helper';

src/domain/entities/graph.entity.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { DirectedGraph } from 'typescript-graph';
2+
3+
export type TEdge = { A: string; B: string };
4+
5+
export type TPath = TEdge & { path: string[] };
6+
7+
/**
8+
* ## DirectedGraphHierarchy<T>
9+
* This class is inherited from DirectedGraph, you can have more details about DirectedGraph
10+
* in here https://segfaultx64.github.io/typescript-graph/
11+
* The purpose of this class is to create sub trees of Directed Graph Acyclic
12+
*/
13+
export class DirectedGraphHierarchy<T> extends DirectedGraph<T> {
14+
/**
15+
* ## createHierarchy
16+
* This method is used to build the hierarchical of graph from path for example [[1, 2], [1, 3], [1, 2, 4], [1, 2, 5]]
17+
* @param collectionOfNodeIdentity string[][]
18+
*/
19+
public createHierarchy(collectionOfNodeIdentity: string[][]) {
20+
// Loop the list of paths for example [[1, 2], [1, 3]]
21+
for (let i = 0; i < collectionOfNodeIdentity.length; i++) {
22+
// Loop the nodeId from paths for example first [1, 2] then [1, 3] for second
23+
for (let j = 0; j < collectionOfNodeIdentity[i].length; j++) {
24+
// Take the current node
25+
const currentNode: any = this.getNode(collectionOfNodeIdentity[i][j]);
26+
27+
// Take the next node
28+
const nextNode: any = this.getNode(collectionOfNodeIdentity[i][j + 1]);
29+
30+
// If currentNode exist, we need to make sure node have field groups
31+
if (currentNode) {
32+
if (currentNode.groups === undefined || currentNode.groups == null)
33+
currentNode.groups = [];
34+
}
35+
36+
// If nextNode exists, then we push to field groups
37+
if (nextNode) {
38+
if (
39+
!currentNode.groups.find((node: any) => node.id === nextNode.id)
40+
) {
41+
currentNode.groups.push(nextNode);
42+
}
43+
44+
// Update the latest nodes into old nodes
45+
this.nodes.set(collectionOfNodeIdentity[i][j], currentNode);
46+
}
47+
}
48+
}
49+
}
50+
}

0 commit comments

Comments
 (0)