leangraph 1.1.0 → 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.
package/dist/executor.js CHANGED
@@ -77,6 +77,9 @@ function cloneContext(ctx, cloneRows = true) {
77
77
  export class Executor {
78
78
  db;
79
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();
80
83
  constructor(db) {
81
84
  this.db = db;
82
85
  }
@@ -97,13 +100,89 @@ export class Executor {
97
100
  }
98
101
  return props || {};
99
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
+ }
100
177
  /**
101
178
  * Execute a Cypher query and return formatted results
102
179
  */
103
180
  execute(cypher, params = {}) {
104
181
  const startTime = performance.now();
105
- // Clear property cache at start of each query execution
182
+ // Clear property caches at start of each query execution
106
183
  this.propertyCache.clear();
184
+ this.edgePropertyCache.clear();
185
+ this.edgeInfoCache.clear();
107
186
  try {
108
187
  // 1. Parse the Cypher query
109
188
  const parseResult = parse(cypher);
@@ -118,124 +197,94 @@ export class Executor {
118
197
  },
119
198
  };
120
199
  }
121
- // 2. Try phase-based execution for complex multi-phase queries
122
- const phasedResult = this.tryPhasedExecution(parseResult.query, params);
123
- if (phasedResult !== null) {
124
- const endTime = performance.now();
125
- return {
126
- success: true,
127
- data: phasedResult,
128
- meta: {
129
- count: phasedResult.length,
130
- time_ms: Math.round((endTime - startTime) * 100) / 100,
131
- },
132
- };
133
- }
134
- // 2.1. Check for UNWIND with CREATE pattern (needs special handling)
135
- const unwindCreateResult = this.tryUnwindCreateExecution(parseResult.query, params);
136
- if (unwindCreateResult !== null) {
137
- const endTime = performance.now();
138
- return {
139
- success: true,
140
- data: unwindCreateResult,
141
- meta: {
142
- count: unwindCreateResult.length,
143
- time_ms: Math.round((endTime - startTime) * 100) / 100,
144
- },
145
- };
146
- }
147
- // 2.2. Check for UNWIND with MERGE pattern (needs special handling)
148
- const unwindMergeResult = this.tryUnwindMergeExecution(parseResult.query, params);
149
- if (unwindMergeResult !== null) {
150
- const endTime = performance.now();
151
- return {
152
- success: true,
153
- data: unwindMergeResult,
154
- meta: {
155
- count: unwindMergeResult.length,
156
- time_ms: Math.round((endTime - startTime) * 100) / 100,
157
- },
158
- };
159
- }
160
- // 2.3. Check for MATCH+WITH(COLLECT)+UNWIND+RETURN pattern (needs subquery for aggregates)
161
- const collectUnwindResult = this.tryCollectUnwindExecution(parseResult.query, params);
162
- if (collectUnwindResult !== null) {
163
- const endTime = performance.now();
164
- return {
165
- success: true,
166
- data: collectUnwindResult,
167
- meta: {
168
- count: collectUnwindResult.length,
169
- time_ms: Math.round((endTime - startTime) * 100) / 100,
170
- },
171
- };
172
- }
173
- // 2.4. Check for MATCH+WITH(COLLECT)+DELETE[expr] pattern
174
- const collectDeleteResult = this.tryCollectDeleteExecution(parseResult.query, params);
175
- if (collectDeleteResult !== null) {
176
- const endTime = performance.now();
177
- return {
178
- success: true,
179
- data: collectDeleteResult,
180
- meta: {
181
- count: collectDeleteResult.length,
182
- time_ms: Math.round((endTime - startTime) * 100) / 100,
183
- },
184
- };
185
- }
186
- // 2.5. Check for CREATE...RETURN pattern (needs special handling)
187
- const createReturnResult = this.tryCreateReturnExecution(parseResult.query, params);
188
- if (createReturnResult !== null) {
189
- const endTime = performance.now();
190
- return {
191
- success: true,
192
- data: createReturnResult,
193
- meta: {
194
- count: createReturnResult.length,
195
- time_ms: Math.round((endTime - startTime) * 100) / 100,
196
- },
197
- };
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);
198
206
  }
