leangraph 1.0.1 → 1.1.1

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.
Files changed (54) hide show
  1. package/README.md +198 -111
  2. package/dist/auth.d.ts +1 -4
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/auth.js +5 -15
  5. package/dist/auth.js.map +1 -1
  6. package/dist/backup.d.ts +1 -3
  7. package/dist/backup.d.ts.map +1 -1
  8. package/dist/backup.js +10 -15
  9. package/dist/backup.js.map +1 -1
  10. package/dist/cli-helpers.d.ts +2 -3
  11. package/dist/cli-helpers.d.ts.map +1 -1
  12. package/dist/cli-helpers.js +11 -30
  13. package/dist/cli-helpers.js.map +1 -1
  14. package/dist/cli.js +82 -129
  15. package/dist/cli.js.map +1 -1
  16. package/dist/db.d.ts +9 -2
  17. package/dist/db.d.ts.map +1 -1
  18. package/dist/db.js +82 -9
  19. package/dist/db.js.map +1 -1
  20. package/dist/executor.d.ts +114 -0
  21. package/dist/executor.d.ts.map +1 -1
  22. package/dist/executor.js +1248 -341
  23. package/dist/executor.js.map +1 -1
  24. package/dist/index.d.ts +8 -34
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +12 -38
  27. package/dist/index.js.map +1 -1
  28. package/dist/local.d.ts +3 -3
  29. package/dist/local.d.ts.map +1 -1
  30. package/dist/local.js +13 -15
  31. package/dist/local.js.map +1 -1
  32. package/dist/parser.d.ts +15 -3
  33. package/dist/parser.d.ts.map +1 -1
  34. package/dist/parser.js +231 -42
  35. package/dist/parser.js.map +1 -1
  36. package/dist/remote.d.ts +3 -3
  37. package/dist/remote.d.ts.map +1 -1
  38. package/dist/remote.js +8 -10
  39. package/dist/remote.js.map +1 -1
  40. package/dist/routes.d.ts +0 -1
  41. package/dist/routes.d.ts.map +1 -1
  42. package/dist/routes.js +15 -39
  43. package/dist/routes.js.map +1 -1
  44. package/dist/server.js +1 -1
  45. package/dist/server.js.map +1 -1
  46. package/dist/translator.d.ts +36 -0
  47. package/dist/translator.d.ts.map +1 -1
  48. package/dist/translator.js +634 -136
  49. package/dist/translator.js.map +1 -1
  50. package/dist/types.d.ts +24 -26
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/types.js +2 -2
  53. package/dist/types.js.map +1 -1
  54. package/package.json +8 -2
