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 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 mongodb
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.getNodesByProperty('age', 30);
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, IOrderBy, NodeData, ITransactionHandle, EdgeData, GraphData } from 'grafio';
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
- getAllNodes(limit?: number, orderBy?: IOrderBy, transaction?: ITransactionHandle): Promise<NodeData[]>;
23
- getNodesByType(type: string, transaction?: ITransactionHandle): Promise<NodeData[]>;
24
- getNodesByProperty(key: string, value: unknown, nodeType?: string, transaction?: ITransactionHandle): Promise<NodeData[]>;
25
- getTotalNodeCount(transaction?: ITransactionHandle): Promise<number>;
26
- getEdgesByProperty(key: string, value: unknown, edgeType?: string, transaction?: ITransactionHandle): Promise<EdgeData[]>;
27
- getTotalEdgeCount(transaction?: ITransactionHandle): Promise<number>;
28
- insertEdge(edge: EdgeData, transaction?: ITransactionHandle): Promise<void>;
29
- deleteEdge(id: string, transaction?: ITransactionHandle): Promise<void>;
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
- getAllEdges(limit?: number, orderBy?: IOrderBy, transaction?: ITransactionHandle): Promise<EdgeData[]>;
33
- getEdgesByType(type: string, transaction?: ITransactionHandle): Promise<EdgeData[]>;
34
- getEdgesBySource(nodeId: string, type?: string, transaction?: ITransactionHandle): Promise<EdgeData[]>;
35
- getEdgesByTarget(nodeId: string, type?: string, transaction?: ITransactionHandle): Promise<EdgeData[]>;
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, type?: string): Promise<void>;
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 ?? 'sgdb_nodes';
15
- const edgesColl = opts.edgesCollection ?? 'sgdb_edges';
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 getAllNodes(limit, orderBy, transaction) {
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({ graphId: this._graphId }, { session }).batchSize(this._batchSize);
86
- if (orderBy)
87
- cursor.sort(orderBy.field, orderBy.direction);
88
- if (limit)
89
- cursor.limit(limit);
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 getNodesByType(type, transaction) {
140
+ async hasEdge(id, transaction) {
96
141
  const session = transaction?.context;
97
- const docs = await this._nodes.find({ graphId: this._graphId, type }, { session }).toArray();
98
- return docs.map(d => this._docToNode(d));
142
+ const doc = await this._edges.findOne({ graphId: this._graphId, id }, { projection: { _id: 1 }, session });
143
+ return doc !== null;
99
144
  }
100
- async getNodesByProperty(key, value, nodeType, transaction) {
145
+ async getEdge(id, transaction) {
101
146
  const session = transaction?.context;
102
- const filter = {
103
- graphId: this._graphId,
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 getTotalNodeCount(transaction) {
113
- const session = transaction?.context;
114
- return this._nodes.countDocuments({ graphId: this._graphId }, { session });
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 getEdgesByProperty(key, value, edgeType, transaction) {
117
- const session = transaction?.context;
118
- const filter = {
119
- graphId: this._graphId,
120
- [`properties.${key}`]: value,
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
- if (edgeType !== undefined) {
123
- filter.type = edgeType;
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
- const docs = await this._edges.find(filter, { session }).toArray();
126
- return docs.map(d => this._docToEdge(d));
206
+ return edges;
127
207
  }
128
- async getTotalEdgeCount(transaction) {
129
- const session = transaction?.context;
130
- return this._edges.countDocuments({ graphId: this._graphId }, { session });
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, type) {
367
+ async createIndex(target, propertyKey) {
298
368
  if (target === 'node') {
299
369
  const indexFields = { graphId: 1, [`properties.${propertyKey}`]: 1 };
300
- if (type && type !== '*') {
301
- indexFields['type'] = 1;
302
- await this._nodes.createIndex(indexFields, {
303
- name: `node_graphId_type_${propertyKey}`,
304
- background: true
305
- });
306
- }
307
- else {
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
- if (type && type !== '*') {
317
- indexFields['type'] = 1;
318
- await this._edges.createIndex(indexFields, {
319
- name: `edge_graphId_type_${propertyKey}`,
320
- background: true
321
- });
322
- }
323
- else {
324
- await this._edges.createIndex(indexFields, {
325
- name: `edge_graphId_${propertyKey}`,
326
- background: true
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": "1.2.0",
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
- "grafio": ">=6.2.0",
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",