199
- // 2.5. Check for MERGE with ON CREATE SET / ON MATCH SET (needs special handling)
200
- const mergeResult = this.tryMergeExecution(parseResult.query, params);
201
- if (mergeResult !== null) {
202
- const endTime = performance.now();
203
- return {
204
- success: true,
205
- data: mergeResult,
206
- meta: {
207
- count: mergeResult.length,
208
- time_ms: Math.round((endTime - startTime) * 100) / 100,
209
- },
210
- };
207
+ if (flags.hasSet) {
208
+ this.validateSetClauseValueVariables(parseResult.query, params);
211
209
  }
212
- // 2.6. Check for MATCH...WITH...MATCH pattern with bound relationship list
213
- // e.g., MATCH ()-[r1]->()-[r2]->() WITH [r1, r2] AS rs MATCH (a)-[rs*]->(b) RETURN a, b
214
- const boundRelListResult = this.tryBoundRelationshipListExecution(parseResult.query, params);
215
- if (boundRelListResult !== null) {
216
- const endTime = performance.now();
217
- return {
218
- success: true,
219
- data: boundRelListResult,
220
- meta: {
221
- count: boundRelListResult.length,
222
- time_ms: Math.round((endTime - startTime) * 100) / 100,
223
- },
224
- };
210
+ if (flags.hasWith || flags.hasReturn) {
211
+ this.validateOrderByVariables(parseResult.query, params);
225
212
  }
226
- // 3. Check if this is a pattern that needs multi-phase execution
227
- // (MATCH...CREATE, MATCH...SET, MATCH...DELETE with relationship patterns)
228
- const multiPhaseResult = this.tryMultiPhaseExecution(parseResult.query, params);
229
- if (multiPhaseResult !== null) {
213
+ // Helper to return successful result
214
+ const makeResult = (data) => {
230
215
  const endTime = performance.now();
231
216
  return {
232
217
  success: true,
233
- data: multiPhaseResult,
218
+ data,
234
219
  meta: {
235
- count: multiPhaseResult.length,
220
+ count: data.length,
236
221
  time_ms: Math.round((endTime - startTime) * 100) / 100,
237
222
  },
238
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
239
288
  }
240
289
  // 3. Standard single-phase execution: Translate to SQL
241
290
  const translator = new Translator(params);
@@ -274,6 +323,300 @@ export class Executor {
274
323
  }
275
324
  }
276
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
+ // ============================================================================
277
620
  // Phase-Based Execution
278
621
  // ============================================================================
279
622
  /**
@@ -288,12 +631,8 @@ export class Executor {
288
631
  */
289
632
  tryPhasedExecution(query, params) {
290
633
  const phases = this.detectPhases(query);
291
- // Semantic validation: Check if MERGE tries to use a variable already bound by MATCH
292
- this.validateMergeVariables(query);
293
- // Semantic validation: SET expressions cannot reference undefined variables
294
- this.validateSetClauseValueVariables(query, params);
295
- // Semantic validation: ORDER BY expressions cannot reference undefined or out-of-scope variables
296
- this.validateOrderByVariables(query, params);
634
+ // Note: Semantic validations (validateMergeVariables, validateSetClauseValueVariables,
635
+ // validateOrderByVariables) are now called in execute() before classification
297
636
  // Check if we need phased execution for MATCH + MERGE combinations
298
637
  // These need special handling for proper Cartesian product semantics
299
638
  // BUT: If MERGE has ON CREATE SET or ON MATCH SET, let tryMergeExecution handle it
@@ -785,6 +1124,30 @@ export class Executor {
785
1124
  break;
786
1125
  }
787
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
+ }
788
1151
  }
789
1152
  // Check if MATCH after a WITH with aggregates - needs phase boundary
790
1153
  // This handles patterns like:
@@ -1457,12 +1820,12 @@ export class Executor {
1457
1820
  // Build query to find existing matching nodes
1458
1821
  const conditions = [];
1459
1822
  const conditionParams = [];
1460
- // Label condition
1823
+ // Label condition (uses primary label index with fallback for secondary labels)
1461
1824
  if (pattern.label) {
1462
1825
  const labels = Array.isArray(pattern.label) ? pattern.label : [pattern.label];
1463
1826
  for (const label of labels) {
1464
- conditions.push(`EXISTS (SELECT 1 FROM json_each(label) WHERE value = ?)`);
1465
- 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);
1466
1829
  }
1467
1830
  }
1468
1831
  // Property conditions
@@ -1575,9 +1938,7 @@ export class Executor {
1575
1938
  for (const edgeRow of findResult.rows) {
1576
1939
  const outputRow = new Map(inputRow);
1577
1940
  if (pattern.edge.variable) {
1578
- const props = typeof edgeRow.properties === "string"
1579
- ? JSON.parse(edgeRow.properties)
1580
- : edgeRow.properties;
1941
+ const props = this.getEdgeProperties(edgeRow.id, edgeRow.properties);
1581
1942
  // Include _nf_start and _nf_end for startNode() and endNode() functions
1582
1943
  // Use the actual source/target from the found edge (may be reversed for undirected)
1583
1944
  outputRow.set(pattern.edge.variable, {
@@ -2014,7 +2375,9 @@ export class Executor {
2014
2375
  // Get variables referenced in WHERE that are context variables
2015
2376
  const whereReferencesContext = clause.where ?
2016
2377
  this.whereReferencesContextVars(clause.where, contextVarNames, introducedVars) : false;
2017
- 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) {
2018
2381
  // For complex patterns (multi-hop, anonymous nodes), use SQL translation with
2019
2382
  // constraints for bound variables. This handles patterns like:
2020
2383
  // WITH me, you MATCH (me)-[r1:ATE]->()<-[r2:ATE]-(you)
@@ -2153,9 +2516,7 @@ export class Executor {
2153
2516
  for (const row of edgeResult.rows) {
2154
2517
  const matchRow = new Map();
2155
2518
  if (edgeVar) {
2156
- const edgeProps = typeof row.properties === "string"
2157
- ? JSON.parse(row.properties)
2158
- : row.properties;
2519
+ const edgeProps = this.getEdgeProperties(row.id, row.properties);
2159
2520
  matchRow.set(edgeVar, { ...edgeProps, _nf_id: row.id });
2160
2521
  }
2161
2522
  results.push(matchRow);
@@ -2190,9 +2551,55 @@ export class Executor {
2190
2551
  // Transform the clause to substitute context variable references with parameters
2191
2552
  // This handles cases like: WITH x AS foo MATCH (n) WHERE n.id = foo
2192
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
+ }
2193
2599
  // Build a MATCH + RETURN query for all pattern variables
2194
2600
  const matchQuery = {
2195
2601
  clauses: [
2602
+ ...prefixMatchClauses,
2196
2603
  transformedClause,
2197
2604
  {
2198
2605
  type: "RETURN",
@@ -2201,9 +2608,17 @@ export class Executor {
2201
2608
  ],
2202
2609
  };
2203
2610
  // Create params with context values prefixed with _ctx_
2611
+ // For node variables, extract the node ID for use in id() comparisons
2204
2612
  const mergedParams = { ...params };
2205
2613
  for (const [key, value] of inputRow) {
2206
- 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
+ }
2207
2622
  }
2208
2623
  // Translate to SQL with merged parameters
2209
2624
  const translator = new Translator(mergedParams);
@@ -2268,19 +2683,126 @@ export class Executor {
2268
2683
  * Transform a MATCH clause to substitute context variable references with parameter references.
2269
2684
  * This converts WHERE conditions like `n.id = foo` (where foo is from context)
2270
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.
2271
2691
  */
2272
2692
  transformClauseForContext(clause, contextVars) {
2273
- if (!clause.where) {
2274
- return clause;
2275
- }
2276
2693
  // Deep clone the clause
2277
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
+ }
2278
2700
  // Transform WHERE condition to use parameter references for context variables
2279
2701
  if (transformed.where) {
2280
2702
  transformed.where = this.transformWhereForContext(transformed.where, contextVars);
2281
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
+ }
2282
2763
  return transformed;
2283
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
+ }
2284
2806
  /**
2285
2807
  * Transform a WHERE condition to substitute context variable references with parameter references
2286
2808
  */
@@ -2437,56 +2959,24 @@ export class Executor {
2437
2959
  }
2438
2960
  // Follow the sequence of relationships to find the path endpoints
2439
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);
2440
2971
  let currentNodeId = null;
2441
2972
  let firstNodeId = null;
2442
2973
  let lastNodeId = null;
2443
2974
  let valid = true;
2444
2975
  for (let i = 0; i < relList.length; i++) {
2445
2976
  const rel = relList[i];
2446
- // Extract edge info - could be object with id, source_id, target_id or string ID
2447
- let edgeInfo = null;
2448
- if (typeof rel === "object" && rel !== null) {
2449
- // Edge object from MATCH - may have _nf_id instead of id
2450
- const relObj = rel;
2451
- const edgeId = (relObj._nf_id || relObj.id);
2452
- if (edgeId) {
2453
- // Look up the edge to get source/target
2454
- const edgeResult = this.db.execute("SELECT id, source_id, target_id FROM edges WHERE id = ?", [edgeId]);
2455
- if (edgeResult.rows.length > 0) {
2456
- const row = edgeResult.rows[0];
2457
- edgeInfo = {
2458
- id: row.id,
2459
- source_id: row.source_id,
2460
- target_id: row.target_id
2461
- };
2462
- }
2463
- }
2464
- }
2465
- else if (typeof rel === "string") {
2466
- // Could be a JSON string like '{"_nf_id":"uuid"}' or a raw UUID string
2467
- let edgeId = null;
2468
- try {
2469
- const parsed = JSON.parse(rel);
2470
- if (typeof parsed === "object" && parsed !== null) {
2471
- edgeId = (parsed._nf_id || parsed.id);
2472
- }
2473
- }
2474
- catch {
2475
- // Not JSON, assume it's a raw ID
2476
- edgeId = rel;
2477
- }
2478
- if (edgeId) {
2479
- const edgeResult = this.db.execute("SELECT id, source_id, target_id FROM edges WHERE id = ?", [edgeId]);
2480
- if (edgeResult.rows.length > 0) {
2481
- const row = edgeResult.rows[0];
2482
- edgeInfo = {
2483
- id: row.id,
2484
- source_id: row.source_id,
2485
- target_id: row.target_id
2486
- };
2487
- }
2488
- }
2489
- }
2977
+ // Extract edge ID and get info from batch result
2978
+ const edgeId = this.extractEdgeId(rel);
2979
+ const edgeInfo = edgeId ? edgeInfoMap.get(edgeId) : null;
2490
2980
  if (!edgeInfo) {
2491
2981
  valid = false;
2492
2982
  break;
@@ -2564,12 +3054,8 @@ export class Executor {
2564
3054
  const firstNode = firstNodeResult.rows[0];
2565
3055
  const lastNode = lastNodeResult.rows[0];
2566
3056
  // Format nodes like the translator does (with _nf_id embedded)
2567
- const firstProps = typeof firstNode.properties === "string"
2568
- ? JSON.parse(firstNode.properties)
2569
- : firstNode.properties;
2570
- const lastProps = typeof lastNode.properties === "string"
2571
- ? JSON.parse(lastNode.properties)
2572
- : lastNode.properties;
3057
+ const firstProps = this.getNodeProperties(firstNode.id, firstNode.properties);
3058
+ const lastProps = this.getNodeProperties(lastNode.id, lastNode.properties);
2573
3059
  outputRow.set(boundInfo.sourceVar, { ...firstProps, _nf_id: firstNode.id });
2574
3060
  outputRow.set(boundInfo.targetVar, { ...lastProps, _nf_id: lastNode.id });
2575
3061
  newRows.push(outputRow);
@@ -2743,6 +3229,8 @@ export class Executor {
2743
3229
  if (assignment.property && assignment.value) {
2744
3230
  const value = this.evaluateExpressionInRow(assignment.value, row, params);
2745
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);
2746
3234
  }
2747
3235
  }
2748
3236
  }
@@ -2754,7 +3242,9 @@ export class Executor {
2754
3242
  executeDeleteClause(clause, context, params) {
2755
3243
  for (const row of context.rows) {
2756
3244
  for (const variable of clause.variables) {
2757
- 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);
2758
3248
  if (!id)
2759
3249
  continue;
2760
3250
  if (clause.detach) {
@@ -2930,6 +3420,27 @@ export class Executor {
2930
3420
  return null;
2931
3421
  }
2932
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
+ }
2933
3444
  case "comparison": {
2934
3445
  // Evaluate comparison expression: left op right
2935
3446
  const left = this.evaluateExpressionInRow(expr.left, row, params);
@@ -3038,7 +3549,12 @@ export class Executor {
3038
3549
  }
3039
3550
  if (!edgeId)
3040
3551
  return null;
3041
- // 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
3042
3558
  const result = this.db.execute("SELECT type FROM edges WHERE id = ?", [edgeId]);
3043
3559
  if (result.rows.length > 0) {
3044
3560
  return result.rows[0].type;
@@ -3057,10 +3573,17 @@ export class Executor {
3057
3573
  startNodeId = edgeObj._nf_start;
3058
3574
  }
3059
3575
  else if ("_nf_id" in edgeObj) {
3060
- // Look up edge from database to get source_id
3061
- const edgeResult = this.db.execute("SELECT source_id FROM edges WHERE id = ?", [edgeObj._nf_id]);
3062
- if (edgeResult.rows.length > 0) {
3063
- 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
+ }
3064
3587
  }
3065
3588
  }
3066
3589
  }
@@ -3069,9 +3592,7 @@ export class Executor {
3069
3592
  // Look up node from database
3070
3593
  const nodeResult = this.db.execute("SELECT id, properties FROM nodes WHERE id = ?", [startNodeId]);
3071
3594
  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;
3595
+ const nodeProps = this.getNodeProperties(nodeResult.rows[0].id, nodeResult.rows[0].properties);
3075
3596
  return { ...nodeProps, _nf_id: nodeResult.rows[0].id };
3076
3597
  }
3077
3598
  return null;
@@ -3088,10 +3609,17 @@ export class Executor {
3088
3609
  endNodeId = edgeObj._nf_end;
3089
3610
  }
3090
3611
  else if ("_nf_id" in edgeObj) {
3091
- // Look up edge from database to get target_id
3092
- const edgeResult = this.db.execute("SELECT target_id FROM edges WHERE id = ?", [edgeObj._nf_id]);
3093
- if (edgeResult.rows.length > 0) {
3094
- 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
+ }
3095
3623
  }
3096
3624
  }
3097
3625
  }
@@ -3100,9 +3628,7 @@ export class Executor {
3100
3628
  // Look up node from database
3101
3629
  const nodeResult = this.db.execute("SELECT id, properties FROM nodes WHERE id = ?", [endNodeId]);
3102
3630
  if (nodeResult.rows.length > 0) {
3103
- const nodeProps = typeof nodeResult.rows[0].properties === "string"
3104
- ? JSON.parse(nodeResult.rows[0].properties)
3105
- : nodeResult.rows[0].properties;
3631
+ const nodeProps = this.getNodeProperties(nodeResult.rows[0].id, nodeResult.rows[0].properties);
3106
3632
  return { ...nodeProps, _nf_id: nodeResult.rows[0].id };
3107
3633
  }
3108
3634
  return null;
@@ -3591,6 +4117,10 @@ export class Executor {
3591
4117
  this.db.transaction(() => {
3592
4118
  // Collect all node inserts for batching
3593
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();
3594
4124
  for (let comboIndex = 0; comboIndex < combinations.length; comboIndex++) {
3595
4125
  const combination = combinations[comboIndex];
3596
4126
  // Build a map of unwind variable -> current value
@@ -3600,12 +4130,8 @@ export class Executor {
3600
4130
  }
3601
4131
  for (const createClause of createClauses) {
3602
4132
  for (const pattern of createClause.patterns) {
3603
- if (this.isRelationshipPattern(pattern)) {
3604
- // Relationships are handled individually (they may reference nodes created in the same combination)
3605
- const createdIds = new Map();
3606
- this.executeCreateRelationshipPatternWithUnwind(pattern, createdIds, params, unwindContext);
3607
- }
3608
- else {
4133
+ if (!this.isRelationshipPattern(pattern)) {
4134
+ // Only collect node patterns in this pass; relationships handled after nodes are inserted
3609
4135
  const id = crypto.randomUUID();
3610
4136
  const labelJson = this.normalizeLabelToJson(pattern.label);
3611
4137
  const props = this.resolvePropertiesWithUnwind(pattern.properties || {}, params, unwindContext);
@@ -3648,7 +4174,7 @@ export class Executor {
3648
4174
  for (const createClause of createClauses) {
3649
4175
  for (const pattern of createClause.patterns) {
3650
4176
  if (this.isRelationshipPattern(pattern)) {
3651
- this.executeCreateRelationshipPatternWithUnwind(pattern, createdIds, params, unwindContext);
4177
+ this.executeCreateRelationshipPatternWithUnwind(pattern, createdIds, params, unwindContext, edgeInserts, pendingEdgeInfo);
3652
4178
  }
3653
4179
  }
3654
4180
  }
@@ -3672,23 +4198,37 @@ export class Executor {
3672
4198
  let value;
3673
4199
  const id = createdIds.get(aggInfo.argVariable);
3674
4200
  if (id) {
3675
- let result = this.db.execute("SELECT properties FROM nodes WHERE id = ?", [id]);
3676
- if (result.rows.length === 0) {
3677
- result = this.db.execute("SELECT properties FROM edges WHERE id = ?", [id]);
3678
- }
3679
- if (result.rows.length > 0) {
3680
- const props = typeof result.rows[0].properties === "string"
3681
- ? JSON.parse(result.rows[0].properties)
3682
- : 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;
3683
4205
  if (aggInfo.argProperty) {
3684
- // Collect property value
3685
4206
  value = props[aggInfo.argProperty];
3686
4207
  }
3687
4208
  else {
3688
- // Collect the whole node object (for collect(n))
3689
4209
  value = props;
3690
4210
  }
3691
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
+ }
3692
4232
  }
3693
4233
  if (value !== undefined) {
3694
4234
  if (!withAggregateValues.has(alias)) {
@@ -3713,17 +4253,25 @@ export class Executor {
3713
4253
  const property = arg.property;
3714
4254
  const id = createdIds.get(variable);
3715
4255
  if (id) {
3716
- // Try nodes first, then edges
3717
- let result = this.db.execute("SELECT properties FROM nodes WHERE id = ?", [id]);
3718
- if (result.rows.length === 0) {
3719
- // Try edges table
3720
- 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];
3721
4260
  }
3722
- if (result.rows.length > 0) {
3723
- const props = typeof result.rows[0].properties === "string"
3724
- ? JSON.parse(result.rows[0].properties)
3725
- : result.rows[0].properties;
3726
- 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
+ }
3727
4275
  }
3728
4276
  }
3729
4277
  }
@@ -3758,9 +4306,7 @@ export class Executor {
3758
4306
  if (nodeResult.rows.length > 0) {
3759
4307
  const row = nodeResult.rows[0];
3760
4308
  // Neo4j 3.5 format: return properties directly
3761
- resultRow[alias] = typeof row.properties === "string"
3762
- ? JSON.parse(row.properties)
3763
- : row.properties;
4309
+ resultRow[alias] = this.getNodeProperties(row.id, row.properties);
3764
4310
  }
3765
4311
  }
3766
4312
  }
@@ -3770,17 +4316,25 @@ export class Executor {
3770
4316
  const property = item.expression.property;
3771
4317
  const id = createdIds.get(variable);
3772
4318
  if (id) {
3773
- // Try nodes first, then edges
3774
- let nodeResult = this.db.execute("SELECT properties FROM nodes WHERE id = ?", [id]);
3775
- if (nodeResult.rows.length === 0) {
3776
- // Try edges table
3777
- 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];
3778
4323
  }
3779
- if (nodeResult.rows.length > 0) {
3780
- const props = typeof nodeResult.rows[0].properties === "string"
3781
- ? JSON.parse(nodeResult.rows[0].properties)
3782
- : nodeResult.rows[0].properties;
3783
- 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
+ }
3784
4338
  }
3785
4339
  }
3786
4340
  }
@@ -3791,6 +4345,14 @@ export class Executor {
3791
4345
  }
3792
4346
  }
3793
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
+ }
3794
4356
  });
3795
4357
  // Compute WITH aggregate results if RETURN references them
3796
4358
  if (returnsWithAggregateAliases && returnClause) {
@@ -3969,14 +4531,15 @@ export class Executor {
3969
4531
  const id = resolvedIds[variable];
3970
4532
  if (id) {
3971
4533
  // Try nodes first, then edges
3972
- let result = this.db.execute("SELECT properties FROM nodes WHERE id = ?", [id]);
3973
- if (result.rows.length === 0) {
3974
- 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]);
3975
4538
  }
3976
4539
  if (result.rows.length > 0) {
3977
- const props = typeof result.rows[0].properties === "string"
3978
- ? JSON.parse(result.rows[0].properties)
3979
- : 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);
3980
4543
  return props[property];
3981
4544
  }
3982
4545
  }
@@ -4012,15 +4575,16 @@ export class Executor {
4012
4575
  const id = createdIds.get(variable);
4013
4576
  if (id) {
4014
4577
  // Try nodes first, then edges
4015
- let result = this.db.execute("SELECT properties FROM nodes WHERE id = ?", [id]);
4016
- 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) {
4017
4581
  // Try edges table
4018
- result = this.db.execute("SELECT properties FROM edges WHERE id = ?", [id]);
4582
+ result = this.db.execute("SELECT id, properties FROM edges WHERE id = ?", [id]);
4019
4583
  }
4020
4584
  if (result.rows.length > 0) {
4021
- const props = typeof result.rows[0].properties === "string"
4022
- ? JSON.parse(result.rows[0].properties)
4023
- : 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);
4024
4588
  return props[property];
4025
4589
  }
4026
4590
  }
@@ -4108,8 +4672,9 @@ export class Executor {
4108
4672
  let whereConditions = [];
4109
4673
  let whereParams = [];
4110
4674
  if (nodePattern.label) {
4111
- whereConditions.push("EXISTS (SELECT 1 FROM json_each(label) WHERE value = ?)");
4112
- 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);
4113
4678
  }
4114
4679
  for (const [key, value] of Object.entries(props)) {
4115
4680
  whereConditions.push(`json_extract(properties, '$.${key}') = ?`);
@@ -5059,7 +5624,7 @@ export class Executor {
5059
5624
  /**
5060
5625
  * Execute CREATE relationship pattern with unwind context
5061
5626
  */
5062
- executeCreateRelationshipPatternWithUnwind(rel, createdIds, params, unwindContext) {
5627
+ executeCreateRelationshipPatternWithUnwind(rel, createdIds, params, unwindContext, edgeInserts, pendingEdgeInfo) {
5063
5628
  let sourceId;
5064
5629
  let targetId;
5065
5630
  // Determine source node ID
@@ -5102,7 +5667,30 @@ export class Executor {
5102
5667
  const edgeId = crypto.randomUUID();
5103
5668
  const edgeType = rel.edge.type || "";
5104
5669
  const edgeProps = this.resolvePropertiesWithUnwind(rel.edge.properties || {}, params, unwindContext);
5105
- 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
+ }
5106
5694
  if (rel.edge.variable) {
5107
5695
  createdIds.set(rel.edge.variable, edgeId);
5108
5696
  }
@@ -5186,9 +5774,7 @@ export class Executor {
5186
5774
  if (nodeResult.rows.length > 0) {
5187
5775
  const row = nodeResult.rows[0];
5188
5776
  // Neo4j 3.5 format: return properties directly
5189
- resultRow[alias] = typeof row.properties === "string"
5190
- ? JSON.parse(row.properties)
5191
- : row.properties;
5777
+ resultRow[alias] = this.getNodeProperties(row.id, row.properties);
5192
5778
  }
5193
5779
  }
5194
5780
  }
@@ -5428,6 +6014,15 @@ export class Executor {
5428
6014
  }
5429
6015
  if (relList.length === 0)
5430
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);
5431
6026
  // Follow the sequence of relationships to find endpoints
5432
6027
  let currentNodeId = null;
5433
6028
  let firstNodeId = null;
@@ -5435,45 +6030,9 @@ export class Executor {
5435
6030
  let valid = true;
5436
6031
  for (let i = 0; i < relList.length; i++) {
5437
6032
  const rel = relList[i];
5438
- // Extract edge info
5439
- let edgeInfo = null;
5440
- if (typeof rel === "object" && rel !== null) {
5441
- const relObj = rel;
5442
- const edgeId = (relObj._nf_id || relObj.id);
5443
- if (edgeId) {
5444
- const edgeResult = this.db.execute("SELECT id, source_id, target_id FROM edges WHERE id = ?", [edgeId]);
5445
- if (edgeResult.rows.length > 0) {
5446
- const r = edgeResult.rows[0];
5447
- edgeInfo = {
5448
- id: r.id,
5449
- source_id: r.source_id,
5450
- target_id: r.target_id
5451
- };
5452
- }
5453
- }
5454
- }
5455
- else if (typeof rel === "string") {
5456
- // Could be a JSON string like '{"_nf_id":"uuid"}' or a raw edge ID
5457
- let edgeId = rel;
5458
- try {
5459
- const parsed = JSON.parse(rel);
5460
- if (typeof parsed === "object" && parsed !== null && (parsed._nf_id || parsed.id)) {
5461
- edgeId = (parsed._nf_id || parsed.id);
5462
- }
5463
- }
5464
- catch {
5465
- // Not JSON, use as-is
5466
- }
5467
- const edgeResult = this.db.execute("SELECT id, source_id, target_id FROM edges WHERE id = ?", [edgeId]);
5468
- if (edgeResult.rows.length > 0) {
5469
- const r = edgeResult.rows[0];
5470
- edgeInfo = {
5471
- id: r.id,
5472
- source_id: r.source_id,
5473
- target_id: r.target_id
5474
- };
5475
- }
5476
- }
6033
+ // Extract edge ID and get info from batch result
6034
+ const edgeId = this.extractEdgeId(rel);
6035
+ const edgeInfo = edgeId ? edgeInfoMap.get(edgeId) : null;
5477
6036
  if (!edgeInfo) {
5478
6037
  valid = false;
5479
6038
  break;
@@ -5524,12 +6083,8 @@ export class Executor {
5524
6083
  }
5525
6084
  const firstNode = firstNodeResult.rows[0];
5526
6085
  const lastNode = lastNodeResult.rows[0];
5527
- const firstProps = typeof firstNode.properties === "string"
5528
- ? JSON.parse(firstNode.properties)
5529
- : firstNode.properties;
5530
- const lastProps = typeof lastNode.properties === "string"
5531
- ? JSON.parse(lastNode.properties)
5532
- : lastNode.properties;
6086
+ const firstProps = this.getNodeProperties(firstNode.id, firstNode.properties);
6087
+ const lastProps = this.getNodeProperties(lastNode.id, lastNode.properties);
5533
6088
  // Build result based on RETURN items
5534
6089
  const resultRow = {};
5535
6090
  for (const item of returnClause.items) {
@@ -5760,6 +6315,8 @@ export class Executor {
5760
6315
  continue;
5761
6316
  const value = this.evaluateExpressionWithMatchedNodes(assignment.value, params, matchedNodes);
5762
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);
5763
6320
  }
5764
6321
  }
5765
6322
  }
@@ -5771,7 +6328,7 @@ export class Executor {
5771
6328
  matchedNodes.set(pattern.variable, {
5772
6329
  id: row.id,
5773
6330
  label: row.label,
5774
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
6331
+ properties: this.getNodeProperties(row.id, row.properties),
5775
6332
  });
5776
6333
  }
5777
6334
  }
@@ -5836,7 +6393,7 @@ export class Executor {
5836
6393
  type: row.type,
5837
6394
  source_id: row.source_id,
5838
6395
  target_id: row.target_id,
5839
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
6396
+ properties: this.getEdgeProperties(row.id, row.properties),
5840
6397
  });
5841
6398
  }
5842
6399
  }
@@ -6109,7 +6666,7 @@ export class Executor {
6109
6666
  newRow.set(nodePattern.variable, {
6110
6667
  id: sqlRow.id,
6111
6668
  label: labelValue,
6112
- properties: typeof sqlRow.properties === "string" ? JSON.parse(sqlRow.properties) : sqlRow.properties,
6669
+ properties: this.getNodeProperties(sqlRow.id, sqlRow.properties),
6113
6670
  });
6114
6671
  }
6115
6672
  newMatchRows.push(newRow);
@@ -6258,6 +6815,8 @@ export class Executor {
6258
6815
  continue;
6259
6816
  const value = this.evaluateExpressionWithMatchedNodes(assignment.value, params, matchedNodes);
6260
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);
6261
6820
  }
6262
6821
  }
6263
6822
  }
@@ -6269,7 +6828,7 @@ export class Executor {
6269
6828
  matchedNodes.set(pattern.variable, {
6270
6829
  id: row.id,
6271
6830
  label: row.label,
6272
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
6831
+ properties: this.getNodeProperties(row.id, row.properties),
6273
6832
  });
6274
6833
  }
6275
6834
  }
@@ -6392,7 +6951,7 @@ export class Executor {
6392
6951
  type: row.type,
6393
6952
  source_id: row.source_id,
6394
6953
  target_id: row.target_id,
6395
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
6954
+ properties: this.getEdgeProperties(row.id, row.properties),
6396
6955
  });
6397
6956
  }
6398
6957
  }
@@ -6596,6 +7155,8 @@ export class Executor {
6596
7155
  ? this.evaluateExpressionWithContext(assignment.value, params, resolvedIds)
6597
7156
  : this.evaluateExpression(assignment.value, params);
6598
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);
6599
7160
  }
6600
7161
  }
6601
7162
  }
@@ -6607,7 +7168,7 @@ export class Executor {
6607
7168
  matchedNodes.set(pattern.variable, {
6608
7169
  id: row.id,
6609
7170
  label: row.label,
6610
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
7171
+ properties: this.getNodeProperties(row.id, row.properties),
6611
7172
  });
6612
7173
  }
6613
7174
  }
@@ -6705,6 +7266,8 @@ export class Executor {
6705
7266
  const value = this.evaluateExpression(assignment.value, params);
6706
7267
  // Update target node with ON CREATE SET
6707
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);
6708
7271
  }
6709
7272
  }