package/dist/executor.js CHANGED
@@ -59,12 +59,16 @@ function createEmptyContext() {
59
59
  /**
60
60
  * Clone a phase context
61
61
  */
62
- function cloneContext(ctx) {
62
+ /**
63
+ * Clone a phase context with optimized row cloning
64
+ * For read-only operations, we can avoid the expensive row cloning
65
+ */
66
+ function cloneContext(ctx, cloneRows = true) {
63
67
  return {
64
68
  nodeIds: new Map(ctx.nodeIds),
65
69
  edgeIds: new Map(ctx.edgeIds),
66
70
  values: new Map(ctx.values),
67
- rows: ctx.rows.map(row => new Map(row)),
71
+ rows: cloneRows ? ctx.rows.map(row => new Map(row)) : ctx.rows,
68
72
  };
69
73
  }
70
74
  // ============================================================================
@@ -72,14 +76,113 @@ function cloneContext(ctx) {
72
76
  // ============================================================================
73
77
  export class Executor {
74
78
  db;
79
+ propertyCache = new Map();
80
+ edgePropertyCache = new Map();
81
+ // Cache for full edge info (type, source_id, target_id) - populated by batchGetEdgeInfo
82
+ edgeInfoCache = new Map();
75
83
  constructor(db) {
76
84
  this.db = db;
77
85
  }
86
+ /**
87
+ * Get node properties from cache or parse from JSON string and cache them
88
+ */
89
+ getNodeProperties(nodeId, propsJson) {
90
+ let props = this.propertyCache.get(nodeId);
91
+ if (!props) {
92
+ props = typeof propsJson === "string" ? JSON.parse(propsJson) : propsJson;
93
+ if (props && typeof props === "object" && !Array.isArray(props)) {
94
+ this.propertyCache.set(nodeId, props);
95
+ }
96
+ else {
97
+ // Fallback for invalid data
98
+ props = {};
99
+ }
100
+ }
101
+ return props || {};
102
+ }
103
+ /**
104
+ * Get edge properties from cache or parse from JSON string and cache them
105
+ */
106
+ getEdgeProperties(edgeId, propsJson) {
107
+ let props = this.edgePropertyCache.get(edgeId);
108
+ if (!props) {
109
+ props = typeof propsJson === "string" ? JSON.parse(propsJson) : propsJson;
110
+ if (props && typeof props === "object" && !Array.isArray(props)) {
111
+ this.edgePropertyCache.set(edgeId, props);
112
+ }
113
+ else {
114
+ // Fallback for invalid data
115
+ props = {};
116
+ }
117
+ }
118
+ return props || {};
119
+ }
120
+ /**
121
+ * Invalidate cached properties for a node or edge after it has been updated
122
+ */
123
+ invalidatePropertyCache(id) {
124
+ this.propertyCache.delete(id);
125
+ this.edgePropertyCache.delete(id);
126
+ }
127
+ /**
128
+ * Extract edge ID from various representations (object with _nf_id/id, JSON string, or raw ID)
129
+ */
130
+ extractEdgeId(rel) {
131
+ if (typeof rel === "object" && rel !== null) {
132
+ const relObj = rel;
133
+ return (relObj._nf_id || relObj.id);
134
+ }
135
+ else if (typeof rel === "string") {
136
+ try {
137
+ const parsed = JSON.parse(rel);
138
+ if (typeof parsed === "object" && parsed !== null) {
139
+ return (parsed._nf_id || parsed.id);
140
+ }
141
+ }
142
+ catch {
143
+ // Not JSON, assume it's a raw ID
144
+ return rel;
145
+ }
146
+ }
147
+ return null;
148
+ }
149
+ /**
150
+ * Batch fetch edge info for multiple edge IDs in a single query
151
+ * Returns a Map from edge ID to edge info (id, type, source_id, target_id, properties)
152
+ */
153
+ batchGetEdgeInfo(edgeIds) {
154
+ const result = new Map();
155
+ if (edgeIds.length === 0)
156
+ return result;
157
+ // Batch query with IN clause
158
+ const placeholders = edgeIds.map(() => '?').join(',');
159
+ const queryResult = this.db.execute(`SELECT id, type, source_id, target_id, properties FROM edges WHERE id IN (${placeholders})`, edgeIds);
160
+ for (const row of queryResult.rows) {
161
+ const id = row.id;
162
+ const type = row.type;
163
+ const source_id = row.source_id;
164
+ const target_id = row.target_id;
165
+ result.set(id, {
166
+ id,
167
+ type,
168
+ source_id,
169
+ target_id,
170
+ properties: this.getEdgeProperties(id, row.properties)
171
+ });
172
+ // Also populate edge info cache for type()/startNode()/endNode() lookups
173
+ this.edgeInfoCache.set(id, { type, source_id, target_id });
174
+ }
175
+ return result;
176
+ }
78
177
  /**
79
178
  * Execute a Cypher query and return formatted results
80
179
  */
81
180
  execute(cypher, params = {}) {
82
181
  const startTime = performance.now();
182
+ // Clear property caches at start of each query execution
183
+ this.propertyCache.clear();
184
+ this.edgePropertyCache.clear();
185
+ this.edgeInfoCache.clear();
83
186
  try {
84
187
  // 1. Parse the Cypher query
85
188
  const parseResult = parse(cypher);
@@ -94,124 +197,94 @@ export class Executor {
94
197
  },
95
198
  };
96
199
  }
97
- // 2. Try phase-based execution for complex multi-phase queries
98
- const phasedResult = this.tryPhasedExecution(parseResult.query, params);
99
- if (phasedResult !== null) {
100
- const endTime = performance.now();
101
- return {
102
- success: true,
103
- data: phasedResult,
104
- meta: {
105
- count: phasedResult.length,
106
- time_ms: Math.round((endTime - startTime) * 100) / 100,
107
- },
108
- };
109
- }
110
- // 2.1. Check for UNWIND with CREATE pattern (needs special handling)
111
- const unwindCreateResult = this.tryUnwindCreateExecution(parseResult.query, params);
112
- if (unwindCreateResult !== null) {
113
- const endTime = performance.now();
114
- return {
115
- success: true,
116
- data: unwindCreateResult,
117
- meta: {
118
- count: unwindCreateResult.length,
119
- time_ms: Math.round((endTime - startTime) * 100) / 100,
120
- },
121
- };
122
- }
123
- // 2.2. Check for UNWIND with MERGE pattern (needs special handling)
124
- const unwindMergeResult = this.tryUnwindMergeExecution(parseResult.query, params);
125
- if (unwindMergeResult !== null) {
126
- const endTime = performance.now();
127
- return {
128
- success: true,
129
- data: unwindMergeResult,
130
- meta: {
131
- count: unwindMergeResult.length,
132
- time_ms: Math.round((endTime - startTime) * 100) / 100,
133
- },
134
- };
135
- }
136
- // 2.3. Check for MATCH+WITH(COLLECT)+UNWIND+RETURN pattern (needs subquery for aggregates)
137
- const collectUnwindResult = this.tryCollectUnwindExecution(parseResult.query, params);
138
- if (collectUnwindResult !== null) {
139
- const endTime = performance.now();
140
- return {
141
- success: true,
142
- data: collectUnwindResult,
143
- meta: {
144
- count: collectUnwindResult.length,
145
- time_ms: Math.round((endTime - startTime) * 100) / 100,
146
- },
147
- };
200
+ // 2. Classify query with single-pass and dispatch to appropriate handler
201
+ const { pattern, flags } = this.classifyQuery(parseResult.query);
202
+ // 3. Run semantic validations only when relevant clauses are present
203
+ // (avoids extra iteration overhead for simple queries)
204
+ if (flags.hasMerge) {
205
+ this.validateMergeVariables(parseResult.query);
148
206
  }
149
- // 2.4. Check for MATCH+WITH(COLLECT)+DELETE[expr] pattern
150
- const collectDeleteResult = this.tryCollectDeleteExecution(parseResult.query, params);
151
- if (collectDeleteResult !== null) {
152
- const endTime = performance.now();
153
- return {
154
- success: true,
155
- data: collectDeleteResult,
156
- meta: {
157
- count: collectDeleteResult.length,
158
- time_ms: Math.round((endTime - startTime) * 100) / 100,
159
- },
160
- };
207
+ if (flags.hasSet) {
208
+ this.validateSetClauseValueVariables(parseResult.query, params);
161
209
  }
162
- // 2.5. Check for CREATE...RETURN pattern (needs special handling)
163
- const createReturnResult = this.tryCreateReturnExecution(parseResult.query, params);
164
- if (createReturnResult !== null) {
165
- const endTime = performance.now();
166
- return {
167
- success: true,
168
- data: createReturnResult,
169
- meta: {
170
- count: createReturnResult.length,
171
- time_ms: Math.round((endTime - startTime) * 100) / 100,
172
- },
173
- };
210
+ if (flags.hasWith || flags.hasReturn) {
211
+ this.validateOrderByVariables(parseResult.query, params);
174
212
  }
175
- // 2.5. Check for MERGE with ON CREATE SET / ON MATCH SET (needs special handling)
176
- const mergeResult = this.tryMergeExecution(parseResult.query, params);
177
- if (mergeResult !== null) {
213
+ // Helper to return successful result
214
+ const makeResult = (data) => {
178
215
  const endTime = performance.now();
179
216
  return {
180
217
  success: true,
181
- data: mergeResult,
218
+ data,
182
219
  meta: {
183
- count: mergeResult.length,
184
- time_ms: Math.round((endTime - startTime) * 100) / 100,
185
- },
186
- };
187
- }
188
- // 2.6. Check for MATCH...WITH...MATCH pattern with bound relationship list
189
- // e.g., MATCH ()-[r1]->()-[r2]->() WITH [r1, r2] AS rs MATCH (a)-[rs*]->(b) RETURN a, b
190
- const boundRelListResult = this.tryBoundRelationshipListExecution(parseResult.query, params);
191
- if (boundRelListResult !== null) {
192
- const endTime = performance.now();
193
- return {
194
- success: true,
195
- data: boundRelListResult,
196
- meta: {
197
- count: boundRelListResult.length,
198
- time_ms: Math.round((endTime - startTime) * 100) / 100,
199
- },
200
- };
201
- }
202
- // 3. Check if this is a pattern that needs multi-phase execution
203
- // (MATCH...CREATE, MATCH...SET, MATCH...DELETE with relationship patterns)
204
- const multiPhaseResult = this.tryMultiPhaseExecution(parseResult.query, params);
205
- if (multiPhaseResult !== null) {
206
- const endTime = performance.now();
207
- return {
208
- success: true,
209
- data: multiPhaseResult,
210
- meta: {
211
- count: multiPhaseResult.length,
220
+ count: data.length,
212
221
  time_ms: Math.round((endTime - startTime) * 100) / 100,
213
222
  },
214
223
  };
224
+ };
225
+ // Dispatch based on pattern (each try* method still validates and may return null)
226
+ switch (pattern) {
227
+ case "PHASED": {
228
+ const result = this.tryPhasedExecution(parseResult.query, params);
229
+ if (result !== null)
230
+ return makeResult(result);
231
+ break;
232
+ }
233
+ case "UNWIND_CREATE": {
234
+ const result = this.tryUnwindCreateExecution(parseResult.query, params);
235
+ if (result !== null)
236
+ return makeResult(result);
237
+ break;
238
+ }
239
+ case "UNWIND_MERGE": {
240
+ const result = this.tryUnwindMergeExecution(parseResult.query, params);
241
+ if (result !== null)
242
+ return makeResult(result);
243
+ break;
244
+ }
245
+ case "COLLECT_UNWIND": {
246
+ const result = this.tryCollectUnwindExecution(parseResult.query, params);
247
+ if (result !== null)
248
+ return makeResult(result);
249
+ break;
250
+ }
251
+ case "COLLECT_DELETE": {
252
+ const result = this.tryCollectDeleteExecution(parseResult.query, params);
253
+ if (result !== null)
254
+ return makeResult(result);
255
+ break;
256
+ }
257
+ case "CREATE_RETURN": {
258
+ const result = this.tryCreateReturnExecution(parseResult.query, params);
259
+ if (result !== null)
260
+ return makeResult(result);
261
+ break;
262
+ }
263
+ case "BOUND_REL_LIST": {
264
+ const result = this.tryBoundRelationshipListExecution(parseResult.query, params);
265
+ if (result !== null)
266
+ return makeResult(result);
267
+ break;
268
+ }
269
+ case "MERGE": {
270
+ const result = this.tryMergeExecution(parseResult.query, params);
271
+ if (result !== null)
272
+ return makeResult(result);
273
+ break;
274
+ }
275
+ case "MULTI_PHASE": {
276
+ const result = this.tryMultiPhaseExecution(parseResult.query, params);
277
+ if (result !== null)
278
+ return makeResult(result);
279
+ break;
280
+ }
281
+ case "FOREACH": {
282
+ const result = this.tryForeachExecution(parseResult.query, params);
283
+ if (result !== null)
284
+ return makeResult(result);
285
+ break;
286
+ }
287
+ // STANDARD falls through to SQL translation below
215
288
  }
216
289
  // 3. Standard single-phase execution: Translate to SQL
217
290
  const translator = new Translator(params);
@@ -250,6 +323,300 @@ export class Executor {
250
323
  }
251
324
  }
252
325
  // ============================================================================
326
+ // Query Classification (Single-Pass)
327
+ // ============================================================================
328
+ /**
329
+ * Single-pass query classifier - scans clauses once to determine execution pattern
330
+ *
331
+ * This replaces the sequential try*Execution() calls that each scanned all clauses.
332
+ * By doing one pass and collecting all needed flags, we avoid O(n*m) complexity
333
+ * where n is number of clauses and m is number of try* methods.
334
+ */
335
+ classifyQuery(query) {
336
+ const flags = {
337
+ // Clause presence
338
+ hasMatch: false,
339
+ hasOptionalMatch: false,
340
+ hasCreate: false,
341
+ hasMerge: false,
342
+ hasSet: false,
343
+ hasDelete: false,
344
+ hasUnwind: false,
345
+ hasWith: false,
346
+ hasReturn: false,
347
+ hasForeach: false,
348
+ // Detailed flags
349
+ mergeHasSetClauses: false,
350
+ mergeHasRelationshipPattern: false,
351
+ mergeHasPathExpressions: false,
352
+ withHasCollect: false,
353
+ matchHasRelationshipPattern: false,
354
+ hasMutations: false,
355
+ // Counts
356
+ mergeCount: 0,
357
+ withCount: 0,
358
+ matchCount: 0,
359
+ // Pre-categorized clauses
360
+ matchClauses: [],
361
+ createClauses: [],
362
+ mergeClauses: [],
363
+ setClauses: [],
364
+ deleteClauses: [],
365
+ withClauses: [],
366
+ unwindClauses: [],
367
+ returnClause: null,
368
+ // Merge details
369
+ firstMergeClause: null,
370
+ // WITH COLLECT detection
371
+ singleWithCollectAlias: null,
372
+ // Bound list detection
373
+ boundListVar: null,
374
+ boundListExprVars: [],
375
+ };
376
+ // Single pass through all clauses
377
+ for (const clause of query.clauses) {
378
+ switch (clause.type) {
379
+ case "MATCH":
380
+ flags.hasMatch = true;
381
+ flags.matchCount++;
382
+ flags.matchClauses.push(clause);
383
+ // Check for relationship patterns
384
+ if (clause.patterns.some(p => this.isRelationshipPattern(p))) {
385
+ flags.matchHasRelationshipPattern = true;
386
+ }
387
+ break;
388
+ case "OPTIONAL_MATCH":
389
+ flags.hasOptionalMatch = true;
390
+ flags.matchCount++;
391
+ // OPTIONAL_MATCH is stored as MatchClause with isOptional flag
392
+ flags.matchClauses.push(clause);
393
+ break;
394
+ case "CREATE":
395
+ flags.hasCreate = true;
396
+ flags.createClauses.push(clause);
397
+ break;
398
+ case "MERGE": {
399
+ flags.hasMerge = true;
400
+ flags.mergeCount++;
401
+ flags.mergeClauses.push(clause);
402
+ if (!flags.firstMergeClause) {
403
+ flags.firstMergeClause = clause;
404
+ }
405
+ // Check for ON CREATE SET / ON MATCH SET
406
+ if (clause.onCreateSet || clause.onMatchSet) {
407
+ flags.mergeHasSetClauses = true;
408
+ }
409
+ // Check for relationship patterns
410
+ if (clause.patterns.some(p => this.isRelationshipPattern(p))) {
411
+ flags.mergeHasRelationshipPattern = true;
412
+ }
413
+ // Check for path expressions
414
+ if (clause.pathExpressions && clause.pathExpressions.length > 0) {
415
+ flags.mergeHasPathExpressions = true;
416
+ }
417
+ break;
418
+ }
419
+ case "SET":
420
+ flags.hasSet = true;
421
+ flags.setClauses.push(clause);
422
+ break;
423
+ case "DELETE":
424
+ flags.hasDelete = true;
425
+ flags.deleteClauses.push(clause);
426
+ break;
427
+ case "UNWIND":
428
+ flags.hasUnwind = true;
429
+ flags.unwindClauses.push(clause);
430
+ break;
431
+ case "WITH": {
432
+ flags.hasWith = true;
433
+ flags.withCount++;
434
+ flags.withClauses.push(clause);
435
+ // Check for COLLECT function
436
+ if (clause.items.length === 1) {
437
+ const item = clause.items[0];
438
+ if (item.expression.type === "function" &&
439
+ item.expression.functionName?.toUpperCase() === "COLLECT" &&
440
+ item.alias) {
441
+ flags.withHasCollect = true;
442
+ flags.singleWithCollectAlias = item.alias;
443
+ }
444
+ }
445
+ // Check for list literal pattern [r1, r2] for bound relationship list detection
446
+ for (const item of clause.items) {
447
+ if (item.alias && item.expression.type === "function" &&
448
+ item.expression.functionName === "LIST" && item.expression.args) {
449
+ flags.boundListVar = item.alias;
450
+ flags.boundListExprVars = [];
451
+ for (const elem of item.expression.args) {
452
+ if (elem.type === "variable" && elem.variable) {
453
+ flags.boundListExprVars.push(elem.variable);
454
+ }
455
+ }
456
+ }
457
+ }
458
+ break;
459
+ }
460
+ case "RETURN":
461
+ flags.hasReturn = true;
462
+ flags.returnClause = clause;
463
+ break;
464
+ case "FOREACH":
465
+ flags.hasForeach = true;
466
+ break;
467
+ }
468
+ }
469
+ // Derived flags
470
+ flags.hasMutations = flags.hasCreate || flags.hasSet || flags.hasDelete;
471
+ // Determine pattern using the collected flags
472
+ const pattern = this.determineQueryPattern(query, flags);
473
+ return { pattern, flags };
474
+ }
475
+ /**
476
+ * Determine execution pattern from collected flags
477
+ * Order matters - patterns are checked in priority order
478
+ */
479
+ determineQueryPattern(query, flags) {
480
+ // 0. FOREACH - Queries with FOREACH clause (checked first, high priority)
481
+ if (flags.hasForeach) {
482
+ return "FOREACH";
483
+ }
484
+ // 1. PHASED - Complex multi-phase queries with phase boundaries
485
+ const phases = this.detectPhases(query);
486
+ const needsMergePhasedExecution = (flags.hasMatch || flags.hasCreate) &&
487
+ flags.hasMerge &&
488
+ !flags.mergeHasSetClauses;
489
+ const needsCreateWithPhasedExecution = flags.hasCreate && flags.hasWith && !flags.hasMatch;
490
+ if (phases.length > 1 || needsMergePhasedExecution || needsCreateWithPhasedExecution) {
491
+ return "PHASED";
492
+ }
493
+ // 2. UNWIND_CREATE - UNWIND + CREATE without MATCH
494
+ if (flags.hasUnwind && flags.hasCreate && !flags.hasMatch) {
495
+ return "UNWIND_CREATE";
496
+ }
497
+ // 3. UNWIND_MERGE - UNWIND + MERGE without MATCH or CREATE
498
+ if (flags.hasUnwind && flags.hasMerge && !flags.hasMatch && !flags.hasCreate) {
499
+ return "UNWIND_MERGE";
500
+ }
501
+ // 4. COLLECT_UNWIND - MATCH + WITH(COLLECT) + UNWIND + RETURN
502
+ if (flags.hasMatch && flags.withHasCollect && flags.hasUnwind && flags.hasReturn) {
503
+ // Additional validation: WITH must have exactly one COLLECT item
504
+ // and UNWIND must reference the COLLECT alias
505
+ const lastUnwind = flags.unwindClauses[flags.unwindClauses.length - 1];
506
+ if (lastUnwind &&
507
+ lastUnwind.expression.type === "variable" &&
508
+ lastUnwind.expression.variable === flags.singleWithCollectAlias) {
509
+ return "COLLECT_UNWIND";
510
+ }
511
+ }
512
+ // 5. COLLECT_DELETE - MATCH + WITH(COLLECT) + DELETE
513
+ if (flags.hasMatch && flags.withHasCollect && flags.hasDelete &&
514
+ flags.deleteClauses[0]?.expressions && flags.deleteClauses[0].expressions.length > 0) {
515
+ return "COLLECT_DELETE";
516
+ }
517
+ // 6. CREATE_RETURN - CREATE + RETURN without MATCH or MERGE
518
+ if (flags.hasCreate && flags.hasReturn && !flags.hasMatch && !flags.hasMerge) {
519
+ return "CREATE_RETURN";
520
+ }
521
+ // 7. BOUND_REL_LIST - MATCH + WITH + MATCH with bound list pattern
522
+ if (flags.matchCount >= 2 && flags.hasWith && flags.boundListVar &&
523
+ flags.boundListExprVars.length > 0 && flags.hasReturn) {
524
+ return "BOUND_REL_LIST";
525
+ }
526
+ // 8. MERGE - MERGE with special handling needs
527
+ if (flags.hasMerge) {
528
+ const hasSpecialMerge = flags.mergeHasRelationshipPattern ||
529
+ flags.mergeHasSetClauses ||
530
+ flags.mergeHasPathExpressions ||
531
+ flags.hasReturn ||
532
+ flags.mergeCount > 1;
533
+ if (hasSpecialMerge) {
534
+ return "MERGE";
535
+ }
536
+ }
537
+ // 10. MULTI_PHASE - MATCH with mutations (CREATE/SET/DELETE)
538
+ if (flags.hasMatch && flags.hasMutations) {
539
+ return "MULTI_PHASE";
540
+ }
541
+ // 10. UNWIND + MATCH where MATCH references UNWIND variables
542
+ // This handles: UNWIND list AS x MATCH (n {prop: x}) RETURN n
543
+ // The MATCH property constraints reference UNWIND variables
544
+ if (flags.hasUnwind && flags.hasMatch) {
545
+ const unwindAliases = new Set();
546
+ for (const clause of query.clauses) {
547
+ if (clause.type === "UNWIND") {
548
+ unwindAliases.add(clause.alias);
549
+ }
550
+ }
551
+ // Check if any MATCH pattern references UNWIND variables
552
+ for (const clause of query.clauses) {
553
+ if (clause.type === "MATCH" || clause.type === "OPTIONAL_MATCH") {
554
+ for (const pattern of clause.patterns) {
555
+ if (this.patternReferencesVariables(pattern, unwindAliases)) {
556
+ return "PHASED";
557
+ }
558
+ }
559
+ }
560
+ }
561
+ }
562
+ // 11. STANDARD - Default SQL translation
563
+ return "STANDARD";
564
+ }
565
+ /**
566
+ * Check if a pattern references any of the given variables
567
+ */
568
+ patternReferencesVariables(pattern, variables) {
569
+ // Check if this is a relationship pattern
570
+ if ('source' in pattern && 'edge' in pattern && 'target' in pattern) {
571
+ const relPattern = pattern;
572
+ return this.nodePropertiesReferenceVariables(relPattern.source, variables) ||
573
+ this.nodePropertiesReferenceVariables(relPattern.target, variables) ||
574
+ this.edgePropertiesReferenceVariables(relPattern.edge, variables);
575
+ }
576
+ else {
577
+ return this.nodePropertiesReferenceVariables(pattern, variables);
578
+ }
579
+ }
580
+ /**
581
+ * Check if node properties reference any of the given variables
582
+ */
583
+ nodePropertiesReferenceVariables(node, variables) {
584
+ if (!node.properties)
585
+ return false;
586
+ for (const value of Object.values(node.properties)) {
587
+ if (this.propertyValueReferencesVariables(value, variables)) {
588
+ return true;
589
+ }
590
+ }
591
+ return false;
592
+ }
593
+ /**
594
+ * Check if edge properties reference any of the given variables
595
+ */
596
+ edgePropertiesReferenceVariables(edge, variables) {
597
+ if (!edge.properties)
598
+ return false;
599
+ for (const value of Object.values(edge.properties)) {
600
+ if (this.propertyValueReferencesVariables(value, variables)) {
601
+ return true;
602
+ }
603
+ }
604
+ return false;
605
+ }
606
+ /**
607
+ * Check if a property value references any of the given variables
608
+ */
609
+ propertyValueReferencesVariables(value, variables) {
610
+ if (value === null || typeof value !== 'object')
611
+ return false;
612
+ if ('type' in value) {
613
+ if (value.type === 'variable' && 'name' in value && typeof value.name === 'string') {
614
+ return variables.has(value.name);
615
+ }
616
+ }
617
+ return false;
618
+ }
619
+ // ============================================================================
253
620
  // Phase-Based Execution
254
621
  // ============================================================================
255
622
  /**
@@ -264,12 +631,8 @@ export class Executor {
264
631
  */
265
632
  tryPhasedExecution(query, params) {
266
633
  const phases = this.detectPhases(query);
267
- // Semantic validation: Check if MERGE tries to use a variable already bound by MATCH
268
- this.validateMergeVariables(query);
269
- // Semantic validation: SET expressions cannot reference undefined variables
270
- this.validateSetClauseValueVariables(query, params);
271
- // Semantic validation: ORDER BY expressions cannot reference undefined or out-of-scope variables
272
- this.validateOrderByVariables(query, params);
634
+ // Note: Semantic validations (validateMergeVariables, validateSetClauseValueVariables,
635
+ // validateOrderByVariables) are now called in execute() before classification
273
636
  // Check if we need phased execution for MATCH + MERGE combinations
274
637
  // These need special handling for proper Cartesian product semantics
275
638
  // BUT: If MERGE has ON CREATE SET or ON MATCH SET, let tryMergeExecution handle it
@@ -761,6 +1124,30 @@ export class Executor {
761
1124
  break;
762
1125
  }
763
1126
  }
1127
+ // Track UNWIND alias as a known variable
1128
+ knownVariables.add(clause.alias);
1129
+ }
1130
+ // Check if MATCH pattern properties reference UNWIND variables - needs phase boundary
1131
+ // This handles: UNWIND list AS x MATCH (n {prop: x}) RETURN n
1132
+ // The MATCH needs to be executed for each UNWIND row, which requires phased execution
1133
+ if ((clause.type === "MATCH" || clause.type === "OPTIONAL_MATCH") && i > 0) {
1134
+ // Collect all UNWIND aliases from previous clauses
1135
+ const unwindAliases = new Set();
1136
+ for (let j = 0; j < i; j++) {
1137
+ const prevClause = clauses[j];
1138
+ if (prevClause.type === "UNWIND") {
1139
+ unwindAliases.add(prevClause.alias);
1140
+ }
1141
+ }
1142
+ // Check if MATCH pattern properties reference any UNWIND variables
1143
+ if (unwindAliases.size > 0) {
1144
+ for (const pattern of clause.patterns) {
1145
+ if (this.patternReferencesVariables(pattern, unwindAliases)) {
1146
+ needsNewPhase = true;
1147
+ break;
1148
+ }
1149
+ }
1150
+ }
764
1151
  }
765
1152
  // Check if MATCH after a WITH with aggregates - needs phase boundary
766
1153
  // This handles patterns like:
@@ -1269,7 +1656,9 @@ export class Executor {
1269
1656
  * Execute a single phase with the given context
1270
1657
  */
1271
1658
  executePhase(clauses, inputContext, params, isLastPhase) {
1272
- let context = cloneContext(inputContext);
1659
+ // Check if this phase contains only read-only clauses
1660
+ const hasWriteClauses = clauses.some(clause => !this.isReadOnlyClause(clause));
1661
+ let context = cloneContext(inputContext, hasWriteClauses);
1273
1662
  for (const clause of clauses) {
1274
1663
  context = this.executeClause(clause, context, params);
1275
1664
  }
@@ -1278,25 +1667,32 @@ export class Executor {
1278
1667
  /**
1279
1668
  * Execute a single clause and update context
1280
1669
  */
1670
+ isReadOnlyClause(clause) {
1671
+ // These clauses are read-only and don't modify the context structure
1672
+ return clause.type === "MATCH" || clause.type === "OPTIONAL_MATCH" || clause.type === "RETURN";
1673
+ }
1281
1674
  executeClause(clause, context, params) {
1675
+ // For read-only clauses, avoid the expensive row cloning operation
1676
+ const cloneRows = !this.isReadOnlyClause(clause);
1677
+ const newContext = cloneContext(context, cloneRows);
1282
1678
  switch (clause.type) {
1283
1679
  case "CREATE":
1284
- return this.executeCreateClause(clause, context, params);
1680
+ return this.executeCreateClause(clause, newContext, params);
1285
1681
  case "MERGE":
1286
- return this.executeMergeClause(clause, context, params);
1682
+ return this.executeMergeClause(clause, newContext, params);
1287
1683
  case "WITH":
1288
- return this.executeWithClause(clause, context, params);
1684
+ return this.executeWithClause(clause, newContext, params);
1289
1685
  case "UNWIND":
1290
- return this.executeUnwindClause(clause, context, params);
1686
+ return this.executeUnwindClause(clause, newContext, params);
1291
1687
  case "MATCH":
1292
1688
  case "OPTIONAL_MATCH":
1293
- return this.executeMatchClause(clause, context, params);
1689
+ return this.executeMatchClause(clause, newContext, params);
1294
1690
  case "RETURN":
1295
- return this.executeReturnClause(clause, context, params);
1691
+ return this.executeReturnClause(clause, newContext, params);
1296
1692
  case "SET":
1297
- return this.executeSetClause(clause, context, params);
1693
+ return this.executeSetClause(clause, newContext, params);
1298
1694
  case "DELETE":
1299
- return this.executeDeleteClause(clause, context, params);
1695
+ return this.executeDeleteClause(clause, newContext, params);
1300
1696
  default:
1301
1697
  // For unsupported clause types, return context unchanged
1302
1698
  return context;
@@ -1424,12 +1820,12 @@ export class Executor {
1424
1820
  // Build query to find existing matching nodes
1425
1821
  const conditions = [];
1426
1822
  const conditionParams = [];
1427
- // Label condition
1823
+ // Label condition (uses primary label index with fallback for secondary labels)
1428
1824
  if (pattern.label) {
1429
1825
  const labels = Array.isArray(pattern.label) ? pattern.label : [pattern.label];
1430
1826
  for (const label of labels) {
1431
- conditions.push(`EXISTS (SELECT 1 FROM json_each(label) WHERE value = ?)`);
1432
- conditionParams.push(label);
1827
+ conditions.push(`(json_extract(label, '$[0]') = ? OR EXISTS (SELECT 1 FROM json_each(label) WHERE value = ? AND json_extract(label, '$[0]') != ?))`);
1828
+ conditionParams.push(label, label, label);
1433
1829
  }
1434
1830
  }
1435
1831
  // Property conditions
@@ -1448,9 +1844,7 @@ export class Executor {
1448
1844
  for (const row of findResult.rows) {
1449
1845
  const outputRow = new Map(inputRow);
1450
1846
  if (pattern.variable) {
1451
- const nodeProps = typeof row.properties === "string"
1452
- ? JSON.parse(row.properties)
1453
- : row.properties;
1847
+ const nodeProps = this.getNodeProperties(typeof row.id === "string" ? row.id : "", typeof row.properties === "string" || (typeof row.properties === "object" && row.properties !== null) ? row.properties : "{}");
1454
1848
  const nodeObj = { ...nodeProps, _nf_id: row.id };
1455
1849
  outputRow.set(pattern.variable, nodeObj);
1456
1850
  }
@@ -1544,9 +1938,7 @@ export class Executor {
1544
1938
  for (const edgeRow of findResult.rows) {
1545
1939
  const outputRow = new Map(inputRow);
1546
1940
  if (pattern.edge.variable) {
1547
- const props = typeof edgeRow.properties === "string"
1548
- ? JSON.parse(edgeRow.properties)
1549
- : edgeRow.properties;
1941
+ const props = this.getEdgeProperties(edgeRow.id, edgeRow.properties);
1550
1942
  // Include _nf_start and _nf_end for startNode() and endNode() functions
1551
1943
  // Use the actual source/target from the found edge (may be reversed for undirected)
1552
1944
  outputRow.set(pattern.edge.variable, {
@@ -1983,7 +2375,9 @@ export class Executor {
1983
2375
  // Get variables referenced in WHERE that are context variables
1984
2376
  const whereReferencesContext = clause.where ?
1985
2377
  this.whereReferencesContextVars(clause.where, contextVarNames, introducedVars) : false;
1986
- if (boundVars.size > 0 || whereReferencesContext) {
2378
+ // Check if pattern properties reference context variables (e.g., MATCH (n {prop: x}) where x is from UNWIND)
2379
+ const patternPropertiesReferenceContext = clause.patterns.some(pattern => this.patternReferencesVariables(pattern, contextVarNames));
2380
+ if (boundVars.size > 0 || whereReferencesContext || patternPropertiesReferenceContext) {
1987
2381
  // For complex patterns (multi-hop, anonymous nodes), use SQL translation with
1988
2382
  // constraints for bound variables. This handles patterns like:
1989
2383
  // WITH me, you MATCH (me)-[r1:ATE]->()<-[r2:ATE]-(you)
@@ -2122,9 +2516,7 @@ export class Executor {
2122
2516
  for (const row of edgeResult.rows) {
2123
2517
  const matchRow = new Map();
2124
2518
  if (edgeVar) {
2125
- const edgeProps = typeof row.properties === "string"
2126
- ? JSON.parse(row.properties)
2127
- : row.properties;
2519
+ const edgeProps = this.getEdgeProperties(row.id, row.properties);
2128
2520
  matchRow.set(edgeVar, { ...edgeProps, _nf_id: row.id });
2129
2521
  }
2130
2522
  results.push(matchRow);
@@ -2159,9 +2551,55 @@ export class Executor {
2159
2551
  // Transform the clause to substitute context variable references with parameters
2160
2552
  // This handles cases like: WITH x AS foo MATCH (n) WHERE n.id = foo
2161
2553
  const transformedClause = this.transformClauseForContext(clause, contextVarNames);
2554
+ // For OPTIONAL MATCH with bound source variable BUT new target variable, we need
2555
+ // to prepend a regular MATCH clause to bind the source first. This ensures the
2556
+ // translator includes the source node in the FROM clause.
2557
+ // e.g., "OPTIONAL MATCH (n)-[r]->() WHERE id(n) = ?" becomes
2558
+ // "MATCH (n) WHERE id(n) = ? OPTIONAL MATCH (n)-[r]->()"
2559
+ // Note: When BOTH source and target are bound, we don't need prefix MATCHes - the
2560
+ // WHERE constraints added by transformClauseForContext are sufficient.
2561
+ const prefixMatchClauses = [];
2562
+ if (clause.type === "OPTIONAL_MATCH") {
2563
+ for (const pattern of clause.patterns) {
2564
+ if ('source' in pattern && 'edge' in pattern && 'target' in pattern) {
2565
+ const relPattern = pattern;
2566
+ const sourceVar = relPattern.source?.variable;
2567
+ const targetVar = relPattern.target?.variable;
2568
+ const sourceIsBound = sourceVar && contextVarNames.has(sourceVar);
2569
+ const targetIsBound = targetVar && contextVarNames.has(targetVar);
2570
+ // Only add prefix MATCH when source is bound but target is new
2571
+ // This handles: WITH n OPTIONAL MATCH (n)-[r]->() where n is bound, target is new
2572
+ if (sourceIsBound && !targetIsBound) {
2573
+ const nodeId = this.extractNodeId(inputRow.get(sourceVar));
2574
+ if (nodeId) {
2575
+ prefixMatchClauses.push({
2576
+ type: "MATCH",
2577
+ patterns: [{
2578
+ variable: sourceVar
2579
+ }],
2580
+ where: {
2581
+ type: "comparison",
2582
+ left: {
2583
+ type: "function",
2584
+ functionName: "ID",
2585
+ args: [{ type: "variable", variable: sourceVar }]
2586
+ },
2587
+ operator: "=",
2588
+ right: {
2589
+ type: "parameter",
2590
+ name: `_ctx_${sourceVar}`
2591
+ }
2592
+ }
2593
+ });
2594
+ }
2595
+ }
2596
+ }
2597
+ }
2598
+ }
2162
2599
  // Build a MATCH + RETURN query for all pattern variables
2163
2600
  const matchQuery = {
2164
2601
  clauses: [
2602
+ ...prefixMatchClauses,
2165
2603
  transformedClause,
2166
2604
  {
2167
2605
  type: "RETURN",
@@ -2170,9 +2608,17 @@ export class Executor {
2170
2608
  ],
2171
2609
  };
2172
2610
  // Create params with context values prefixed with _ctx_
2611
+ // For node variables, extract the node ID for use in id() comparisons
2173
2612
  const mergedParams = { ...params };
2174
2613
  for (const [key, value] of inputRow) {
2175
- mergedParams[`_ctx_${key}`] = value;
2614
+ // If value is a node object, extract its ID for the parameter
2615
+ const nodeId = this.extractNodeId(value);
2616
+ if (nodeId) {
2617
+ mergedParams[`_ctx_${key}`] = nodeId;
2618
+ }
2619
+ else {
2620
+ mergedParams[`_ctx_${key}`] = value;
2621
+ }
2176
2622
  }
2177
2623
  // Translate to SQL with merged parameters
2178
2624
  const translator = new Translator(mergedParams);
@@ -2237,19 +2683,126 @@ export class Executor {
2237
2683
  * Transform a MATCH clause to substitute context variable references with parameter references.
2238
2684
  * This converts WHERE conditions like `n.id = foo` (where foo is from context)
2239
2685
  * to `n.id = $_ctx_foo` so the translator can handle it.
2686
+ * Also transforms pattern properties like {name: foo} to use context parameters.
2687
+ *
2688
+ * For bound variables that appear as sources in relationship patterns, we add
2689
+ * WHERE constraints like `id(n) = $_ctx_n` so the translator knows to include
2690
+ * the node in the FROM clause.
2240
2691
  */
2241
2692
  transformClauseForContext(clause, contextVars) {
2242
- if (!clause.where) {
2243
- return clause;
2244
- }
2245
2693
  // Deep clone the clause
2246
2694
  const transformed = JSON.parse(JSON.stringify(clause));
2695
+ // Transform pattern properties to use parameter references for context variables
2696
+ // This handles MATCH (n:Label {prop: contextVar}) from UNWIND
2697
+ for (const pattern of transformed.patterns) {
2698
+ this.transformPatternPropertiesForContext(pattern, contextVars);
2699
+ }
2247
2700
  // Transform WHERE condition to use parameter references for context variables
2248
2701
  if (transformed.where) {
2249
2702
  transformed.where = this.transformWhereForContext(transformed.where, contextVars);
2250
2703
  }
2704
+ // For OPTIONAL MATCH with bound source but new target, add WHERE constraints
2705
+ // to ensure the source node is properly included in the SQL FROM clause.
2706
+ // This handles: WITH n OPTIONAL MATCH (n)-[r:REL]->() where n is bound but target is new
2707
+ // Note: When BOTH source and target are bound, we don't add WHERE constraints here
2708
+ // because the translator can handle that case and adding constraints causes bad SQL
2709
+ // (referencing tables before they're joined).
2710
+ const boundSourceVars = new Set();
2711
+ for (const pattern of transformed.patterns) {
2712
+ if ('source' in pattern && 'edge' in pattern && 'target' in pattern) {
2713
+ const relPattern = pattern;
2714
+ const sourceVar = relPattern.source?.variable;
2715
+ const targetVar = relPattern.target?.variable;
2716
+ const sourceIsBound = sourceVar && contextVars.has(sourceVar);
2717
+ const targetIsBound = targetVar && contextVars.has(targetVar);
2718
+ // Only add constraint when source is bound but target is new
2719
+ if (sourceIsBound && !targetIsBound) {
2720
+ boundSourceVars.add(sourceVar);
2721
+ }
2722
+ }
2723
+ }
2724
+ // Add WHERE constraints for bound source variables: id(varName) = $_ctx_varName
2725
+ if (boundSourceVars.size > 0) {
2726
+ const idConditions = [];
2727
+ for (const varName of boundSourceVars) {
2728
+ idConditions.push({
2729
+ type: "comparison",
2730
+ left: {
2731
+ type: "function",
2732
+ functionName: "ID",
2733
+ args: [{ type: "variable", variable: varName }]
2734
+ },
2735
+ operator: "=",
2736
+ right: {
2737
+ type: "parameter",
2738
+ name: `_ctx_${varName}`
2739
+ }
2740
+ });
2741
+ }
2742
+ // Combine with existing WHERE using conditions array
2743
+ if (transformed.where) {
2744
+ // Combine all conditions into a single AND clause
2745
+ transformed.where = {
2746
+ type: "and",
2747
+ conditions: [...idConditions, transformed.where]
2748
+ };
2749
+ }
2750
+ else {
2751
+ // Just use the ID conditions
2752
+ if (idConditions.length === 1) {
2753
+ transformed.where = idConditions[0];
2754
+ }
2755
+ else {
2756
+ transformed.where = {
2757
+ type: "and",
2758
+ conditions: idConditions
2759
+ };
2760
+ }
2761
+ }
2762
+ }
2251
2763
  return transformed;
2252
2764
  }
2765
+ /**
2766
+ * Transform pattern properties to use context parameter references
2767
+ */
2768
+ transformPatternPropertiesForContext(pattern, contextVars) {
2769
+ // Check if this is a relationship pattern with source/edge/target
2770
+ if ('source' in pattern && 'edge' in pattern && 'target' in pattern) {
2771
+ this.transformNodePropertiesForContext(pattern.source, contextVars);
2772
+ this.transformNodePropertiesForContext(pattern.target, contextVars);
2773
+ if (pattern.edge.properties) {
2774
+ this.transformPropertiesMapForContext(pattern.edge.properties, contextVars);
2775
+ }
2776
+ }
2777
+ else {
2778
+ // Simple node pattern
2779
+ this.transformNodePropertiesForContext(pattern, contextVars);
2780
+ }
2781
+ }
2782
+ /**
2783
+ * Transform node properties to use context parameter references
2784
+ */
2785
+ transformNodePropertiesForContext(node, contextVars) {
2786
+ if (node.properties) {
2787
+ this.transformPropertiesMapForContext(node.properties, contextVars);
2788
+ }
2789
+ }
2790
+ /**
2791
+ * Transform a properties map to use context parameter references
2792
+ */
2793
+ transformPropertiesMapForContext(properties, contextVars) {
2794
+ for (const [key, value] of Object.entries(properties)) {
2795
+ if (value && typeof value === 'object' && 'type' in value &&
2796
+ value.type === "variable" && 'name' in value &&
2797
+ typeof value.name === 'string' && contextVars.has(value.name)) {
2798
+ // Replace with parameter reference
2799
+ properties[key] = {
2800
+ type: "parameter",
2801
+ name: `_ctx_${value.name}`,
2802
+ };
2803
+ }
2804
+ }
2805
+ }
2253
2806
  /**
2254
2807
  * Transform a WHERE condition to substitute context variable references with parameter references
2255
2808
  */
@@ -2406,56 +2959,24 @@ export class Executor {
2406
2959
  }
2407
2960
  // Follow the sequence of relationships to find the path endpoints
2408
2961
  // Each relationship in the list is either an edge object or edge ID
2962
+ // Batch collect all edge IDs first
2963
+ const edgeIds = [];
2964
+ for (const rel of relList) {
2965
+ const edgeId = this.extractEdgeId(rel);
2966
+ if (edgeId)
2967
+ edgeIds.push(edgeId);
2968
+ }
2969
+ // Batch fetch all edge info in a single query
2970
+ const edgeInfoMap = this.batchGetEdgeInfo(edgeIds);
2409
2971
  let currentNodeId = null;
2410
2972
  let firstNodeId = null;
2411
2973
  let lastNodeId = null;
2412
2974
  let valid = true;
2413
2975
  for (let i = 0; i < relList.length; i++) {
2414
2976
  const rel = relList[i];
2415
- // Extract edge info - could be object with id, source_id, target_id or string ID
2416
- let edgeInfo = null;
2417
- if (typeof rel === "object" && rel !== null) {
2418
- // Edge object from MATCH - may have _nf_id instead of id
2419
- const relObj = rel;
2420
- const edgeId = (relObj._nf_id || relObj.id);
2421
- if (edgeId) {
2422
- // Look up the edge to get source/target
2423
- const edgeResult = this.db.execute("SELECT id, source_id, target_id FROM edges WHERE id = ?", [edgeId]);
2424
- if (edgeResult.rows.length > 0) {
2425
- const row = edgeResult.rows[0];
2426
- edgeInfo = {
2427
- id: row.id,
2428
- source_id: row.source_id,
2429
- target_id: row.target_id
2430
- };
2431
- }
2432
- }
2433
- }
2434
- else if (typeof rel === "string") {
2435
- // Could be a JSON string like '{"_nf_id":"uuid"}' or a raw UUID string
2436
- let edgeId = null;
2437
- try {
2438
- const parsed = JSON.parse(rel);
2439
- if (typeof parsed === "object" && parsed !== null) {
2440
- edgeId = (parsed._nf_id || parsed.id);
2441
- }
2442
- }
2443
- catch {
2444
- // Not JSON, assume it's a raw ID
2445
- edgeId = rel;
2446
- }
2447
- if (edgeId) {
2448
- const edgeResult = this.db.execute("SELECT id, source_id, target_id FROM edges WHERE id = ?", [edgeId]);
2449
- if (edgeResult.rows.length > 0) {
2450
- const row = edgeResult.rows[0];
2451
- edgeInfo = {
2452
- id: row.id,
2453
- source_id: row.source_id,
2454
- target_id: row.target_id
2455
- };
2456
- }
2457
- }
2458
- }
2977
+ // Extract edge ID and get info from batch result
2978
+ const edgeId = this.extractEdgeId(rel);
2979
+ const edgeInfo = edgeId ? edgeInfoMap.get(edgeId) : null;
2459
2980
  if (!edgeInfo) {
2460
2981
  valid = false;
2461
2982
  break;
@@ -2533,12 +3054,8 @@ export class Executor {
2533
3054
  const firstNode = firstNodeResult.rows[0];
2534
3055
  const lastNode = lastNodeResult.rows[0];
2535
3056
  // Format nodes like the translator does (with _nf_id embedded)
2536
- const firstProps = typeof firstNode.properties === "string"
2537
- ? JSON.parse(firstNode.properties)
2538
- : firstNode.properties;
2539
- const lastProps = typeof lastNode.properties === "string"
2540
- ? JSON.parse(lastNode.properties)
2541
- : lastNode.properties;
3057
+ const firstProps = this.getNodeProperties(firstNode.id, firstNode.properties);
3058
+ const lastProps = this.getNodeProperties(lastNode.id, lastNode.properties);
2542
3059
  outputRow.set(boundInfo.sourceVar, { ...firstProps, _nf_id: firstNode.id });
2543
3060
  outputRow.set(boundInfo.targetVar, { ...lastProps, _nf_id: lastNode.id });
2544
3061
  newRows.push(outputRow);
@@ -2712,6 +3229,8 @@ export class Executor {
2712
3229
  if (assignment.property && assignment.value) {
2713
3230
  const value = this.evaluateExpressionInRow(assignment.value, row, params);
2714
3231
  this.db.execute(`UPDATE nodes SET properties = json_set(properties, '$.${assignment.property}', json(?)) WHERE id = ?`, [JSON.stringify(value), nodeId]);
3232
+ // Invalidate cache after UPDATE
3233
+ this.invalidatePropertyCache(nodeId);
2715
3234
  }
2716
3235
  }
2717
3236
  }
@@ -2723,7 +3242,9 @@ export class Executor {
2723
3242
  executeDeleteClause(clause, context, params) {
2724
3243
  for (const row of context.rows) {
2725
3244
  for (const variable of clause.variables) {
2726
- const id = row.get(variable);
3245
+ const value = row.get(variable);
3246
+ // Extract node/edge ID from the value (could be object with _nf_id or string ID)
3247
+ const id = this.extractNodeId(value);
2727
3248
  if (!id)
2728
3249
  continue;
2729
3250
  if (clause.detach) {
@@ -2899,6 +3420,27 @@ export class Executor {
2899
3420
  return null;
2900
3421
  }
2901
3422
  }
3423
+ case "reduce": {
3424
+ // Evaluate reduce expression: reduce(acc = init, x IN list | expr)
3425
+ const listValue = this.evaluateExpressionInRow(expr.listExpr, row, params);
3426
+ if (!Array.isArray(listValue))
3427
+ return null;
3428
+ // Start with the initial value
3429
+ let accumulator = this.evaluateExpressionInRow(expr.initialValue, row, params);
3430
+ for (const item of listValue) {
3431
+ // Create a new row with both the accumulator and loop variable
3432
+ const itemRow = new Map(row);
3433
+ if (expr.accumulator) {
3434
+ itemRow.set(expr.accumulator, accumulator);
3435
+ }
3436
+ if (expr.variable) {
3437
+ itemRow.set(expr.variable, item);
3438
+ }
3439
+ // Evaluate the reduce expression to get the new accumulator value
3440
+ accumulator = this.evaluateExpressionInRow(expr.reduceExpr, itemRow, params);
3441
+ }
3442
+ return accumulator;
3443
+ }
2902
3444
  case "comparison": {
2903
3445
  // Evaluate comparison expression: left op right
2904
3446
  const left = this.evaluateExpressionInRow(expr.left, row, params);
@@ -3007,7 +3549,12 @@ export class Executor {
3007
3549
  }
3008
3550
  if (!edgeId)
3009
3551
  return null;
3010
- // Look up edge type from database
3552
+ // Check edge info cache first (populated by batchGetEdgeInfo)
3553
+ const cachedInfo = this.edgeInfoCache.get(edgeId);
3554
+ if (cachedInfo) {
3555
+ return cachedInfo.type;
3556
+ }
3557
+ // Fall back to database lookup
3011
3558
  const result = this.db.execute("SELECT type FROM edges WHERE id = ?", [edgeId]);
3012
3559
  if (result.rows.length > 0) {
3013
3560
  return result.rows[0].type;
@@ -3026,10 +3573,17 @@ export class Executor {
3026
3573
  startNodeId = edgeObj._nf_start;
3027
3574
  }
3028
3575
  else if ("_nf_id" in edgeObj) {
3029
- // Look up edge from database to get source_id
3030
- const edgeResult = this.db.execute("SELECT source_id FROM edges WHERE id = ?", [edgeObj._nf_id]);
3031
- if (edgeResult.rows.length > 0) {
3032
- startNodeId = edgeResult.rows[0].source_id;
3576
+ // Check edge info cache first (populated by batchGetEdgeInfo)
3577
+ const cachedInfo = this.edgeInfoCache.get(edgeObj._nf_id);
3578
+ if (cachedInfo) {
3579
+ startNodeId = cachedInfo.source_id;
3580
+ }
3581
+ else {
3582
+ // Fall back to database lookup
3583
+ const edgeResult = this.db.execute("SELECT source_id FROM edges WHERE id = ?", [edgeObj._nf_id]);
3584
+ if (edgeResult.rows.length > 0) {
3585
+ startNodeId = edgeResult.rows[0].source_id;
3586
+ }
3033
3587
  }
3034
3588
  }
3035
3589
  }
@@ -3038,9 +3592,7 @@ export class Executor {
3038
3592
  // Look up node from database
3039
3593
  const nodeResult = this.db.execute("SELECT id, properties FROM nodes WHERE id = ?", [startNodeId]);
3040
3594
  if (nodeResult.rows.length > 0) {
3041
- const nodeProps = typeof nodeResult.rows[0].properties === "string"
3042
- ? JSON.parse(nodeResult.rows[0].properties)
3043
- : nodeResult.rows[0].properties;
3595
+ const nodeProps = this.getNodeProperties(nodeResult.rows[0].id, nodeResult.rows[0].properties);
3044
3596
  return { ...nodeProps, _nf_id: nodeResult.rows[0].id };
3045
3597
  }
3046
3598
  return null;
@@ -3057,10 +3609,17 @@ export class Executor {
3057
3609
  endNodeId = edgeObj._nf_end;
3058
3610
  }
3059
3611
  else if ("_nf_id" in edgeObj) {
3060
- // Look up edge from database to get target_id
3061
- const edgeResult = this.db.execute("SELECT target_id FROM edges WHERE id = ?", [edgeObj._nf_id]);
3062
- if (edgeResult.rows.length > 0) {
3063
- endNodeId = edgeResult.rows[0].target_id;
3612
+ // Check edge info cache first (populated by batchGetEdgeInfo)
3613
+ const cachedInfo = this.edgeInfoCache.get(edgeObj._nf_id);
3614
+ if (cachedInfo) {
3615
+ endNodeId = cachedInfo.target_id;
3616
+ }
3617
+ else {
3618
+ // Fall back to database lookup
3619
+ const edgeResult = this.db.execute("SELECT target_id FROM edges WHERE id = ?", [edgeObj._nf_id]);
3620
+ if (edgeResult.rows.length > 0) {
3621
+ endNodeId = edgeResult.rows[0].target_id;
3622
+ }
3064
3623
  }
3065
3624
  }
3066
3625
  }
@@ -3069,9 +3628,7 @@ export class Executor {
3069
3628
  // Look up node from database
3070
3629
  const nodeResult = this.db.execute("SELECT id, properties FROM nodes WHERE id = ?", [endNodeId]);
3071
3630
  if (nodeResult.rows.length > 0) {
3072
- const nodeProps = typeof nodeResult.rows[0].properties === "string"
3073
- ? JSON.parse(nodeResult.rows[0].properties)
3074
- : nodeResult.rows[0].properties;
3631
+ const nodeProps = this.getNodeProperties(nodeResult.rows[0].id, nodeResult.rows[0].properties);
3075
3632
  return { ...nodeProps, _nf_id: nodeResult.rows[0].id };
3076
3633
  }
3077
3634
  return null;
@@ -3558,27 +4115,66 @@ export class Executor {
3558
4115
  // Also collect values for WITH aggregates (can be numbers or objects for collect)
3559
4116
  const withAggregateValues = new Map();
3560
4117
  this.db.transaction(() => {
3561
- for (const combination of combinations) {
4118
+ // Collect all node inserts for batching
4119
+ const nodeInserts = [];
4120
+ // Collect all edge inserts for batching
4121
+ const edgeInserts = [];
4122
+ // Cache edge info for RETURN lookups before batch insert
4123
+ const pendingEdgeInfo = new Map();
4124
+ for (let comboIndex = 0; comboIndex < combinations.length; comboIndex++) {
4125
+ const combination = combinations[comboIndex];
3562
4126
  // Build a map of unwind variable -> current value
3563
4127
  const unwindContext = {};
3564
4128
  for (let i = 0; i < unwindClauses.length; i++) {
3565
4129
  unwindContext[unwindClauses[i].alias] = combination[i];
3566
4130
  }
3567
- // Execute CREATE with the unwind context
3568
- const createdIds = new Map();
3569
4131
  for (const createClause of createClauses) {
3570
4132
  for (const pattern of createClause.patterns) {
3571
- if (this.isRelationshipPattern(pattern)) {
3572
- this.executeCreateRelationshipPatternWithUnwind(pattern, createdIds, params, unwindContext);
3573
- }
3574
- else {
4133
+ if (!this.isRelationshipPattern(pattern)) {
4134
+ // Only collect node patterns in this pass; relationships handled after nodes are inserted
3575
4135
  const id = crypto.randomUUID();
3576
4136
  const labelJson = this.normalizeLabelToJson(pattern.label);
3577
4137
  const props = this.resolvePropertiesWithUnwind(pattern.properties || {}, params, unwindContext);
3578
- this.db.execute("INSERT INTO nodes (id, label, properties) VALUES (?, ?, ?)", [id, labelJson, JSON.stringify(props)]);
3579
- if (pattern.variable) {
3580
- createdIds.set(pattern.variable, id);
3581
- }
4138
+ nodeInserts.push({
4139
+ id,
4140
+ labelJson,
4141
+ propsJson: JSON.stringify(props),
4142
+ variable: pattern.variable,
4143
+ combinationIndex: comboIndex
4144
+ });
4145
+ }
4146
+ }
4147
+ }
4148
+ }
4149
+ // Batch insert nodes (cap at 500 rows per statement)
4150
+ const BATCH_SIZE = 500;
4151
+ for (let i = 0; i < nodeInserts.length; i += BATCH_SIZE) {
4152
+ const batch = nodeInserts.slice(i, i + BATCH_SIZE);
4153
+ const placeholders = batch.map(() => '(?, ?, ?)').join(',');
4154
+ const values = batch.flatMap(insert => [insert.id, insert.labelJson, insert.propsJson]);
4155
+ this.db.execute(`INSERT INTO nodes (id, label, properties) VALUES ${placeholders}`, values);
4156
+ }
4157
+ // Process each combination with batched inserts
4158
+ for (let comboIndex = 0; comboIndex < combinations.length; comboIndex++) {
4159
+ const combination = combinations[comboIndex];
4160
+ // Build a map of unwind variable -> current value
4161
+ const unwindContext = {};
4162
+ for (let i = 0; i < unwindClauses.length; i++) {
4163
+ unwindContext[unwindClauses[i].alias] = combination[i];
4164
+ }
4165
+ // Execute CREATE with the unwind context
4166
+ const createdIds = new Map();
4167
+ // Add nodes from batch insert to createdIds
4168
+ for (const insert of nodeInserts) {
4169
+ if (insert.combinationIndex === comboIndex && insert.variable) {
4170
+ createdIds.set(insert.variable, insert.id);
4171
+ }
4172
+ }
4173
+ // Handle relationships (they may reference nodes created in this combination)
4174
+ for (const createClause of createClauses) {
4175
+ for (const pattern of createClause.patterns) {
4176
+ if (this.isRelationshipPattern(pattern)) {
4177
+ this.executeCreateRelationshipPatternWithUnwind(pattern, createdIds, params, unwindContext, edgeInserts, pendingEdgeInfo);
3582
4178
  }
3583
4179
  }
3584
4180
  }
@@ -3602,23 +4198,37 @@ export class Executor {
3602
4198
  let value;
3603
4199
  const id = createdIds.get(aggInfo.argVariable);
3604
4200
  if (id) {
3605
- let result = this.db.execute("SELECT properties FROM nodes WHERE id = ?", [id]);
3606
- if (result.rows.length === 0) {
3607
- result = this.db.execute("SELECT properties FROM edges WHERE id = ?", [id]);
3608
- }
3609
- if (result.rows.length > 0) {
3610
- const props = typeof result.rows[0].properties === "string"
3611
- ? JSON.parse(result.rows[0].properties)
3612
- : result.rows[0].properties;
4201
+ // Check pendingEdgeInfo first (edges not yet batch-inserted)
4202
+ const pendingEdge = pendingEdgeInfo.get(id);
4203
+ if (pendingEdge) {
4204
+ const props = pendingEdge.properties;
3613
4205
  if (aggInfo.argProperty) {
3614
- // Collect property value
3615
4206
  value = props[aggInfo.argProperty];
3616
4207
  }
3617
4208
  else {
3618
- // Collect the whole node object (for collect(n))
3619
4209
  value = props;
3620
4210
  }
3621
4211
  }
4212
+ else {
4213
+ let result = this.db.execute("SELECT id, properties FROM nodes WHERE id = ?", [id]);
4214
+ let isNode = result.rows.length > 0;
4215
+ if (!isNode) {
4216
+ result = this.db.execute("SELECT id, properties FROM edges WHERE id = ?", [id]);
4217
+ }
4218
+ if (result.rows.length > 0) {
4219
+ const props = isNode
4220
+ ? this.getNodeProperties(result.rows[0].id, result.rows[0].properties)
4221
+ : this.getEdgeProperties(result.rows[0].id, result.rows[0].properties);
4222
+ if (aggInfo.argProperty) {
4223
+ // Collect property value
4224
+ value = props[aggInfo.argProperty];
4225
+ }
4226
+ else {
4227
+ // Collect the whole node object (for collect(n))
4228
+ value = props;
4229
+ }
4230
+ }
4231
+ }
3622
4232
  }
3623
4233
  if (value !== undefined) {
3624
4234
  if (!withAggregateValues.has(alias)) {
@@ -3643,17 +4253,25 @@ export class Executor {
3643
4253
  const property = arg.property;
3644
4254
  const id = createdIds.get(variable);
3645
4255
  if (id) {
3646
- // Try nodes first, then edges
3647
- let result = this.db.execute("SELECT properties FROM nodes WHERE id = ?", [id]);
3648
- if (result.rows.length === 0) {
3649
- // Try edges table
3650
- result = this.db.execute("SELECT properties FROM edges WHERE id = ?", [id]);
4256
+ // Check pendingEdgeInfo first (edges not yet batch-inserted)
4257
+ const pendingEdge = pendingEdgeInfo.get(id);
4258
+ if (pendingEdge) {
4259
+ value = pendingEdge.properties[property];
3651
4260
  }
3652
- if (result.rows.length > 0) {
3653
- const props = typeof result.rows[0].properties === "string"
3654
- ? JSON.parse(result.rows[0].properties)
3655
- : result.rows[0].properties;
3656
- value = props[property];
4261
+ else {
4262
+ // Try nodes first, then edges
4263
+ let result = this.db.execute("SELECT id, properties FROM nodes WHERE id = ?", [id]);
4264
+ let isNode = result.rows.length > 0;
4265
+ if (!isNode) {
4266
+ // Try edges table
4267
+ result = this.db.execute("SELECT id, properties FROM edges WHERE id = ?", [id]);
4268
+ }
4269
+ if (result.rows.length > 0) {
4270
+ const props = isNode
4271
+ ? this.getNodeProperties(result.rows[0].id, result.rows[0].properties)
4272
+ : this.getEdgeProperties(result.rows[0].id, result.rows[0].properties);
4273
+ value = props[property];
4274
+ }
3657
4275
  }
3658
4276
  }
3659
4277
  }
@@ -3688,9 +4306,7 @@ export class Executor {
3688
4306
  if (nodeResult.rows.length > 0) {
3689
4307
  const row = nodeResult.rows[0];
3690
4308
  // Neo4j 3.5 format: return properties directly
3691
- resultRow[alias] = typeof row.properties === "string"
3692
- ? JSON.parse(row.properties)
3693
- : row.properties;
4309
+ resultRow[alias] = this.getNodeProperties(row.id, row.properties);
3694
4310
  }
3695
4311
  }
3696
4312
  }
@@ -3700,17 +4316,25 @@ export class Executor {
3700
4316
  const property = item.expression.property;
3701
4317
  const id = createdIds.get(variable);
3702
4318
  if (id) {
3703
- // Try nodes first, then edges
3704
- let nodeResult = this.db.execute("SELECT properties FROM nodes WHERE id = ?", [id]);
3705
- if (nodeResult.rows.length === 0) {
3706
- // Try edges table
3707
- nodeResult = this.db.execute("SELECT properties FROM edges WHERE id = ?", [id]);
4319
+ // Check pendingEdgeInfo first (edges not yet batch-inserted)
4320
+ const pendingEdge = pendingEdgeInfo.get(id);
4321
+ if (pendingEdge) {
4322
+ resultRow[alias] = pendingEdge.properties[property];
3708
4323
  }
3709
- if (nodeResult.rows.length > 0) {
3710
- const props = typeof nodeResult.rows[0].properties === "string"
3711
- ? JSON.parse(nodeResult.rows[0].properties)
3712
- : nodeResult.rows[0].properties;
3713
- resultRow[alias] = props[property];
4324
+ else {
4325
+ // Try nodes first, then edges
4326
+ let nodeResult = this.db.execute("SELECT id, properties FROM nodes WHERE id = ?", [id]);
4327
+ let isNode = nodeResult.rows.length > 0;
4328
+ if (!isNode) {
4329
+ // Try edges table
4330
+ nodeResult = this.db.execute("SELECT id, properties FROM edges WHERE id = ?", [id]);
4331
+ }
4332
+ if (nodeResult.rows.length > 0) {
4333
+ const props = isNode
4334
+ ? this.getNodeProperties(nodeResult.rows[0].id, nodeResult.rows[0].properties)
4335
+ : this.getEdgeProperties(nodeResult.rows[0].id, nodeResult.rows[0].properties);
4336
+ resultRow[alias] = props[property];
4337
+ }
3714
4338
  }
3715
4339
  }
3716
4340
  }
@@ -3721,6 +4345,14 @@ export class Executor {
3721
4345
  }
3722
4346
  }
3723
4347
  }
4348
+ // Batch insert edges (cap at 500 rows per statement)
4349
+ const EDGE_BATCH_SIZE = 500;
4350
+ for (let i = 0; i < edgeInserts.length; i += EDGE_BATCH_SIZE) {
4351
+ const batch = edgeInserts.slice(i, i + EDGE_BATCH_SIZE);
4352
+ const placeholders = batch.map(() => '(?, ?, ?, ?, ?)').join(',');
4353
+ const values = batch.flatMap(insert => [insert.id, insert.type, insert.sourceId, insert.targetId, insert.propsJson]);
4354
+ this.db.execute(`INSERT INTO edges (id, type, source_id, target_id, properties) VALUES ${placeholders}`, values);
4355
+ }
3724
4356
  });
3725
4357
  // Compute WITH aggregate results if RETURN references them
3726
4358
  if (returnsWithAggregateAliases && returnClause) {
@@ -3899,14 +4531,15 @@ export class Executor {
3899
4531
  const id = resolvedIds[variable];
3900
4532
  if (id) {
3901
4533
  // Try nodes first, then edges
3902
- let result = this.db.execute("SELECT properties FROM nodes WHERE id = ?", [id]);
3903
- if (result.rows.length === 0) {
3904
- result = this.db.execute("SELECT properties FROM edges WHERE id = ?", [id]);
4534
+ let result = this.db.execute("SELECT id, properties FROM nodes WHERE id = ?", [id]);
4535
+ let isNode = result.rows.length > 0;
4536
+ if (!isNode) {
4537
+ result = this.db.execute("SELECT id, properties FROM edges WHERE id = ?", [id]);
3905
4538
  }
3906
4539
  if (result.rows.length > 0) {
3907
- const props = typeof result.rows[0].properties === "string"
3908
- ? JSON.parse(result.rows[0].properties)
3909
- : result.rows[0].properties;
4540
+ const props = isNode
4541
+ ? this.getNodeProperties(result.rows[0].id, result.rows[0].properties)
4542
+ : this.getEdgeProperties(result.rows[0].id, result.rows[0].properties);
3910
4543
  return props[property];
3911
4544
  }
3912
4545
  }
@@ -3942,15 +4575,16 @@ export class Executor {
3942
4575
  const id = createdIds.get(variable);
3943
4576
  if (id) {
3944
4577
  // Try nodes first, then edges
3945
- let result = this.db.execute("SELECT properties FROM nodes WHERE id = ?", [id]);
3946
- if (result.rows.length === 0) {
4578
+ let result = this.db.execute("SELECT id, properties FROM nodes WHERE id = ?", [id]);
4579
+ let isNode = result.rows.length > 0;
4580
+ if (!isNode) {
3947
4581
  // Try edges table
3948
- result = this.db.execute("SELECT properties FROM edges WHERE id = ?", [id]);
4582
+ result = this.db.execute("SELECT id, properties FROM edges WHERE id = ?", [id]);
3949
4583
  }
3950
4584
  if (result.rows.length > 0) {
3951
- const props = typeof result.rows[0].properties === "string"
3952
- ? JSON.parse(result.rows[0].properties)
3953
- : result.rows[0].properties;
4585
+ const props = isNode
4586
+ ? this.getNodeProperties(result.rows[0].id, result.rows[0].properties)
4587
+ : this.getEdgeProperties(result.rows[0].id, result.rows[0].properties);
3954
4588
  return props[property];
3955
4589
  }
3956
4590
  }
@@ -4038,8 +4672,9 @@ export class Executor {
4038
4672
  let whereConditions = [];
4039
4673
  let whereParams = [];
4040
4674
  if (nodePattern.label) {
4041
- whereConditions.push("EXISTS (SELECT 1 FROM json_each(label) WHERE value = ?)");
4042
- whereParams.push(nodePattern.label);
4675
+ // Use primary label index with fallback for secondary labels
4676
+ whereConditions.push("(json_extract(label, '$[0]') = ? OR EXISTS (SELECT 1 FROM json_each(label) WHERE value = ? AND json_extract(label, '$[0]') != ?))");
4677
+ whereParams.push(nodePattern.label, nodePattern.label, nodePattern.label);
4043
4678
  }
4044
4679
  for (const [key, value] of Object.entries(props)) {
4045
4680
  whereConditions.push(`json_extract(properties, '$.${key}') = ?`);
@@ -4989,7 +5624,7 @@ export class Executor {
4989
5624
  /**
4990
5625
  * Execute CREATE relationship pattern with unwind context
4991
5626
  */
4992
- executeCreateRelationshipPatternWithUnwind(rel, createdIds, params, unwindContext) {
5627
+ executeCreateRelationshipPatternWithUnwind(rel, createdIds, params, unwindContext, edgeInserts, pendingEdgeInfo) {
4993
5628
  let sourceId;
4994
5629
  let targetId;
4995
5630
  // Determine source node ID
@@ -5032,7 +5667,30 @@ export class Executor {
5032
5667
  const edgeId = crypto.randomUUID();
5033
5668
  const edgeType = rel.edge.type || "";
5034
5669
  const edgeProps = this.resolvePropertiesWithUnwind(rel.edge.properties || {}, params, unwindContext);
5035
- this.db.execute("INSERT INTO edges (id, type, source_id, target_id, properties) VALUES (?, ?, ?, ?, ?)", [edgeId, edgeType, actualSource, actualTarget, JSON.stringify(edgeProps)]);
5670
+ if (edgeInserts) {
5671
+ // Collect for batch insert
5672
+ edgeInserts.push({
5673
+ id: edgeId,
5674
+ type: edgeType,
5675
+ sourceId: actualSource,
5676
+ targetId: actualTarget,
5677
+ propsJson: JSON.stringify(edgeProps),
5678
+ variable: rel.edge.variable
5679
+ });
5680
+ // Cache for RETURN lookups before batch insert
5681
+ if (pendingEdgeInfo) {
5682
+ pendingEdgeInfo.set(edgeId, {
5683
+ type: edgeType,
5684
+ sourceId: actualSource,
5685
+ targetId: actualTarget,
5686
+ properties: edgeProps
5687
+ });
5688
+ }
5689
+ }
5690
+ else {
5691
+ // Direct insert (for non-batched callers)
5692
+ this.db.execute("INSERT INTO edges (id, type, source_id, target_id, properties) VALUES (?, ?, ?, ?, ?)", [edgeId, edgeType, actualSource, actualTarget, JSON.stringify(edgeProps)]);
5693
+ }
5036
5694
  if (rel.edge.variable) {
5037
5695
  createdIds.set(rel.edge.variable, edgeId);
5038
5696
  }
@@ -5116,9 +5774,7 @@ export class Executor {
5116
5774
  if (nodeResult.rows.length > 0) {
5117
5775
  const row = nodeResult.rows[0];
5118
5776
  // Neo4j 3.5 format: return properties directly
5119
- resultRow[alias] = typeof row.properties === "string"
5120
- ? JSON.parse(row.properties)
5121
- : row.properties;
5777
+ resultRow[alias] = this.getNodeProperties(row.id, row.properties);
5122
5778
  }
5123
5779
  }
5124
5780
  }
@@ -5358,6 +6014,15 @@ export class Executor {
5358
6014
  }
5359
6015
  if (relList.length === 0)
5360
6016
  continue;
6017
+ // Batch collect all edge IDs first
6018
+ const edgeIds = [];
6019
+ for (const rel of relList) {
6020
+ const edgeId = this.extractEdgeId(rel);
6021
+ if (edgeId)
6022
+ edgeIds.push(edgeId);
6023
+ }
6024
+ // Batch fetch all edge info in a single query
6025
+ const edgeInfoMap = this.batchGetEdgeInfo(edgeIds);
5361
6026
  // Follow the sequence of relationships to find endpoints
5362
6027
  let currentNodeId = null;
5363
6028
  let firstNodeId = null;
@@ -5365,45 +6030,9 @@ export class Executor {
5365
6030
  let valid = true;
5366
6031
  for (let i = 0; i < relList.length; i++) {
5367
6032
  const rel = relList[i];
5368
- // Extract edge info
5369
- let edgeInfo = null;
5370
- if (typeof rel === "object" && rel !== null) {
5371
- const relObj = rel;
5372
- const edgeId = (relObj._nf_id || relObj.id);
5373
- if (edgeId) {
5374
- const edgeResult = this.db.execute("SELECT id, source_id, target_id FROM edges WHERE id = ?", [edgeId]);
5375
- if (edgeResult.rows.length > 0) {
5376
- const r = edgeResult.rows[0];
5377
- edgeInfo = {
5378
- id: r.id,
5379
- source_id: r.source_id,
5380
- target_id: r.target_id
5381
- };
5382
- }
5383
- }
5384
- }
5385
- else if (typeof rel === "string") {
5386
- // Could be a JSON string like '{"_nf_id":"uuid"}' or a raw edge ID
5387
- let edgeId = rel;
5388
- try {
5389
- const parsed = JSON.parse(rel);
5390
- if (typeof parsed === "object" && parsed !== null && (parsed._nf_id || parsed.id)) {
5391
- edgeId = (parsed._nf_id || parsed.id);
5392
- }
5393
- }
5394
- catch {
5395
- // Not JSON, use as-is
5396
- }
5397
- const edgeResult = this.db.execute("SELECT id, source_id, target_id FROM edges WHERE id = ?", [edgeId]);
5398
- if (edgeResult.rows.length > 0) {
5399
- const r = edgeResult.rows[0];
5400
- edgeInfo = {
5401
- id: r.id,
5402
- source_id: r.source_id,
5403
- target_id: r.target_id
5404
- };
5405
- }
5406
- }
6033
+ // Extract edge ID and get info from batch result
6034
+ const edgeId = this.extractEdgeId(rel);
6035
+ const edgeInfo = edgeId ? edgeInfoMap.get(edgeId) : null;
5407
6036
  if (!edgeInfo) {
5408
6037
  valid = false;
5409
6038
  break;
@@ -5454,12 +6083,8 @@ export class Executor {
5454
6083
  }
5455
6084
  const firstNode = firstNodeResult.rows[0];
5456
6085
  const lastNode = lastNodeResult.rows[0];
5457
- const firstProps = typeof firstNode.properties === "string"
5458
- ? JSON.parse(firstNode.properties)
5459
- : firstNode.properties;
5460
- const lastProps = typeof lastNode.properties === "string"
5461
- ? JSON.parse(lastNode.properties)
5462
- : lastNode.properties;
6086
+ const firstProps = this.getNodeProperties(firstNode.id, firstNode.properties);
6087
+ const lastProps = this.getNodeProperties(lastNode.id, lastNode.properties);
5463
6088
  // Build result based on RETURN items
5464
6089
  const resultRow = {};
5465
6090
  for (const item of returnClause.items) {
@@ -5575,7 +6200,7 @@ export class Executor {
5575
6200
  matchedNodes.set(nodePattern.variable, {
5576
6201
  id: row.id,
5577
6202
  label: row.label,
5578
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
6203
+ properties: this.getNodeProperties(typeof row.id === "string" ? row.id : "", typeof row.properties === "string" || (typeof row.properties === "object" && row.properties !== null) ? row.properties : "{}"),
5579
6204
  });
5580
6205
  }
5581
6206
  }
@@ -5690,6 +6315,8 @@ export class Executor {
5690
6315
  continue;
5691
6316
  const value = this.evaluateExpressionWithMatchedNodes(assignment.value, params, matchedNodes);
5692
6317
  this.db.execute(`UPDATE nodes SET properties = json_set(properties, '$.${assignment.property}', json(?)) WHERE id = ?`, [JSON.stringify(value), nodeId]);
6318
+ // Invalidate cache after UPDATE
6319
+ this.invalidatePropertyCache(nodeId);
5693
6320
  }
5694
6321
  }
5695
6322
  }
@@ -5701,7 +6328,7 @@ export class Executor {
5701
6328
  matchedNodes.set(pattern.variable, {
5702
6329
  id: row.id,
5703
6330
  label: row.label,
5704
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
6331
+ properties: this.getNodeProperties(row.id, row.properties),
5705
6332
  });
5706
6333
  }
5707
6334
  }
@@ -5766,7 +6393,7 @@ export class Executor {
5766
6393
  type: row.type,
5767
6394
  source_id: row.source_id,
5768
6395
  target_id: row.target_id,
5769
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
6396
+ properties: this.getEdgeProperties(row.id, row.properties),
5770
6397
  });
5771
6398
  }
5772
6399
  }
@@ -6039,7 +6666,7 @@ export class Executor {
6039
6666
  newRow.set(nodePattern.variable, {
6040
6667
  id: sqlRow.id,
6041
6668
  label: labelValue,
6042
- properties: typeof sqlRow.properties === "string" ? JSON.parse(sqlRow.properties) : sqlRow.properties,
6669
+ properties: this.getNodeProperties(sqlRow.id, sqlRow.properties),
6043
6670
  });
6044
6671
  }
6045
6672
  newMatchRows.push(newRow);
@@ -6188,6 +6815,8 @@ export class Executor {
6188
6815
  continue;
6189
6816
  const value = this.evaluateExpressionWithMatchedNodes(assignment.value, params, matchedNodes);
6190
6817
  this.db.execute(`UPDATE nodes SET properties = json_set(properties, '$.${assignment.property}', json(?)) WHERE id = ?`, [JSON.stringify(value), nodeId]);
6818
+ // Invalidate cache after UPDATE
6819
+ this.invalidatePropertyCache(nodeId);
6191
6820
  }
6192
6821
  }
6193
6822
  }
@@ -6199,7 +6828,7 @@ export class Executor {
6199
6828
  matchedNodes.set(pattern.variable, {
6200
6829
  id: row.id,
6201
6830
  label: row.label,
6202
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
6831
+ properties: this.getNodeProperties(row.id, row.properties),
6203
6832
  });
6204
6833
  }
6205
6834
  }
@@ -6322,7 +6951,7 @@ export class Executor {
6322
6951
  type: row.type,
6323
6952
  source_id: row.source_id,
6324
6953
  target_id: row.target_id,
6325
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
6954
+ properties: this.getEdgeProperties(row.id, row.properties),
6326
6955
  });
6327
6956
  }
6328
6957
  }
@@ -6526,6 +7155,8 @@ export class Executor {
6526
7155
  ? this.evaluateExpressionWithContext(assignment.value, params, resolvedIds)
6527
7156
  : this.evaluateExpression(assignment.value, params);
6528
7157
  this.db.execute(`UPDATE nodes SET properties = json_set(properties, '$.${assignment.property}', json(?)) WHERE id = ?`, [JSON.stringify(value), nodeId]);
7158
+ // Invalidate cache after UPDATE
7159
+ this.invalidatePropertyCache(nodeId);
6529
7160
  }
6530
7161
  }
6531
7162
  }
@@ -6537,7 +7168,7 @@ export class Executor {
6537
7168
  matchedNodes.set(pattern.variable, {
6538
7169
  id: row.id,
6539
7170
  label: row.label,
6540
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
7171
+ properties: this.getNodeProperties(row.id, row.properties),
6541
7172
  });
6542
7173
  }
6543
7174
  }
@@ -6635,6 +7266,8 @@ export class Executor {
6635
7266
  const value = this.evaluateExpression(assignment.value, params);
6636
7267
  // Update target node with ON CREATE SET
6637
7268
  this.db.execute(`UPDATE nodes SET properties = json_set(properties, '$.${assignment.property}', json(?)) WHERE id = ?`, [JSON.stringify(value), targetNodeId]);
7269
+ // Invalidate cache after UPDATE
7270
+ this.invalidatePropertyCache(targetNodeId);
6638
7271
  }
6639
7272
  }
6640
7273
  this.db.execute("INSERT INTO edges (id, type, source_id, target_id, properties) VALUES (?, ?, ?, ?, ?)", [edgeId, edgeType, sourceNodeId, targetNodeId, JSON.stringify(finalEdgeProps)]);
@@ -6652,6 +7285,8 @@ export class Executor {
6652
7285
  const value = this.evaluateExpression(assignment.value, params);
6653
7286
  // Update target node with ON MATCH SET
6654
7287
  this.db.execute(`UPDATE nodes SET properties = json_set(properties, '$.${assignment.property}', json(?)) WHERE id = ?`, [JSON.stringify(value), targetNodeId]);
7288
+ // Invalidate cache after UPDATE
7289
+ this.invalidatePropertyCache(targetNodeId);
6655
7290
  }
6656
7291
  }
6657
7292
  }
@@ -6665,7 +7300,7 @@ export class Executor {
6665
7300
  type: row.type,
6666
7301
  source_id: row.source_id,
6667
7302
  target_id: row.target_id,
6668
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
7303
+ properties: this.getEdgeProperties(row.id, row.properties),
6669
7304
  });
6670
7305
  }
6671
7306
  }
@@ -6677,7 +7312,7 @@ export class Executor {
6677
7312
  matchedNodes.set(targetVar, {
6678
7313
  id: row.id,
6679
7314
  label: row.label,
6680
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
7315
+ properties: this.getNodeProperties(row.id, row.properties),
6681
7316
  });
6682
7317
  }
6683
7318
  }
@@ -6712,7 +7347,7 @@ export class Executor {
6712
7347
  return {
6713
7348
  id: row.id,
6714
7349
  label: typeof row.label === "string" ? JSON.parse(row.label) : row.label,
6715
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
7350
+ properties: this.getNodeProperties(row.id, row.properties),
6716
7351
  };
6717
7352
  }
6718
7353
  }
@@ -6971,7 +7606,7 @@ export class Executor {
6971
7606
  case "variable":
6972
7607
  return expr.variable;
6973
7608
  case "property":
6974
- return `${expr.variable}_${expr.property}`;
7609
+ return `${expr.variable}.${expr.property}`;
6975
7610
  case "function": {
6976
7611
  // Build function expression like count(a) or count(*)
6977
7612
  const funcName = expr.functionName.toLowerCase();
@@ -7010,6 +7645,236 @@ export class Executor {
7010
7645
  return "expr";
7011
7646
  }
7012
7647
  }
7648
+ /**
7649
+ * Execute queries with FOREACH clause.
7650
+ * FOREACH iterates over a list and executes mutation clauses for each item.
7651
+ * Pattern: ... FOREACH (var IN list | SET/CREATE/DELETE...) ...
7652
+ */
7653
+ tryForeachExecution(query, params) {
7654
+ // Execute query with phased approach using PhaseContext
7655
+ let context = createEmptyContext();
7656
+ this.db.transaction(() => {
7657
+ for (const clause of query.clauses) {
7658
+ if (clause.type === "FOREACH") {
7659
+ // Execute FOREACH for the current context
7660
+ this.executeForeachClause(clause, context, params);
7661
+ }
7662
+ else if (clause.type === "RETURN") {
7663
+ // RETURN is handled after transaction
7664
+ }
7665
+ else if (clause.type === "CREATE") {
7666
+ context = this.executeCreateClause(clause, context, params);
7667
+ }
7668
+ else if (clause.type === "WITH") {
7669
+ context = this.executeWithClause(clause, context, params);
7670
+ }
7671
+ else if (clause.type === "SET") {
7672
+ this.executeForeachSetClause(clause, context, params);
7673
+ }
7674
+ // Other clauses could be added as needed
7675
+ }
7676
+ });
7677
+ // Handle RETURN clause if present
7678
+ const returnClause = query.clauses.find((c) => c.type === "RETURN");
7679
+ if (returnClause) {
7680
+ return this.executeForeachReturn(returnClause, context, params);
7681
+ }
7682
+ return [];
7683
+ }
7684
+ /**
7685
+ * Execute a FOREACH clause within PhaseContext.
7686
+ */
7687
+ executeForeachClause(foreachClause, context, params) {
7688
+ for (const row of context.rows) {
7689
+ // Evaluate the list expression in the current row context
7690
+ const listValue = this.evaluateExpressionInRow(foreachClause.expression, row, params);
7691
+ if (!Array.isArray(listValue)) {
7692
+ continue;
7693
+ }
7694
+ // For each item in the list, execute the body clauses
7695
+ for (const item of listValue) {
7696
+ // Create a row context with the iteration variable bound
7697
+ const iterationRow = new Map(row);
7698
+ iterationRow.set(foreachClause.variable, item);
7699
+ // Execute each body clause
7700
+ for (const bodyClause of foreachClause.body) {
7701
+ this.executeForeachBodyClause(bodyClause, iterationRow, context, params);
7702
+ }
7703
+ }
7704
+ }
7705
+ }
7706
+ /**
7707
+ * Execute a single clause within FOREACH body.
7708
+ */
7709
+ executeForeachBodyClause(clause, row, context, params) {
7710
+ switch (clause.type) {
7711
+ case "SET":
7712
+ this.executeForeachSetInRow(clause, row, params);
7713
+ break;
7714
+ case "CREATE":
7715
+ this.executeForeachCreateInRow(clause, row, params);
7716
+ break;
7717
+ case "DELETE":
7718
+ this.executeForeachDeleteInRow(clause, row);
7719
+ break;
7720
+ case "FOREACH":
7721
+ // Nested FOREACH - create temporary context with single row
7722
+ const tempContext = {
7723
+ nodeIds: new Map(context.nodeIds),
7724
+ edgeIds: new Map(context.edgeIds),
7725
+ values: new Map(context.values),
7726
+ rows: [row],
7727
+ };
7728
+ this.executeForeachClause(clause, tempContext, params);
7729
+ break;
7730
+ default:
7731
+ throw new Error(`Unsupported clause type '${clause.type}' in FOREACH body`);
7732
+ }
7733
+ }
7734
+ /**
7735
+ * Execute SET within FOREACH context using Map-based row.
7736
+ */
7737
+ executeForeachSetInRow(clause, row, params) {
7738
+ for (const assignment of clause.assignments) {
7739
+ const targetVar = assignment.variable;
7740
+ const targetObj = row.get(targetVar);
7741
+ if (!targetObj || typeof targetObj !== "object") {
7742
+ continue;
7743
+ }
7744
+ const target = targetObj;
7745
+ // Support both _nf_id (node) and _id (edge)
7746
+ const nodeId = target._nf_id || target._id;
7747
+ if (!nodeId) {
7748
+ continue;
7749
+ }
7750
+ // Determine if this is a node or edge based on which id field exists
7751
+ const isNode = "_nf_id" in target;
7752
+ const table = isNode ? "nodes" : "edges";
7753
+ if (assignment.value) {
7754
+ const newValue = this.evaluateExpressionInRow(assignment.value, row, params);
7755
+ if (assignment.property) {
7756
+ // SET n.prop = value
7757
+ const propName = assignment.property;
7758
+ const sql = `UPDATE ${table} SET properties = json_set(properties, '$.${propName}', json(?)) WHERE id = ?`;
7759
+ this.db.execute(sql, [JSON.stringify(newValue), nodeId]);
7760
+ // Update the row object so subsequent iterations see the new value
7761
+ target[propName] = newValue;
7762
+ }
7763
+ else if (assignment.mergeProps) {
7764
+ // SET n += {...}
7765
+ if (typeof newValue === "object" && newValue !== null) {
7766
+ const propsJson = JSON.stringify(newValue);
7767
+ const sql = `UPDATE ${table} SET properties = json_patch(properties, ?) WHERE id = ?`;
7768
+ this.db.execute(sql, [propsJson, nodeId]);
7769
+ }
7770
+ }
7771
+ else if (assignment.replaceProps) {
7772
+ // SET n = {...}
7773
+ if (typeof newValue === "object" && newValue !== null) {
7774
+ const propsJson = JSON.stringify(newValue);
7775
+ const sql = `UPDATE ${table} SET properties = ? WHERE id = ?`;
7776
+ this.db.execute(sql, [propsJson, nodeId]);
7777
+ }
7778
+ }
7779
+ }
7780
+ }
7781
+ }
7782
+ /**
7783
+ * Execute CREATE within FOREACH context using Map-based row.
7784
+ */
7785
+ executeForeachCreateInRow(clause, row, _params) {
7786
+ for (const pattern of clause.patterns) {
7787
+ if (this.isRelationshipPattern(pattern)) {
7788
+ const relPattern = pattern;
7789
+ const sourceNode = row.get(relPattern.source.variable ?? "");
7790
+ const targetNode = row.get(relPattern.target.variable ?? "");
7791
+ if (sourceNode?._id && targetNode?._id && relPattern.edge.type) {
7792
+ const props = relPattern.edge.properties || {};
7793
+ const propsJson = JSON.stringify(props);
7794
+ this.db.execute("INSERT INTO edges (source, target, type, properties) VALUES (?, ?, ?, ?)", [sourceNode._id, targetNode._id, relPattern.edge.type, propsJson]);
7795
+ }
7796
+ }
7797
+ else {
7798
+ const nodePattern = pattern;
7799
+ const label = nodePattern.label;
7800
+ const labels = Array.isArray(label) ? label : (label ? [label] : []);
7801
+ const props = nodePattern.properties || {};
7802
+ const propsJson = JSON.stringify(props);
7803
+ const labelsJson = JSON.stringify(labels);
7804
+ const result = this.db.execute("INSERT INTO nodes (labels, properties) VALUES (?, ?)", [labelsJson, propsJson]);
7805
+ if (nodePattern.variable && result.lastInsertRowid) {
7806
+ row.set(nodePattern.variable, {
7807
+ _id: result.lastInsertRowid,
7808
+ _labels: labels,
7809
+ ...props,
7810
+ });
7811
+ }
7812
+ }
7813
+ }
7814
+ }
7815
+ /**
7816
+ * Execute DELETE within FOREACH context using Map-based row.
7817
+ */
7818
+ executeForeachDeleteInRow(clause, row) {
7819
+ if (!clause.expressions)
7820
+ return;
7821
+ for (const expr of clause.expressions) {
7822
+ if (expr.type === "variable" && expr.variable) {
7823
+ const target = row.get(expr.variable);
7824
+ if (target?._id !== undefined) {
7825
+ const isNode = "_labels" in target;
7826
+ if (isNode) {
7827
+ if (clause.detach) {
7828
+ this.db.execute("DELETE FROM edges WHERE source = ? OR target = ?", [target._id, target._id]);
7829
+ }
7830
+ this.db.execute("DELETE FROM nodes WHERE id = ?", [target._id]);
7831
+ }
7832
+ else {
7833
+ this.db.execute("DELETE FROM edges WHERE id = ?", [target._id]);
7834
+ }
7835
+ }
7836
+ }
7837
+ }
7838
+ }
7839
+ /**
7840
+ * Execute SET clause within PhaseContext (not inside FOREACH iteration).
7841
+ */
7842
+ executeForeachSetClause(clause, context, params) {
7843
+ for (const row of context.rows) {
7844
+ this.executeForeachSetInRow(clause, row, params);
7845
+ }
7846
+ }
7847
+ /**
7848
+ * Execute RETURN clause for FOREACH execution path.
7849
+ */
7850
+ executeForeachReturn(clause, context, params) {
7851
+ const resultRows = [];
7852
+ for (const row of context.rows) {
7853
+ const resultRow = {};
7854
+ for (const item of clause.items) {
7855
+ const alias = item.alias || this.getForeachExpressionName(item.expression);
7856
+ const value = this.evaluateExpressionInRow(item.expression, row, params);
7857
+ resultRow[alias] = value;
7858
+ }
7859
+ resultRows.push(resultRow);
7860
+ }
7861
+ return resultRows;
7862
+ }
7863
+ /**
7864
+ * Get expression name for FOREACH return aliases.
7865
+ */
7866
+ getForeachExpressionName(expr) {
7867
+ switch (expr.type) {
7868
+ case "variable":
7869
+ return expr.variable || "value";
7870
+ case "property":
7871
+ return `${expr.variable}.${expr.property}`;
7872
+ case "function":
7873
+ return expr.functionName?.toLowerCase() || "value";
7874
+ default:
7875
+ return "value";
7876
+ }
7877
+ }
7013
7878
  /**
7014
7879
  * Detect and handle patterns that need multi-phase execution:
7015
7880
  * - MATCH...CREATE that references matched variables
@@ -7770,10 +8635,10 @@ export class Executor {
7770
8635
  for (const resolvedIds of allResolvedIds) {
7771
8636
  const nodeId = resolvedIds[varName];
7772
8637
  if (nodeId) {
7773
- const nodeResult = this.db.execute("SELECT properties FROM nodes WHERE id = ?", [nodeId]);
8638
+ const nodeResult = this.db.execute("SELECT id, properties FROM nodes WHERE id = ?", [nodeId]);
7774
8639
  if (nodeResult.rows.length > 0) {
7775
8640
  const row = nodeResult.rows[0];
7776
- values.push(typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties);
8641
+ values.push(this.getNodeProperties(row.id, row.properties));
7777
8642
  }
7778
8643
  }
7779
8644
  }
@@ -7879,9 +8744,7 @@ export class Executor {
7879
8744
  if (nodeResult.rows.length > 0) {
7880
8745
  const row = nodeResult.rows[0];
7881
8746
  // Neo4j 3.5 format: return properties directly
7882
- resultRow[alias] = typeof row.properties === "string"
7883
- ? JSON.parse(row.properties)
7884
- : row.properties;
8747
+ resultRow[alias] = this.getNodeProperties(row.id, row.properties);
7885
8748
  }
7886
8749
  else {
7887
8750
  // Try edges
@@ -7889,9 +8752,7 @@ export class Executor {
7889
8752
  if (edgeResult.rows.length > 0) {
7890
8753
  const row = edgeResult.rows[0];
7891
8754
  // Neo4j 3.5 format: return properties directly
7892
- resultRow[alias] = typeof row.properties === "string"
7893
- ? JSON.parse(row.properties)
7894
- : row.properties;
8755
+ resultRow[alias] = this.getEdgeProperties(row.id, row.properties);
7895
8756
  }
7896
8757
  }
7897
8758
  }
@@ -8104,6 +8965,8 @@ export class Executor {
8104
8965
  if (nodeResult.changes === 0) {
8105
8966
  this.db.execute(`UPDATE edges SET properties = ? WHERE id = ?`, [JSON.stringify(filteredProps), nodeId]);
8106
8967
  }
8968
+ // Invalidate cache after UPDATE
8969
+ this.invalidatePropertyCache(nodeId);
8107
8970
  continue;
8108
8971
  }
8109
8972
  // Handle SET n += {props} - merge properties
@@ -8137,6 +9000,8 @@ export class Executor {
8137
9000
  this.db.execute(`UPDATE edges SET properties = json_patch(properties, ?) WHERE id = ?`, [JSON.stringify(nonNullProps), nodeId]);
8138
9001
  }
8139
9002
  }
9003
+ // Invalidate cache after UPDATE
9004
+ this.invalidatePropertyCache(nodeId);
8140
9005
  continue;
8141
9006
  }
8142
9007
  // Handle property assignments
@@ -8144,7 +9009,7 @@ export class Executor {
8144
9009
  throw new Error(`Invalid SET assignment for variable: ${assignment.variable}`);
8145
9010
  }
8146
9011
  // Use context-aware evaluation for expressions that may reference properties
8147
- const value = assignment.value.type === "binary" || assignment.value.type === "property"
9012
+ const value = assignment.value.type === "binary" || assignment.value.type === "property" || assignment.value.type === "case"
8148
9013
  ? this.evaluateExpressionWithContext(assignment.value, params, resolvedIds)
8149
9014
  : this.evaluateExpression(assignment.value, params);
8150
9015
  // If value is null, remove the property instead of setting it to null
@@ -8164,6 +9029,8 @@ export class Executor {
8164
9029
  this.db.execute(`UPDATE edges SET properties = json_set(properties, '$.${assignment.property}', json(?)) WHERE id = ?`, [JSON.stringify(value), nodeId]);
8165
9030
  }
8166
9031
  }
9032
+ // Invalidate cache after UPDATE
9033
+ this.invalidatePropertyCache(nodeId);
8167
9034
  }
8168
9035
  }
8169
9036
  /**
@@ -8219,6 +9086,28 @@ export class Executor {
8219
9086
  }
8220
9087
  return deletedVariables;
8221
9088
  }
9089
+ /**
9090
+ * Build a row context from resolved node/edge IDs for condition evaluation
9091
+ */
9092
+ buildRowFromResolvedIds(resolvedIds) {
9093
+ const row = {};
9094
+ for (const [varName, entityId] of Object.entries(resolvedIds)) {
9095
+ // Try to get properties from nodes first
9096
+ const nodeResult = this.db.execute(`SELECT properties FROM nodes WHERE id = ?`, [entityId]);
9097
+ if (nodeResult.rows.length > 0) {
9098
+ const props = JSON.parse(nodeResult.rows[0].properties || "{}");
9099
+ row[varName] = props;
9100
+ continue;
9101
+ }
9102
+ // Try edges
9103
+ const edgeResult = this.db.execute(`SELECT properties FROM edges WHERE id = ?`, [entityId]);
9104
+ if (edgeResult.rows.length > 0) {
9105
+ const props = JSON.parse(edgeResult.rows[0].properties || "{}");
9106
+ row[varName] = props;
9107
+ }
9108
+ }
9109
+ return row;
9110
+ }
8222
9111
  /**
8223
9112
  * Evaluate an expression to get its value
8224
9113
  * Note: For property and binary expressions that reference nodes, use evaluateExpressionWithContext
@@ -8329,6 +9218,19 @@ export class Executor {
8329
9218
  const args = expr.args || [];
8330
9219
  return this.evaluateFunctionInProperty(funcName, args, params, {});
8331
9220
  }
9221
+ case "case": {
9222
+ // Evaluate CASE WHEN ... THEN ... ELSE ... END
9223
+ // Build a row context from resolvedIds for condition evaluation
9224
+ const row = this.buildRowFromResolvedIds(resolvedIds);
9225
+ if (expr.whens) {
9226
+ for (const when of expr.whens) {
9227
+ if (this.evaluateWhereConditionOnRow(when.condition, row, params)) {
9228
+ return this.evaluateExpressionWithContext(when.result, params, resolvedIds);
9229
+ }
9230
+ }
9231
+ }
9232
+ return expr.elseExpr ? this.evaluateExpressionWithContext(expr.elseExpr, params, resolvedIds) : null;
9233
+ }
8332
9234
  default:
8333
9235
  throw new Error(`Cannot evaluate expression of type ${expr.type}`);
8334
9236
  }
@@ -8518,21 +9420,26 @@ export class Executor {
8518
9420
  /**
8519
9421
  * Generate SQL condition for label matching
8520
9422
  * Supports both single and multiple labels
9423
+ * Uses primary label index with fallback for secondary labels
8521
9424
  */
8522
9425
  generateLabelCondition(label) {
8523
9426
  const labels = Array.isArray(label) ? label : [label];
8524
9427
  if (labels.length === 1) {
8525
9428
  return {
8526
- sql: `EXISTS (SELECT 1 FROM json_each(label) WHERE value = ?)`,
8527
- params: [labels[0]]
9429
+ sql: `(json_extract(label, '$[0]') = ? OR EXISTS (SELECT 1 FROM json_each(label) WHERE value = ? AND json_extract(label, '$[0]') != ?))`,
9430
+ params: [labels[0], labels[0], labels[0]]
8528
9431
  };
8529
9432
  }
8530
9433
  else {
8531
- // Multiple labels: all must exist
8532
- const conditions = labels.map(() => `EXISTS (SELECT 1 FROM json_each(label) WHERE value = ?)`);
9434
+ // Multiple labels: all must exist (each uses indexed primary label check with fallback)
9435
+ const conditions = labels.map(() => `(json_extract(label, '$[0]') = ? OR EXISTS (SELECT 1 FROM json_each(label) WHERE value = ? AND json_extract(label, '$[0]') != ?))`);
9436
+ const params = [];
9437
+ for (const l of labels) {
9438
+ params.push(l, l, l);
9439
+ }
8533
9440
  return {
8534
9441
  sql: conditions.join(" AND "),
8535
- params: labels
9442
+ params
8536
9443
  };
8537
9444
  }
8538
9445
  }