grafio-mongo 1.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +210 -6
- package/dist/MongoStorageProvider.d.ts +37 -14
- package/dist/MongoStorageProvider.js +323 -107
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -17,10 +17,7 @@ This package provides the MongoDB storage provider for grafio, extracted from th
|
|
|
17
17
|
## Installation
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
npm install grafio-mongo
|
|
21
|
-
|
|
22
|
-
# peer dependencies
|
|
23
|
-
npm install grafio
|
|
20
|
+
npm install grafio-mongo
|
|
24
21
|
```
|
|
25
22
|
|
|
26
23
|
## Quick Start
|
|
@@ -155,6 +152,213 @@ try {
|
|
|
155
152
|
- `isFailed()` — returns true if a storage operation failed within the transaction
|
|
156
153
|
- `isActive()` — returns true if transaction is active and not failed
|
|
157
154
|
|
|
155
|
+
## Cypher Query Language
|
|
156
|
+
|
|
157
|
+
MongoDB-backed graphs support read-only openCypher-compatible queries via the `CypherEngine`:
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
import { Graph } from 'grafio';
|
|
161
|
+
import { CypherEngine } from 'grafio/cypher';
|
|
162
|
+
|
|
163
|
+
const graph = factory.forGraph('my-graph');
|
|
164
|
+
|
|
165
|
+
// Build your graph
|
|
166
|
+
const alice = await graph.addNode('Person', { name: 'Alice', age: 30 });
|
|
167
|
+
const bob = await graph.addNode('Person', { name: 'Bob', age: 25 });
|
|
168
|
+
await graph.addEdge(alice.id, bob.id, 'KNOWS', { since: 2020 });
|
|
169
|
+
|
|
170
|
+
const engine = new CypherEngine(graph);
|
|
171
|
+
|
|
172
|
+
// Scan nodes by type
|
|
173
|
+
const result = await engine.query('MATCH (p:Person) RETURN p.name, p.age');
|
|
174
|
+
|
|
175
|
+
// Filter with WHERE
|
|
176
|
+
const adults = await engine.query(
|
|
177
|
+
'MATCH (p:Person) WHERE p.age > 25 RETURN p.name'
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// Follow relationships
|
|
181
|
+
const friends = await engine.query(
|
|
182
|
+
'MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a.name, b.name'
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Multi-hop traversal
|
|
186
|
+
const network = await engine.query(
|
|
187
|
+
'MATCH (a:Person)-[:KNOWS*1..2]->(b:Person) RETURN DISTINCT a.name, b.name'
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// Parameterized queries
|
|
191
|
+
const byName = await engine.query(
|
|
192
|
+
'MATCH (p:Person {name: $name}) RETURN p',
|
|
193
|
+
{ name: 'Alice' }
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// Pagination
|
|
197
|
+
const page = await engine.query(
|
|
198
|
+
'MATCH (p:Person) RETURN p ORDER BY p.age DESC SKIP 0 LIMIT 10'
|
|
199
|
+
);
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Supported Clauses
|
|
203
|
+
|
|
204
|
+
| Clause | Support | Notes |
|
|
205
|
+
|--------|---------|-------|
|
|
206
|
+
| `MATCH` | ✅ Read-only patterns | Typed/untyped nodes, directed edges, multi-label `(n:A\|B)`, inline property maps |
|
|
207
|
+
| `WHERE` | ✅ Full expressions | `AND`/`OR`/`NOT`, comparisons, `IN`, `NOT IN`, `IS NULL`, `IS NOT NULL` |
|
|
208
|
+
| `RETURN` | ✅ With `DISTINCT` | Property access, aliases with `AS` |
|
|
209
|
+
| `ORDER BY` | ✅ ASC/DESC | Default ASC when omitted |
|
|
210
|
+
| `SKIP` | ✅ Literal + `$param` | Evaluated at runtime |
|
|
211
|
+
| `LIMIT` | ✅ Literal + `$param` | Evaluated at runtime |
|
|
212
|
+
| `CREATE` / `DELETE` / `SET` / `REMOVE` / `MERGE` | ❌ Rejected | Validation gate prevents execution |
|
|
213
|
+
|
|
214
|
+
#### Aggregation Functions
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
// Basic count
|
|
218
|
+
const total = await engine.query('MATCH (p:Person) RETURN COUNT(p) AS total');
|
|
219
|
+
|
|
220
|
+
// Group by with aggregation
|
|
221
|
+
const byCity = await engine.query(
|
|
222
|
+
'MATCH (p:Person) RETURN p.city, COUNT(*) AS cnt ORDER BY cnt DESC'
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// HAVING clause
|
|
226
|
+
const popular = await engine.query(
|
|
227
|
+
'MATCH (p:Person) RETURN p.city, COUNT(*) AS cnt HAVING cnt > 1'
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Multiple aggregates
|
|
231
|
+
const stats = await engine.query(
|
|
232
|
+
'MATCH (p:Person) RETURN MIN(p.age), MAX(p.age), AVG(p.age)'
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Named path variable
|
|
236
|
+
const paths = await engine.query(
|
|
237
|
+
'MATCH p = (a:Person)-[:KNOWS]->(b:Person)-[:KNOWS]->(c:Person) RETURN p'
|
|
238
|
+
);
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
#### Query Plan — Inspect Execution Steps
|
|
242
|
+
|
|
243
|
+
Use `getQueryPlan()` to inspect the logical execution plan for a query without running it:
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
// Get query plan in JSON format (default)
|
|
247
|
+
const planJson = await engine.getQueryPlan('MATCH (p:Person) RETURN p.name');
|
|
248
|
+
// Returns: { plan: { steps: [...] } }
|
|
249
|
+
|
|
250
|
+
// Get in Text tree format
|
|
251
|
+
const planText = await engine.getQueryPlan('MATCH (p:Person)-[:KNOWS]->(b) RETURN p.name, b.name', undefined, 'text');
|
|
252
|
+
/*
|
|
253
|
+
NodeScanStep (Person)
|
|
254
|
+
EdgeExpandStep (KNOWS, outgoing)
|
|
255
|
+
ProjectStep [p.name, b.name]
|
|
256
|
+
*/
|
|
257
|
+
|
|
258
|
+
// Get in Mermaid flowchart format
|
|
259
|
+
const planMermaid = await engine.getQueryPlan('MATCH (p:Person)-[:KNOWS*1..2]->(b) RETURN p.name', undefined, 'mermaid');
|
|
260
|
+
/*
|
|
261
|
+
flowchart TD
|
|
262
|
+
Step1[NodeScanStep Person]
|
|
263
|
+
Step2[EdgeExpandStep KNOWS, 1..2 hops, outgoing]
|
|
264
|
+
Step3[ProjectStep [p.name]]
|
|
265
|
+
Step1 --> Step2
|
|
266
|
+
Step2 --> Step3
|
|
267
|
+
*/
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
#### Execution Plan — Query Plan with Runtime Statistics
|
|
271
|
+
|
|
272
|
+
Use `execute()` with the `executionPlan` option to get the query plan enriched with per-step timing and row counts:
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
const result = await engine.execute(
|
|
276
|
+
'MATCH (p:Person)-[:KNOWS]->(b:Person) RETURN p.name, b.name',
|
|
277
|
+
{},
|
|
278
|
+
{ executionPlan: { format: 'json' } }
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// result.executionPlan contains the formatted plan with stats
|
|
282
|
+
// result.summary contains timing metadata
|
|
283
|
+
// result.summary.planExecutionStats contains per-step timing data
|
|
284
|
+
|
|
285
|
+
// Get execution plan in Text format with timing
|
|
286
|
+
const execText = await engine.execute(
|
|
287
|
+
'MATCH (a:Person)-[:KNOWS]->(b:Person)-[:KNOWS]->(c:Person) RETURN a.name',
|
|
288
|
+
{},
|
|
289
|
+
{ executionPlan: { format: 'text' } }
|
|
290
|
+
);
|
|
291
|
+
console.log(execText.executionPlan);
|
|
292
|
+
/*
|
|
293
|
+
NodeScanStep Person (1ms, 33.3%, 2 rows)
|
|
294
|
+
EdgeExpandStep KNOWS, outgoing (1ms, 33.3%, 5 rows)
|
|
295
|
+
EdgeExpandStep KNOWS, outgoing (1ms, 33.3%, 3 rows)
|
|
296
|
+
ProjectStep [a.name]
|
|
297
|
+
*/
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Supported formats: `'json'`, `'text'`, `'mermaid'`
|
|
301
|
+
|
|
302
|
+
The execution plan includes:
|
|
303
|
+
- **timeMs**: Time spent in each step
|
|
304
|
+
- **percentageOfTotal**: Percentage of total query time
|
|
305
|
+
- **rowsOut**: Number of rows output by each step
|
|
306
|
+
|
|
307
|
+
### Variable-length Edge Syntax
|
|
308
|
+
|
|
309
|
+
| Syntax | Meaning |
|
|
310
|
+
|--------|---------|
|
|
311
|
+
| `[*]` | Unbounded (up to 100 hops) |
|
|
312
|
+
| `[*1..3]` | 1 to 3 hops (BFS by default) |
|
|
313
|
+
| `[*2]` | Exactly 2 hops |
|
|
314
|
+
| `[*..5]` | Up to 5 hops |
|
|
315
|
+
|
|
316
|
+
> **Strategy selection**: BFS is used by default for multi-hop expansion. When `LIMIT` is present, DFS is selected automatically for better early-result performance.
|
|
317
|
+
|
|
318
|
+
#### Property Filter Operators
|
|
319
|
+
|
|
320
|
+
The `filter.properties` option supports various comparison operators:
|
|
321
|
+
|
|
322
|
+
| Operator | Syntax | Description |
|
|
323
|
+
|----------|--------|-------------|
|
|
324
|
+
| `=` | `{ key: 'age', value: 30 }` | Equality (default) |
|
|
325
|
+
| `<>` | `{ key: 'age', value: 30, op: '<>' }` | Not equal |
|
|
326
|
+
| `>` | `{ key: 'age', value: 25, op: '>' }` | Greater than |
|
|
327
|
+
| `<` | `{ key: 'age', value: 25, op: '<' }` | Less than |
|
|
328
|
+
| `>=` | `{ key: 'age', value: 25, op: '>=' }` | Greater than or equal |
|
|
329
|
+
| `<=` | `{ key: 'age', value: 25, op: '<=' }` | Less than or equal |
|
|
330
|
+
| `CONTAINS` | `{ key: 'name', value: 'John', op: 'CONTAINS' }` | String contains |
|
|
331
|
+
| `STARTS_WITH` | `{ key: 'name', value: 'J', op: 'STARTS_WITH' }` | String prefix |
|
|
332
|
+
| `ENDS_WITH` | `{ key: 'name', value: 'n', op: 'ENDS_WITH' }` | String suffix |
|
|
333
|
+
| `IN` | `{ key: 'city', value: ['NYC', 'LA'], op: 'IN' }` | In array |
|
|
334
|
+
| `NOT_IN` | `{ key: 'city', value: ['SF', 'CHI'], op: 'NOT_IN' }` | Not in array |
|
|
335
|
+
| `IS_NULL` | `{ key: 'age', op: 'IS_NULL' }` | Is null |
|
|
336
|
+
| `IS_NOT_NULL` | `{ key: 'age', op: 'IS_NOT_NULL' }` | Is not null |
|
|
337
|
+
|
|
338
|
+
**AND/OR Chaining:**
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
// AND chaining
|
|
342
|
+
const result = await graph.getNodes({
|
|
343
|
+
filter: {
|
|
344
|
+
AND: [
|
|
345
|
+
{ key: 'age', value: 25, op: '>' },
|
|
346
|
+
{ key: 'city', value: 'NYC' }
|
|
347
|
+
]
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// OR chaining
|
|
352
|
+
const result = await graph.getNodes({
|
|
353
|
+
filter: {
|
|
354
|
+
OR: [
|
|
355
|
+
{ key: 'city', value: 'NYC' },
|
|
356
|
+
{ key: 'city', value: 'LA' }
|
|
357
|
+
]
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
```
|
|
361
|
+
|
|
158
362
|
## Graph Operations
|
|
159
363
|
|
|
160
364
|
All graph operations from grafio are available when using MongoDB storage. See the [grafio documentation](https://github.com/satyajugran/grafio) for the complete API reference.
|
|
@@ -179,8 +383,8 @@ const path = await graph.traverse(alice.id, bob.id, { method: 'bfs' });
|
|
|
179
383
|
// Type filtering
|
|
180
384
|
const allPersons = await graph.getNodesByType('Person');
|
|
181
385
|
|
|
182
|
-
// Property queries
|
|
183
|
-
const adults = await graph.
|
|
386
|
+
// Property queries with operators
|
|
387
|
+
const adults = await graph.getNodes({ filter: { properties: [{ key: 'age', value: 25, op: '>' }] } });
|
|
184
388
|
|
|
185
389
|
// DAG check and topological sort
|
|
186
390
|
const isDag = await graph.isDAG();
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { Db } from 'mongodb';
|
|
2
|
-
import { IStorageProvider,
|
|
2
|
+
import { IStorageProvider, NodeData, ITransactionHandle, EdgeData, GraphData, StorageQueryOptions } from 'grafio';
|
|
3
|
+
export interface QueryOptionsFilterProperty {
|
|
4
|
+
key?: string;
|
|
5
|
+
value?: unknown;
|
|
6
|
+
op?: '=' | '<>' | '>' | '<' | '>=' | '<=' | 'CONTAINS' | 'STARTS_WITH' | 'ENDS_WITH' | 'IN' | 'NOT_IN' | 'IS_NULL' | 'IS_NOT_NULL';
|
|
7
|
+
AND?: QueryOptionsFilterProperty[];
|
|
8
|
+
OR?: QueryOptionsFilterProperty[];
|
|
9
|
+
}
|
|
3
10
|
export interface MongoStorageProviderOptions {
|
|
4
11
|
graphId?: string;
|
|
5
12
|
nodesCollection?: string;
|
|
@@ -19,23 +26,34 @@ export declare class MongoStorageProvider implements IStorageProvider {
|
|
|
19
26
|
deleteNode(id: string, transaction?: ITransactionHandle): Promise<void>;
|
|
20
27
|
hasNode(id: string, transaction?: ITransactionHandle): Promise<boolean>;
|
|
21
28
|
getNode(id: string, transaction?: ITransactionHandle): Promise<NodeData | undefined>;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
getNodeCount(options?: StorageQueryOptions): Promise<number>;
|
|
30
|
+
aggregateNodeProperty(key: string, options?: StorageQueryOptions): Promise<{
|
|
31
|
+
count: number;
|
|
32
|
+
sum?: number;
|
|
33
|
+
avg?: number;
|
|
34
|
+
min?: number;
|
|
35
|
+
max?: number;
|
|
36
|
+
}>;
|
|
37
|
+
getNodes(options?: StorageQueryOptions): Promise<NodeData[]>;
|
|
30
38
|
hasEdge(id: string, transaction?: ITransactionHandle): Promise<boolean>;
|
|
31
39
|
getEdge(id: string, transaction?: ITransactionHandle): Promise<EdgeData | undefined>;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
getEdgeCount(options?: StorageQueryOptions): Promise<number>;
|
|
41
|
+
aggregateEdgeProperty(key: string, options?: StorageQueryOptions): Promise<{
|
|
42
|
+
count: number;
|
|
43
|
+
sum?: number;
|
|
44
|
+
avg?: number;
|
|
45
|
+
min?: number;
|
|
46
|
+
max?: number;
|
|
47
|
+
}>;
|
|
48
|
+
getEdges(options?: StorageQueryOptions): Promise<EdgeData[]>;
|
|
49
|
+
getEdgesBySource(nodeId: string, options?: StorageQueryOptions): Promise<EdgeData[]>;
|
|
50
|
+
getEdgesByTarget(nodeId: string, options?: StorageQueryOptions): Promise<EdgeData[]>;
|
|
51
|
+
insertEdge(edge: EdgeData, transaction?: ITransactionHandle): Promise<void>;
|
|
52
|
+
deleteEdge(id: string, transaction?: ITransactionHandle): Promise<void>;
|
|
36
53
|
exportJSON(): Promise<GraphData>;
|
|
37
54
|
importJSON(data: GraphData): Promise<void>;
|
|
38
|
-
createIndex(target: 'node' | 'edge', propertyKey: string
|
|
55
|
+
createIndex(target: 'node' | 'edge', propertyKey: string): Promise<void>;
|
|
56
|
+
hasIndex(target: 'node' | 'edge', propertyKey: string): Promise<boolean>;
|
|
39
57
|
addProperty(target: 'node' | 'edge', id: string, key: string, value: unknown, transaction?: ITransactionHandle): Promise<void>;
|
|
40
58
|
updateProperty(target: 'node' | 'edge', id: string, key: string, value: unknown, transaction?: ITransactionHandle): Promise<void>;
|
|
41
59
|
deleteProperty(target: 'node' | 'edge', id: string, key: string, transaction?: ITransactionHandle): Promise<void>;
|
|
@@ -44,6 +62,11 @@ export declare class MongoStorageProvider implements IStorageProvider {
|
|
|
44
62
|
beginTransaction(): Promise<ITransactionHandle>;
|
|
45
63
|
commitTransaction(handle: ITransactionHandle): Promise<void>;
|
|
46
64
|
rollbackTransaction(handle: ITransactionHandle): Promise<void>;
|
|
65
|
+
private _buildNodeFilter;
|
|
66
|
+
private _buildEdgeFilter;
|
|
67
|
+
private _buildPropertyFilter;
|
|
68
|
+
private _buildSimplePropertyFilter;
|
|
69
|
+
private _escapeRegex;
|
|
47
70
|
private _docToNode;
|
|
48
71
|
private _docToEdge;
|
|
49
72
|
private _isDuplicateKeyError;
|
|
@@ -11,8 +11,8 @@ class MongoStorageProvider {
|
|
|
11
11
|
_batchSize;
|
|
12
12
|
_client = null;
|
|
13
13
|
constructor(db, opts = {}) {
|
|
14
|
-
const nodesColl = opts.nodesCollection ?? '
|
|
15
|
-
const edgesColl = opts.edgesCollection ?? '
|
|
14
|
+
const nodesColl = opts.nodesCollection ?? 'grafiodb_nodes';
|
|
15
|
+
const edgesColl = opts.edgesCollection ?? 'grafiodb_edges';
|
|
16
16
|
this._nodes = db.collection(nodesColl);
|
|
17
17
|
this._edges = db.collection(edgesColl);
|
|
18
18
|
this._graphId = opts.graphId ?? 'default';
|
|
@@ -79,55 +79,169 @@ class MongoStorageProvider {
|
|
|
79
79
|
const doc = await this._nodes.findOne({ graphId: this._graphId, id }, { session });
|
|
80
80
|
return doc ? this._docToNode(doc) : undefined;
|
|
81
81
|
}
|
|
82
|
-
async
|
|
83
|
-
const session = transaction?.context;
|
|
82
|
+
async getNodeCount(options) {
|
|
83
|
+
const session = options?.transaction?.context;
|
|
84
|
+
const filter = this._buildNodeFilter(options);
|
|
85
|
+
return this._nodes.countDocuments(filter, { session });
|
|
86
|
+
}
|
|
87
|
+
async aggregateNodeProperty(key, options) {
|
|
88
|
+
const session = options?.transaction?.context;
|
|
89
|
+
const matchFilter = this._buildNodeFilter(options);
|
|
90
|
+
const propFilter = { $exists: true, $type: 'number' };
|
|
91
|
+
if (matchFilter.$and) {
|
|
92
|
+
matchFilter.$and.push({ [`properties.${key}`]: propFilter });
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
matchFilter[`properties.${key}`] = propFilter;
|
|
96
|
+
}
|
|
97
|
+
const pipeline = [
|
|
98
|
+
{ $match: matchFilter },
|
|
99
|
+
{
|
|
100
|
+
$group: {
|
|
101
|
+
_id: null,
|
|
102
|
+
count: { $sum: 1 },
|
|
103
|
+
sum: { $sum: `$properties.${key}` },
|
|
104
|
+
avg: { $avg: `$properties.${key}` },
|
|
105
|
+
min: { $min: `$properties.${key}` },
|
|
106
|
+
max: { $max: `$properties.${key}` },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
const result = await this._nodes.aggregate(pipeline, { session }).next();
|
|
111
|
+
if (!result) {
|
|
112
|
+
return { count: 0 };
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
count: result.count,
|
|
116
|
+
sum: result.sum,
|
|
117
|
+
avg: result.avg,
|
|
118
|
+
min: result.min,
|
|
119
|
+
max: result.max,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
async getNodes(options) {
|
|
123
|
+
const session = options?.transaction?.context;
|
|
124
|
+
const filter = this._buildNodeFilter(options);
|
|
84
125
|
const nodes = [];
|
|
85
|
-
const cursor = this._nodes.find(
|
|
86
|
-
if (orderBy)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
cursor.
|
|
126
|
+
const cursor = this._nodes.find(filter, { session }).batchSize(this._batchSize);
|
|
127
|
+
if (options?.orderBy) {
|
|
128
|
+
const { field, direction } = options.orderBy;
|
|
129
|
+
const sortField = field === 'createdOn' || field === 'updatedOn' ? field : `properties.${field}`;
|
|
130
|
+
cursor.sort(sortField, direction);
|
|
131
|
+
}
|
|
132
|
+
if (options?.limit) {
|
|
133
|
+
cursor.limit(options.limit);
|
|
134
|
+
}
|
|
90
135
|
for await (const doc of cursor) {
|
|
91
136
|
nodes.push(this._docToNode(doc));
|
|
92
137
|
}
|
|
93
138
|
return nodes;
|
|
94
139
|
}
|
|
95
|
-
async
|
|
140
|
+
async hasEdge(id, transaction) {
|
|
96
141
|
const session = transaction?.context;
|
|
97
|
-
const
|
|
98
|
-
return
|
|
142
|
+
const doc = await this._edges.findOne({ graphId: this._graphId, id }, { projection: { _id: 1 }, session });
|
|
143
|
+
return doc !== null;
|
|
99
144
|
}
|
|
100
|
-
async
|
|
145
|
+
async getEdge(id, transaction) {
|
|
101
146
|
const session = transaction?.context;
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
[`properties.${key}`]: value,
|
|
105
|
-
};
|
|
106
|
-
if (nodeType !== undefined) {
|
|
107
|
-
filter.type = nodeType;
|
|
108
|
-
}
|
|
109
|
-
const docs = await this._nodes.find(filter, { session }).toArray();
|
|
110
|
-
return docs.map(d => this._docToNode(d));
|
|
147
|
+
const doc = await this._edges.findOne({ graphId: this._graphId, id }, { session });
|
|
148
|
+
return doc ? this._docToEdge(doc) : undefined;
|
|
111
149
|
}
|
|
112
|
-
async
|
|
113
|
-
const session = transaction?.context;
|
|
114
|
-
|
|
150
|
+
async getEdgeCount(options) {
|
|
151
|
+
const session = options?.transaction?.context;
|
|
152
|
+
const filter = this._buildEdgeFilter(options);
|
|
153
|
+
return this._edges.countDocuments(filter, { session });
|
|
115
154
|
}
|
|
116
|
-
async
|
|
117
|
-
const session = transaction?.context;
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
155
|
+
async aggregateEdgeProperty(key, options) {
|
|
156
|
+
const session = options?.transaction?.context;
|
|
157
|
+
const matchFilter = this._buildEdgeFilter(options);
|
|
158
|
+
const propFilter = { $exists: true, $type: 'number' };
|
|
159
|
+
if (matchFilter.$and) {
|
|
160
|
+
matchFilter.$and.push({ [`properties.${key}`]: propFilter });
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
matchFilter[`properties.${key}`] = propFilter;
|
|
164
|
+
}
|
|
165
|
+
const pipeline = [
|
|
166
|
+
{ $match: matchFilter },
|
|
167
|
+
{
|
|
168
|
+
$group: {
|
|
169
|
+
_id: null,
|
|
170
|
+
count: { $sum: 1 },
|
|
171
|
+
sum: { $sum: `$properties.${key}` },
|
|
172
|
+
avg: { $avg: `$properties.${key}` },
|
|
173
|
+
min: { $min: `$properties.${key}` },
|
|
174
|
+
max: { $max: `$properties.${key}` },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
const result = await this._edges.aggregate(pipeline, { session }).next();
|
|
179
|
+
if (!result) {
|
|
180
|
+
return { count: 0 };
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
count: result.count,
|
|
184
|
+
sum: result.sum,
|
|
185
|
+
avg: result.avg,
|
|
186
|
+
min: result.min,
|
|
187
|
+
max: result.max,
|
|
121
188
|
};
|
|
122
|
-
|
|
123
|
-
|
|
189
|
+
}
|
|
190
|
+
async getEdges(options) {
|
|
191
|
+
const session = options?.transaction?.context;
|
|
192
|
+
const filter = this._buildEdgeFilter(options);
|
|
193
|
+
const edges = [];
|
|
194
|
+
const cursor = this._edges.find(filter, { session }).batchSize(this._batchSize);
|
|
195
|
+
if (options?.orderBy) {
|
|
196
|
+
const { field, direction } = options.orderBy;
|
|
197
|
+
const sortField = field === 'createdOn' || field === 'updatedOn' ? field : `properties.${field}`;
|
|
198
|
+
cursor.sort(sortField, direction);
|
|
199
|
+
}
|
|
200
|
+
if (options?.limit) {
|
|
201
|
+
cursor.limit(options.limit);
|
|
202
|
+
}
|
|
203
|
+
for await (const doc of cursor) {
|
|
204
|
+
edges.push(this._docToEdge(doc));
|
|
124
205
|
}
|
|
125
|
-
|
|
126
|
-
return docs.map(d => this._docToEdge(d));
|
|
206
|
+
return edges;
|
|
127
207
|
}
|
|
128
|
-
async
|
|
129
|
-
const session = transaction?.context;
|
|
130
|
-
|
|
208
|
+
async getEdgesBySource(nodeId, options) {
|
|
209
|
+
const session = options?.transaction?.context;
|
|
210
|
+
const baseFilter = { graphId: this._graphId, sourceId: nodeId };
|
|
211
|
+
const filter = this._buildEdgeFilter(options, baseFilter);
|
|
212
|
+
const edges = [];
|
|
213
|
+
const cursor = this._edges.find(filter, { session }).batchSize(this._batchSize);
|
|
214
|
+
if (options?.orderBy) {
|
|
215
|
+
const { field, direction } = options.orderBy;
|
|
216
|
+
const sortField = field === 'createdOn' || field === 'updatedOn' ? field : `properties.${field}`;
|
|
217
|
+
cursor.sort(sortField, direction);
|
|
218
|
+
}
|
|
219
|
+
if (options?.limit) {
|
|
220
|
+
cursor.limit(options.limit);
|
|
221
|
+
}
|
|
222
|
+
for await (const doc of cursor) {
|
|
223
|
+
edges.push(this._docToEdge(doc));
|
|
224
|
+
}
|
|
225
|
+
return edges;
|
|
226
|
+
}
|
|
227
|
+
async getEdgesByTarget(nodeId, options) {
|
|
228
|
+
const session = options?.transaction?.context;
|
|
229
|
+
const baseFilter = { graphId: this._graphId, targetId: nodeId };
|
|
230
|
+
const filter = this._buildEdgeFilter(options, baseFilter);
|
|
231
|
+
const edges = [];
|
|
232
|
+
const cursor = this._edges.find(filter, { session }).batchSize(this._batchSize);
|
|
233
|
+
if (options?.orderBy) {
|
|
234
|
+
const { field, direction } = options.orderBy;
|
|
235
|
+
const sortField = field === 'createdOn' || field === 'updatedOn' ? field : `properties.${field}`;
|
|
236
|
+
cursor.sort(sortField, direction);
|
|
237
|
+
}
|
|
238
|
+
if (options?.limit) {
|
|
239
|
+
cursor.limit(options.limit);
|
|
240
|
+
}
|
|
241
|
+
for await (const doc of cursor) {
|
|
242
|
+
edges.push(this._docToEdge(doc));
|
|
243
|
+
}
|
|
244
|
+
return edges;
|
|
131
245
|
}
|
|
132
246
|
async insertEdge(edge, transaction) {
|
|
133
247
|
const now = Date.now();
|
|
@@ -160,50 +274,6 @@ class MongoStorageProvider {
|
|
|
160
274
|
const session = transaction?.context;
|
|
161
275
|
await this._edges.deleteOne({ graphId: this._graphId, id }, { session });
|
|
162
276
|
}
|
|
163
|
-
async hasEdge(id, transaction) {
|
|
164
|
-
const session = transaction?.context;
|
|
165
|
-
const doc = await this._edges.findOne({ graphId: this._graphId, id }, { projection: { _id: 1 }, session });
|
|
166
|
-
return doc !== null;
|
|
167
|
-
}
|
|
168
|
-
async getEdge(id, transaction) {
|
|
169
|
-
const session = transaction?.context;
|
|
170
|
-
const doc = await this._edges.findOne({ graphId: this._graphId, id }, { session });
|
|
171
|
-
return doc ? this._docToEdge(doc) : undefined;
|
|
172
|
-
}
|
|
173
|
-
async getAllEdges(limit, orderBy, transaction) {
|
|
174
|
-
const session = transaction?.context;
|
|
175
|
-
const edges = [];
|
|
176
|
-
const cursor = this._edges.find({ graphId: this._graphId }, { session }).batchSize(this._batchSize);
|
|
177
|
-
if (orderBy)
|
|
178
|
-
cursor.sort(orderBy.field, orderBy.direction);
|
|
179
|
-
if (limit)
|
|
180
|
-
cursor.limit(limit);
|
|
181
|
-
for await (const doc of cursor) {
|
|
182
|
-
edges.push(this._docToEdge(doc));
|
|
183
|
-
}
|
|
184
|
-
return edges;
|
|
185
|
-
}
|
|
186
|
-
async getEdgesByType(type, transaction) {
|
|
187
|
-
const session = transaction?.context;
|
|
188
|
-
const docs = await this._edges.find({ graphId: this._graphId, type }, { session }).toArray();
|
|
189
|
-
return docs.map(d => this._docToEdge(d));
|
|
190
|
-
}
|
|
191
|
-
async getEdgesBySource(nodeId, type, transaction) {
|
|
192
|
-
const session = transaction?.context;
|
|
193
|
-
const filter = { graphId: this._graphId, sourceId: nodeId };
|
|
194
|
-
if (type)
|
|
195
|
-
filter.type = type;
|
|
196
|
-
const docs = await this._edges.find(filter, { session }).toArray();
|
|
197
|
-
return docs.map(d => this._docToEdge(d));
|
|
198
|
-
}
|
|
199
|
-
async getEdgesByTarget(nodeId, type, transaction) {
|
|
200
|
-
const session = transaction?.context;
|
|
201
|
-
const filter = { graphId: this._graphId, targetId: nodeId };
|
|
202
|
-
if (type)
|
|
203
|
-
filter.type = type;
|
|
204
|
-
const docs = await this._edges.find(filter, { session }).toArray();
|
|
205
|
-
return docs.map(d => this._docToEdge(d));
|
|
206
|
-
}
|
|
207
277
|
async exportJSON() {
|
|
208
278
|
const nodes = [];
|
|
209
279
|
const edges = [];
|
|
@@ -294,39 +364,41 @@ class MongoStorageProvider {
|
|
|
294
364
|
}
|
|
295
365
|
}
|
|
296
366
|
}
|
|
297
|
-
async createIndex(target, propertyKey
|
|
367
|
+
async createIndex(target, propertyKey) {
|
|
298
368
|
if (target === 'node') {
|
|
299
369
|
const indexFields = { graphId: 1, [`properties.${propertyKey}`]: 1 };
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
await this._nodes.createIndex(indexFields, {
|
|
309
|
-
name: `node_graphId_${propertyKey}`,
|
|
310
|
-
background: true
|
|
311
|
-
});
|
|
312
|
-
}
|
|
370
|
+
await this._nodes.createIndex(indexFields, {
|
|
371
|
+
name: `node_graphId_${propertyKey}`,
|
|
372
|
+
background: true
|
|
373
|
+
});
|
|
374
|
+
await this._nodes.createIndex({ ...indexFields, type: 1 }, {
|
|
375
|
+
name: `node_graphId_type_${propertyKey}`,
|
|
376
|
+
background: true
|
|
377
|
+
});
|
|
313
378
|
}
|
|
314
379
|
else {
|
|
315
380
|
const indexFields = { graphId: 1, [`properties.${propertyKey}`]: 1 };
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
381
|
+
await this._edges.createIndex(indexFields, {
|
|
382
|
+
name: `edge_graphId_${propertyKey}`,
|
|
383
|
+
background: true
|
|
384
|
+
});
|
|
385
|
+
await this._edges.createIndex({ ...indexFields, type: 1 }, {
|
|
386
|
+
name: `edge_graphId_type_${propertyKey}`,
|
|
387
|
+
background: true
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async hasIndex(target, propertyKey) {
|
|
392
|
+
const collection = target === 'node' ? this._nodes : this._edges;
|
|
393
|
+
const indexNameWithoutType = `${target === 'node' ? 'node' : 'edge'}_graphId_${propertyKey}`;
|
|
394
|
+
const indexNameWithType = `${target === 'node' ? 'node' : 'edge'}_graphId_type_${propertyKey}`;
|
|
395
|
+
for await (const index of collection.listIndexes()) {
|
|
396
|
+
const name = index.name;
|
|
397
|
+
if (name === indexNameWithoutType || name === indexNameWithType) {
|
|
398
|
+
return true;
|
|
328
399
|
}
|
|
329
400
|
}
|
|
401
|
+
return false;
|
|
330
402
|
}
|
|
331
403
|
async addProperty(target, id, key, value, transaction) {
|
|
332
404
|
if (!(0, grafio_1.isPrimitive)(value)) {
|
|
@@ -432,6 +504,150 @@ class MongoStorageProvider {
|
|
|
432
504
|
session.endSession();
|
|
433
505
|
}
|
|
434
506
|
}
|
|
507
|
+
_buildNodeFilter(options, baseFilter = {}) {
|
|
508
|
+
const filter = { graphId: this._graphId, ...baseFilter };
|
|
509
|
+
const andConditions = [];
|
|
510
|
+
if (options?.filter) {
|
|
511
|
+
if (options.filter.types && options.filter.types.length > 0) {
|
|
512
|
+
filter.type = { $in: options.filter.types };
|
|
513
|
+
}
|
|
514
|
+
if (options.filter.properties && options.filter.properties.length > 0) {
|
|
515
|
+
for (const prop of options.filter.properties) {
|
|
516
|
+
const propFilter = this._buildPropertyFilter(prop);
|
|
517
|
+
if (propFilter) {
|
|
518
|
+
andConditions.push(propFilter);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (andConditions.length > 0) {
|
|
524
|
+
filter.$and = andConditions;
|
|
525
|
+
}
|
|
526
|
+
return filter;
|
|
527
|
+
}
|
|
528
|
+
_buildEdgeFilter(options, baseFilter = {}) {
|
|
529
|
+
const filter = { graphId: this._graphId, ...baseFilter };
|
|
530
|
+
const andConditions = [];
|
|
531
|
+
if (options?.filter) {
|
|
532
|
+
if (options.filter.types && options.filter.types.length > 0) {
|
|
533
|
+
filter.type = { $in: options.filter.types };
|
|
534
|
+
}
|
|
535
|
+
if (options.filter.properties && options.filter.properties.length > 0) {
|
|
536
|
+
for (const prop of options.filter.properties) {
|
|
537
|
+
const propFilter = this._buildPropertyFilter(prop);
|
|
538
|
+
if (propFilter) {
|
|
539
|
+
andConditions.push(propFilter);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (andConditions.length > 0) {
|
|
545
|
+
filter.$and = andConditions;
|
|
546
|
+
}
|
|
547
|
+
return filter;
|
|
548
|
+
}
|
|
549
|
+
_buildPropertyFilter(prop) {
|
|
550
|
+
if (prop.AND && prop.AND.length > 0) {
|
|
551
|
+
const andFilters = [];
|
|
552
|
+
for (const subProp of prop.AND) {
|
|
553
|
+
const subFilter = this._buildPropertyFilter(subProp);
|
|
554
|
+
if (subFilter) {
|
|
555
|
+
andFilters.push(subFilter);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (andFilters.length > 0) {
|
|
559
|
+
return { $and: andFilters };
|
|
560
|
+
}
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
563
|
+
if (prop.OR && prop.OR.length > 0) {
|
|
564
|
+
const orFilters = [];
|
|
565
|
+
for (const subProp of prop.OR) {
|
|
566
|
+
const subFilter = this._buildPropertyFilter(subProp);
|
|
567
|
+
if (subFilter) {
|
|
568
|
+
orFilters.push(subFilter);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (orFilters.length > 0) {
|
|
572
|
+
return { $or: orFilters };
|
|
573
|
+
}
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
if (prop.key === undefined) {
|
|
577
|
+
return undefined;
|
|
578
|
+
}
|
|
579
|
+
const op = prop.op ?? '=';
|
|
580
|
+
const propPath = `properties.${prop.key}`;
|
|
581
|
+
switch (op) {
|
|
582
|
+
case '=':
|
|
583
|
+
return { [propPath]: prop.value };
|
|
584
|
+
case '<>':
|
|
585
|
+
return { [propPath]: { $ne: prop.value } };
|
|
586
|
+
case '>':
|
|
587
|
+
return { [propPath]: { $gt: prop.value } };
|
|
588
|
+
case '<':
|
|
589
|
+
return { [propPath]: { $lt: prop.value } };
|
|
590
|
+
case '>=':
|
|
591
|
+
return { [propPath]: { $gte: prop.value } };
|
|
592
|
+
case '<=':
|
|
593
|
+
return { [propPath]: { $lte: prop.value } };
|
|
594
|
+
case 'CONTAINS':
|
|
595
|
+
return { [propPath]: { $regex: String(prop.value), $options: 'i' } };
|
|
596
|
+
case 'STARTS_WITH':
|
|
597
|
+
return { [propPath]: { $regex: `^${this._escapeRegex(String(prop.value))}`, $options: 'i' } };
|
|
598
|
+
case 'ENDS_WITH':
|
|
599
|
+
return { [propPath]: { $regex: `${this._escapeRegex(String(prop.value))}$`, $options: 'i' } };
|
|
600
|
+
case 'IN':
|
|
601
|
+
return { [propPath]: { $in: Array.isArray(prop.value) ? prop.value : [prop.value] } };
|
|
602
|
+
case 'NOT_IN':
|
|
603
|
+
return { [propPath]: { $nin: Array.isArray(prop.value) ? prop.value : [prop.value] } };
|
|
604
|
+
case 'IS_NULL':
|
|
605
|
+
return { $or: [{ [propPath]: null }, { [propPath]: { $exists: false } }] };
|
|
606
|
+
case 'IS_NOT_NULL':
|
|
607
|
+
return { $and: [{ [propPath]: { $exists: true } }, { [propPath]: { $ne: null } }] };
|
|
608
|
+
default:
|
|
609
|
+
return { [propPath]: prop.value };
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
_buildSimplePropertyFilter(prop) {
|
|
613
|
+
if (prop.key === undefined) {
|
|
614
|
+
return undefined;
|
|
615
|
+
}
|
|
616
|
+
const op = prop.op ?? '=';
|
|
617
|
+
switch (op) {
|
|
618
|
+
case '=':
|
|
619
|
+
return prop.value;
|
|
620
|
+
case '<>':
|
|
621
|
+
return { $ne: prop.value };
|
|
622
|
+
case '>':
|
|
623
|
+
return { $gt: prop.value };
|
|
624
|
+
case '<':
|
|
625
|
+
return { $lt: prop.value };
|
|
626
|
+
case '>=':
|
|
627
|
+
return { $gte: prop.value };
|
|
628
|
+
case '<=':
|
|
629
|
+
return { $lte: prop.value };
|
|
630
|
+
case 'CONTAINS':
|
|
631
|
+
return { $regex: String(prop.value), $options: 'i' };
|
|
632
|
+
case 'STARTS_WITH':
|
|
633
|
+
return { $regex: `^${this._escapeRegex(String(prop.value))}`, $options: 'i' };
|
|
634
|
+
case 'ENDS_WITH':
|
|
635
|
+
return { $regex: `${this._escapeRegex(String(prop.value))}$`, $options: 'i' };
|
|
636
|
+
case 'IN':
|
|
637
|
+
return { $in: Array.isArray(prop.value) ? prop.value : [prop.value] };
|
|
638
|
+
case 'NOT_IN':
|
|
639
|
+
return { $nin: Array.isArray(prop.value) ? prop.value : [prop.value] };
|
|
640
|
+
case 'IS_NULL':
|
|
641
|
+
return { $or: [{ [prop.key]: null }, { [prop.key]: { $exists: false } }] };
|
|
642
|
+
case 'IS_NOT_NULL':
|
|
643
|
+
return { $and: [{ [prop.key]: { $exists: true } }, { [prop.key]: { $ne: null } }] };
|
|
644
|
+
default:
|
|
645
|
+
return prop.value;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
_escapeRegex(str) {
|
|
649
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
650
|
+
}
|
|
435
651
|
_docToNode(doc) {
|
|
436
652
|
return {
|
|
437
653
|
id: doc.id,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "grafio-mongo",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "MongoDB storage backend for grafio",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -14,14 +14,14 @@
|
|
|
14
14
|
"perf:gc": "cross-env TS_NODE_PROJECT=tsconfig.perf.json node --expose-gc -r ts-node/register tests/perf/entryPoint.ts"
|
|
15
15
|
|
|
16
16
|
},
|
|
17
|
-
"peerDependencies": {
|
|
18
|
-
|
|
17
|
+
"peerDependencies": {},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"grafio": ">=7.0.0",
|
|
19
20
|
"mongodb": ">=5.0.0"
|
|
20
21
|
},
|
|
21
22
|
"devDependencies": {
|
|
22
23
|
"@types/jest": "^29.5.0",
|
|
23
24
|
"@types/node": "^25.6.0",
|
|
24
|
-
"grafio": "^6.2.0",
|
|
25
25
|
"cross-env": "^10.1.0",
|
|
26
26
|
"jest": "^29.5.0",
|
|
27
27
|
"rimraf": "^5.0.0",
|