6710
7273
  this.db.execute("INSERT INTO edges (id, type, source_id, target_id, properties) VALUES (?, ?, ?, ?, ?)", [edgeId, edgeType, sourceNodeId, targetNodeId, JSON.stringify(finalEdgeProps)]);
@@ -6722,6 +7285,8 @@ export class Executor {
6722
7285
  const value = this.evaluateExpression(assignment.value, params);
6723
7286
  // Update target node with ON MATCH SET
6724
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);
6725
7290
  }
6726
7291
  }
6727
7292
  }
@@ -6735,7 +7300,7 @@ export class Executor {
6735
7300
  type: row.type,
6736
7301
  source_id: row.source_id,
6737
7302
  target_id: row.target_id,
6738
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
7303
+ properties: this.getEdgeProperties(row.id, row.properties),
6739
7304
  });
6740
7305
  }
6741
7306
  }
@@ -6747,7 +7312,7 @@ export class Executor {
6747
7312
  matchedNodes.set(targetVar, {
6748
7313
  id: row.id,
6749
7314
  label: row.label,
6750
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
7315
+ properties: this.getNodeProperties(row.id, row.properties),
6751
7316
  });
6752
7317
  }
6753
7318
  }
@@ -6782,7 +7347,7 @@ export class Executor {
6782
7347
  return {
6783
7348
  id: row.id,
6784
7349
  label: typeof row.label === "string" ? JSON.parse(row.label) : row.label,
6785
- properties: typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties,
7350
+ properties: this.getNodeProperties(row.id, row.properties),
6786
7351
  };
6787
7352
  }
6788
7353
  }
@@ -7041,7 +7606,7 @@ export class Executor {
7041
7606
  case "variable":
7042
7607
  return expr.variable;
7043
7608
  case "property":
7044
- return `${expr.variable}_${expr.property}`;
7609
+ return `${expr.variable}.${expr.property}`;
7045
7610
  case "function": {
7046
7611
  // Build function expression like count(a) or count(*)
7047
7612
  const funcName = expr.functionName.toLowerCase();
@@ -7080,6 +7645,236 @@ export class Executor {
7080
7645
  return "expr";
7081
7646
  }
7082
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
+ }
7083
7878
  /**
7084
7879
  * Detect and handle patterns that need multi-phase execution:
7085
7880
  * - MATCH...CREATE that references matched variables
@@ -7840,10 +8635,10 @@ export class Executor {
7840
8635
  for (const resolvedIds of allResolvedIds) {
7841
8636
  const nodeId = resolvedIds[varName];
7842
8637
  if (nodeId) {
7843
- 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]);
7844
8639
  if (nodeResult.rows.length > 0) {
7845
8640
  const row = nodeResult.rows[0];
7846
- values.push(typeof row.properties === "string" ? JSON.parse(row.properties) : row.properties);
8641
+ values.push(this.getNodeProperties(row.id, row.properties));
7847
8642
  }
7848
8643
  }
7849
8644
  }
@@ -7949,9 +8744,7 @@ export class Executor {
7949
8744
  if (nodeResult.rows.length > 0) {
7950
8745
  const row = nodeResult.rows[0];
7951
8746
  // Neo4j 3.5 format: return properties directly
7952
- resultRow[alias] = typeof row.properties === "string"
7953
- ? JSON.parse(row.properties)
7954
- : row.properties;
8747
+ resultRow[alias] = this.getNodeProperties(row.id, row.properties);
7955
8748
  }
7956
8749
  else {
7957
8750
  // Try edges
@@ -7959,9 +8752,7 @@ export class Executor {
7959
8752
  if (edgeResult.rows.length > 0) {
7960
8753
  const row = edgeResult.rows[0];
7961
8754
  // Neo4j 3.5 format: return properties directly
7962
- resultRow[alias] = typeof row.properties === "string"
7963
- ? JSON.parse(row.properties)
7964
- : row.properties;
8755
+ resultRow[alias] = this.getEdgeProperties(row.id, row.properties);
7965
8756
  }
7966
8757
  }
7967
8758
  }
@@ -8174,6 +8965,8 @@ export class Executor {
8174
8965
  if (nodeResult.changes === 0) {
8175
8966
  this.db.execute(`UPDATE edges SET properties = ? WHERE id = ?`, [JSON.stringify(filteredProps), nodeId]);
8176
8967
  }
8968
+ // Invalidate cache after UPDATE
8969
+ this.invalidatePropertyCache(nodeId);
8177
8970
  continue;
8178
8971
  }
8179
8972
  // Handle SET n += {props} - merge properties
@@ -8207,6 +9000,8 @@ export class Executor {
8207
9000
  this.db.execute(`UPDATE edges SET properties = json_patch(properties, ?) WHERE id = ?`, [JSON.stringify(nonNullProps), nodeId]);
8208
9001
  }
8209
9002
  }
9003
+ // Invalidate cache after UPDATE
9004
+ this.invalidatePropertyCache(nodeId);
8210
9005
  continue;
8211
9006
  }
8212
9007
  // Handle property assignments
@@ -8214,7 +9009,7 @@ export class Executor {
8214
9009
  throw new Error(`Invalid SET assignment for variable: ${assignment.variable}`);
8215
9010
  }
8216
9011
  // Use context-aware evaluation for expressions that may reference properties
8217
- 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"
8218
9013
  ? this.evaluateExpressionWithContext(assignment.value, params, resolvedIds)
8219
9014
  : this.evaluateExpression(assignment.value, params);
8220
9015
  // If value is null, remove the property instead of setting it to null
@@ -8234,6 +9029,8 @@ export class Executor {
8234
9029
  this.db.execute(`UPDATE edges SET properties = json_set(properties, '$.${assignment.property}', json(?)) WHERE id = ?`, [JSON.stringify(value), nodeId]);
8235
9030
  }
8236
9031
  }
9032
+ // Invalidate cache after UPDATE
9033
+ this.invalidatePropertyCache(nodeId);
8237
9034
  }
8238
9035
  }
8239
9036
  /**
@@ -8289,6 +9086,28 @@ export class Executor {
8289
9086
  }
8290
9087
  return deletedVariables;
8291
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
+ }
8292
9111
  /**
8293
9112
  * Evaluate an expression to get its value
8294
9113
  * Note: For property and binary expressions that reference nodes, use evaluateExpressionWithContext
@@ -8399,6 +9218,19 @@ export class Executor {
8399
9218
  const args = expr.args || [];
8400
9219
  return this.evaluateFunctionInProperty(funcName, args, params, {});
8401
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
+ }
8402
9234
  default:
8403
9235
  throw new Error(`Cannot evaluate expression of type ${expr.type}`);
8404
9236
  }
@@ -8588,21 +9420,26 @@ export class Executor {
8588
9420
  /**
8589
9421
  * Generate SQL condition for label matching
8590
9422
  * Supports both single and multiple labels
9423
+ * Uses primary label index with fallback for secondary labels
8591
9424
  */
8592
9425
  generateLabelCondition(label) {
8593
9426
  const labels = Array.isArray(label) ? label : [label];
8594
9427
  if (labels.length === 1) {
8595
9428
  return {
8596
- sql: `EXISTS (SELECT 1 FROM json_each(label) WHERE value = ?)`,
8597
- 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]]
8598
9431
  };
8599
9432
  }
8600
9433
  else {
8601
- // Multiple labels: all must exist
8602
- 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
+ }
8603
9440
  return {
8604
9441
  sql: conditions.join(" AND "),
8605
- params: labels
9442
+ params
8606
9443
  };
8607
9444
  }
8608
9445
  }